export: align reanimator and enrich redfish metrics
This commit is contained in:
@@ -30,25 +30,41 @@ Implementation files:
|
|||||||
- `internal/exporter/reanimator_models.go`
|
- `internal/exporter/reanimator_models.go`
|
||||||
- `internal/exporter/reanimator_converter.go`
|
- `internal/exporter/reanimator_converter.go`
|
||||||
- `internal/server/handlers.go`
|
- `internal/server/handlers.go`
|
||||||
|
- `bible-local/docs/hardware-ingest-contract.md`
|
||||||
|
|
||||||
Conversion rules:
|
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
|
- timestamps are RFC3339
|
||||||
- status is normalized to Reanimator-friendly values
|
- status is normalized to Reanimator-friendly values
|
||||||
- missing PCIe serials may be generated from board serial + slot
|
- 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
|
- `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
|
## Inclusion rules
|
||||||
|
|
||||||
Included:
|
Included:
|
||||||
- empty memory slots (`present=false`) for topology visibility
|
|
||||||
- PCIe-class devices even when serial must be synthesized
|
- 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:
|
Excluded:
|
||||||
|
- memory with missing serial number
|
||||||
|
- memory with `present=false` or `status=Empty`
|
||||||
|
- CPUs with `present=false`
|
||||||
- storage without `serial_number`
|
- storage without `serial_number`
|
||||||
|
- storage with `present=false`
|
||||||
- power supplies without `serial_number`
|
- power supplies without `serial_number`
|
||||||
|
- power supplies with `present=false`
|
||||||
- non-present network adapters
|
- non-present network adapters
|
||||||
|
- non-present PCIe / GPU devices
|
||||||
- device-bound firmware duplicated at top-level firmware list
|
- device-bound firmware duplicated at top-level firmware list
|
||||||
|
- any field not present in the strict ingest contract
|
||||||
|
|
||||||
## Batch convert
|
## Batch convert
|
||||||
|
|
||||||
@@ -61,3 +77,8 @@ Behavior:
|
|||||||
- each file is parsed independently
|
- each file is parsed independently
|
||||||
- one bad file must not fail the whole batch if at least one conversion succeeds
|
- one bad file must not fail the whole batch if at least one conversion succeeds
|
||||||
- result artifact is temporary and deleted after download
|
- 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.
|
||||||
|
|||||||
@@ -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
|
correct prefix+digit checks (`"gpu" + digit`, `"nic" + digit`) and explicit string checks
|
||||||
(`"nvmecontroller"`, `"power supply"`, `"software inventory"`).
|
(`"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 -->
|
<!-- Add new decisions below this line using the format above -->
|
||||||
|
|||||||
@@ -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 |
|
| [05-collectors.md](05-collectors.md) | Live collection behavior |
|
||||||
| [06-parsers.md](06-parsers.md) | Archive parser framework and vendor coverage |
|
| [06-parsers.md](06-parsers.md) | Archive parser framework and vendor coverage |
|
||||||
| [07-exporters.md](07-exporters.md) | Raw export, Reanimator export, batch convert |
|
| [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 |
|
| [08-build-release.md](08-build-release.md) | Build and release workflow |
|
||||||
| [09-testing.md](09-testing.md) | Test expectations and regression rules |
|
| [09-testing.md](09-testing.md) | Test expectations and regression rules |
|
||||||
| [10-decisions.md](10-decisions.md) | Architectural Decision Log |
|
| [10-decisions.md](10-decisions.md) | Architectural Decision Log |
|
||||||
|
|||||||
@@ -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
|
||||||
+587
-19
@@ -417,11 +417,13 @@ func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Clie
|
|||||||
driveDocs, err := c.getCollectionMembers(ctx, client, req, baseURL, driveCollectionPath)
|
driveDocs, err := c.getCollectionMembers(ctx, client, req, baseURL, driveCollectionPath)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
for _, driveDoc := range driveDocs {
|
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 {
|
if len(driveDocs) == 0 {
|
||||||
for _, driveDoc := range c.probeDirectDiskBayChildren(ctx, client, req, baseURL, driveCollectionPath) {
|
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 {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
out = append(out, parseDrive(driveDoc))
|
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
||||||
|
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Some implementations return drive fields right in storage member object.
|
// Some implementations return drive fields right in storage member object.
|
||||||
if looksLikeDrive(member) {
|
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
|
// 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 {
|
if err == nil {
|
||||||
for _, driveDoc := range driveDocs {
|
for _, driveDoc := range driveDocs {
|
||||||
if looksLikeDrive(driveDoc) {
|
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 {
|
if len(driveDocs) == 0 {
|
||||||
@@ -477,7 +482,8 @@ func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Clie
|
|||||||
"/Storage/IntelVROC/Controllers/1/Drives",
|
"/Storage/IntelVROC/Controllers/1/Drives",
|
||||||
}) {
|
}) {
|
||||||
if looksLikeDrive(driveDoc) {
|
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) {
|
if !ok || !looksLikeDrive(devDoc) {
|
||||||
continue
|
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) {
|
if !looksLikeDrive(driveDoc) {
|
||||||
continue
|
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 {
|
for _, chassisPath := range chassisPaths {
|
||||||
@@ -519,7 +526,8 @@ func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Clie
|
|||||||
if !looksLikeDrive(driveDoc) {
|
if !looksLikeDrive(driveDoc) {
|
||||||
continue
|
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
|
continue
|
||||||
}
|
}
|
||||||
functionDocs := c.getLinkedPCIeFunctions(ctx, client, req, baseURL, pcieDoc)
|
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)
|
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.
|
// 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 {
|
if memberDocs, err := c.getCollectionMembers(ctx, client, req, baseURL, joinPath(chassisPath, "/PowerSubsystem/PowerSupplies")); err == nil && len(memberDocs) > 0 {
|
||||||
for _, doc := range memberDocs {
|
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
|
continue
|
||||||
}
|
}
|
||||||
@@ -604,7 +617,8 @@ func (c *RedfishConnector) collectPSUs(ctx context.Context, client *http.Client,
|
|||||||
if !ok {
|
if !ok {
|
||||||
continue
|
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 ""
|
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 {
|
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)
|
collections := make([]string, 0, len(systemPaths)*3+len(chassisPaths)*2)
|
||||||
for _, systemPath := range systemPaths {
|
for _, systemPath := range systemPaths {
|
||||||
@@ -681,7 +726,11 @@ func (c *RedfishConnector) collectGPUs(ctx context.Context, client *http.Client,
|
|||||||
continue
|
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++
|
idx++
|
||||||
if shouldSkipGenericGPUDuplicate(out, gpu) {
|
if shouldSkipGenericGPUDuplicate(out, gpu) {
|
||||||
continue
|
continue
|
||||||
@@ -723,7 +772,11 @@ func (c *RedfishConnector) collectPCIeDevices(ctx context.Context, client *http.
|
|||||||
if looksLikeGPU(doc, functionDocs) {
|
if looksLikeGPU(doc, functionDocs) {
|
||||||
continue
|
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) {
|
if isUnidentifiablePCIeDevice(dev) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -738,7 +791,8 @@ func (c *RedfishConnector) collectPCIeDevices(ctx context.Context, client *http.
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for idx, fn := range functionDocs {
|
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)
|
out = append(out, dev)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1945,9 +1999,15 @@ func shouldCrawlPath(path string) bool {
|
|||||||
if strings.Contains(normalized, "/Memory/") {
|
if strings.Contains(normalized, "/Memory/") {
|
||||||
after := strings.SplitN(normalized, "/Memory/", 2)
|
after := strings.SplitN(normalized, "/Memory/", 2)
|
||||||
if len(after) == 2 && strings.Count(after[1], "/") >= 1 {
|
if len(after) == 2 && strings.Count(after[1], "/") >= 1 {
|
||||||
// Keep direct DIMM resources (/Memory/<slot>) but skip nested subresources
|
// Keep direct DIMM resources and selected metrics subresources, but skip
|
||||||
// like /Memory/<slot>/Assembly and /Memory/<slot>/MemoryMetrics.
|
// unrelated nested branches like Assembly.
|
||||||
return false
|
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.
|
// Non-inventory top-level service branches.
|
||||||
@@ -2551,6 +2611,7 @@ func parseCPUs(docs []map[string]interface{}) []models.CPU {
|
|||||||
L2CacheKB: l2,
|
L2CacheKB: l2,
|
||||||
L3CacheKB: l3,
|
L3CacheKB: l3,
|
||||||
Status: mapStatus(doc["Status"]),
|
Status: mapStatus(doc["Status"]),
|
||||||
|
Details: redfishCPUDetails(doc),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return cpus
|
return cpus
|
||||||
@@ -2614,12 +2675,102 @@ func parseMemory(docs []map[string]interface{}) []models.MemoryDIMM {
|
|||||||
SerialNumber: findFirstNormalizedStringByKeys(doc, "SerialNumber"),
|
SerialNumber: findFirstNormalizedStringByKeys(doc, "SerialNumber"),
|
||||||
PartNumber: asString(doc["PartNumber"]),
|
PartNumber: asString(doc["PartNumber"]),
|
||||||
Status: mapStatus(doc["Status"]),
|
Status: mapStatus(doc["Status"]),
|
||||||
|
Details: redfishMemoryDetails(doc),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return out
|
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 {
|
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
|
sizeGB := 0
|
||||||
if capBytes := asInt64(doc["CapacityBytes"]); capBytes > 0 {
|
if capBytes := asInt64(doc["CapacityBytes"]); capBytes > 0 {
|
||||||
sizeGB = int(capBytes / (1024 * 1024 * 1024))
|
sizeGB = int(capBytes / (1024 * 1024 * 1024))
|
||||||
@@ -2644,6 +2795,7 @@ func parseDrive(doc map[string]interface{}) models.Storage {
|
|||||||
Firmware: asString(doc["Revision"]),
|
Firmware: asString(doc["Revision"]),
|
||||||
Interface: asString(doc["Protocol"]),
|
Interface: asString(doc["Protocol"]),
|
||||||
Present: true,
|
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"])),
|
Slot: firstNonEmpty(asString(doc["Id"]), asString(doc["Name"])),
|
||||||
Location: location,
|
Location: location,
|
||||||
Present: !strings.EqualFold(mapStatus(doc["Status"]), "Absent"),
|
Present: !strings.EqualFold(mapStatus(doc["Status"]), "Absent"),
|
||||||
|
BDF: asString(doc["BDF"]),
|
||||||
Model: strings.TrimSpace(model),
|
Model: strings.TrimSpace(model),
|
||||||
Vendor: strings.TrimSpace(vendor),
|
Vendor: strings.TrimSpace(vendor),
|
||||||
VendorID: vendorID,
|
VendorID: vendorID,
|
||||||
@@ -2753,6 +2906,7 @@ func parseNIC(doc map[string]interface{}) models.NetworkAdapter {
|
|||||||
Firmware: firmware,
|
Firmware: firmware,
|
||||||
PortCount: portCount,
|
PortCount: portCount,
|
||||||
Status: mapStatus(doc["Status"]),
|
Status: mapStatus(doc["Status"]),
|
||||||
|
Details: redfishPCIeDetails(doc, nil),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2786,23 +2940,53 @@ func networkAdapterPCIeDevicePaths(doc map[string]interface{}) []string {
|
|||||||
return out
|
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 {
|
if nic == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if strings.TrimSpace(nic.BDF) == "" {
|
||||||
|
nic.BDF = firstNonEmpty(asString(pcieDoc["BDF"]), buildBDFfromOemPublic(pcieDoc))
|
||||||
|
}
|
||||||
if nic.VendorID == 0 {
|
if nic.VendorID == 0 {
|
||||||
nic.VendorID = asHexOrInt(pcieDoc["VendorId"])
|
nic.VendorID = asHexOrInt(pcieDoc["VendorId"])
|
||||||
}
|
}
|
||||||
if nic.DeviceID == 0 {
|
if nic.DeviceID == 0 {
|
||||||
nic.DeviceID = asHexOrInt(pcieDoc["DeviceId"])
|
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 {
|
for _, fn := range functionDocs {
|
||||||
|
if strings.TrimSpace(nic.BDF) == "" {
|
||||||
|
nic.BDF = asString(fn["FunctionId"])
|
||||||
|
}
|
||||||
if nic.VendorID == 0 {
|
if nic.VendorID == 0 {
|
||||||
nic.VendorID = asHexOrInt(fn["VendorId"])
|
nic.VendorID = asHexOrInt(fn["VendorId"])
|
||||||
}
|
}
|
||||||
if nic.DeviceID == 0 {
|
if nic.DeviceID == 0 {
|
||||||
nic.DeviceID = asHexOrInt(fn["DeviceId"])
|
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) == "" {
|
if strings.TrimSpace(nic.Vendor) == "" {
|
||||||
nic.Vendor = pciids.VendorName(nic.VendorID)
|
nic.Vendor = pciids.VendorName(nic.VendorID)
|
||||||
@@ -2812,9 +2996,14 @@ func enrichNICFromPCIe(nic *models.NetworkAdapter, pcieDoc map[string]interface{
|
|||||||
nic.Model = resolved
|
nic.Model = resolved
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
nic.Details = mergeGenericDetails(nic.Details, redfishPCIeDetailsWithSupplementalDocs(pcieDoc, functionDocs, supplementalDocs))
|
||||||
}
|
}
|
||||||
|
|
||||||
func parsePSU(doc map[string]interface{}, idx int) models.PSU {
|
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"])
|
status := mapStatus(doc["Status"])
|
||||||
present := true
|
present := true
|
||||||
if statusMap, ok := doc["Status"].(map[string]interface{}); ok {
|
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"]),
|
InputPowerW: asInt(doc["PowerInputWatts"]),
|
||||||
OutputPowerW: asInt(doc["LastPowerOutputWatts"]),
|
OutputPowerW: asInt(doc["LastPowerOutputWatts"]),
|
||||||
InputVoltage: asFloat(doc["LineInputVoltage"]),
|
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 {
|
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(
|
slot := firstNonEmpty(
|
||||||
redfishLocationLabel(doc["Slot"]),
|
redfishLocationLabel(doc["Slot"]),
|
||||||
redfishLocationLabel(doc["Location"]),
|
redfishLocationLabel(doc["Location"]),
|
||||||
@@ -2876,6 +3389,7 @@ func parseGPU(doc map[string]interface{}, functionDocs []map[string]interface{},
|
|||||||
PartNumber: asString(doc["PartNumber"]),
|
PartNumber: asString(doc["PartNumber"]),
|
||||||
Firmware: asString(doc["FirmwareVersion"]),
|
Firmware: asString(doc["FirmwareVersion"]),
|
||||||
Status: mapStatus(doc["Status"]),
|
Status: mapStatus(doc["Status"]),
|
||||||
|
Details: redfishPCIeDetailsWithSupplementalDocs(doc, functionDocs, supplementalDocs),
|
||||||
}
|
}
|
||||||
|
|
||||||
if bdf := asString(doc["BDF"]); bdf != "" {
|
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 {
|
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{
|
dev := models.PCIeDevice{
|
||||||
Slot: firstNonEmpty(redfishLocationLabel(doc["Slot"]), redfishLocationLabel(doc["Location"]), asString(doc["Name"]), asString(doc["Id"])),
|
Slot: firstNonEmpty(redfishLocationLabel(doc["Slot"]), redfishLocationLabel(doc["Location"]), asString(doc["Name"]), asString(doc["Id"])),
|
||||||
BDF: asString(doc["BDF"]),
|
BDF: asString(doc["BDF"]),
|
||||||
@@ -2937,6 +3455,7 @@ func parsePCIeDevice(doc map[string]interface{}, functionDocs []map[string]inter
|
|||||||
SerialNumber: findFirstNormalizedStringByKeys(doc, "SerialNumber"),
|
SerialNumber: findFirstNormalizedStringByKeys(doc, "SerialNumber"),
|
||||||
VendorID: asHexOrInt(doc["VendorId"]),
|
VendorID: asHexOrInt(doc["VendorId"]),
|
||||||
DeviceID: asHexOrInt(doc["DeviceId"]),
|
DeviceID: asHexOrInt(doc["DeviceId"]),
|
||||||
|
Details: redfishPCIeDetailsWithSupplementalDocs(doc, functionDocs, supplementalDocs),
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(dev.BDF) == "" {
|
if strings.TrimSpace(dev.BDF) == "" {
|
||||||
dev.BDF = buildBDFfromOemPublic(doc)
|
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 {
|
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"]))
|
slot := firstNonEmpty(redfishLocationLabel(doc["Location"]), asString(doc["Id"]), asString(doc["Name"]))
|
||||||
if slot == "" {
|
if slot == "" {
|
||||||
slot = fmt.Sprintf("PCIeFn%d", idx)
|
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"])),
|
LinkSpeed: firstNonEmpty(asString(doc["CurrentLinkSpeedGTs"]), asString(doc["CurrentLinkSpeed"])),
|
||||||
MaxLinkWidth: asInt(doc["MaxLinkWidth"]),
|
MaxLinkWidth: asInt(doc["MaxLinkWidth"]),
|
||||||
MaxLinkSpeed: firstNonEmpty(asString(doc["MaxLinkSpeedGTs"]), asString(doc["MaxLinkSpeed"])),
|
MaxLinkSpeed: firstNonEmpty(asString(doc["MaxLinkSpeedGTs"]), asString(doc["MaxLinkSpeed"])),
|
||||||
|
Details: redfishPCIeDetailsWithSupplementalDocs(doc, nil, supplementalDocs),
|
||||||
}
|
}
|
||||||
if isGenericPCIeClassLabel(dev.DeviceClass) {
|
if isGenericPCIeClassLabel(dev.DeviceClass) {
|
||||||
if resolved := pciids.DeviceName(dev.VendorID, dev.DeviceID); resolved != "" {
|
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 {
|
func richerStorageEntry(a, b models.Storage) models.Storage {
|
||||||
if storageRichnessScore(b) > storageRichnessScore(a) {
|
if storageRichnessScore(b) > storageRichnessScore(a) {
|
||||||
|
b.Details = mergeGenericDetails(b.Details, a.Details)
|
||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
a.Details = mergeGenericDetails(a.Details, b.Details)
|
||||||
return a
|
return a
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3815,6 +4341,9 @@ func networkAdapterRichnessScore(nic models.NetworkAdapter) int {
|
|||||||
if normalizeRedfishIdentityField(nic.Firmware) != "" {
|
if normalizeRedfishIdentityField(nic.Firmware) != "" {
|
||||||
score += 8
|
score += 8
|
||||||
}
|
}
|
||||||
|
if looksLikeCanonicalBDF(strings.TrimSpace(nic.BDF)) {
|
||||||
|
score += 10
|
||||||
|
}
|
||||||
if normalizeRedfishIdentityField(nic.PartNumber) != "" {
|
if normalizeRedfishIdentityField(nic.PartNumber) != "" {
|
||||||
score += 6
|
score += 6
|
||||||
}
|
}
|
||||||
@@ -3827,6 +4356,12 @@ func networkAdapterRichnessScore(nic models.NetworkAdapter) int {
|
|||||||
if nic.PortCount > 0 {
|
if nic.PortCount > 0 {
|
||||||
score += 4
|
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 {
|
if len(nic.MACAddresses) > 0 {
|
||||||
score += 4
|
score += 4
|
||||||
}
|
}
|
||||||
@@ -3853,6 +4388,9 @@ func mergeNetworkAdapterEntries(a, b models.NetworkAdapter) models.NetworkAdapte
|
|||||||
if strings.TrimSpace(out.Location) == "" && strings.TrimSpace(donor.Location) != "" {
|
if strings.TrimSpace(out.Location) == "" && strings.TrimSpace(donor.Location) != "" {
|
||||||
out.Location = donor.Location
|
out.Location = donor.Location
|
||||||
}
|
}
|
||||||
|
if strings.TrimSpace(out.BDF) == "" && strings.TrimSpace(donor.BDF) != "" {
|
||||||
|
out.BDF = donor.BDF
|
||||||
|
}
|
||||||
if normalizeNetworkAdapterModel(out) == "" && normalizeNetworkAdapterModel(donor) != "" {
|
if normalizeNetworkAdapterModel(out) == "" && normalizeNetworkAdapterModel(donor) != "" {
|
||||||
out.Model = donor.Model
|
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) != "" {
|
if strings.TrimSpace(out.PortType) == "" && strings.TrimSpace(donor.PortType) != "" {
|
||||||
out.PortType = 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) != "" {
|
if strings.TrimSpace(out.Status) == "" && strings.TrimSpace(donor.Status) != "" {
|
||||||
out.Status = donor.Status
|
out.Status = donor.Status
|
||||||
}
|
}
|
||||||
@@ -3890,6 +4440,7 @@ func mergeNetworkAdapterEntries(a, b models.NetworkAdapter) models.NetworkAdapte
|
|||||||
if len(donor.MACAddresses) > 0 {
|
if len(donor.MACAddresses) > 0 {
|
||||||
out.MACAddresses = dedupeStrings(append(append([]string{}, out.MACAddresses...), donor.MACAddresses...))
|
out.MACAddresses = dedupeStrings(append(append([]string{}, out.MACAddresses...), donor.MACAddresses...))
|
||||||
}
|
}
|
||||||
|
out.Details = mergeGenericDetails(out.Details, donor.Details)
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4041,6 +4592,7 @@ func mergePCIeDeviceEntries(a, b models.PCIeDevice) models.PCIeDevice {
|
|||||||
if len(donor.MACAddresses) > 0 {
|
if len(donor.MACAddresses) > 0 {
|
||||||
out.MACAddresses = dedupeStrings(append(append([]string{}, out.MACAddresses...), donor.MACAddresses...))
|
out.MACAddresses = dedupeStrings(append(append([]string{}, out.MACAddresses...), donor.MACAddresses...))
|
||||||
}
|
}
|
||||||
|
out.Details = mergeGenericDetails(out.Details, donor.Details)
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4147,9 +4699,25 @@ func mergePSUEntries(a, b models.PSU) models.PSU {
|
|||||||
if out.TemperatureC == 0 && donor.TemperatureC > 0 {
|
if out.TemperatureC == 0 && donor.TemperatureC > 0 {
|
||||||
out.TemperatureC = donor.TemperatureC
|
out.TemperatureC = donor.TemperatureC
|
||||||
}
|
}
|
||||||
|
out.Details = mergeGenericDetails(out.Details, donor.Details)
|
||||||
return out
|
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 {
|
func dedupeStorageVolumes(items []models.StorageVolume) []models.StorageVolume {
|
||||||
seen := make(map[string]struct{}, len(items))
|
seen := make(map[string]struct{}, len(items))
|
||||||
out := make([]models.StorageVolume, 0, len(items))
|
out := make([]models.StorageVolume, 0, len(items))
|
||||||
|
|||||||
@@ -62,8 +62,8 @@ func ReplayRedfishFromRawPayloads(rawPayloads map[string]any, emit ProgressFn) (
|
|||||||
if emit != nil {
|
if emit != nil {
|
||||||
emit(Progress{Status: "running", Progress: 55, Message: "Redfish snapshot: replay CPU/RAM/Storage..."})
|
emit(Progress{Status: "running", Progress: 55, Message: "Redfish snapshot: replay CPU/RAM/Storage..."})
|
||||||
}
|
}
|
||||||
processors, _ := r.getCollectionMembers(joinPath(primarySystem, "/Processors"))
|
processors := r.collectProcessors(primarySystem)
|
||||||
memory, _ := r.getCollectionMembers(joinPath(primarySystem, "/Memory"))
|
memory := r.collectMemory(primarySystem)
|
||||||
storageDevices := r.collectStorage(primarySystem)
|
storageDevices := r.collectStorage(primarySystem)
|
||||||
storageVolumes := r.collectStorageVolumes(primarySystem)
|
storageVolumes := r.collectStorageVolumes(primarySystem)
|
||||||
|
|
||||||
@@ -101,8 +101,8 @@ func ReplayRedfishFromRawPayloads(rawPayloads map[string]any, emit ProgressFn) (
|
|||||||
RawPayloads: cloneRawPayloads(rawPayloads),
|
RawPayloads: cloneRawPayloads(rawPayloads),
|
||||||
Hardware: &models.HardwareConfig{
|
Hardware: &models.HardwareConfig{
|
||||||
BoardInfo: boardInfo,
|
BoardInfo: boardInfo,
|
||||||
CPUs: parseCPUs(processors),
|
CPUs: processors,
|
||||||
Memory: parseMemory(memory),
|
Memory: memory,
|
||||||
Storage: storageDevices,
|
Storage: storageDevices,
|
||||||
Volumes: storageVolumes,
|
Volumes: storageVolumes,
|
||||||
PCIeDevices: pcieDevices,
|
PCIeDevices: pcieDevices,
|
||||||
@@ -977,6 +977,77 @@ func (r redfishSnapshotReader) getLinkedPCIeFunctions(doc map[string]interface{}
|
|||||||
return nil
|
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 {
|
func (r redfishSnapshotReader) collectStorage(systemPath string) []models.Storage {
|
||||||
var out []models.Storage
|
var out []models.Storage
|
||||||
storageMembers, _ := r.getCollectionMembers(joinPath(systemPath, "/Storage"))
|
storageMembers, _ := r.getCollectionMembers(joinPath(systemPath, "/Storage"))
|
||||||
@@ -987,12 +1058,14 @@ func (r redfishSnapshotReader) collectStorage(systemPath string) []models.Storag
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
for _, driveDoc := range driveDocs {
|
for _, driveDoc := range driveDocs {
|
||||||
if !isVirtualStorageDrive(driveDoc) {
|
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 {
|
if len(driveDocs) == 0 {
|
||||||
for _, driveDoc := range r.probeDirectDiskBayChildren(driveCollectionPath) {
|
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
|
continue
|
||||||
}
|
}
|
||||||
if !isVirtualStorageDrive(driveDoc) {
|
if !isVirtualStorageDrive(driveDoc) {
|
||||||
out = append(out, parseDrive(driveDoc))
|
supplementalDocs := r.getLinkedSupplementalDocs(driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
||||||
|
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if looksLikeDrive(member) {
|
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") {
|
for _, enclosurePath := range redfishLinkRefs(member, "Links", "Enclosures") {
|
||||||
@@ -1028,7 +1103,8 @@ func (r redfishSnapshotReader) collectStorage(systemPath string) []models.Storag
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
for _, driveDoc := range driveDocs {
|
for _, driveDoc := range driveDocs {
|
||||||
if looksLikeDrive(driveDoc) {
|
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 {
|
if len(driveDocs) == 0 {
|
||||||
@@ -1045,7 +1121,8 @@ func (r redfishSnapshotReader) collectStorage(systemPath string) []models.Storag
|
|||||||
"/Storage/IntelVROC/Controllers/1/Drives",
|
"/Storage/IntelVROC/Controllers/1/Drives",
|
||||||
}) {
|
}) {
|
||||||
if looksLikeDrive(driveDoc) {
|
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
|
continue
|
||||||
}
|
}
|
||||||
functionDocs := r.getLinkedPCIeFunctions(pcieDoc)
|
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.
|
// Collect MACs from NetworkDeviceFunctions when not found via PCIe path.
|
||||||
if len(nic.MACAddresses) == 0 {
|
if len(nic.MACAddresses) == 0 {
|
||||||
@@ -1184,7 +1265,8 @@ func (r redfishSnapshotReader) collectPSUs(chassisPaths []string) []models.PSU {
|
|||||||
for _, chassisPath := range chassisPaths {
|
for _, chassisPath := range chassisPaths {
|
||||||
if memberDocs, err := r.getCollectionMembers(joinPath(chassisPath, "/PowerSubsystem/PowerSupplies")); err == nil && len(memberDocs) > 0 {
|
if memberDocs, err := r.getCollectionMembers(joinPath(chassisPath, "/PowerSubsystem/PowerSupplies")); err == nil && len(memberDocs) > 0 {
|
||||||
for _, doc := range memberDocs {
|
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
|
continue
|
||||||
}
|
}
|
||||||
@@ -1195,7 +1277,8 @@ func (r redfishSnapshotReader) collectPSUs(chassisPaths []string) []models.PSU {
|
|||||||
if !ok {
|
if !ok {
|
||||||
continue
|
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) {
|
if !looksLikeGPU(doc, functionDocs) {
|
||||||
continue
|
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++
|
idx++
|
||||||
if shouldSkipGenericGPUDuplicate(out, gpu) {
|
if shouldSkipGenericGPUDuplicate(out, gpu) {
|
||||||
continue
|
continue
|
||||||
@@ -1265,7 +1352,11 @@ func (r redfishSnapshotReader) collectPCIeDevices(systemPaths, chassisPaths []st
|
|||||||
if looksLikeGPU(doc, functionDocs) {
|
if looksLikeGPU(doc, functionDocs) {
|
||||||
continue
|
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) {
|
if isUnidentifiablePCIeDevice(dev) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -1278,7 +1369,8 @@ func (r redfishSnapshotReader) collectPCIeDevices(systemPaths, chassisPaths []st
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for idx, fn := range functionDocs {
|
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)
|
out = append(out, dev)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -584,18 +584,32 @@ func TestEnrichNICFromPCIeFunctions(t *testing.T) {
|
|||||||
}
|
}
|
||||||
functionDocs := []map[string]interface{}{
|
functionDocs := []map[string]interface{}{
|
||||||
{
|
{
|
||||||
|
"FunctionId": "0000:17:00.0",
|
||||||
"VendorId": "0x15b3",
|
"VendorId": "0x15b3",
|
||||||
"DeviceId": "0x1021",
|
"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 {
|
if nic.VendorID != 0x15b3 || nic.DeviceID != 0x1021 {
|
||||||
t.Fatalf("unexpected NIC IDs: vendor=%#x device=%#x", nic.VendorID, nic.DeviceID)
|
t.Fatalf("unexpected NIC IDs: vendor=%#x device=%#x", nic.VendorID, nic.DeviceID)
|
||||||
}
|
}
|
||||||
if nic.Location != "PCIe Slot 1 (1)" {
|
if nic.Location != "PCIe Slot 1 (1)" {
|
||||||
t.Fatalf("unexpected NIC location: %q", nic.Location)
|
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) {
|
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) {
|
func TestRedfishCollectionMemberRefs_IncludesOemPublicMembers(t *testing.T) {
|
||||||
collection := map[string]interface{}{
|
collection := map[string]interface{}{
|
||||||
"Members": []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) {
|
func TestRecoverCriticalRedfishDocsPlanB_RetriesMembersFromExistingCollection(t *testing.T) {
|
||||||
t.Setenv("LOGPILE_REDFISH_CRITICAL_COOLDOWN", "0s")
|
t.Setenv("LOGPILE_REDFISH_CRITICAL_COOLDOWN", "0s")
|
||||||
t.Setenv("LOGPILE_REDFISH_CRITICAL_SLOW_GAP", "0s")
|
t.Setenv("LOGPILE_REDFISH_CRITICAL_SLOW_GAP", "0s")
|
||||||
@@ -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") {
|
if !shouldCrawlPath("/redfish/v1/Systems/1/Memory/CPU0_C0D0") {
|
||||||
t.Fatalf("expected direct DIMM resource to be crawlable")
|
t.Fatalf("expected direct DIMM resource to be crawlable")
|
||||||
}
|
}
|
||||||
if shouldCrawlPath("/redfish/v1/Systems/1/Memory/CPU0_C0D0/Assembly") {
|
if shouldCrawlPath("/redfish/v1/Systems/1/Memory/CPU0_C0D0/Assembly") {
|
||||||
t.Fatalf("expected DIMM assembly subresource to be skipped")
|
t.Fatalf("expected DIMM assembly subresource to be skipped")
|
||||||
}
|
}
|
||||||
if shouldCrawlPath("/redfish/v1/Systems/1/Memory/CPU0_C0D0/MemoryMetrics") {
|
if !shouldCrawlPath("/redfish/v1/Systems/1/Memory/CPU0_C0D0/MemoryMetrics") {
|
||||||
t.Fatalf("expected DIMM metrics subresource to be skipped")
|
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") {
|
if shouldCrawlPath("/redfish/v1/Chassis/1/PCIeDevices/0/PCIeFunctions/1") {
|
||||||
t.Fatalf("expected noisy chassis pciefunctions branch to be skipped")
|
t.Fatalf("expected noisy chassis pciefunctions branch to be skipped")
|
||||||
|
|||||||
+62
-113
@@ -66,104 +66,15 @@ func (e *Exporter) ExportCSV(w io.Writer) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CPUs
|
seenCanonical := make(map[string]struct{})
|
||||||
for _, cpu := range e.result.Hardware.CPUs {
|
for _, dev := range canonicalDevicesForExport(e.result.Hardware) {
|
||||||
if !hasUsableSerial(cpu.SerialNumber) {
|
if !hasUsableSerial(dev.SerialNumber) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := writer.Write([]string{
|
serial := strings.TrimSpace(dev.SerialNumber)
|
||||||
cpu.Model,
|
seenCanonical[serial] = struct{}{}
|
||||||
strings.TrimSpace(cpu.SerialNumber),
|
component, manufacturer, location := csvFieldsFromCanonicalDevice(dev)
|
||||||
"",
|
if err := writer.Write([]string{component, serial, manufacturer, location}); err != nil {
|
||||||
"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 {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -173,26 +84,15 @@ func (e *Exporter) ExportCSV(w io.Writer) error {
|
|||||||
if !hasUsableSerial(nic.SerialNumber) {
|
if !hasUsableSerial(nic.SerialNumber) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := writer.Write([]string{
|
serial := strings.TrimSpace(nic.SerialNumber)
|
||||||
nic.Model,
|
if _, ok := seenCanonical[serial]; ok {
|
||||||
strings.TrimSpace(nic.SerialNumber),
|
|
||||||
"",
|
|
||||||
"Network",
|
|
||||||
}); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Power supplies
|
|
||||||
for _, psu := range e.result.Hardware.PowerSupply {
|
|
||||||
if !hasUsableSerial(psu.SerialNumber) {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := writer.Write([]string{
|
if err := writer.Write([]string{
|
||||||
psu.Model,
|
nic.Model,
|
||||||
strings.TrimSpace(psu.SerialNumber),
|
serial,
|
||||||
psu.Vendor,
|
"",
|
||||||
psu.Slot,
|
"Network",
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -221,3 +121,52 @@ func hasUsableSerial(serial string) bool {
|
|||||||
return true
|
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
@@ -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 {
|
if len(result) != 2 {
|
||||||
t.Fatalf("expected 2 CPUs, got %d", len(result))
|
t.Fatalf("expected 2 CPUs, got %d", len(result))
|
||||||
@@ -227,6 +227,9 @@ func TestConvertCPUs(t *testing.T) {
|
|||||||
if result[0].Status != "Unknown" {
|
if result[0].Status != "Unknown" {
|
||||||
t.Errorf("expected Unknown status, got %q", result[0].Status)
|
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) {
|
func TestConvertMemory(t *testing.T) {
|
||||||
@@ -247,17 +250,13 @@ func TestConvertMemory(t *testing.T) {
|
|||||||
|
|
||||||
result := convertMemory(memory, "2026-02-10T15:30:00Z")
|
result := convertMemory(memory, "2026-02-10T15:30:00Z")
|
||||||
|
|
||||||
if len(result) != 2 {
|
if len(result) != 1 {
|
||||||
t.Fatalf("expected 2 memory modules, got %d", len(result))
|
t.Fatalf("expected 1 populated memory module, got %d", len(result))
|
||||||
}
|
}
|
||||||
|
|
||||||
if result[0].Status != "OK" {
|
if result[0].Status != "OK" {
|
||||||
t.Errorf("expected OK status for first module, got %q", result[0].Status)
|
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) {
|
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) {
|
func TestConvertPCIeDevices(t *testing.T) {
|
||||||
hw := &models.HardwareConfig{
|
hw := &models.HardwareConfig{
|
||||||
PCIeDevices: []models.PCIeDevice{
|
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
|
// Should have: 2 PCIe devices + 1 GPU + 1 NIC = 4 total
|
||||||
if len(result) != 4 {
|
if len(result) != 4 {
|
||||||
t.Fatalf("expected 4 PCIe devices total, got %d", len(result))
|
t.Fatalf("expected 4 PCIe devices total, got %d", len(result))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check that serial is empty for second PCIe device (no auto-generation)
|
// Check that serial is generated for second PCIe device
|
||||||
if result[1].SerialNumber != "" {
|
if result[1].SerialNumber != "BOARD-001-PCIE-PCIeCard2" {
|
||||||
t.Errorf("expected empty serial for missing device serial, got %q", result[1].SerialNumber)
|
t.Errorf("expected generated serial for missing device serial, got %q", result[1].SerialNumber)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check GPU was included
|
// Check GPU was included
|
||||||
@@ -346,8 +387,8 @@ func TestConvertPCIeDevices(t *testing.T) {
|
|||||||
for _, dev := range result {
|
for _, dev := range result {
|
||||||
if dev.SerialNumber == "GPU-001" {
|
if dev.SerialNumber == "GPU-001" {
|
||||||
foundGPU = true
|
foundGPU = true
|
||||||
if dev.DeviceClass != "DisplayController" {
|
if dev.DeviceClass != "VideoController" {
|
||||||
t.Errorf("expected GPU device_class DisplayController, got %q", dev.DeviceClass)
|
t.Errorf("expected GPU device_class VideoController, got %q", dev.DeviceClass)
|
||||||
}
|
}
|
||||||
break
|
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 {
|
if len(result) != 1 {
|
||||||
t.Fatalf("expected 1 PCIe device, got %d", len(result))
|
t.Fatalf("expected 1 PCIe device, got %d", len(result))
|
||||||
}
|
}
|
||||||
|
|
||||||
if result[0].SerialNumber != "" {
|
if result[0].SerialNumber != "BOARD-001-PCIE-NVSWITCH1" {
|
||||||
t.Fatalf("expected empty NVSwitch serial, got %q", result[0].SerialNumber)
|
t.Fatalf("expected generated NVSwitch serial, got %q", result[0].SerialNumber)
|
||||||
}
|
}
|
||||||
if result[0].Firmware != "96.10.6D.00.01" {
|
if result[0].Firmware != "96.10.6D.00.01" {
|
||||||
t.Fatalf("expected NVSwitch firmware 96.10.6D.00.01, got %q", result[0].Firmware)
|
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 {
|
if len(result) != 1 {
|
||||||
t.Fatalf("expected only dedicated GPU record without duplicate display PCIe, got %d", len(result))
|
t.Fatalf("expected only dedicated GPU record without duplicate display PCIe, got %d", len(result))
|
||||||
}
|
}
|
||||||
if result[0].DeviceClass != "DisplayController" {
|
if result[0].DeviceClass != "VideoController" {
|
||||||
t.Fatalf("expected GPU record with DisplayController class, got %q", result[0].DeviceClass)
|
t.Fatalf("expected GPU record with VideoController class, got %q", result[0].DeviceClass)
|
||||||
}
|
}
|
||||||
if result[0].Status != "OK" {
|
if result[0].Status != "OK" {
|
||||||
t.Fatalf("expected GPU status OK, got %q", result[0].Status)
|
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 {
|
if len(result) != 1 {
|
||||||
t.Fatalf("expected 1 converted GPU, got %d", len(result))
|
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" {
|
if result[0].StatusHistory[0].ChangedAt != "2026-01-12T15:05:18Z" {
|
||||||
t.Fatalf("unexpected history changed_at: %q", result[0].StatusHistory[0].ChangedAt)
|
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) {
|
func TestConvertPowerSupplies(t *testing.T) {
|
||||||
@@ -518,8 +556,8 @@ func TestSourceTypeOmittedWhenInvalidOrEmpty(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("marshal failed: %v", err)
|
t.Fatalf("marshal failed: %v", err)
|
||||||
}
|
}
|
||||||
if strings.Contains(string(payload), `"source_type"`) {
|
if !strings.Contains(string(payload), `"source_type":"logfile"`) {
|
||||||
t.Fatalf("expected source_type to be omitted for invalid value, got %s", string(payload))
|
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 {
|
if got.StatusCheckedAt != wantTs {
|
||||||
t.Fatalf("expected status_checked_at=%q, got %q", wantTs, got.StatusCheckedAt)
|
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) {
|
func TestConvertToReanimator_FirmwareExcludesDeviceBoundEntries(t *testing.T) {
|
||||||
@@ -698,6 +733,9 @@ func TestConvertToReanimator_FirmwareExcludesDeviceBoundEntries(t *testing.T) {
|
|||||||
Filename: "fw-filter-test.json",
|
Filename: "fw-filter-test.json",
|
||||||
Hardware: &models.HardwareConfig{
|
Hardware: &models.HardwareConfig{
|
||||||
BoardInfo: models.BoardInfo{SerialNumber: "BOARD-001"},
|
BoardInfo: models.BoardInfo{SerialNumber: "BOARD-001"},
|
||||||
|
CPUs: []models.CPU{
|
||||||
|
{Socket: 0, Model: "Intel Xeon Gold"},
|
||||||
|
},
|
||||||
Firmware: []models.FirmwareInfo{
|
Firmware: []models.FirmwareInfo{
|
||||||
{DeviceName: "BIOS", Version: "1.0.0"},
|
{DeviceName: "BIOS", Version: "1.0.0"},
|
||||||
{DeviceName: "BMC", Version: "2.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 {
|
if _, exists := got["NVSwitch NVSWITCH0 (965-25612-0002-000)"]; exists {
|
||||||
t.Fatalf("expected NVSwitch firmware to be excluded from hardware.firmware")
|
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) {
|
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{
|
input := &models.AnalysisResult{
|
||||||
Filename: "vitals.json",
|
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{
|
Hardware: &models.HardwareConfig{
|
||||||
BoardInfo: models.BoardInfo{SerialNumber: "BOARD-001"},
|
BoardInfo: models.BoardInfo{SerialNumber: "BOARD-001"},
|
||||||
Devices: []models.HardwareDevice{
|
Devices: []models.HardwareDevice{
|
||||||
@@ -786,11 +932,6 @@ func TestConvertToReanimator_BindsDeviceVitals(t *testing.T) {
|
|||||||
Model: "B200 180GB HBM3e",
|
Model: "B200 180GB HBM3e",
|
||||||
SerialNumber: "GPU-001",
|
SerialNumber: "GPU-001",
|
||||||
BDF: "0000:17:00.0",
|
BDF: "0000:17:00.0",
|
||||||
Details: map[string]any{
|
|
||||||
"temperature": 71,
|
|
||||||
"power": 350,
|
|
||||||
"voltage": 12.2,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Kind: models.DeviceKindPSU,
|
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))
|
t.Fatalf("expected one pcie device, got %d", len(out.Hardware.PCIeDevices))
|
||||||
}
|
}
|
||||||
pcie := out.Hardware.PCIeDevices[0]
|
pcie := out.Hardware.PCIeDevices[0]
|
||||||
if pcie.TemperatureC != 71 {
|
if pcie.TemperatureC != 0 {
|
||||||
t.Fatalf("expected GPU temperature 71C, got %d", pcie.TemperatureC)
|
t.Fatalf("expected canonical GPU telemetry to stay off the component unless sourced from details/gpu path, got %.2f", 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 len(out.Hardware.PowerSupplies) != 1 {
|
if len(out.Hardware.PowerSupplies) != 1 {
|
||||||
t.Fatalf("expected one PSU, got %d", len(out.Hardware.PowerSupplies))
|
t.Fatalf("expected one PSU, got %d", len(out.Hardware.PowerSupplies))
|
||||||
}
|
}
|
||||||
psu := out.Hardware.PowerSupplies[0]
|
psu := out.Hardware.PowerSupplies[0]
|
||||||
|
if psu.InputPowerW != 1400 {
|
||||||
|
t.Fatalf("expected PSU input power 1400W, got %.2f", psu.InputPowerW)
|
||||||
|
}
|
||||||
if psu.TemperatureC != 44 {
|
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{
|
input := &models.AnalysisResult{
|
||||||
Filename: "dedup-vitals.json",
|
Filename: "dedup-vitals.json",
|
||||||
Hardware: &models.HardwareConfig{
|
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))
|
t.Fatalf("expected deduped one pcie entry, got %d", len(out.Hardware.PCIeDevices))
|
||||||
}
|
}
|
||||||
got := out.Hardware.PCIeDevices[0]
|
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 {
|
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 {
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ type ReanimatorHardware struct {
|
|||||||
Storage []ReanimatorStorage `json:"storage,omitempty"`
|
Storage []ReanimatorStorage `json:"storage,omitempty"`
|
||||||
PCIeDevices []ReanimatorPCIe `json:"pcie_devices,omitempty"`
|
PCIeDevices []ReanimatorPCIe `json:"pcie_devices,omitempty"`
|
||||||
PowerSupplies []ReanimatorPSU `json:"power_supplies,omitempty"`
|
PowerSupplies []ReanimatorPSU `json:"power_supplies,omitempty"`
|
||||||
|
Sensors *ReanimatorSensors `json:"sensors,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReanimatorBoard represents motherboard/server information
|
// ReanimatorBoard represents motherboard/server information
|
||||||
@@ -36,11 +37,6 @@ type ReanimatorFirmware struct {
|
|||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ReanimatorStatusAtCollection struct {
|
|
||||||
Status string `json:"status"`
|
|
||||||
At string `json:"at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ReanimatorStatusHistoryEntry struct {
|
type ReanimatorStatusHistoryEntry struct {
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
ChangedAt string `json:"changed_at"`
|
ChangedAt string `json:"changed_at"`
|
||||||
@@ -50,16 +46,25 @@ type ReanimatorStatusHistoryEntry struct {
|
|||||||
// ReanimatorCPU represents processor information
|
// ReanimatorCPU represents processor information
|
||||||
type ReanimatorCPU struct {
|
type ReanimatorCPU struct {
|
||||||
Socket int `json:"socket"`
|
Socket int `json:"socket"`
|
||||||
Model string `json:"model"`
|
Model string `json:"model,omitempty"`
|
||||||
Cores int `json:"cores,omitempty"`
|
Cores int `json:"cores,omitempty"`
|
||||||
Threads int `json:"threads,omitempty"`
|
Threads int `json:"threads,omitempty"`
|
||||||
FrequencyMHz int `json:"frequency_mhz,omitempty"`
|
FrequencyMHz int `json:"frequency_mhz,omitempty"`
|
||||||
MaxFrequencyMHz int `json:"max_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"`
|
Manufacturer string `json:"manufacturer,omitempty"`
|
||||||
Status string `json:"status,omitempty"`
|
Status string `json:"status,omitempty"`
|
||||||
StatusCheckedAt string `json:"status_checked_at,omitempty"`
|
StatusCheckedAt string `json:"status_checked_at,omitempty"`
|
||||||
StatusChangedAt string `json:"status_changed_at,omitempty"`
|
StatusChangedAt string `json:"status_changed_at,omitempty"`
|
||||||
StatusAtCollect *ReanimatorStatusAtCollection `json:"status_at_collection,omitempty"`
|
|
||||||
StatusHistory []ReanimatorStatusHistoryEntry `json:"status_history,omitempty"`
|
StatusHistory []ReanimatorStatusHistoryEntry `json:"status_history,omitempty"`
|
||||||
ErrorDescription string `json:"error_description,omitempty"`
|
ErrorDescription string `json:"error_description,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -68,18 +73,25 @@ type ReanimatorCPU struct {
|
|||||||
type ReanimatorMemory struct {
|
type ReanimatorMemory struct {
|
||||||
Slot string `json:"slot"`
|
Slot string `json:"slot"`
|
||||||
Location string `json:"location,omitempty"`
|
Location string `json:"location,omitempty"`
|
||||||
Present bool `json:"present"`
|
Present *bool `json:"present,omitempty"`
|
||||||
SizeMB int `json:"size_mb,omitempty"`
|
SizeMB int `json:"size_mb,omitempty"`
|
||||||
Type string `json:"type,omitempty"`
|
Type string `json:"type,omitempty"`
|
||||||
MaxSpeedMHz int `json:"max_speed_mhz,omitempty"`
|
MaxSpeedMHz int `json:"max_speed_mhz,omitempty"`
|
||||||
CurrentSpeedMHz int `json:"current_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"`
|
Manufacturer string `json:"manufacturer,omitempty"`
|
||||||
SerialNumber string `json:"serial_number,omitempty"`
|
SerialNumber string `json:"serial_number,omitempty"`
|
||||||
PartNumber string `json:"part_number,omitempty"`
|
PartNumber string `json:"part_number,omitempty"`
|
||||||
Status string `json:"status,omitempty"`
|
Status string `json:"status,omitempty"`
|
||||||
StatusCheckedAt string `json:"status_checked_at,omitempty"`
|
StatusCheckedAt string `json:"status_checked_at,omitempty"`
|
||||||
StatusChangedAt string `json:"status_changed_at,omitempty"`
|
StatusChangedAt string `json:"status_changed_at,omitempty"`
|
||||||
StatusAtCollect *ReanimatorStatusAtCollection `json:"status_at_collection,omitempty"`
|
|
||||||
StatusHistory []ReanimatorStatusHistoryEntry `json:"status_history,omitempty"`
|
StatusHistory []ReanimatorStatusHistoryEntry `json:"status_history,omitempty"`
|
||||||
ErrorDescription string `json:"error_description,omitempty"`
|
ErrorDescription string `json:"error_description,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -94,11 +106,25 @@ type ReanimatorStorage struct {
|
|||||||
Manufacturer string `json:"manufacturer,omitempty"`
|
Manufacturer string `json:"manufacturer,omitempty"`
|
||||||
Firmware string `json:"firmware,omitempty"`
|
Firmware string `json:"firmware,omitempty"`
|
||||||
Interface string `json:"interface,omitempty"`
|
Interface string `json:"interface,omitempty"`
|
||||||
Present bool `json:"present"`
|
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"`
|
Status string `json:"status,omitempty"`
|
||||||
StatusCheckedAt string `json:"status_checked_at,omitempty"`
|
StatusCheckedAt string `json:"status_checked_at,omitempty"`
|
||||||
StatusChangedAt string `json:"status_changed_at,omitempty"`
|
StatusChangedAt string `json:"status_changed_at,omitempty"`
|
||||||
StatusAtCollect *ReanimatorStatusAtCollection `json:"status_at_collection,omitempty"`
|
|
||||||
StatusHistory []ReanimatorStatusHistoryEntry `json:"status_history,omitempty"`
|
StatusHistory []ReanimatorStatusHistoryEntry `json:"status_history,omitempty"`
|
||||||
ErrorDescription string `json:"error_description,omitempty"`
|
ErrorDescription string `json:"error_description,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -108,6 +134,24 @@ type ReanimatorPCIe struct {
|
|||||||
Slot string `json:"slot"`
|
Slot string `json:"slot"`
|
||||||
VendorID int `json:"vendor_id,omitempty"`
|
VendorID int `json:"vendor_id,omitempty"`
|
||||||
DeviceID int `json:"device_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"`
|
BDF string `json:"bdf,omitempty"`
|
||||||
DeviceClass string `json:"device_class,omitempty"`
|
DeviceClass string `json:"device_class,omitempty"`
|
||||||
Manufacturer string `json:"manufacturer,omitempty"`
|
Manufacturer string `json:"manufacturer,omitempty"`
|
||||||
@@ -116,15 +160,13 @@ type ReanimatorPCIe struct {
|
|||||||
LinkSpeed string `json:"link_speed,omitempty"`
|
LinkSpeed string `json:"link_speed,omitempty"`
|
||||||
MaxLinkWidth int `json:"max_link_width,omitempty"`
|
MaxLinkWidth int `json:"max_link_width,omitempty"`
|
||||||
MaxLinkSpeed string `json:"max_link_speed,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"`
|
SerialNumber string `json:"serial_number,omitempty"`
|
||||||
Firmware string `json:"firmware,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"`
|
Status string `json:"status,omitempty"`
|
||||||
StatusCheckedAt string `json:"status_checked_at,omitempty"`
|
StatusCheckedAt string `json:"status_checked_at,omitempty"`
|
||||||
StatusChangedAt string `json:"status_changed_at,omitempty"`
|
StatusChangedAt string `json:"status_changed_at,omitempty"`
|
||||||
StatusAtCollect *ReanimatorStatusAtCollection `json:"status_at_collection,omitempty"`
|
|
||||||
StatusHistory []ReanimatorStatusHistoryEntry `json:"status_history,omitempty"`
|
StatusHistory []ReanimatorStatusHistoryEntry `json:"status_history,omitempty"`
|
||||||
ErrorDescription string `json:"error_description,omitempty"`
|
ErrorDescription string `json:"error_description,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -132,7 +174,7 @@ type ReanimatorPCIe struct {
|
|||||||
// ReanimatorPSU represents a power supply unit
|
// ReanimatorPSU represents a power supply unit
|
||||||
type ReanimatorPSU struct {
|
type ReanimatorPSU struct {
|
||||||
Slot string `json:"slot"`
|
Slot string `json:"slot"`
|
||||||
Present bool `json:"present"`
|
Present *bool `json:"present,omitempty"`
|
||||||
Model string `json:"model,omitempty"`
|
Model string `json:"model,omitempty"`
|
||||||
Vendor string `json:"vendor,omitempty"`
|
Vendor string `json:"vendor,omitempty"`
|
||||||
WattageW int `json:"wattage_w,omitempty"`
|
WattageW int `json:"wattage_w,omitempty"`
|
||||||
@@ -141,13 +183,54 @@ type ReanimatorPSU struct {
|
|||||||
Firmware string `json:"firmware,omitempty"`
|
Firmware string `json:"firmware,omitempty"`
|
||||||
Status string `json:"status,omitempty"`
|
Status string `json:"status,omitempty"`
|
||||||
InputType string `json:"input_type,omitempty"`
|
InputType string `json:"input_type,omitempty"`
|
||||||
InputPowerW int `json:"input_power_w,omitempty"`
|
InputPowerW float64 `json:"input_power_w,omitempty"`
|
||||||
OutputPowerW int `json:"output_power_w,omitempty"`
|
OutputPowerW float64 `json:"output_power_w,omitempty"`
|
||||||
InputVoltage float64 `json:"input_voltage,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"`
|
StatusCheckedAt string `json:"status_checked_at,omitempty"`
|
||||||
StatusChangedAt string `json:"status_changed_at,omitempty"`
|
StatusChangedAt string `json:"status_changed_at,omitempty"`
|
||||||
StatusAtCollect *ReanimatorStatusAtCollection `json:"status_at_collection,omitempty"`
|
|
||||||
StatusHistory []ReanimatorStatusHistoryEntry `json:"status_history,omitempty"`
|
StatusHistory []ReanimatorStatusHistoryEntry `json:"status_history,omitempty"`
|
||||||
ErrorDescription string `json:"error_description,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"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -146,6 +146,8 @@ type HardwareDevice struct {
|
|||||||
OutputPowerW int `json:"output_power_w,omitempty"`
|
OutputPowerW int `json:"output_power_w,omitempty"`
|
||||||
InputVoltage float64 `json:"input_voltage,omitempty"`
|
InputVoltage float64 `json:"input_voltage,omitempty"`
|
||||||
TemperatureC int `json:"temperature_c,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"`
|
Status string `json:"status,omitempty"`
|
||||||
|
|
||||||
StatusCheckedAt *time.Time `json:"status_checked_at,omitempty"`
|
StatusCheckedAt *time.Time `json:"status_checked_at,omitempty"`
|
||||||
@@ -199,6 +201,7 @@ type CPU struct {
|
|||||||
StatusAtCollect *StatusAtCollection `json:"status_at_collection,omitempty"`
|
StatusAtCollect *StatusAtCollection `json:"status_at_collection,omitempty"`
|
||||||
StatusHistory []StatusHistoryEntry `json:"status_history,omitempty"`
|
StatusHistory []StatusHistoryEntry `json:"status_history,omitempty"`
|
||||||
ErrorDescription string `json:"error_description,omitempty"`
|
ErrorDescription string `json:"error_description,omitempty"`
|
||||||
|
Details map[string]any `json:"details,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// MemoryDIMM represents a memory module
|
// MemoryDIMM represents a memory module
|
||||||
@@ -223,6 +226,7 @@ type MemoryDIMM struct {
|
|||||||
StatusAtCollect *StatusAtCollection `json:"status_at_collection,omitempty"`
|
StatusAtCollect *StatusAtCollection `json:"status_at_collection,omitempty"`
|
||||||
StatusHistory []StatusHistoryEntry `json:"status_history,omitempty"`
|
StatusHistory []StatusHistoryEntry `json:"status_history,omitempty"`
|
||||||
ErrorDescription string `json:"error_description,omitempty"`
|
ErrorDescription string `json:"error_description,omitempty"`
|
||||||
|
Details map[string]any `json:"details,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Storage represents a storage device
|
// Storage represents a storage device
|
||||||
@@ -239,7 +243,9 @@ type Storage struct {
|
|||||||
Present bool `json:"present"`
|
Present bool `json:"present"`
|
||||||
Location string `json:"location,omitempty"` // Front/Rear
|
Location string `json:"location,omitempty"` // Front/Rear
|
||||||
BackplaneID int `json:"backplane_id,omitempty"`
|
BackplaneID int `json:"backplane_id,omitempty"`
|
||||||
|
RemainingEndurancePct *int `json:"remaining_endurance_pct,omitempty"` // 0-100 %; nil = not reported
|
||||||
Status string `json:"status,omitempty"`
|
Status string `json:"status,omitempty"`
|
||||||
|
Details map[string]any `json:"details,omitempty"`
|
||||||
|
|
||||||
StatusCheckedAt *time.Time `json:"status_checked_at,omitempty"`
|
StatusCheckedAt *time.Time `json:"status_checked_at,omitempty"`
|
||||||
StatusChangedAt *time.Time `json:"status_changed_at,omitempty"`
|
StatusChangedAt *time.Time `json:"status_changed_at,omitempty"`
|
||||||
@@ -277,6 +283,7 @@ type PCIeDevice struct {
|
|||||||
PartNumber string `json:"part_number,omitempty"`
|
PartNumber string `json:"part_number,omitempty"`
|
||||||
SerialNumber string `json:"serial_number,omitempty"`
|
SerialNumber string `json:"serial_number,omitempty"`
|
||||||
MACAddresses []string `json:"mac_addresses,omitempty"`
|
MACAddresses []string `json:"mac_addresses,omitempty"`
|
||||||
|
NUMANode int `json:"numa_node,omitempty"` // 0 = not reported/N/A
|
||||||
Status string `json:"status,omitempty"`
|
Status string `json:"status,omitempty"`
|
||||||
|
|
||||||
StatusCheckedAt *time.Time `json:"status_checked_at,omitempty"`
|
StatusCheckedAt *time.Time `json:"status_checked_at,omitempty"`
|
||||||
@@ -284,6 +291,7 @@ type PCIeDevice struct {
|
|||||||
StatusAtCollect *StatusAtCollection `json:"status_at_collection,omitempty"`
|
StatusAtCollect *StatusAtCollection `json:"status_at_collection,omitempty"`
|
||||||
StatusHistory []StatusHistoryEntry `json:"status_history,omitempty"`
|
StatusHistory []StatusHistoryEntry `json:"status_history,omitempty"`
|
||||||
ErrorDescription string `json:"error_description,omitempty"`
|
ErrorDescription string `json:"error_description,omitempty"`
|
||||||
|
Details map[string]any `json:"details,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NIC represents a network interface card
|
// NIC represents a network interface card
|
||||||
@@ -314,6 +322,7 @@ type PSU struct {
|
|||||||
InputVoltage float64 `json:"input_voltage,omitempty"`
|
InputVoltage float64 `json:"input_voltage,omitempty"`
|
||||||
OutputVoltage float64 `json:"output_voltage,omitempty"`
|
OutputVoltage float64 `json:"output_voltage,omitempty"`
|
||||||
TemperatureC int `json:"temperature_c,omitempty"`
|
TemperatureC int `json:"temperature_c,omitempty"`
|
||||||
|
Details map[string]any `json:"details,omitempty"`
|
||||||
|
|
||||||
StatusCheckedAt *time.Time `json:"status_checked_at,omitempty"`
|
StatusCheckedAt *time.Time `json:"status_checked_at,omitempty"`
|
||||||
StatusChangedAt *time.Time `json:"status_changed_at,omitempty"`
|
StatusChangedAt *time.Time `json:"status_changed_at,omitempty"`
|
||||||
@@ -358,6 +367,7 @@ type GPU struct {
|
|||||||
StatusAtCollect *StatusAtCollection `json:"status_at_collection,omitempty"`
|
StatusAtCollect *StatusAtCollection `json:"status_at_collection,omitempty"`
|
||||||
StatusHistory []StatusHistoryEntry `json:"status_history,omitempty"`
|
StatusHistory []StatusHistoryEntry `json:"status_history,omitempty"`
|
||||||
ErrorDescription string `json:"error_description,omitempty"`
|
ErrorDescription string `json:"error_description,omitempty"`
|
||||||
|
Details map[string]any `json:"details,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NetworkAdapter represents a network adapter with detailed info
|
// NetworkAdapter represents a network adapter with detailed info
|
||||||
@@ -365,6 +375,7 @@ type NetworkAdapter struct {
|
|||||||
Slot string `json:"slot"`
|
Slot string `json:"slot"`
|
||||||
Location string `json:"location"`
|
Location string `json:"location"`
|
||||||
Present bool `json:"present"`
|
Present bool `json:"present"`
|
||||||
|
BDF string `json:"bdf,omitempty"`
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"`
|
||||||
Vendor string `json:"vendor,omitempty"`
|
Vendor string `json:"vendor,omitempty"`
|
||||||
@@ -376,6 +387,11 @@ type NetworkAdapter struct {
|
|||||||
PortCount int `json:"port_count,omitempty"`
|
PortCount int `json:"port_count,omitempty"`
|
||||||
PortType string `json:"port_type,omitempty"`
|
PortType string `json:"port_type,omitempty"`
|
||||||
MACAddresses []string `json:"mac_addresses,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"`
|
Status string `json:"status,omitempty"`
|
||||||
|
|
||||||
StatusCheckedAt *time.Time `json:"status_checked_at,omitempty"`
|
StatusCheckedAt *time.Time `json:"status_checked_at,omitempty"`
|
||||||
@@ -383,4 +399,5 @@ type NetworkAdapter struct {
|
|||||||
StatusAtCollect *StatusAtCollection `json:"status_at_collection,omitempty"`
|
StatusAtCollect *StatusAtCollection `json:"status_at_collection,omitempty"`
|
||||||
StatusHistory []StatusHistoryEntry `json:"status_history,omitempty"`
|
StatusHistory []StatusHistoryEntry `json:"status_history,omitempty"`
|
||||||
ErrorDescription string `json:"error_description,omitempty"`
|
ErrorDescription string `json:"error_description,omitempty"`
|
||||||
|
Details map[string]any `json:"details,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
+70
-11
@@ -16,6 +16,7 @@ import (
|
|||||||
|
|
||||||
"git.mchus.pro/mchus/logpile/internal/models"
|
"git.mchus.pro/mchus/logpile/internal/models"
|
||||||
"git.mchus.pro/mchus/logpile/internal/parser"
|
"git.mchus.pro/mchus/logpile/internal/parser"
|
||||||
|
"git.mchus.pro/mchus/logpile/internal/parser/vendors/pciids"
|
||||||
)
|
)
|
||||||
|
|
||||||
const parserVersion = "3.0"
|
const parserVersion = "3.0"
|
||||||
@@ -199,7 +200,7 @@ func parseDCIMViewXML(content []byte, result *models.AnalysisResult) {
|
|||||||
parsePowerSupplyView(props, result)
|
parsePowerSupplyView(props, result)
|
||||||
case "DCIM_PCIDeviceView":
|
case "DCIM_PCIDeviceView":
|
||||||
parsePCIeDeviceView(props, result)
|
parsePCIeDeviceView(props, result)
|
||||||
case "DCIM_NICView":
|
case "DCIM_NICView", "DCIM_InfiniBandView":
|
||||||
parseNICView(props, result)
|
parseNICView(props, result)
|
||||||
case "DCIM_VideoView":
|
case "DCIM_VideoView":
|
||||||
parseVideoView(props, result)
|
parseVideoView(props, result)
|
||||||
@@ -374,6 +375,10 @@ func parsePhysicalDiskView(props map[string]string, result *models.AnalysisResul
|
|||||||
Location: strings.TrimSpace(props["devicedescription"]),
|
Location: strings.TrimSpace(props["devicedescription"]),
|
||||||
Status: normalizeStatus(firstNonEmpty(props["raidstatus"], props["primarystatus"])),
|
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)
|
result.Hardware.Storage = append(result.Hardware.Storage, st)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -437,11 +442,18 @@ var pcieFQDDNoisePrefix = []string{
|
|||||||
"SMBus.Embedded.",
|
"SMBus.Embedded.",
|
||||||
"AHCI.Embedded.",
|
"AHCI.Embedded.",
|
||||||
"Video.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) {
|
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"]))
|
fqdd := strings.TrimSpace(firstNonEmpty(props["fqdd"], props["instanceid"]))
|
||||||
if desc == "" && fqdd == "" {
|
if desc == "" && fqdd == "" {
|
||||||
return
|
return
|
||||||
@@ -451,14 +463,26 @@ func parsePCIeDeviceView(props map[string]string, result *models.AnalysisResult)
|
|||||||
return
|
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{
|
p := models.PCIeDevice{
|
||||||
Slot: fqdd,
|
Slot: fqdd,
|
||||||
Description: desc,
|
Description: desc,
|
||||||
VendorID: parseHexOrDec(firstNonEmpty(props["pcivendorid"], props["vendorid"])),
|
VendorID: vendorID,
|
||||||
DeviceID: parseHexOrDec(firstNonEmpty(props["pcideviceid"], props["deviceid"])),
|
DeviceID: deviceID,
|
||||||
BDF: formatBDF(props["busnumber"], props["devicenumber"], props["functionnumber"]),
|
BDF: formatBDF(props["busnumber"], props["devicenumber"], props["functionnumber"]),
|
||||||
DeviceClass: strings.TrimSpace(props["databuswidth"]),
|
Manufacturer: manufacturer,
|
||||||
Manufacturer: strings.TrimSpace(props["manufacturer"]),
|
NUMANode: parseIntLoose(props["cpuaffinity"]),
|
||||||
Status: normalizeStatus(props["primarystatus"]),
|
Status: normalizeStatus(props["primarystatus"]),
|
||||||
}
|
}
|
||||||
result.Hardware.PCIeDevices = append(result.Hardware.PCIeDevices, p)
|
result.Hardware.PCIeDevices = append(result.Hardware.PCIeDevices, p)
|
||||||
@@ -471,15 +495,31 @@ func parseNICView(props map[string]string, result *models.AnalysisResult) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
mac := strings.TrimSpace(firstNonEmpty(props["currentmacaddress"], props["permanentmacaddress"]))
|
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{
|
n := models.NetworkAdapter{
|
||||||
Slot: fqdd,
|
Slot: fqdd,
|
||||||
Location: strings.TrimSpace(firstNonEmpty(props["devicedescription"], fqdd)),
|
Location: strings.TrimSpace(firstNonEmpty(props["devicedescription"], fqdd)),
|
||||||
Present: true,
|
Present: true,
|
||||||
Model: model,
|
Model: model,
|
||||||
Description: strings.TrimSpace(props["protocol"]),
|
Description: strings.TrimSpace(props["protocol"]),
|
||||||
Vendor: strings.TrimSpace(firstNonEmpty(props["vendorname"], props["manufacturer"])),
|
Vendor: vendor,
|
||||||
VendorID: parseHexOrDec(firstNonEmpty(props["pcivendorid"], props["vendorid"])),
|
VendorID: vendorID,
|
||||||
DeviceID: parseHexOrDec(firstNonEmpty(props["pcideviceid"], props["deviceid"])),
|
DeviceID: deviceID,
|
||||||
SerialNumber: strings.TrimSpace(props["serialnumber"]),
|
SerialNumber: strings.TrimSpace(props["serialnumber"]),
|
||||||
PartNumber: strings.TrimSpace(props["partnumber"]),
|
PartNumber: strings.TrimSpace(props["partnumber"]),
|
||||||
Firmware: strings.TrimSpace(firstNonEmpty(
|
Firmware: strings.TrimSpace(firstNonEmpty(
|
||||||
@@ -489,6 +529,7 @@ func parseNICView(props map[string]string, result *models.AnalysisResult) {
|
|||||||
props["controllerbiosversion"],
|
props["controllerbiosversion"],
|
||||||
)),
|
)),
|
||||||
PortCount: inferPortCountFromFQDD(fqdd),
|
PortCount: inferPortCountFromFQDD(fqdd),
|
||||||
|
NUMANode: parseIntLoose(props["cpuaffinity"]),
|
||||||
Status: normalizeStatus(props["primarystatus"]),
|
Status: normalizeStatus(props["primarystatus"]),
|
||||||
}
|
}
|
||||||
if mac != "" {
|
if mac != "" {
|
||||||
@@ -542,10 +583,11 @@ func parseControllerView(props map[string]string, result *models.AnalysisResult)
|
|||||||
DeviceClass: "storage-controller",
|
DeviceClass: "storage-controller",
|
||||||
Manufacturer: strings.TrimSpace(firstNonEmpty(props["devicecardmanufacturer"], props["manufacturer"])),
|
Manufacturer: strings.TrimSpace(firstNonEmpty(props["devicecardmanufacturer"], props["manufacturer"])),
|
||||||
PartNumber: strings.TrimSpace(firstNonEmpty(props["ppid"], props["boardpartnumber"])),
|
PartNumber: strings.TrimSpace(firstNonEmpty(props["ppid"], props["boardpartnumber"])),
|
||||||
|
NUMANode: parseIntLoose(props["cpuaffinity"]),
|
||||||
Status: normalizeStatus(props["primarystatus"]),
|
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) {
|
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.Location, src.Location)
|
||||||
setIfEmpty(&dst.Status, src.Status)
|
setIfEmpty(&dst.Status, src.Status)
|
||||||
|
dst.Details = mergeDellDetails(dst.Details, src.Details)
|
||||||
}
|
}
|
||||||
|
|
||||||
func dedupeVolumes(items []models.StorageVolume) []models.StorageVolume {
|
func dedupeVolumes(items []models.StorageVolume) []models.StorageVolume {
|
||||||
@@ -1202,6 +1245,22 @@ func mergePSU(dst *models.PSU, src models.PSU) {
|
|||||||
dst.InputVoltage = src.InputVoltage
|
dst.InputVoltage = src.InputVoltage
|
||||||
}
|
}
|
||||||
setIfEmpty(&dst.InputType, src.InputType)
|
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 {
|
func dedupeNetworkAdapters(items []models.NetworkAdapter) []models.NetworkAdapter {
|
||||||
|
|||||||
+256
@@ -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 {
|
func makeZipArchive(t *testing.T, files map[string][]byte) []byte {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
|
|||||||
+17
@@ -3024,6 +3024,7 @@ func mergeStorage(dst *models.Storage, src models.Storage) {
|
|||||||
}
|
}
|
||||||
setStorageString(&dst.Location, src.Location)
|
setStorageString(&dst.Location, src.Location)
|
||||||
setStorageString(&dst.Status, normalizeStorageStatus(src.Status, src.Present || dst.Present))
|
setStorageString(&dst.Status, normalizeStorageStatus(src.Status, src.Present || dst.Present))
|
||||||
|
dst.Details = mergeH3CDetails(dst.Details, src.Details)
|
||||||
}
|
}
|
||||||
|
|
||||||
func setStorageString(dst *string, value string) {
|
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.PartNumber, src.PartNumber)
|
||||||
setStorageString(&dst.Firmware, src.Firmware)
|
setStorageString(&dst.Firmware, src.Firmware)
|
||||||
setStorageString(&dst.Status, src.Status)
|
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 {
|
func dedupeVolumes(items []models.StorageVolume) []models.StorageVolume {
|
||||||
|
|||||||
+279
-11
@@ -100,10 +100,18 @@ func parseMemoryInfo(text string, hw *models.HardwareConfig) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replace memory data with detailed info from component.log
|
var merged []models.MemoryDIMM
|
||||||
hw.Memory = nil
|
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 {
|
for _, mem := range memInfo.MemModules {
|
||||||
hw.Memory = append(hw.Memory, models.MemoryDIMM{
|
item := models.MemoryDIMM{
|
||||||
Slot: mem.MemModSlot,
|
Slot: mem.MemModSlot,
|
||||||
Location: mem.MemModSlot,
|
Location: mem.MemModSlot,
|
||||||
Present: mem.MemModStatus == 1 && mem.MemModSize > 0,
|
Present: mem.MemModStatus == 1 && mem.MemModSize > 0,
|
||||||
@@ -117,8 +125,18 @@ func parseMemoryInfo(text string, hw *models.HardwareConfig) {
|
|||||||
PartNumber: strings.TrimSpace(mem.MemModPartNum),
|
PartNumber: strings.TrimSpace(mem.MemModPartNum),
|
||||||
Status: mem.Status,
|
Status: mem.Status,
|
||||||
Ranks: mem.MemModRanks,
|
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
|
// PSURESTInfo represents the RESTful PSU info structure
|
||||||
@@ -159,10 +177,18 @@ func parsePSUInfo(text string, hw *models.HardwareConfig) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear existing PSU data and populate with RESTful data
|
var merged []models.PSU
|
||||||
hw.PowerSupply = nil
|
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 {
|
for _, psu := range psuInfo.PowerSupplies {
|
||||||
hw.PowerSupply = append(hw.PowerSupply, models.PSU{
|
item := models.PSU{
|
||||||
Slot: fmt.Sprintf("PSU%d", psu.ID),
|
Slot: fmt.Sprintf("PSU%d", psu.ID),
|
||||||
Present: psu.Present == 1,
|
Present: psu.Present == 1,
|
||||||
Model: strings.TrimSpace(psu.Model),
|
Model: strings.TrimSpace(psu.Model),
|
||||||
@@ -178,8 +204,18 @@ func parsePSUInfo(text string, hw *models.HardwareConfig) {
|
|||||||
InputVoltage: psu.PSInVolt,
|
InputVoltage: psu.PSInVolt,
|
||||||
OutputVoltage: psu.PSOutVolt,
|
OutputVoltage: psu.PSOutVolt,
|
||||||
TemperatureC: psu.PSUMaxTemp,
|
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
|
// HDDRESTInfo represents the RESTful HDD info structure
|
||||||
@@ -357,7 +393,16 @@ func parseNetworkAdapterInfo(text string, hw *models.HardwareConfig) {
|
|||||||
return
|
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 {
|
for _, adapter := range netInfo.SysAdapters {
|
||||||
var macs []string
|
var macs []string
|
||||||
for _, port := range adapter.Ports {
|
for _, port := range adapter.Ports {
|
||||||
@@ -377,7 +422,7 @@ func parseNetworkAdapterInfo(text string, hw *models.HardwareConfig) {
|
|||||||
vendor = normalizeModelLabel(pciids.VendorName(adapter.VendorID))
|
vendor = normalizeModelLabel(pciids.VendorName(adapter.VendorID))
|
||||||
}
|
}
|
||||||
|
|
||||||
hw.NetworkAdapters = append(hw.NetworkAdapters, models.NetworkAdapter{
|
item := models.NetworkAdapter{
|
||||||
Slot: fmt.Sprintf("Slot %d", adapter.Slot),
|
Slot: fmt.Sprintf("Slot %d", adapter.Slot),
|
||||||
Location: adapter.Location,
|
Location: adapter.Location,
|
||||||
Present: adapter.Present == 1,
|
Present: adapter.Present == 1,
|
||||||
@@ -392,8 +437,231 @@ func parseNetworkAdapterInfo(text string, hw *models.HardwareConfig) {
|
|||||||
PortType: adapter.PortType,
|
PortType: adapter.PortType,
|
||||||
MACAddresses: macs,
|
MACAddresses: macs,
|
||||||
Status: adapter.Status,
|
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 {
|
func parseFanSensors(text string) []models.SensorReading {
|
||||||
|
|||||||
@@ -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) {
|
func TestParseComponentLogSensors_ExtractsFanBackplaneAndPSUSummary(t *testing.T) {
|
||||||
text := `RESTful PSU info:
|
text := `RESTful PSU info:
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user