Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e9af89c46 | ||
|
|
db74df9994 | ||
|
|
bb82387d48 | ||
|
|
475f6ac472 | ||
|
|
93ce676f04 | ||
|
|
c47c34fd11 | ||
|
|
d8c3256e41 | ||
|
|
1b2d978d29 | ||
|
|
0f310d57c4 | ||
|
|
3547ef9083 | ||
|
|
99f0d6217c | ||
|
|
8acbba3cc9 | ||
|
|
8942991f0c | ||
|
|
9b71c4a95f | ||
|
|
125f77ef69 | ||
|
|
063b08d5fb | ||
|
|
e3ff1745fc | ||
|
|
96e65d8f65 |
2
bible
2
bible
Submodule bible updated: 0c829182a1...52444350c1
@@ -27,11 +27,14 @@ All modes converge on the same normalized hardware model and exporter pipeline.
|
||||
## Current vendor coverage
|
||||
|
||||
- Dell TSR
|
||||
- Reanimator Easy Bee support bundles
|
||||
- H3C SDS G5/G6
|
||||
- Inspur / Kaytus
|
||||
- HPE iLO AHS
|
||||
- NVIDIA HGX Field Diagnostics
|
||||
- NVIDIA Bug Report
|
||||
- Unraid
|
||||
- xFusion iBMC dump / file export
|
||||
- XigmaNAS
|
||||
- Generic fallback parser
|
||||
|
||||
|
||||
@@ -100,6 +100,13 @@ Live Redfish collection must expose profile-match diagnostics:
|
||||
- the collect page should render active modules as chips from structured status data, not by
|
||||
parsing log lines
|
||||
|
||||
Profile matching may use stable platform grammar signals in addition to vendor strings:
|
||||
- discovered member/resource naming from lightweight discovery collections
|
||||
- firmware inventory member IDs
|
||||
- OEM action names and linked target paths embedded in discovery documents
|
||||
- replay-only snapshot hints such as OEM assembly/type markers when they are present in
|
||||
`raw_payloads.redfish_tree`
|
||||
|
||||
On replay, profile-derived analysis directives may enable vendor-specific inventory linking
|
||||
helpers such as processor-GPU fallback, chassis-ID alias resolution, and bounded storage recovery.
|
||||
Replay should now resolve a structured analysis plan inside `redfishprofile/`, analogous to the
|
||||
|
||||
@@ -50,12 +50,15 @@ When `vendor_id` and `device_id` are known but the model name is missing or gene
|
||||
| Vendor ID | Input family | Notes |
|
||||
|-----------|--------------|-------|
|
||||
| `dell` | TSR ZIP archives | Broad hardware, firmware, sensors, lifecycle events |
|
||||
| `easy_bee` | `bee-support-*.tar.gz` | Imports embedded `export/bee-audit.json` snapshot from reanimator-easy-bee bundles |
|
||||
| `h3c_g5` | H3C SDS G5 bundles | INI/XML/CSV-driven hardware and event parsing |
|
||||
| `h3c_g6` | H3C SDS G6 bundles | Similar flow with G6-specific files |
|
||||
| `hpe_ilo_ahs` | HPE iLO Active Health System (`.ahs`) | Proprietary `ABJR` container with gzip-compressed `zbb` members; parser combines SMBIOS-style inventory strings and embedded Redfish storage JSON |
|
||||
| `inspur` | onekeylog archives | FRU/SDR plus optional Redis enrichment |
|
||||
| `nvidia` | HGX Field Diagnostics | GPU- and fabric-heavy diagnostic input |
|
||||
| `nvidia_bug_report` | `nvidia-bug-report-*.log.gz` | dmidecode, lspci, NVIDIA driver sections |
|
||||
| `unraid` | Unraid diagnostics/log bundles | Server and storage-focused parsing |
|
||||
| `xfusion` | xFusion iBMC `tar.gz` dump / file export | AppDump + RTOSDump + LogDump merge for hardware and firmware |
|
||||
| `xigmanas` | XigmaNAS plain logs | FreeBSD/NAS-oriented inventory |
|
||||
| `generic` | fallback | Low-confidence text fallback when nothing else matches |
|
||||
|
||||
@@ -120,6 +123,55 @@ with content markers (e.g. `Unraid kernel build`, parity data markers).
|
||||
|
||||
---
|
||||
|
||||
### HPE iLO AHS (`hpe_ilo_ahs`)
|
||||
|
||||
**Status:** Ready (v1.0.0). Tested on HPE ProLiant Gen11 `.ahs` export from iLO 6.
|
||||
|
||||
**Archive format:** `.ahs` single-file Active Health System export.
|
||||
|
||||
**Detection:** Single-file input with `ABJR` container header and HPE AHS member names
|
||||
such as `CUST_INFO.DAT`, `*.zbb`, `ilo_boot_support.zbb`.
|
||||
|
||||
**Extracted data (current):**
|
||||
- System board identity (manufacturer, model, serial, part number)
|
||||
- iLO / System ROM / SPS top-level firmware
|
||||
- CPU inventory (model-level)
|
||||
- Memory DIMM inventory for populated slots
|
||||
- PSU inventory
|
||||
- PCIe / OCP NIC inventory from SMBIOS-style slot records
|
||||
- Storage controller and physical drives from embedded Redfish JSON inside `zbb` members
|
||||
- Basic iLO event log entries with timestamps when present
|
||||
|
||||
**Implementation note:** The format is proprietary. Parser support is intentionally hybrid:
|
||||
container parsing (`ABJR` + gzip) plus structured extraction from embedded Redfish objects and
|
||||
printable SMBIOS/FRU payloads. This is sufficient for inventory-grade parsing without decoding the
|
||||
entire internal `zbb` schema.
|
||||
|
||||
---
|
||||
|
||||
### xFusion iBMC Dump / File Export (`xfusion`)
|
||||
|
||||
**Status:** Ready (v1.1.0). Tested on xFusion G5500 V7 `tar.gz` exports.
|
||||
|
||||
**Archive format:** `tar.gz` dump exported from the iBMC UI, including `AppDump/`, `RTOSDump/`,
|
||||
and `LogDump/` trees.
|
||||
|
||||
**Detection:** `AppDump/FruData/fruinfo.txt`, `AppDump/card_manage/card_info`,
|
||||
`RTOSDump/versioninfo/app_revision.txt`, and `LogDump/netcard/netcard_info.txt`.
|
||||
|
||||
**Extracted data (current):**
|
||||
- Board / FRU inventory from `fruinfo.txt`
|
||||
- CPU inventory from `CpuMem/cpu_info`
|
||||
- Memory DIMM inventory from `CpuMem/mem_info`
|
||||
- GPU inventory from `card_info`
|
||||
- OCP NIC inventory by merging `card_info` with `LogDump/netcard/netcard_info.txt`
|
||||
- PSU inventory from `BMC/psu_info.txt`
|
||||
- Physical storage from `StorageMgnt/PhysicalDrivesInfo/*/disk_info`
|
||||
- System firmware entries from `RTOSDump/versioninfo/app_revision.txt`
|
||||
- Maintenance events from `LogDump/maintenance_log`
|
||||
|
||||
---
|
||||
|
||||
### Generic text fallback (`generic`)
|
||||
|
||||
**Status:** Ready (v1.0.0).
|
||||
@@ -139,10 +191,13 @@ with content markers (e.g. `Unraid kernel build`, parity data markers).
|
||||
| Vendor | ID | Status | Tested on |
|
||||
|--------|----|--------|-----------|
|
||||
| Dell TSR | `dell` | Ready | TSR nested zip archives |
|
||||
| Reanimator Easy Bee | `easy_bee` | Ready | `bee-support-*.tar.gz` support bundles |
|
||||
| HPE iLO AHS | `hpe_ilo_ahs` | Ready | iLO 6 `.ahs` exports |
|
||||
| Inspur / Kaytus | `inspur` | Ready | KR4268X2 onekeylog |
|
||||
| NVIDIA HGX Field Diag | `nvidia` | Ready | Various HGX servers |
|
||||
| NVIDIA Bug Report | `nvidia_bug_report` | Ready | H100 systems |
|
||||
| Unraid | `unraid` | Ready | Unraid diagnostics archives |
|
||||
| xFusion iBMC dump | `xfusion` | Ready | G5500 V7 file-export `tar.gz` bundles |
|
||||
| XigmaNAS | `xigmanas` | Ready | FreeBSD NAS logs |
|
||||
| H3C SDS G5 | `h3c_g5` | Ready | H3C UniServer R4900 G5 SDS archives |
|
||||
| H3C SDS G6 | `h3c_g6` | Ready | H3C UniServer R4700 G6 SDS archives |
|
||||
|
||||
@@ -258,6 +258,9 @@ at parse time before storing in any model struct. Use the regex
|
||||
**Date:** 2026-03-12
|
||||
**Context:** `shouldAdaptiveNVMeProbe` was introduced in `2fa4a12` to recover NVMe drives on
|
||||
Supermicro BMCs that expose empty `Drives` collections but serve disks at direct `Disk.Bay.N`
|
||||
|
||||
---
|
||||
|
||||
paths. The function returns `true` for any chassis with an empty `Members` array. On
|
||||
Supermicro HGX systems (SYS-A21GE-NBRT and similar) ~35 sub-chassis (GPU, NVSwitch,
|
||||
PCIeRetimer, ERoT, IRoT, BMC, FPGA) all carry `ChassisType=Module/Component/Zone` and
|
||||
@@ -822,3 +825,298 @@ special acquisition strategy.
|
||||
- Repo-owned compact fixtures under `internal/collector/redfishprofile/testdata/`, derived from
|
||||
representative raw-export snapshots, are used to lock profile matching and acquisition tuning
|
||||
for known MSI and Supermicro-family shapes.
|
||||
|
||||
---
|
||||
|
||||
## ADL-032 — MSI ghost GPU filter: exclude GPUs with temperature=0 on powered-on host
|
||||
|
||||
**Date:** 2026-03-18
|
||||
**Context:**
|
||||
MSI/AMI BMC caches GPU inventory from the host via Host Interface (in-band). When GPUs are
|
||||
removed without a reboot the old entries remain in `Chassis/GPU*` and
|
||||
`Systems/Self/Processors/GPU*` with `Status.Health: OK, State: Enabled`. The BMC has no
|
||||
out-of-band mechanism to detect physical absence. A physically present GPU always reports
|
||||
an ambient temperature (>0°C) even when idle; a stale cached entry returns `Reading: 0`.
|
||||
|
||||
**Decision:**
|
||||
- Add `EnableMSIGhostGPUFilter` directive (enabled by MSI profile's `refineAnalysis`
|
||||
alongside `EnableProcessorGPUFallback`).
|
||||
- In `collectGPUsFromProcessors`: for each processor GPU, resolve its chassis path and read
|
||||
`Chassis/GPU{n}/Sensors/GPU{n}_Temperature`. If `PowerState=On` and `Reading=0` → skip.
|
||||
- Filter only applies when host is powered on; when host is off all temperatures are 0 and
|
||||
the signal is ambiguous.
|
||||
|
||||
**Consequences:**
|
||||
- Ghost GPUs from previous hardware configurations no longer appear in the inventory.
|
||||
- Filter is MSI-profile-owned and does not affect HGX, Supermicro, or generic paths.
|
||||
- Any new MSI GPU chassis that uses a different temperature sensor path will bypass the filter
|
||||
(safe default: include rather than wrongly exclude).
|
||||
|
||||
---
|
||||
|
||||
## ADL-033 — Reanimator export collected_at uses inventory LastModifiedTime with 30-day fallback
|
||||
|
||||
**Date:** 2026-03-18
|
||||
**Context:**
|
||||
For Redfish sources the BMC Manager `DateTime` reflects when the BMC clock read the time, not
|
||||
when the hardware inventory was last known-good. `InventoryData/Status.LastModifiedTime`
|
||||
(AMI/MSI OEM endpoint) records the actual timestamp of the last successful host-pushed
|
||||
inventory cycle and is a better proxy for "when was this hardware configuration last confirmed".
|
||||
|
||||
**Decision:**
|
||||
- `inferInventoryLastModifiedTime` reads `LastModifiedTime` from the snapshot and sets
|
||||
`AnalysisResult.InventoryLastModifiedAt`.
|
||||
- `reanimatorCollectedAt()` in the exporter selects `InventoryLastModifiedAt` when it is set
|
||||
and no older than 30 days; otherwise falls back to `CollectedAt`.
|
||||
- Fallback rationale: inventory older than 30 days is likely from a long-running server with
|
||||
no recent reboot; using the actual collection date is more useful for the downstream consumer.
|
||||
- The inventory timestamp is also logged during replay and live collection for diagnostics.
|
||||
|
||||
**Consequences:**
|
||||
- Reanimator export `collected_at` reflects the last confirmed inventory cycle on AMI/MSI BMCs.
|
||||
- On non-AMI BMCs or when `InventoryData/Status` is absent, behavior is unchanged.
|
||||
- If inventory is stale (>30 days), collection date is used as before.
|
||||
|
||||
---
|
||||
|
||||
## ADL-034 — Redfish inventory invalidated before host power-on
|
||||
|
||||
**Date:** 2026-03-18
|
||||
**Context:**
|
||||
When a host is powered on by the collector (`power_on_if_host_off=true`), the BMC still holds
|
||||
inventory from the previous boot. If hardware changed between shutdowns, the new boot will push
|
||||
fresh inventory — but only if the BMC accepts it (CRC mismatch triggers re-population). Without
|
||||
explicit invalidation, unchanged CRCs can cause the BMC to skip re-processing even after a
|
||||
hardware change.
|
||||
|
||||
**Decision:**
|
||||
- Before any power-on attempt, `invalidateRedfishInventory` POSTs to
|
||||
`{systemPath}/Oem/Ami/Inventory/Crc` with all groups zeroed (`CPU`, `DIMM`, `PCIE`,
|
||||
`CERTIFICATES`, `SECUREBOOT`).
|
||||
- Best-effort: a 404/405 response (non-AMI BMC) is logged and silently ignored.
|
||||
- The invalidation is logged at `INFO` level and surfaced as a collect progress message.
|
||||
|
||||
**Consequences:**
|
||||
- On AMI/MSI BMCs: the next boot will push a full fresh inventory regardless of whether
|
||||
CRCs appear unchanged, eliminating ghost components from prior hardware configurations.
|
||||
- On non-AMI BMCs: the POST fails immediately (endpoint does not exist), nothing changes.
|
||||
- Invalidation runs only when `power_on_if_host_off=true` and host is confirmed off.
|
||||
|
||||
---
|
||||
|
||||
## ADL-035 — Redfish hardware event log collection from Systems LogServices
|
||||
|
||||
**Date:** 2026-03-18
|
||||
**Context:** Redfish BMCs expose event logs via `LogServices/{svc}/Entries`. On MSI/AMI this includes the IPMI SEL with hardware events (temperature, power, drive failures, etc.). Live collection previously collected only inventory/sensor snapshots; event history was unavailable in Reanimator.
|
||||
**Decision:**
|
||||
- After tree-walk, fetch hardware log entries separately via `collectRedfishLogEntries()` (not part of tree-walk to avoid bloat).
|
||||
- Only `Systems/{sys}/LogServices` is queried — Managers LogServices (BMC audit/journal) are excluded.
|
||||
- Log services with Id/Name containing "audit", "journal", "bmc", "security", "manager", "debug" are skipped.
|
||||
- Entries older than 7 days (client-side filter) are discarded. Pages are followed until an out-of-window entry is found (assumes newest-first ordering, typical for BMCs).
|
||||
- Entries with `EntryType: "Oem"` or `MessageId` containing user/auth/login keywords are filtered as non-hardware.
|
||||
- Raw entries stored in `rawPayloads["redfish_log_entries"]` as `[]map[string]interface{}`.
|
||||
- Parsed to `models.Event` in `parseRedfishLogEntries()` during replay — same path for live and offline.
|
||||
- Max 200 entries per log service, 500 total to limit BMC load.
|
||||
**Consequences:**
|
||||
- Hardware event history (last 7 days) visible in Reanimator `EventLogs` section.
|
||||
- No impact on existing inventory pipeline or offline archive replay (archives without `redfish_log_entries` key silently skip parsing).
|
||||
- Adds extra HTTP requests during live collection (sequential, after tree-walk completes).
|
||||
|
||||
---
|
||||
|
||||
## ADL-036 — Redfish profile matching may use platform grammar hints beyond vendor strings
|
||||
|
||||
**Date:** 2026-03-25
|
||||
**Context:**
|
||||
Some BMCs expose unusable `Manufacturer` / `Model` values (`NULL`, placeholders, or generic SoC
|
||||
names) while still exposing a stable platform-specific Redfish grammar: repeated member names,
|
||||
firmware inventory IDs, OEM action names, and target-path quirks. Matching only on vendor
|
||||
strings forced such systems into fallback mode even when the platform shape was consistent.
|
||||
|
||||
**Decision:**
|
||||
- Extend `redfishprofile.MatchSignals` with doc-derived hint tokens collected from discovery docs
|
||||
and replay snapshots.
|
||||
- Allow profile matchers to score on stable platform grammar such as:
|
||||
- collection member naming (`outboardPCIeCard*`, drive slot grammars)
|
||||
- firmware inventory member IDs
|
||||
- OEM action/type markers and linked target paths
|
||||
- During live collection, gather only lightweight extra hint collections needed for matching
|
||||
(`NetworkInterfaces`, `NetworkAdapters`, `Drives`, `UpdateService/FirmwareInventory`), not slow
|
||||
deep inventory branches.
|
||||
- Keep such profiles out of fallback aggregation unless they are proven safe as broad additive
|
||||
hints.
|
||||
|
||||
**Consequences:**
|
||||
- Platform-family profiles can activate even when vendor strings are absent or set to `NULL`.
|
||||
- Matching logic becomes more robust for OEM BMC implementations that differ mainly by Redfish
|
||||
grammar rather than by explicit vendor strings.
|
||||
- Live collection gains a small amount of extra discovery I/O to harvest stable member IDs, but
|
||||
avoids slow deep probes such as `Assembly` just for profile selection.
|
||||
|
||||
---
|
||||
|
||||
## ADL-037 — easy-bee archives are parsed from the embedded bee-audit snapshot
|
||||
|
||||
**Date:** 2026-03-25
|
||||
**Context:**
|
||||
`reanimator-easy-bee` support bundles already contain a normalized hardware snapshot in
|
||||
`export/bee-audit.json` plus supporting logs and techdump files. Rebuilding the same inventory
|
||||
from raw `techdump/` files inside LOGPile would duplicate parser logic and create drift between
|
||||
the producer utility and archive importer.
|
||||
|
||||
**Decision:**
|
||||
- Add a dedicated `easy_bee` vendor parser for `bee-support-*.tar.gz` bundles.
|
||||
- Detect the bundle by `manifest.txt` (`bee_version=...`) plus `export/bee-audit.json`.
|
||||
- Parse the archive from the embedded snapshot first; treat `techdump/` and runtime files as
|
||||
secondary context only.
|
||||
- Normalize snapshot-only fields needed by LOGPile, notably:
|
||||
- flatten `hardware.sensors` groups into `[]SensorReading`
|
||||
- turn runtime issues/status into `[]Event`
|
||||
- synthesize a board FRU entry when the snapshot does not include FRU data
|
||||
|
||||
**Consequences:**
|
||||
- LOGPile stays aligned with the schema emitted by `reanimator-easy-bee`.
|
||||
- Adding support required only a thin archive adapter instead of a full hardware parser.
|
||||
- If the upstream utility changes the embedded snapshot schema, the `easy_bee` adapter is the
|
||||
only place that must be updated.
|
||||
|
||||
---
|
||||
|
||||
## ADL-038 — HPE AHS parser uses hybrid extraction instead of full `zbb` schema decoding
|
||||
|
||||
**Date:** 2026-03-30
|
||||
**Context:** HPE iLO Active Health System exports (`.ahs`) are proprietary `ABJR` containers
|
||||
with gzip-compressed `zbb` payloads. The sample inventory data contains two practical signal
|
||||
families: printable SMBIOS/FRU-style strings and embedded Redfish JSON subtrees, especially for
|
||||
storage controllers and drives. Full `zbb` binary schema decoding is not documented and would add
|
||||
significant complexity before proving user value.
|
||||
**Decision:** Support HPE AHS with a hybrid parser:
|
||||
- decode the outer `ABJR` container
|
||||
- gunzip embedded members when applicable
|
||||
- extract inventory from printable SMBIOS/FRU payloads
|
||||
- extract storage/controller/backplane details from embedded Redfish JSON objects
|
||||
- enrich firmware and PSU inventory from auxiliary package payloads such as `bcert.pkg`
|
||||
- do not attempt complete semantic decoding of the internal `zbb` record format
|
||||
**Consequences:**
|
||||
- Parser reaches inventory-grade usefulness quickly for HPE `.ahs` uploads.
|
||||
- Storage inventory is stronger than text-only parsing because it reuses structured Redfish data when present.
|
||||
- Auxiliary package payloads can supply missing firmware/PSU fields even when the main SMBIOS-like blob is incomplete.
|
||||
- Future deeper `zbb` decoding can be added incrementally without replacing the current parser contract.
|
||||
|
||||
---
|
||||
|
||||
## ADL-039 — Canonical inventory keeps DIMMs with unknown capacity when identity is known
|
||||
|
||||
**Date:** 2026-03-30
|
||||
**Context:** Some sources, notably HPE iLO AHS SMBIOS-like blobs, expose installed DIMM identity
|
||||
(slot, serial, part number, manufacturer) but do not include capacity. The parser already extracts
|
||||
those modules into `Hardware.Memory`, but canonical device building and export previously dropped
|
||||
them because `size_mb == 0`.
|
||||
**Decision:** Treat a DIMM as installed inventory when `present=true` and it has identifying
|
||||
memory fields such as serial number or part number, even if `size_mb` is unknown.
|
||||
**Consequences:**
|
||||
- HPE AHS uploads now show real installed memory modules instead of hiding them.
|
||||
- Empty slots still stay filtered because they lack inventory identity or are marked absent.
|
||||
- Specification/export can include "size unknown" memory entries without inventing capacity data.
|
||||
|
||||
---
|
||||
|
||||
## ADL-040 — HPE Redfish normalization prefers chassis `Devices/*` over generic PCIe topology labels
|
||||
|
||||
**Date:** 2026-03-30
|
||||
**Context:** HPE ProLiant Gen11 Redfish snapshots expose parallel inventory trees. `Chassis/*/PCIeDevices/*`
|
||||
is good for topology presence, but often reports only generic `DeviceType` values such as
|
||||
`SingleFunction`. `Chassis/*/Devices/*` carries the concrete slot label, richer device type, and
|
||||
product-vs-spare part identifiers for the same physical NIC/controller. Replay fallback over empty
|
||||
storage volume collections can also discover `Volumes/Capabilities` children, which are not real
|
||||
logical volumes.
|
||||
|
||||
**Decision:**
|
||||
- Treat Redfish `SKU` as a valid fallback for `hardware.board.part_number` when `PartNumber` is empty.
|
||||
- Ignore `Volumes/Capabilities` documents during logical-volume parsing.
|
||||
- Enrich `Chassis/*/PCIeDevices/*` entries with matching `Chassis/*/Devices/*` documents by
|
||||
serial/name/part identity.
|
||||
- Keep `pcie.device_class` semantic; do not replace it with model or part-number strings when
|
||||
Redfish exposes only generic topology labels.
|
||||
|
||||
**Consequences:**
|
||||
- HPE Redfish imports now keep the server SKU in `hardware.board.part_number`.
|
||||
- Empty volume collections no longer produce fake `Capabilities` volume records.
|
||||
- HPE PCIe inventory gets better slot labels like `OCP 3.0 Slot 15` plus concrete classes such as
|
||||
`LOM/NIC` or `SAS/SATA Storage Controller`.
|
||||
- `part_number` remains available separately for model identity, without polluting the class field.
|
||||
|
||||
---
|
||||
|
||||
## ADL-041 — Redfish replay drops topology-only PCIe noise classes from canonical inventory
|
||||
|
||||
**Date:** 2026-04-01
|
||||
**Context:** Some Redfish BMCs, especially MSI/AMI GPU systems, expose a very wide PCIe topology
|
||||
tree under `Chassis/*/PCIeDevices/*`. Besides real endpoint devices, the replay sees bridge stages,
|
||||
CPU-side helper functions, IMC/mesh signal-processing nodes, USB/SPI side controllers, and GPU
|
||||
display-function duplicates reported as generic `Display Device`. Keeping all of them in
|
||||
`hardware.pcie_devices` pollutes downstream exports such as Reanimator and hides the actual
|
||||
endpoint inventory signal.
|
||||
|
||||
**Decision:**
|
||||
- Filter topology-only PCIe records during Redfish replay, not in the UI layer.
|
||||
- Drop PCIe entries with replay-resolved classes:
|
||||
- `Bridge`
|
||||
- `Processor`
|
||||
- `SignalProcessingController`
|
||||
- `SerialBusController`
|
||||
- Drop `DisplayController` entries when the source Redfish PCIe document is the generic MSI-style
|
||||
`Description: "Display Device"` duplicate.
|
||||
- Drop PCIe network endpoints when their PCIe functions already link to `NetworkDeviceFunctions`,
|
||||
because those devices are represented canonically in `hardware.network_adapters`.
|
||||
- When `Systems/*/NetworkInterfaces/*` links back to a chassis `NetworkAdapter`, match against the
|
||||
fully enriched chassis NIC identity to avoid creating a second ghost NIC row with the raw
|
||||
`NetworkAdapter_*` slot/name.
|
||||
- Treat generic Redfish object names such as `NetworkAdapter_*` and `PCIeDevice_*` as placeholder
|
||||
models and replace them from PCI IDs when a concrete vendor/device match exists.
|
||||
- Drop MSI-style storage service PCIe endpoints whose resolved device names are only
|
||||
`Volume Management Device NVMe RAID Controller` or `PCIe Switch management endpoint`; storage
|
||||
inventory already comes from the Redfish storage tree.
|
||||
- Normalize Ethernet-class NICs into the single exported class `NetworkController`; do not split
|
||||
`EthernetController` into a separate top-level inventory section.
|
||||
- Keep endpoint classes such as `NetworkController`, `MassStorageController`, and dedicated GPU
|
||||
inventory coming from `hardware.gpus`.
|
||||
|
||||
**Consequences:**
|
||||
- `hardware.pcie_devices` becomes closer to real endpoint inventory instead of raw PCIe topology.
|
||||
- Reanimator exports stop showing MSI bridge/processor/display duplicate noise.
|
||||
- Reanimator exports no longer duplicate the same MSI NIC as both `PCIeDevice_*` and
|
||||
`NetworkAdapter_*`.
|
||||
- Replay no longer creates extra NIC rows from `Systems/NetworkInterfaces` when the same adapter
|
||||
was already normalized from `Chassis/NetworkAdapters`.
|
||||
- MSI VMD / PCIe switch storage service endpoints no longer pollute PCIe inventory.
|
||||
- UI/Reanimator group all Ethernet NICs under the same `NETWORKCONTROLLER` section.
|
||||
- Canonical NIC inventory prefers resolved PCI product names over generic Redfish placeholder names.
|
||||
- The raw Redfish snapshot still remains available in `raw_payloads.redfish_tree` for low-level
|
||||
troubleshooting if topology details are ever needed.
|
||||
|
||||
---
|
||||
|
||||
## ADL-042 — xFusion file-export archives merge AppDump inventory with RTOS/Log snapshots
|
||||
|
||||
**Date:** 2026-04-04
|
||||
**Context:** xFusion iBMC `tar.gz` exports expose the base inventory in `AppDump/`, but the most
|
||||
useful NIC and firmware details live elsewhere: NIC firmware/MAC snapshots in
|
||||
`LogDump/netcard/netcard_info.txt` and system firmware versions in
|
||||
`RTOSDump/versioninfo/app_revision.txt`. Parsing only `AppDump/` left xFusion uploads detectable but
|
||||
incomplete for UI and Reanimator consumers.
|
||||
|
||||
**Decision:**
|
||||
- Treat xFusion file-export `tar.gz` bundles as a first-class archive parser input.
|
||||
- Merge OCP NIC identity from `AppDump/card_manage/card_info` with the latest per-slot snapshot
|
||||
from `LogDump/netcard/netcard_info.txt` to produce `hardware.network_adapters`.
|
||||
- Import system-level firmware from `RTOSDump/versioninfo/app_revision.txt` into
|
||||
`hardware.firmware`.
|
||||
- Allow FRU fallback from `RTOSDump/versioninfo/fruinfo.txt` when `AppDump/FruData/fruinfo.txt`
|
||||
is absent.
|
||||
|
||||
**Consequences:**
|
||||
- xFusion uploads now preserve NIC BDF, MAC, firmware, and serial identity in normalized output.
|
||||
- System firmware such as BIOS and iBMC versions survives xFusion file exports.
|
||||
- xFusion archives participate more reliably in canonical device/export flows without special UI
|
||||
cases.
|
||||
|
||||
343
bible-local/docs/msi-redfish-api.md
Normal file
343
bible-local/docs/msi-redfish-api.md
Normal file
@@ -0,0 +1,343 @@
|
||||
# MSI BMC Redfish API Reference
|
||||
|
||||
Source: MSI Enterprise Platform Solutions — Redfish BMC User Guide v1.0 (AMI/MegaRAC stack).
|
||||
Spec compliance: DSP0266 1.15.1, DSP8010 2019.2.
|
||||
|
||||
> This document is trimmed to sections relevant to LOGPile collection and inventory analysis.
|
||||
> Auth, LDAP/AD, SMTP, VirtualMedia, Certificates, RADIUS, Composability, and BMC config
|
||||
> sections are omitted.
|
||||
|
||||
---
|
||||
|
||||
## Supported HTTP methods
|
||||
|
||||
`GET`, `POST`, `PATCH`, `DELETE`. Unsupported methods return `405`.
|
||||
|
||||
PATCH requires an `If-Match` / `ETag` precondition header; missing header → `428`, mismatch → `412`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Core Redfish API endpoints
|
||||
|
||||
| Resource | URI | Schema |
|
||||
|---|---|---|
|
||||
| Service Root | `/redfish/v1/` | ServiceRoot.v1_7_0 |
|
||||
| ComputerSystem Collection | `/redfish/v1/Systems` | ComputerSystemCollection |
|
||||
| ComputerSystem | `/redfish/v1/Systems/{sys}` | ComputerSystem.v1_16_2 |
|
||||
| Memory Collection | `/redfish/v1/Systems/{sys}/Memory` | MemoryCollection |
|
||||
| Memory | `/redfish/v1/Systems/{sys}/Memory/{mem}` | Memory.v1_19_0 |
|
||||
| MemoryMetrics | `/redfish/v1/Systems/{sys}/Memory/{mem}/MemoryMetrics` | MemoryMetrics.v1_7_0 |
|
||||
| MemoryDomain Collection | `/redfish/v1/Systems/{sys}/MemoryDomain` | MemoryDomainCollection |
|
||||
| MemoryDomain | `/redfish/v1/Systems/{sys}/MemoryDomain/{dom}` | MemoryDomain.v1_2_3 |
|
||||
| MemoryChunks Collection | `/redfish/v1/Systems/{sys}/MemoryDomain/{dom}/MemoryChunks` | MemoryChunksCollection |
|
||||
| MemoryChunks | `/redfish/v1/Systems/{sys}/MemoryDomain/{dom}/MemoryChunks/{chunk}` | MemoryChunks.v1_4_0 |
|
||||
| Processor Collection | `/redfish/v1/Systems/{sys}/Processors` | ProcessorCollection |
|
||||
| Processor | `/redfish/v1/Systems/{sys}/Processors/{proc}` | Processor.v1_15_0 |
|
||||
| SubProcessors Collection | `/redfish/v1/Systems/{sys}/Processors/{proc}/SubProcessors` | ProcessorCollection |
|
||||
| SubProcessor | `/redfish/v1/Systems/{sys}/Processors/{proc}/SubProcessors/{sub}` | Processor.v1_15_0 |
|
||||
| ProcessorMetrics | `/redfish/v1/Systems/{sys}/Processors/{proc}/ProcessorMetrics` | ProcessorMetrics.v1_4_0 |
|
||||
| Bios | `/redfish/v1/Systems/{sys}/Bios` | Bios.v1_2_0 |
|
||||
| SimpleStorage Collection | `/redfish/v1/Systems/{sys}/SimpleStorage` | SimpleStorageCollection |
|
||||
| SimpleStorage | `/redfish/v1/Systems/{sys}/SimpleStorage/{ss}` | SimpleStorage.v1_3_0 |
|
||||
| Storage Collection | `/redfish/v1/Systems/{sys}/Storage` | StorageCollection |
|
||||
| Storage | `/redfish/v1/Systems/{sys}/Storage/{stor}` | Storage.v1_9_0 |
|
||||
| StorageController Collection | `/redfish/v1/Systems/{sys}/Storage/{stor}/Controllers` | StorageControllerCollection |
|
||||
| StorageController | `/redfish/v1/Systems/{sys}/Storage/{stor}/Controllers/{ctrl}` | StorageController.v1_0_0 |
|
||||
| Drive | `/redfish/v1/Systems/{sys}/Storage/{stor}/Drives/{drv}` | Drive.v1_13_0 |
|
||||
| Volume Collection | `/redfish/v1/Systems/{sys}/Storage/{stor}/Volumes` | VolumeCollection |
|
||||
| Volume | `/redfish/v1/Systems/{sys}/Storage/{stor}/Volumes/{vol}` | Volume.v1_5_0 |
|
||||
| NetworkInterface Collection | `/redfish/v1/Systems/{sys}/NetworkInterfaces` | NetworkInterfaceCollection |
|
||||
| NetworkInterface | `/redfish/v1/Systems/{sys}/NetworkInterfaces/{nic}` | NetworkInterface.v1_2_0 |
|
||||
| EthernetInterface (System) | `/redfish/v1/Systems/{sys}/EthernetInterfaces/{eth}` | EthernetInterface.v1_6_2 |
|
||||
| GraphicsController Collection | `/redfish/v1/Systems/{sys}/GraphicsControllers` | GraphicsControllerCollection |
|
||||
| GraphicsController | `/redfish/v1/Systems/{sys}/GraphicsControllers/{gpu}` | GraphicsController.v1_0_0 |
|
||||
| USBController Collection | `/redfish/v1/Systems/{sys}/USBControllers` | USBControllerCollection |
|
||||
| USBController | `/redfish/v1/Systems/{sys}/USBControllers/{usb}` | USBController.v1_0_0 |
|
||||
| SecureBoot | `/redfish/v1/Systems/{sys}/SecureBoot` | SecureBoot.v1_1_0 |
|
||||
| LogService Collection (System) | `/redfish/v1/Systems/{sys}/LogServices` | LogServiceCollection |
|
||||
| LogService (System) | `/redfish/v1/Systems/{sys}/LogServices/{log}` | LogService.v1_1_3 |
|
||||
| LogEntry Collection | `/redfish/v1/Systems/{sys}/LogServices/{log}/Entries` | LogEntryCollection |
|
||||
| LogEntry | `/redfish/v1/Systems/{sys}/LogServices/{log}/Entries/{entry}` | LogEntry.v1_12_0 |
|
||||
| Chassis Collection | `/redfish/v1/Chassis` | ChassisCollection |
|
||||
| Chassis | `/redfish/v1/Chassis/{ch}` | Chassis.v1_15_0 |
|
||||
| Power | `/redfish/v1/Chassis/{ch}/Power` | Power.v1_5_4 |
|
||||
| PowerSubSystem | `/redfish/v1/Chassis/{ch}/PowerSubSystem` | PowerSubsystem.v1_1_0 |
|
||||
| PowerSupplies Collection | `/redfish/v1/Chassis/{ch}/PowerSubSystem/PowerSupplies` | PowerSupplyCollection |
|
||||
| PowerSupply | `/redfish/v1/Chassis/{ch}/PowerSubSystem/PowerSupplies/{psu}` | PowerSupply.v1_3_0 |
|
||||
| PowerSupplyMetrics | `/redfish/v1/Chassis/{ch}/PowerSubSystem/PowerSupplies/{psu}/Metrics` | PowerSupplyMetrics.v1_0_1 |
|
||||
| Thermal | `/redfish/v1/Chassis/{ch}/Thermal` | Thermal.v1_5_3 |
|
||||
| ThermalSubSystem | `/redfish/v1/Chassis/{ch}/ThermalSubSystem` | ThermalSubsystem.v1_0_0 |
|
||||
| ThermalMetrics | `/redfish/v1/Chassis/{ch}/ThermalSubSystem/ThermalMetrics` | ThermalMetrics.v1_0_1 |
|
||||
| Fans Collection | `/redfish/v1/Chassis/{ch}/ThermalSubSystem/Fans` | FanCollection |
|
||||
| Fan | `/redfish/v1/Chassis/{ch}/ThermalSubSystem/Fans/{fan}` | Fan.v1_1_1 |
|
||||
| Sensor Collection | `/redfish/v1/Chassis/{ch}/Sensors` | SensorCollection |
|
||||
| Sensor | `/redfish/v1/Chassis/{ch}/Sensors/{sen}` | Sensor.v1_0_2 |
|
||||
| PCIeDevice Collection | `/redfish/v1/Chassis/{ch}/PCIeDevices` | PCIeDeviceCollection |
|
||||
| PCIeDevice | `/redfish/v1/Chassis/{ch}/PCIeDevices/{dev}` | PCIeDevice.v1_9_0 |
|
||||
| PCIeFunction Collection | `/redfish/v1/Chassis/{ch}/PCIeDevices/{dev}/PCIeFunctions` | PCIeFunctionCollection |
|
||||
| PCIeFunction | `/redfish/v1/Chassis/{ch}/PCIeDevices/{dev}/PCIeFunctions/{fn}` | PCIeFunction.v1_2_3 |
|
||||
| PCIeSlots | `/redfish/v1/Chassis/{ch}/PCIeSlots` | PCIeSlots.v1_5_0 |
|
||||
| NetworkAdapter Collection | `/redfish/v1/Chassis/{ch}/NetworkAdapters` | NetworkAdapterCollection |
|
||||
| NetworkAdapter | `/redfish/v1/Chassis/{ch}/NetworkAdapters/{na}` | NetworkAdapter.v1_8_0 |
|
||||
| NetworkDeviceFunction Collection | `/redfish/v1/Chassis/{ch}/NetworkAdapters/{na}/NetworkDeviceFunctions` | NetworkDeviceFunctionCollection |
|
||||
| NetworkDeviceFunction | `/redfish/v1/Chassis/{ch}/NetworkAdapters/{na}/NetworkDeviceFunctions/{fn}` | NetworkDeviceFunction.v1_5_0 |
|
||||
| Assembly | `/redfish/v1/Chassis/{ch}/Assembly` | Assembly.v1_2_2 |
|
||||
| Assembly (Drive) | `/redfish/v1/Systems/{sys}/Storage/{stor}/Drives/{drv}/Assembly` | Assembly.v1_2_2 |
|
||||
| Assembly (Processor) | `/redfish/v1/Systems/{sys}/Processors/{proc}/Assembly` | Assembly.v1_2_2 |
|
||||
| Assembly (Memory) | `/redfish/v1/Systems/{sys}/Memory/{mem}/Assembly` | Assembly.v1_2_2 |
|
||||
| Assembly (NetworkAdapter) | `/redfish/v1/Chassis/{ch}/NetworkAdapters/{na}/Assembly` | Assembly.v1_2_2 |
|
||||
| Assembly (PCIeDevice) | `/redfish/v1/Chassis/{ch}/PCIeDevices/{dev}/Assembly` | Assembly.v1_2_2 |
|
||||
| MediaController Collection | `/redfish/v1/Chassis/{ch}/MediaControllers` | MediaControllerCollection |
|
||||
| MediaController | `/redfish/v1/Chassis/{ch}/MediaControllers/{mc}` | MediaController.v1_1_0 |
|
||||
| LogService Collection (Chassis) | `/redfish/v1/Chassis/{ch}/LogServices` | LogServiceCollection |
|
||||
| LogService (Chassis) | `/redfish/v1/Chassis/{ch}/LogServices/{log}` | LogService.v1_1_3 |
|
||||
| Manager Collection | `/redfish/v1/Managers` | ManagerCollection |
|
||||
| Manager | `/redfish/v1/Managers/{mgr}` | Manager.v1_13_0 |
|
||||
| EthernetInterface (Manager) | `/redfish/v1/Managers/{mgr}/EthernetInterfaces/{eth}` | EthernetInterface.v1_6_2 |
|
||||
| LogService Collection (Manager) | `/redfish/v1/Managers/{mgr}/LogServices` | LogServiceCollection |
|
||||
| LogService (Manager) | `/redfish/v1/Managers/{mgr}/LogServices/{log}` | LogService.v1_1_3 |
|
||||
| UpdateService | `/redfish/v1/UpdateService` | UpdateService.v1_6_0 |
|
||||
| TaskService | `/redfish/v1/TasksService` | TaskService.v1_1_4 |
|
||||
| Task Collection | `/redfish/v1/TaskService/Tasks` | TaskCollection |
|
||||
| Task | `/redfish/v1/TaskService/Tasks/{task}` | Task.v1_4_2 |
|
||||
|
||||
---
|
||||
|
||||
## 2. Telemetry API endpoints
|
||||
|
||||
| Resource | URI | Schema |
|
||||
|---|---|---|
|
||||
| TelemetryService | `/redfish/v1/TelemetryService` | TelemetryService.v1_2_1 |
|
||||
| MetricDefinition Collection | `/redfish/v1/TelemetryService/MetricDefinitions` | MetricDefinitionCollection |
|
||||
| MetricDefinition | `/redfish/v1/TelemetryService/MetricDefinitions/{md}` | MetricDefinition.v1_0_3 |
|
||||
| MetricReportDefinition Collection | `/redfish/v1/TelemetryService/MetricReportDefinitions` | MetricReportDefinitionCollection |
|
||||
| MetricReportDefinition | `/redfish/v1/TelemetryService/MetricReportDefinitions/{mrd}` | MetricReportDefinition.v1_3_0 |
|
||||
| MetricReport Collection | `/redfish/v1/TelemetryService/MetricReports` | MetricReportCollection |
|
||||
| MetricReport | `/redfish/v1/TelemetryService/MetricReports/{mr}` | MetricReport.v1_2_0 |
|
||||
| Telemetry LogService | `/redfish/v1/TelemetryService/LogService` | LogService.v1_1_3 |
|
||||
| Telemetry LogEntry Collection | `/redfish/v1/TelemetryService/LogService/Entries` | LogEntryCollection |
|
||||
|
||||
---
|
||||
|
||||
## 3. Processor / NIC sub-resources (GPU-relevant)
|
||||
|
||||
| Resource | URI |
|
||||
|---|---|
|
||||
| Processor (NetworkAdapter) | `/redfish/v1/Chassis/{ch}/NetworkAdapters/{na}/Processors/{proc}` |
|
||||
| AccelerationFunction Collection | `/redfish/v1/Systems/{sys}/Processors/{proc}/AccelerationFunctions` |
|
||||
| AccelerationFunction | `/redfish/v1/Systems/{sys}/Processors/{proc}/AccelerationFunctions/{fn}` |
|
||||
| Port Collection (NetworkAdapter) | `/redfish/v1/Chassis/{ch}/NetworkAdapters/{na}/Ports` |
|
||||
| Port (GraphicsController) | `/redfish/v1/Systems/{sys}/GraphicsControllers/{gpu}/Ports/{port}` |
|
||||
| OperatingConfig Collection | `/redfish/v1/Systems/{sys}/Processors/{proc}/OperatingConfigs` |
|
||||
| OperatingConfig | `/redfish/v1/Systems/{sys}/Processors/{proc}/OperatingConfigs/{cfg}` |
|
||||
|
||||
---
|
||||
|
||||
## 4. Error response format
|
||||
|
||||
On error, the service returns an HTTP status code and a JSON body with a single `error` property:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "Base.1.12.0.ActionParameterMissing",
|
||||
"message": "...",
|
||||
"@Message.ExtendedInfo": [
|
||||
{
|
||||
"@odata.type": "#Message.v1_0_8.Message",
|
||||
"MessageId": "Base.1.12.0.ActionParameterMissing",
|
||||
"Message": "...",
|
||||
"MessageArgs": [],
|
||||
"Severity": "Warning",
|
||||
"Resolution": "..."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Common status codes:**
|
||||
|
||||
| Code | Meaning |
|
||||
|------|---------|
|
||||
| 200 | OK with body |
|
||||
| 201 | Created |
|
||||
| 204 | Success, no body |
|
||||
| 400 | Bad request / validation error |
|
||||
| 401 | Unauthorized |
|
||||
| 403 | Forbidden / firmware update in progress |
|
||||
| 404 | Resource not found |
|
||||
| 405 | Method not allowed |
|
||||
| 412 | ETag precondition failed (PATCH) |
|
||||
| 415 | Unsupported media type |
|
||||
| 428 | Missing precondition header (PATCH) |
|
||||
| 501 | Not implemented |
|
||||
|
||||
**Request validation sequence:**
|
||||
1. Authorization check → 401
|
||||
2. Entity privilege check → 403
|
||||
3. URI existence → 404
|
||||
4. Firmware update lock → 403
|
||||
5. Method allowed → 405
|
||||
6. Media type → 415
|
||||
7. Body format → 400
|
||||
8. PATCH: ETag header → 428/412
|
||||
9. Property validation → 400
|
||||
|
||||
---
|
||||
|
||||
## 5. OEM: Inventory refresh (AMI/MSI-specific)
|
||||
|
||||
### 5.1 InventoryCrc — force component re-inventory
|
||||
|
||||
`GET/POST/DELETE /redfish/v1/Systems/{sys}/Oem/Ami/Inventory/Crc`
|
||||
|
||||
The `GroupCrcList` field lists current CRC checksums per component group. When a group's CRC
|
||||
changes (host sends new inventory) or is explicitly zeroed out via POST, the BMC discards its
|
||||
cached inventory and re-reads that group from the host.
|
||||
|
||||
**CRC groups:**
|
||||
|
||||
| Group | Covers |
|
||||
|-------|--------|
|
||||
| `CPU` | Processors, ProcessorMetrics |
|
||||
| `DIMM` | Memory, MemoryDomains, MemoryChunks, MemoryMetrics |
|
||||
| `PCIE` | Storage, PCIeDevices, NetworkInterfaces, NetworkAdapters |
|
||||
| `CERTIFICATES` | Boot Certificates |
|
||||
| `SECURBOOT` | SecureBoot data |
|
||||
|
||||
**POST — invalidate selected groups (force re-inventory):**
|
||||
|
||||
```
|
||||
POST /redfish/v1/Systems/{sys}/Oem/Ami/Inventory/Crc
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"GroupCrcList": [
|
||||
{ "CPU": 0 },
|
||||
{ "DIMM": 0 },
|
||||
{ "PCIE": 0 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Setting a group's value to `0` signals the BMC to invalidate and repopulate that group on next
|
||||
host inventory push (typically at next boot or host-interface inventory cycle).
|
||||
|
||||
**DELETE** — remove all CRC records entirely.
|
||||
|
||||
**Note:** Inventory data is populated by the host via the Redfish Host Interface (in-band),
|
||||
not by the BMC itself. Zeroing a CRC group does not immediately re-read hardware — it marks
|
||||
the group as stale so the next host-side inventory push will be accepted. A cold reboot is the
|
||||
most reliable trigger.
|
||||
|
||||
### 5.2 InventoryData Status — monitor inventory processing
|
||||
|
||||
`GET /redfish/v1/Oem/Ami/InventoryData/Status`
|
||||
|
||||
Available only after the host has posted an inventory file. Shows current processing state.
|
||||
|
||||
**Status enum:**
|
||||
|
||||
| Value | Meaning |
|
||||
|-------|---------|
|
||||
| `BootInProgress` | Host is booting |
|
||||
| `Queued` | Processing task queued |
|
||||
| `In-Progress` | Processing running in background |
|
||||
| `Ready` / `Completed` | Processing finished successfully |
|
||||
| `Failed` | Processing failed |
|
||||
|
||||
Response also includes:
|
||||
- `InventoryData.DeletedModules` — array of groups updated in this population cycle
|
||||
- `InventoryData.Messages` — warnings/errors encountered during processing
|
||||
- `ProcessingTime` — milliseconds taken
|
||||
- `LastModifiedTime` — ISO 8601 timestamp of last successful update
|
||||
|
||||
### 5.3 Systems OEM properties — Inventory reference
|
||||
|
||||
`GET /redfish/v1/Systems/{sys}` → `Oem.Ami` contains:
|
||||
|
||||
| Property | Notes |
|
||||
|----------|-------|
|
||||
| `Inventory` | Reference to InventoryCrc URI + current GroupCrc data |
|
||||
| `RedfishVersion` | BIOS Redfish version (populated via Host Interface) |
|
||||
| `RtpVersion` | BIOS RTP version (populated via Host Interface) |
|
||||
| `ManagerBootConfiguration.ManagerBootMode` | PATCH to trigger soft reset: `SoftReset` / `ResetTimeout` / `None` |
|
||||
|
||||
---
|
||||
|
||||
## 6. OEM: Component state actions
|
||||
|
||||
### 6.1 Memory enable/disable
|
||||
|
||||
```
|
||||
POST /redfish/v1/Systems/{sys}/Memory/{mem}/Actions/AmiBios.ChangeState
|
||||
Content-Type: application/json
|
||||
|
||||
{ "State": "Disabled" }
|
||||
```
|
||||
|
||||
Response: 204.
|
||||
|
||||
### 6.2 PCIeFunction enable/disable
|
||||
|
||||
```
|
||||
POST /redfish/v1/Chassis/{ch}/PCIeDevices/{dev}/PCIeFunctions/{fn}/Actions/AmiBios.ChangeState
|
||||
Content-Type: application/json
|
||||
|
||||
{ "State": "Disabled" }
|
||||
```
|
||||
|
||||
Response: 204.
|
||||
|
||||
---
|
||||
|
||||
## 7. OEM: Storage sensor readings
|
||||
|
||||
`GET /redfish/v1/Systems/{sys}/Storage/{stor}` → `Oem.Ami.StorageControllerSensors`
|
||||
|
||||
Array of sensor objects per storage controller instance. Each entry exposes:
|
||||
- `Reading` (Number) — current sensor value
|
||||
- `ReadingType` (String) — type of reading
|
||||
- `ReadingUnit` (String) — unit
|
||||
|
||||
---
|
||||
|
||||
## 8. OEM: Power and Thermal OwnerLUN
|
||||
|
||||
Both `GET /redfish/v1/Chassis/{ch}/Power` and `GET /redfish/v1/Chassis/{ch}/Thermal` expose
|
||||
`Oem.Ami.OwnerLUN` (Number, read-only) — the IPMI LUN associated with each
|
||||
temperature/fan/voltage sensor entry. Useful for correlating Redfish sensor readings with IPMI
|
||||
SDR records.
|
||||
|
||||
---
|
||||
|
||||
## 9. UpdateService
|
||||
|
||||
`GET /redfish/v1/UpdateService` → `Oem.Ami.BMC.DualImageConfiguration`:
|
||||
|
||||
| Property | Description |
|
||||
|----------|-------------|
|
||||
| `ActiveImage` | Currently active BMC image slot |
|
||||
| `BootImage` | Image slot BMC boots from |
|
||||
| `FirmwareImage1Name` / `FirmwareImage1Version` | First image slot name + version |
|
||||
| `FirmwareImage2Name` / `FirmwareImage2Version` | Second image slot name + version |
|
||||
|
||||
Standard `SimpleUpdate` action available at `/redfish/v1/UpdateService/Actions/UpdateService.SimpleUpdate`.
|
||||
|
||||
---
|
||||
|
||||
## 10. Inventory refresh summary
|
||||
|
||||
| Approach | Trigger | Latency | Scope |
|
||||
|----------|---------|---------|-------|
|
||||
| Host reboot | Physical/soft reset | Minutes | All groups |
|
||||
| `POST InventoryCrc` (groups = 0) | Explicit API call | Next host inventory push | Selected groups |
|
||||
| Firmware update (`SimpleUpdate`) | Explicit API call | Minutes + reboot | Full platform |
|
||||
| Sensor/telemetry reads | Always live on GET | Immediate | Sensors only |
|
||||
|
||||
**Key constraint:** `InventoryCrc POST` marks groups stale but does not re-read hardware
|
||||
directly. Actual inventory data flows from the host to BMC via the Redfish Host Interface
|
||||
in-band channel, typically during POST/boot. For immediate inventory refresh without a full
|
||||
reboot, a soft reset via `ManagerBootMode: SoftReset` PATCH may be sufficient on some
|
||||
configurations.
|
||||
Submodule internal/chart updated: c025ae0477...2fb01d30a6
@@ -110,7 +110,7 @@ func (c *RedfishConnector) Probe(ctx context.Context, req Request) (*ProbeResult
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("redfish system: %w", err)
|
||||
}
|
||||
powerState := strings.TrimSpace(asString(systemDoc["PowerState"]))
|
||||
powerState := redfishSystemPowerState(systemDoc)
|
||||
return &ProbeResult{
|
||||
Reachable: true,
|
||||
Protocol: "redfish",
|
||||
@@ -147,6 +147,7 @@ func (c *RedfishConnector) Collect(ctx context.Context, req Request, emit Progre
|
||||
snapshotClient := c.httpClientWithTimeout(req, redfishSnapshotRequestTimeout())
|
||||
prefetchClient := c.httpClientWithTimeout(req, redfishPrefetchRequestTimeout())
|
||||
criticalClient := c.httpClientWithTimeout(req, redfishCriticalRequestTimeout())
|
||||
hintClient := c.httpClientWithTimeout(req, 4*time.Second)
|
||||
|
||||
if emit != nil {
|
||||
emit(Progress{Status: "running", Progress: 10, Message: "Redfish: подключение к BMC..."})
|
||||
@@ -159,14 +160,11 @@ func (c *RedfishConnector) Collect(ctx context.Context, req Request, emit Progre
|
||||
|
||||
systemPaths := c.discoverMemberPaths(discoveryCtx, snapshotClient, req, baseURL, "/redfish/v1/Systems", "/redfish/v1/Systems/1")
|
||||
primarySystem := firstNonEmptyPath(systemPaths, "/redfish/v1/Systems/1")
|
||||
poweredOnByCollector := false
|
||||
if primarySystem != "" {
|
||||
if on, changed := c.ensureHostPowerForCollection(ctx, snapshotClient, req, baseURL, primarySystem, emit); on {
|
||||
poweredOnByCollector = changed
|
||||
}
|
||||
c.ensureHostPowerForCollection(ctx, snapshotClient, req, baseURL, primarySystem, emit)
|
||||
}
|
||||
defer func() {
|
||||
if !poweredOnByCollector || primarySystem == "" {
|
||||
if primarySystem == "" || !req.StopHostAfterCollect {
|
||||
return
|
||||
}
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||
@@ -181,7 +179,8 @@ func (c *RedfishConnector) Collect(ctx context.Context, req Request, emit Progre
|
||||
chassisDoc, _ := c.getJSON(discoveryCtx, snapshotClient, req, baseURL, primaryChassis)
|
||||
managerDoc, _ := c.getJSON(discoveryCtx, snapshotClient, req, baseURL, primaryManager)
|
||||
resourceHints := append(append([]string{}, systemPaths...), append(chassisPaths, managerPaths...)...)
|
||||
signals := redfishprofile.CollectSignals(serviceRootDoc, systemDoc, chassisDoc, managerDoc, resourceHints)
|
||||
hintDocs := c.collectProfileHintDocs(discoveryCtx, hintClient, req, baseURL, primarySystem, primaryChassis)
|
||||
signals := redfishprofile.CollectSignals(serviceRootDoc, systemDoc, chassisDoc, managerDoc, resourceHints, hintDocs...)
|
||||
matchResult := redfishprofile.MatchProfiles(signals)
|
||||
acquisitionPlan := redfishprofile.BuildAcquisitionPlan(signals)
|
||||
telemetrySummary := telemetry.Snapshot()
|
||||
@@ -311,6 +310,12 @@ func (c *RedfishConnector) Collect(ctx context.Context, req Request, emit Progre
|
||||
if emit != nil {
|
||||
emit(Progress{Status: "running", Progress: 99, Message: "Redfish: анализ raw snapshot..."})
|
||||
}
|
||||
// Collect hardware event logs separately (not part of tree-walk to avoid bloat).
|
||||
rawLogEntries := c.collectRedfishLogEntries(withRedfishTelemetryPhase(ctx, "log_entries"), snapshotClient, req, baseURL, systemPaths, managerPaths)
|
||||
var debugPayloads map[string]any
|
||||
if req.DebugPayloads {
|
||||
debugPayloads = c.collectDebugPayloads(ctx, snapshotClient, req, baseURL, systemPaths)
|
||||
}
|
||||
rawPayloads := map[string]any{
|
||||
"redfish_tree": rawTree,
|
||||
"redfish_profiles": map[string]any{
|
||||
@@ -413,12 +418,24 @@ func (c *RedfishConnector) Collect(ctx context.Context, req Request, emit Progre
|
||||
if len(fetchErrMap) > 0 {
|
||||
rawPayloads["redfish_fetch_errors"] = redfishFetchErrorMapToList(fetchErrMap)
|
||||
}
|
||||
if len(rawLogEntries) > 0 {
|
||||
rawPayloads["redfish_log_entries"] = rawLogEntries
|
||||
}
|
||||
if len(debugPayloads) > 0 {
|
||||
rawPayloads["redfish_debug_payloads"] = debugPayloads
|
||||
}
|
||||
// Unified tunnel: live collection and raw import go through the same analyzer over redfish_tree.
|
||||
result, err := ReplayRedfishFromRawPayloads(rawPayloads, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
totalElapsed := time.Since(collectStart).Round(time.Second)
|
||||
if !result.InventoryLastModifiedAt.IsZero() {
|
||||
log.Printf("redfish-collect: inventory last modified at %s (age: %s)",
|
||||
result.InventoryLastModifiedAt.Format(time.RFC3339),
|
||||
time.Since(result.InventoryLastModifiedAt).Round(time.Minute),
|
||||
)
|
||||
}
|
||||
log.Printf(
|
||||
"redfish-postprobe-metrics: nvme_candidates=%d nvme_selected=%d nvme_added=%d candidates=%d selected=%d skipped_explicit=%d added=%d dur=%s",
|
||||
postProbeMetrics.NVMECandidates,
|
||||
@@ -477,7 +494,7 @@ func (c *RedfishConnector) ensureHostPowerForCollection(ctx context.Context, cli
|
||||
return false, false
|
||||
}
|
||||
|
||||
powerState := strings.TrimSpace(asString(systemDoc["PowerState"]))
|
||||
powerState := redfishSystemPowerState(systemDoc)
|
||||
if isRedfishHostPoweredOn(powerState) {
|
||||
if emit != nil {
|
||||
emit(Progress{Status: "running", Progress: 18, Message: fmt.Sprintf("Redfish: host включен (%s)", firstNonEmpty(powerState, "On"))})
|
||||
@@ -495,6 +512,11 @@ func (c *RedfishConnector) ensureHostPowerForCollection(ctx context.Context, cli
|
||||
return false, false
|
||||
}
|
||||
|
||||
// Invalidate all inventory CRC groups before powering on so the BMC accepts
|
||||
// fresh inventory from the host after boot. Best-effort: failure is logged but
|
||||
// does not block power-on.
|
||||
c.invalidateRedfishInventory(ctx, client, req, baseURL, systemPath, emit)
|
||||
|
||||
resetTarget := redfishResetActionTarget(systemDoc)
|
||||
resetType := redfishPickResetType(systemDoc, "On", "ForceOn")
|
||||
if resetTarget == "" || resetType == "" {
|
||||
@@ -549,11 +571,12 @@ func (c *RedfishConnector) waitForStablePoweredOnHost(ctx context.Context, clien
|
||||
})
|
||||
}
|
||||
timer := time.NewTimer(stabilizationDelay)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return false
|
||||
case <-timer.C:
|
||||
timer.Stop()
|
||||
}
|
||||
}
|
||||
if emit != nil {
|
||||
@@ -563,7 +586,92 @@ func (c *RedfishConnector) waitForStablePoweredOnHost(ctx context.Context, clien
|
||||
Message: "Redfish: повторная проверка PowerState после стабилизации host",
|
||||
})
|
||||
}
|
||||
return c.waitForHostPowerState(ctx, client, req, baseURL, systemPath, true, 5*time.Second)
|
||||
if !c.waitForHostPowerState(ctx, client, req, baseURL, systemPath, true, 5*time.Second) {
|
||||
return false
|
||||
}
|
||||
|
||||
// After the initial stabilization wait, the BMC may still be populating its
|
||||
// hardware inventory (PCIeDevices, memory summary). Poll readiness with
|
||||
// increasing back-off (default: +60s, +120s), then warn and proceed.
|
||||
readinessWaits := redfishBMCReadinessWaits()
|
||||
for attempt, extraWait := range readinessWaits {
|
||||
ready, reason := c.isBMCInventoryReady(ctx, client, req, baseURL, systemPath)
|
||||
if ready {
|
||||
if emit != nil {
|
||||
emit(Progress{
|
||||
Status: "running",
|
||||
Progress: 20,
|
||||
Message: fmt.Sprintf("Redfish: BMC готов (%s)", reason),
|
||||
})
|
||||
}
|
||||
return true
|
||||
}
|
||||
if emit != nil {
|
||||
emit(Progress{
|
||||
Status: "running",
|
||||
Progress: 20,
|
||||
Message: fmt.Sprintf("Redfish: BMC не готов (%s), ожидание %s (попытка %d/%d)", reason, extraWait, attempt+1, len(readinessWaits)),
|
||||
})
|
||||
}
|
||||
timer := time.NewTimer(extraWait)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return false
|
||||
case <-timer.C:
|
||||
timer.Stop()
|
||||
}
|
||||
if emit != nil {
|
||||
emit(Progress{
|
||||
Status: "running",
|
||||
Progress: 20,
|
||||
Message: fmt.Sprintf("Redfish: повторная проверка готовности BMC (%d/%d)...", attempt+1, len(readinessWaits)),
|
||||
})
|
||||
}
|
||||
}
|
||||
ready, reason := c.isBMCInventoryReady(ctx, client, req, baseURL, systemPath)
|
||||
if !ready {
|
||||
if emit != nil {
|
||||
emit(Progress{
|
||||
Status: "running",
|
||||
Progress: 20,
|
||||
Message: fmt.Sprintf("Redfish: WARNING — BMC не подтвердил готовность (%s), сбор может быть неполным", reason),
|
||||
})
|
||||
}
|
||||
} else if emit != nil {
|
||||
emit(Progress{
|
||||
Status: "running",
|
||||
Progress: 20,
|
||||
Message: fmt.Sprintf("Redfish: BMC готов (%s)", reason),
|
||||
})
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// isBMCInventoryReady checks whether the BMC has finished populating its
|
||||
// hardware inventory after a power-on. Returns (ready, reason).
|
||||
// It considers the BMC ready if either the system memory summary reports
|
||||
// a non-zero total or the PCIeDevices collection is non-empty.
|
||||
func (c *RedfishConnector) isBMCInventoryReady(ctx context.Context, client *http.Client, req Request, baseURL, systemPath string) (bool, string) {
|
||||
systemDoc, err := c.getJSON(ctx, client, req, baseURL, systemPath)
|
||||
if err != nil {
|
||||
return false, "не удалось прочитать System"
|
||||
}
|
||||
if summary, ok := systemDoc["MemorySummary"].(map[string]interface{}); ok {
|
||||
if asFloat(summary["TotalSystemMemoryGiB"]) > 0 {
|
||||
return true, "MemorySummary заполнен"
|
||||
}
|
||||
}
|
||||
pcieDoc, err := c.getJSON(ctx, client, req, baseURL, joinPath(systemPath, "/PCIeDevices"))
|
||||
if err == nil {
|
||||
if asInt(pcieDoc["Members@odata.count"]) > 0 {
|
||||
return true, "PCIeDevices не пуст"
|
||||
}
|
||||
if members, ok := pcieDoc["Members"].([]interface{}); ok && len(members) > 0 {
|
||||
return true, "PCIeDevices не пуст"
|
||||
}
|
||||
}
|
||||
return false, "MemorySummary=0, PCIeDevices пуст"
|
||||
}
|
||||
|
||||
func (c *RedfishConnector) restoreHostPowerAfterCollection(ctx context.Context, client *http.Client, req Request, baseURL, systemPath string, emit ProgressFn) {
|
||||
@@ -602,12 +710,50 @@ func (c *RedfishConnector) restoreHostPowerAfterCollection(ctx context.Context,
|
||||
}
|
||||
}
|
||||
|
||||
// collectDebugPayloads fetches vendor-specific diagnostic endpoints on a best-effort basis.
|
||||
// Results are stored in rawPayloads["redfish_debug_payloads"] and exported with the bundle.
|
||||
// Enabled only when Request.DebugPayloads is true.
|
||||
func (c *RedfishConnector) collectDebugPayloads(ctx context.Context, client *http.Client, req Request, baseURL string, systemPaths []string) map[string]any {
|
||||
out := map[string]any{}
|
||||
for _, systemPath := range systemPaths {
|
||||
// AMI/MSI: inventory CRC groups — reveals which groups are supported by this BMC.
|
||||
if doc, err := c.getJSON(ctx, client, req, baseURL, joinPath(systemPath, "/Oem/Ami/Inventory/Crc")); err == nil {
|
||||
out[joinPath(systemPath, "/Oem/Ami/Inventory/Crc")] = doc
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// invalidateRedfishInventory POSTs to the AMI/MSI InventoryCrc endpoint to zero out
|
||||
// all known CRC groups before a host power-on. This causes the BMC to accept fresh
|
||||
// inventory from the host after boot, preventing stale inventory (ghost GPUs, wrong
|
||||
// BIOS version, etc.) from persisting across hardware changes.
|
||||
// Best-effort: any error is logged and the call silently returns.
|
||||
func (c *RedfishConnector) invalidateRedfishInventory(ctx context.Context, client *http.Client, req Request, baseURL, systemPath string, emit ProgressFn) {
|
||||
crcPath := joinPath(systemPath, "/Oem/Ami/Inventory/Crc")
|
||||
body := map[string]any{
|
||||
"GroupCrcList": []map[string]any{
|
||||
{"CPU": 0},
|
||||
{"DIMM": 0},
|
||||
{"PCIE": 0},
|
||||
},
|
||||
}
|
||||
if err := c.postJSON(ctx, client, req, baseURL, crcPath, body); err != nil {
|
||||
log.Printf("redfish: inventory invalidation skipped (not AMI/MSI or endpoint unavailable): %v", err)
|
||||
return
|
||||
}
|
||||
log.Printf("redfish: inventory CRC groups invalidated at %s before host power-on", crcPath)
|
||||
if emit != nil {
|
||||
emit(Progress{Status: "running", Progress: 19, Message: "Redfish: инвентарь BMC инвалидирован перед включением host (все CRC группы сброшены)"})
|
||||
}
|
||||
}
|
||||
|
||||
func (c *RedfishConnector) waitForHostPowerState(ctx context.Context, client *http.Client, req Request, baseURL, systemPath string, wantOn bool, timeout time.Duration) bool {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for {
|
||||
systemDoc, err := c.getJSON(ctx, client, req, baseURL, systemPath)
|
||||
if err == nil {
|
||||
if isRedfishHostPoweredOn(strings.TrimSpace(asString(systemDoc["PowerState"]))) == wantOn {
|
||||
if isRedfishHostPoweredOn(redfishSystemPowerState(systemDoc)) == wantOn {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -640,6 +786,19 @@ func isRedfishHostPoweredOn(state string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func redfishSystemPowerState(systemDoc map[string]interface{}) string {
|
||||
if len(systemDoc) == 0 {
|
||||
return ""
|
||||
}
|
||||
if state := strings.TrimSpace(asString(systemDoc["PowerState"])); state != "" {
|
||||
return state
|
||||
}
|
||||
if summary, ok := systemDoc["PowerSummary"].(map[string]interface{}); ok {
|
||||
return strings.TrimSpace(asString(summary["PowerState"]))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func redfishResetActionTarget(systemDoc map[string]interface{}) string {
|
||||
if systemDoc == nil {
|
||||
return ""
|
||||
@@ -1129,12 +1288,17 @@ func (c *RedfishConnector) collectNICs(ctx context.Context, client *http.Client,
|
||||
}
|
||||
for _, doc := range adapterDocs {
|
||||
nic := parseNIC(doc)
|
||||
adapterFunctionDocs := c.getNetworkAdapterFunctionDocs(ctx, client, req, baseURL, doc)
|
||||
for _, pciePath := range networkAdapterPCIeDevicePaths(doc) {
|
||||
pcieDoc, err := c.getJSON(ctx, client, req, baseURL, pciePath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
functionDocs := c.getLinkedPCIeFunctions(ctx, client, req, baseURL, pcieDoc)
|
||||
for _, adapterFnDoc := range adapterFunctionDocs {
|
||||
functionDocs = append(functionDocs, c.getLinkedPCIeFunctions(ctx, client, req, baseURL, adapterFnDoc)...)
|
||||
}
|
||||
functionDocs = dedupeJSONDocsByPath(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")...)
|
||||
@@ -1354,16 +1518,13 @@ func (c *RedfishConnector) collectPCIeDevices(ctx context.Context, client *http.
|
||||
}
|
||||
|
||||
func (c *RedfishConnector) getChassisScopedPCIeSupplementalDocs(ctx context.Context, client *http.Client, req Request, baseURL string, doc map[string]interface{}) []map[string]interface{} {
|
||||
if !looksLikeNVSwitchPCIeDoc(doc) {
|
||||
return nil
|
||||
}
|
||||
docPath := normalizeRedfishPath(asString(doc["@odata.id"]))
|
||||
chassisPath := chassisPathForPCIeDoc(docPath)
|
||||
if chassisPath == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
out := make([]map[string]interface{}, 0, 4)
|
||||
out := make([]map[string]interface{}, 0, 6)
|
||||
seen := make(map[string]struct{})
|
||||
add := func(path string) {
|
||||
path = normalizeRedfishPath(path)
|
||||
@@ -1381,8 +1542,19 @@ func (c *RedfishConnector) getChassisScopedPCIeSupplementalDocs(ctx context.Cont
|
||||
out = append(out, supplementalDoc)
|
||||
}
|
||||
|
||||
add(joinPath(chassisPath, "/EnvironmentMetrics"))
|
||||
add(joinPath(chassisPath, "/ThermalSubsystem/ThermalMetrics"))
|
||||
if looksLikeNVSwitchPCIeDoc(doc) {
|
||||
add(joinPath(chassisPath, "/EnvironmentMetrics"))
|
||||
add(joinPath(chassisPath, "/ThermalSubsystem/ThermalMetrics"))
|
||||
}
|
||||
deviceDocs, err := c.getCollectionMembers(ctx, client, req, baseURL, joinPath(chassisPath, "/Devices"))
|
||||
if err == nil {
|
||||
for _, deviceDoc := range deviceDocs {
|
||||
if !redfishPCIeMatchesChassisDeviceDoc(doc, deviceDoc) {
|
||||
continue
|
||||
}
|
||||
add(asString(deviceDoc["@odata.id"]))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -1413,6 +1585,33 @@ func (c *RedfishConnector) discoverMemberPaths(ctx context.Context, client *http
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *RedfishConnector) collectProfileHintDocs(ctx context.Context, client *http.Client, req Request, baseURL, systemPath, chassisPath string) []map[string]interface{} {
|
||||
paths := []string{
|
||||
"/redfish/v1/UpdateService/FirmwareInventory",
|
||||
joinPath(systemPath, "/NetworkInterfaces"),
|
||||
joinPath(chassisPath, "/Drives"),
|
||||
joinPath(chassisPath, "/NetworkAdapters"),
|
||||
}
|
||||
seen := make(map[string]struct{}, len(paths))
|
||||
docs := make([]map[string]interface{}, 0, len(paths))
|
||||
for _, path := range paths {
|
||||
path = normalizeRedfishPath(path)
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[path]; ok {
|
||||
continue
|
||||
}
|
||||
seen[path] = struct{}{}
|
||||
doc, err := c.getJSON(ctx, client, req, baseURL, path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
docs = append(docs, doc)
|
||||
}
|
||||
return docs
|
||||
}
|
||||
|
||||
func (c *RedfishConnector) collectRawRedfishTree(ctx context.Context, client *http.Client, req Request, baseURL string, seedPaths []string, tuning redfishprofile.AcquisitionTuning, emit ProgressFn) (map[string]interface{}, []map[string]interface{}, redfishPostProbeMetrics, string) {
|
||||
maxDocuments := redfishSnapshotMaxDocuments(tuning)
|
||||
workers := redfishSnapshotWorkers(tuning)
|
||||
@@ -1420,6 +1619,7 @@ func (c *RedfishConnector) collectRawRedfishTree(ctx context.Context, client *ht
|
||||
crawlStart := time.Now()
|
||||
memoryClient := c.httpClientWithTimeout(req, redfishSnapshotMemoryRequestTimeout())
|
||||
memoryGate := make(chan struct{}, redfishSnapshotMemoryConcurrency())
|
||||
postProbeClient := c.httpClientWithTimeout(req, redfishSnapshotPostProbeRequestTimeout())
|
||||
branchLimiter := newRedfishSnapshotBranchLimiter(redfishSnapshotBranchConcurrency())
|
||||
branchRetryPause := redfishSnapshotBranchRequeueBackoff()
|
||||
timings := newRedfishPathTimingCollector(4)
|
||||
@@ -1722,7 +1922,7 @@ func (c *RedfishConnector) collectRawRedfishTree(ctx context.Context, client *ht
|
||||
ETASeconds: int(estimateProgressETA(postProbeStart, i, len(postProbeCollections), 3*time.Second).Seconds()),
|
||||
})
|
||||
}
|
||||
for childPath, doc := range c.probeDirectRedfishCollectionChildren(ctx, client, req, baseURL, path) {
|
||||
for childPath, doc := range c.probeDirectRedfishCollectionChildren(ctx, postProbeClient, req, baseURL, path) {
|
||||
if _, exists := out[childPath]; exists {
|
||||
continue
|
||||
}
|
||||
@@ -1972,6 +2172,12 @@ func shouldAdaptivePostProbeCollectionPath(path string, collectionDoc map[string
|
||||
if len(memberRefs) == 0 {
|
||||
return true
|
||||
}
|
||||
// If the collection reports an explicit non-zero member count that already
|
||||
// matches the number of discovered member refs, every member is accounted
|
||||
// for and numeric probing cannot find anything new.
|
||||
if odataCount := asInt(collectionDoc["Members@odata.count"]); odataCount > 0 && odataCount == len(memberRefs) {
|
||||
return false
|
||||
}
|
||||
return redfishCollectionHasNumericMemberRefs(memberRefs)
|
||||
}
|
||||
|
||||
@@ -2049,6 +2255,25 @@ func looksLikeRedfishResource(doc map[string]interface{}) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// isHardwareInventoryCollectionPath reports whether the path is a hardware
|
||||
// inventory collection that is expected to have members when the machine is
|
||||
// powered on and the BMC has finished initializing.
|
||||
func isHardwareInventoryCollectionPath(p string) bool {
|
||||
for _, suffix := range []string{
|
||||
"/PCIeDevices",
|
||||
"/NetworkAdapters",
|
||||
"/Processors",
|
||||
"/Drives",
|
||||
"/Storage",
|
||||
"/EthernetInterfaces",
|
||||
} {
|
||||
if strings.HasSuffix(p, suffix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func shouldSlowProbeCriticalCollection(p string, tuning redfishprofile.AcquisitionTuning) bool {
|
||||
p = normalizeRedfishPath(p)
|
||||
if !tuning.RecoveryPolicy.EnableCriticalSlowProbe {
|
||||
@@ -2145,6 +2370,18 @@ func redfishSnapshotRequestTimeout() time.Duration {
|
||||
return 12 * time.Second
|
||||
}
|
||||
|
||||
func redfishSnapshotPostProbeRequestTimeout() time.Duration {
|
||||
if v := strings.TrimSpace(os.Getenv("LOGPILE_REDFISH_POSTPROBE_TIMEOUT")); v != "" {
|
||||
if d, err := time.ParseDuration(v); err == nil && d > 0 {
|
||||
return d
|
||||
}
|
||||
}
|
||||
// Post-probe probes non-existent numeric paths expecting fast 404s.
|
||||
// A short timeout prevents BMCs that hang on unknown paths from stalling
|
||||
// the entire collection for minutes (e.g. HPE iLO on NetworkAdapters Ports).
|
||||
return 4 * time.Second
|
||||
}
|
||||
|
||||
func redfishSnapshotWorkers(tuning redfishprofile.AcquisitionTuning) int {
|
||||
if tuning.SnapshotWorkers >= 1 && tuning.SnapshotWorkers <= 16 {
|
||||
return tuning.SnapshotWorkers
|
||||
@@ -2369,6 +2606,25 @@ func redfishPowerOnStabilizationDelay() time.Duration {
|
||||
return 60 * time.Second
|
||||
}
|
||||
|
||||
// redfishBMCReadinessWaits returns the extra wait durations used when polling
|
||||
// BMC inventory readiness after power-on. Defaults: [60s, 120s].
|
||||
// Override with LOGPILE_REDFISH_BMC_READY_WAITS (comma-separated durations,
|
||||
// e.g. "60s,120s").
|
||||
func redfishBMCReadinessWaits() []time.Duration {
|
||||
if v := strings.TrimSpace(os.Getenv("LOGPILE_REDFISH_BMC_READY_WAITS")); v != "" {
|
||||
var out []time.Duration
|
||||
for _, part := range strings.Split(v, ",") {
|
||||
if d, err := time.ParseDuration(strings.TrimSpace(part)); err == nil && d >= 0 {
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
if len(out) > 0 {
|
||||
return out
|
||||
}
|
||||
}
|
||||
return []time.Duration{60 * time.Second, 120 * time.Second}
|
||||
}
|
||||
|
||||
func redfishSnapshotMemoryRequestTimeout() time.Duration {
|
||||
if v := strings.TrimSpace(os.Getenv("LOGPILE_REDFISH_MEMORY_TIMEOUT")); v != "" {
|
||||
if d, err := time.ParseDuration(v); err == nil && d > 0 {
|
||||
@@ -2561,9 +2817,10 @@ func shouldCrawlPath(path string) bool {
|
||||
}
|
||||
if strings.Contains(normalized, "/Chassis/") &&
|
||||
strings.Contains(normalized, "/PCIeDevices/") &&
|
||||
strings.Contains(normalized, "/PCIeFunctions/") {
|
||||
// Chassis-level PCIeFunctions links are frequently noisy/slow on some BMCs
|
||||
// and duplicate data we already collect from PCIe devices/functions elsewhere.
|
||||
strings.HasSuffix(normalized, "/PCIeFunctions") {
|
||||
// Avoid crawling entire chassis PCIeFunctions collections. Concrete member
|
||||
// docs can still be reached through direct links such as
|
||||
// NetworkDeviceFunction Links.PCIeFunction.
|
||||
return false
|
||||
}
|
||||
if strings.Contains(normalized, "/Memory/") {
|
||||
@@ -2627,6 +2884,9 @@ func shouldCrawlPath(path string) bool {
|
||||
"/Bios/Settings",
|
||||
"/GetServerAllUSBStatus",
|
||||
"/Oem/Public/KVM",
|
||||
"/SecureBoot/SecureBootDatabases",
|
||||
// HPE iLO WorkloadPerformanceAdvisor — operational/advisory data, not inventory.
|
||||
"/WorkloadPerformanceAdvisor",
|
||||
} {
|
||||
if strings.Contains(normalized, part) {
|
||||
return false
|
||||
@@ -2735,6 +2995,15 @@ func (c *RedfishConnector) getLinkedPCIeFunctions(ctx context.Context, client *h
|
||||
}
|
||||
return out
|
||||
}
|
||||
if ref, ok := links["PCIeFunction"].(map[string]interface{}); ok {
|
||||
memberPath := asString(ref["@odata.id"])
|
||||
if memberPath != "" {
|
||||
memberDoc, err := c.getJSON(ctx, client, req, baseURL, memberPath)
|
||||
if err == nil {
|
||||
return []map[string]interface{}{memberDoc}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Some implementations expose a collection object in PCIeFunctions.@odata.id.
|
||||
@@ -2750,6 +3019,22 @@ func (c *RedfishConnector) getLinkedPCIeFunctions(ctx context.Context, client *h
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *RedfishConnector) getNetworkAdapterFunctionDocs(ctx context.Context, client *http.Client, req Request, baseURL string, adapterDoc map[string]interface{}) []map[string]interface{} {
|
||||
ndfCol, ok := adapterDoc["NetworkDeviceFunctions"].(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
colPath := asString(ndfCol["@odata.id"])
|
||||
if colPath == "" {
|
||||
return nil
|
||||
}
|
||||
funcDocs, err := c.getCollectionMembers(ctx, client, req, baseURL, colPath)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return funcDocs
|
||||
}
|
||||
|
||||
func (c *RedfishConnector) getCollectionMembers(ctx context.Context, client *http.Client, req Request, baseURL, collectionPath string) ([]map[string]interface{}, error) {
|
||||
collection, err := c.getJSON(ctx, client, req, baseURL, collectionPath)
|
||||
if err != nil {
|
||||
@@ -2972,6 +3257,38 @@ func (c *RedfishConnector) recoverCriticalRedfishDocsPlanB(ctx context.Context,
|
||||
addTarget(memberPath)
|
||||
}
|
||||
}
|
||||
|
||||
// Re-probe critical hardware collections that were successfully fetched but
|
||||
// returned no members. This happens when the BMC hasn't finished enumerating
|
||||
// hardware at collection time (e.g. PCIeDevices or NetworkAdapters empty right
|
||||
// after power-on). Only hardware inventory collection suffixes are retried.
|
||||
if tuning.RecoveryPolicy.EnableEmptyCriticalCollectionRetry {
|
||||
for _, p := range criticalPaths {
|
||||
p = normalizeRedfishPath(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
if _, queued := seenTargets[p]; queued {
|
||||
continue
|
||||
}
|
||||
docAny, ok := rawTree[p]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
doc, ok := docAny.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if redfishCollectionHasExplicitMembers(doc) {
|
||||
continue
|
||||
}
|
||||
if !isHardwareInventoryCollectionPath(p) {
|
||||
continue
|
||||
}
|
||||
addTarget(p)
|
||||
}
|
||||
}
|
||||
|
||||
if len(targets) == 0 {
|
||||
return 0
|
||||
}
|
||||
@@ -3156,8 +3473,11 @@ func parseBoardInfo(system map[string]interface{}) models.BoardInfo {
|
||||
asString(system["Name"]),
|
||||
)),
|
||||
SerialNumber: normalizeRedfishIdentityField(asString(system["SerialNumber"])),
|
||||
PartNumber: normalizeRedfishIdentityField(asString(system["PartNumber"])),
|
||||
UUID: normalizeRedfishIdentityField(asString(system["UUID"])),
|
||||
PartNumber: normalizeRedfishIdentityField(firstNonEmpty(
|
||||
asString(system["PartNumber"]),
|
||||
asString(system["SKU"]),
|
||||
)),
|
||||
UUID: normalizeRedfishIdentityField(asString(system["UUID"])),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3261,14 +3581,20 @@ func parseCPUs(docs []map[string]interface{}) []models.CPU {
|
||||
}
|
||||
}
|
||||
l1, l2, l3 := parseCPUCachesFromProcessorMemory(doc)
|
||||
publicSerial := redfishCPUPublicSerial(doc)
|
||||
serial := normalizeRedfishIdentityField(asString(doc["SerialNumber"]))
|
||||
if serial == "" && publicSerial == "" {
|
||||
serial = findFirstNormalizedStringByKeys(doc, "SerialNumber")
|
||||
}
|
||||
cpus = append(cpus, models.CPU{
|
||||
Socket: socket,
|
||||
Model: firstNonEmpty(asString(doc["Model"]), asString(doc["Name"])),
|
||||
Cores: asInt(doc["TotalCores"]),
|
||||
Threads: asInt(doc["TotalThreads"]),
|
||||
FrequencyMHz: asInt(doc["OperatingSpeedMHz"]),
|
||||
MaxFreqMHz: asInt(doc["MaxSpeedMHz"]),
|
||||
SerialNumber: findFirstNormalizedStringByKeys(doc, "SerialNumber"),
|
||||
FrequencyMHz: int(redfishFirstNumeric(doc, "OperatingSpeedMHz", "CurrentSpeedMHz", "FrequencyMHz")),
|
||||
MaxFreqMHz: int(redfishFirstNumeric(doc, "MaxSpeedMHz", "TurboEnableMaxSpeedMHz", "TurboDisableMaxSpeedMHz")),
|
||||
PPIN: firstNonEmpty(findFirstNormalizedStringByKeys(doc, "PPIN", "ProtectedIdentificationNumber"), publicSerial),
|
||||
SerialNumber: serial,
|
||||
L1CacheKB: l1,
|
||||
L2CacheKB: l2,
|
||||
L3CacheKB: l3,
|
||||
@@ -3279,6 +3605,12 @@ func parseCPUs(docs []map[string]interface{}) []models.CPU {
|
||||
return cpus
|
||||
}
|
||||
|
||||
func redfishCPUPublicSerial(doc map[string]interface{}) string {
|
||||
oem, _ := doc["Oem"].(map[string]interface{})
|
||||
public, _ := oem["Public"].(map[string]interface{})
|
||||
return normalizeRedfishIdentityField(asString(public["SerialNumber"]))
|
||||
}
|
||||
|
||||
// parseCPUCachesFromProcessorMemory reads L1/L2/L3 cache sizes from the
|
||||
// Redfish ProcessorMemory array (Processor.v1_x spec).
|
||||
func parseCPUCachesFromProcessorMemory(doc map[string]interface{}) (l1, l2, l3 int) {
|
||||
@@ -3528,6 +3860,22 @@ func parseStorageVolume(doc map[string]interface{}, controller string) models.St
|
||||
}
|
||||
}
|
||||
|
||||
func redfishVolumeCapabilitiesDoc(doc map[string]interface{}) bool {
|
||||
if len(doc) == 0 {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(strings.ToLower(strings.TrimSpace(asString(doc["@odata.type"]))), "collectioncapabilities") {
|
||||
return true
|
||||
}
|
||||
path := strings.ToLower(normalizeRedfishPath(asString(doc["@odata.id"])))
|
||||
if strings.HasSuffix(path, "/volumes/capabilities") {
|
||||
return true
|
||||
}
|
||||
id := strings.TrimSpace(asString(doc["Id"]))
|
||||
name := strings.ToLower(strings.TrimSpace(asString(doc["Name"])))
|
||||
return strings.EqualFold(id, "Capabilities") || strings.Contains(name, "capabilities for volumecollection")
|
||||
}
|
||||
|
||||
func parseNIC(doc map[string]interface{}) models.NetworkAdapter {
|
||||
vendorID := asHexOrInt(doc["VendorId"])
|
||||
deviceID := asHexOrInt(doc["DeviceId"])
|
||||
@@ -3550,23 +3898,31 @@ func parseNIC(doc map[string]interface{}) models.NetworkAdapter {
|
||||
var linkSpeed string
|
||||
var maxLinkSpeed string
|
||||
if controllers, ok := doc["Controllers"].([]interface{}); ok && len(controllers) > 0 {
|
||||
if ctrl, ok := controllers[0].(map[string]interface{}); ok {
|
||||
totalPortCount := 0
|
||||
for _, ctrlAny := range controllers {
|
||||
ctrl, ok := ctrlAny.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
ctrlLocation := redfishLocationLabel(ctrl["Location"])
|
||||
location = firstNonEmpty(location, ctrlLocation)
|
||||
if isWeakRedfishNICSlotLabel(slot) {
|
||||
slot = firstNonEmpty(ctrlLocation, slot)
|
||||
}
|
||||
firmware = asString(ctrl["FirmwarePackageVersion"])
|
||||
if caps, ok := ctrl["ControllerCapabilities"].(map[string]interface{}); ok {
|
||||
portCount = sanitizeNetworkPortCount(asInt(caps["NetworkPortCount"]))
|
||||
if normalizeRedfishIdentityField(firmware) == "" {
|
||||
firmware = findFirstNormalizedStringByKeys(ctrl, "FirmwarePackageVersion", "FirmwareVersion")
|
||||
}
|
||||
if pcieIf, ok := ctrl["PCIeInterface"].(map[string]interface{}); ok {
|
||||
if caps, ok := ctrl["ControllerCapabilities"].(map[string]interface{}); ok {
|
||||
totalPortCount += sanitizeNetworkPortCount(asInt(caps["NetworkPortCount"]))
|
||||
}
|
||||
if pcieIf, ok := ctrl["PCIeInterface"].(map[string]interface{}); ok && linkWidth == 0 && maxLinkWidth == 0 && linkSpeed == "" && maxLinkSpeed == "" {
|
||||
linkWidth = asInt(pcieIf["LanesInUse"])
|
||||
maxLinkWidth = asInt(pcieIf["MaxLanes"])
|
||||
linkSpeed = firstNonEmpty(asString(pcieIf["PCIeType"]), asString(pcieIf["CurrentLinkSpeedGTs"]), asString(pcieIf["CurrentLinkSpeed"]))
|
||||
maxLinkSpeed = firstNonEmpty(asString(pcieIf["MaxPCIeType"]), asString(pcieIf["MaxLinkSpeedGTs"]), asString(pcieIf["MaxLinkSpeed"]))
|
||||
}
|
||||
}
|
||||
portCount = sanitizeNetworkPortCount(totalPortCount)
|
||||
}
|
||||
|
||||
return models.NetworkAdapter{
|
||||
@@ -3596,10 +3952,14 @@ func isWeakRedfishNICSlotLabel(slot string) bool {
|
||||
if slot == "" {
|
||||
return true
|
||||
}
|
||||
lower := strings.ToLower(slot)
|
||||
if isNumericString(slot) {
|
||||
return true
|
||||
}
|
||||
if strings.EqualFold(slot, "nic") || strings.HasPrefix(strings.ToLower(slot), "nic") && !strings.Contains(strings.ToLower(slot), "slot") {
|
||||
if strings.EqualFold(slot, "nic") || strings.HasPrefix(lower, "nic") && !strings.Contains(lower, "slot") {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(lower, "devtype") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -3639,6 +3999,16 @@ func enrichNICFromPCIe(nic *models.NetworkAdapter, pcieDoc map[string]interface{
|
||||
if nic == nil {
|
||||
return
|
||||
}
|
||||
pcieSlot := redfishLocationLabel(pcieDoc["Slot"])
|
||||
if pcieSlot == "" {
|
||||
pcieSlot = redfishLocationLabel(pcieDoc["Location"])
|
||||
}
|
||||
if isWeakRedfishNICSlotLabel(nic.Slot) && pcieSlot != "" {
|
||||
nic.Slot = pcieSlot
|
||||
}
|
||||
if strings.TrimSpace(nic.Location) == "" && pcieSlot != "" {
|
||||
nic.Location = pcieSlot
|
||||
}
|
||||
if strings.TrimSpace(nic.BDF) == "" {
|
||||
nic.BDF = firstNonEmpty(asString(pcieDoc["BDF"]), buildBDFfromOemPublic(pcieDoc))
|
||||
}
|
||||
@@ -3660,6 +4030,15 @@ func enrichNICFromPCIe(nic *models.NetworkAdapter, pcieDoc map[string]interface{
|
||||
if strings.TrimSpace(nic.MaxLinkSpeed) == "" {
|
||||
nic.MaxLinkSpeed = firstNonEmpty(asString(pcieDoc["MaxLinkSpeedGTs"]), asString(pcieDoc["MaxLinkSpeed"]))
|
||||
}
|
||||
if normalizeRedfishIdentityField(nic.SerialNumber) == "" {
|
||||
nic.SerialNumber = findFirstNormalizedStringByKeys(pcieDoc, "SerialNumber")
|
||||
}
|
||||
if normalizeRedfishIdentityField(nic.PartNumber) == "" {
|
||||
nic.PartNumber = findFirstNormalizedStringByKeys(pcieDoc, "PartNumber", "ProductPartNumber")
|
||||
}
|
||||
if normalizeRedfishIdentityField(nic.Firmware) == "" {
|
||||
nic.Firmware = findFirstNormalizedStringByKeys(pcieDoc, "FirmwareVersion", "FirmwarePackageVersion")
|
||||
}
|
||||
for _, fn := range functionDocs {
|
||||
if strings.TrimSpace(nic.BDF) == "" {
|
||||
nic.BDF = sanitizeRedfishBDF(asString(fn["FunctionId"]))
|
||||
@@ -3682,6 +4061,15 @@ func enrichNICFromPCIe(nic *models.NetworkAdapter, pcieDoc map[string]interface{
|
||||
if strings.TrimSpace(nic.MaxLinkSpeed) == "" {
|
||||
nic.MaxLinkSpeed = firstNonEmpty(asString(fn["MaxLinkSpeedGTs"]), asString(fn["MaxLinkSpeed"]))
|
||||
}
|
||||
if normalizeRedfishIdentityField(nic.SerialNumber) == "" {
|
||||
nic.SerialNumber = findFirstNormalizedStringByKeys(fn, "SerialNumber")
|
||||
}
|
||||
if normalizeRedfishIdentityField(nic.PartNumber) == "" {
|
||||
nic.PartNumber = findFirstNormalizedStringByKeys(fn, "PartNumber", "ProductPartNumber")
|
||||
}
|
||||
if normalizeRedfishIdentityField(nic.Firmware) == "" {
|
||||
nic.Firmware = findFirstNormalizedStringByKeys(fn, "FirmwareVersion", "FirmwarePackageVersion")
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(nic.Vendor) == "" {
|
||||
nic.Vendor = pciids.VendorName(nic.VendorID)
|
||||
@@ -3727,7 +4115,7 @@ func parsePSUWithSupplementalDocs(doc map[string]interface{}, idx int, supplemen
|
||||
Present: present,
|
||||
Model: firstNonEmpty(asString(doc["Model"]), asString(doc["Name"])),
|
||||
Vendor: asString(doc["Manufacturer"]),
|
||||
WattageW: asInt(doc["PowerCapacityWatts"]),
|
||||
WattageW: redfishPSUNominalWattage(doc),
|
||||
SerialNumber: findFirstNormalizedStringByKeys(doc, "SerialNumber"),
|
||||
PartNumber: asString(doc["PartNumber"]),
|
||||
Firmware: asString(doc["FirmwareVersion"]),
|
||||
@@ -3740,6 +4128,25 @@ func parsePSUWithSupplementalDocs(doc map[string]interface{}, idx int, supplemen
|
||||
}
|
||||
}
|
||||
|
||||
func redfishPSUNominalWattage(doc map[string]interface{}) int {
|
||||
if ranges, ok := doc["InputRanges"].([]interface{}); ok {
|
||||
best := 0
|
||||
for _, rawRange := range ranges {
|
||||
rangeDoc, ok := rawRange.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if wattage := asInt(rangeDoc["OutputWattage"]); wattage > best {
|
||||
best = wattage
|
||||
}
|
||||
}
|
||||
if best > 0 {
|
||||
return best
|
||||
}
|
||||
}
|
||||
return asInt(doc["PowerCapacityWatts"])
|
||||
}
|
||||
|
||||
func redfishDriveDetails(doc map[string]interface{}) map[string]any {
|
||||
return redfishDriveDetailsWithSupplementalDocs(doc)
|
||||
}
|
||||
@@ -4047,6 +4454,39 @@ func redfishFirstBoolAcrossDocs(docs []map[string]interface{}, keys ...string) *
|
||||
return nil
|
||||
}
|
||||
|
||||
func redfishFirstString(doc map[string]interface{}, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if v, ok := redfishLookupValue(doc, key); ok {
|
||||
if s := strings.TrimSpace(asString(v)); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func redfishFirstStringAcrossDocs(docs []map[string]interface{}, keys ...string) string {
|
||||
for _, doc := range docs {
|
||||
if v := redfishFirstString(doc, keys...); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func redfishFirstLocationAcrossDocs(docs []map[string]interface{}, keys ...string) string {
|
||||
for _, doc := range docs {
|
||||
for _, key := range keys {
|
||||
if v, ok := redfishLookupValue(doc, key); ok {
|
||||
if loc := redfishLocationLabel(v); loc != "" {
|
||||
return loc
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func redfishLookupValue(doc map[string]interface{}, key string) (any, bool) {
|
||||
if doc == nil || strings.TrimSpace(key) == "" {
|
||||
return nil, false
|
||||
@@ -4228,8 +4668,9 @@ func parsePCIeDevice(doc map[string]interface{}, functionDocs []map[string]inter
|
||||
}
|
||||
|
||||
func parsePCIeDeviceWithSupplementalDocs(doc map[string]interface{}, functionDocs []map[string]interface{}, supplementalDocs []map[string]interface{}) models.PCIeDevice {
|
||||
supplementalSlot := redfishFirstLocationAcrossDocs(supplementalDocs, "Slot", "Location", "PhysicalLocation")
|
||||
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"]), supplementalSlot, asString(doc["Name"]), asString(doc["Id"])),
|
||||
BDF: sanitizeRedfishBDF(asString(doc["BDF"])),
|
||||
DeviceClass: asString(doc["DeviceType"]),
|
||||
Manufacturer: asString(doc["Manufacturer"]),
|
||||
@@ -4269,6 +4710,9 @@ func parsePCIeDeviceWithSupplementalDocs(doc map[string]interface{}, functionDoc
|
||||
dev.MaxLinkSpeed = firstNonEmpty(asString(fn["MaxLinkSpeedGTs"]), asString(fn["MaxLinkSpeed"]))
|
||||
}
|
||||
}
|
||||
if dev.DeviceClass == "" || isGenericPCIeClassLabel(dev.DeviceClass) {
|
||||
dev.DeviceClass = firstNonEmpty(redfishFirstStringAcrossDocs(supplementalDocs, "DeviceType"), dev.DeviceClass)
|
||||
}
|
||||
|
||||
if dev.DeviceClass == "" {
|
||||
dev.DeviceClass = "PCIe device"
|
||||
@@ -4279,15 +4723,22 @@ func parsePCIeDeviceWithSupplementalDocs(doc map[string]interface{}, functionDoc
|
||||
}
|
||||
}
|
||||
if isGenericPCIeClassLabel(dev.DeviceClass) {
|
||||
// Redfish DeviceType (e.g. MultiFunction/Simulated) is a topology attribute,
|
||||
// not a user-facing device name. Prefer model/part labels when class cannot be resolved.
|
||||
dev.DeviceClass = firstNonEmpty(asString(doc["Model"]), dev.PartNumber, dev.DeviceClass)
|
||||
dev.DeviceClass = "PCIe device"
|
||||
}
|
||||
if strings.TrimSpace(dev.Manufacturer) == "" {
|
||||
dev.Manufacturer = pciids.VendorName(dev.VendorID)
|
||||
dev.Manufacturer = firstNonEmpty(
|
||||
redfishFirstStringAcrossDocs(supplementalDocs, "Manufacturer"),
|
||||
pciids.VendorName(dev.VendorID),
|
||||
)
|
||||
}
|
||||
if strings.TrimSpace(dev.PartNumber) == "" {
|
||||
dev.PartNumber = pciids.DeviceName(dev.VendorID, dev.DeviceID)
|
||||
dev.PartNumber = firstNonEmpty(
|
||||
redfishFirstStringAcrossDocs(supplementalDocs, "ProductPartNumber", "PartNumber"),
|
||||
pciids.DeviceName(dev.VendorID, dev.DeviceID),
|
||||
)
|
||||
}
|
||||
if normalizeRedfishIdentityField(dev.SerialNumber) == "" {
|
||||
dev.SerialNumber = redfishFirstStringAcrossDocs(supplementalDocs, "SerialNumber")
|
||||
}
|
||||
return dev
|
||||
}
|
||||
@@ -4342,6 +4793,9 @@ func isMissingOrRawPCIModel(model string) bool {
|
||||
if l == "unknown" || l == "n/a" || l == "na" || l == "none" {
|
||||
return true
|
||||
}
|
||||
if isGenericRedfishInventoryName(l) {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(l, "0x") && len(l) <= 6 {
|
||||
return true
|
||||
}
|
||||
@@ -4360,6 +4814,26 @@ func isMissingOrRawPCIModel(model string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func isGenericRedfishInventoryName(value string) bool {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
switch {
|
||||
case value == "":
|
||||
return false
|
||||
case value == "networkadapter", strings.HasPrefix(value, "networkadapter_"), strings.HasPrefix(value, "networkadapter "):
|
||||
return true
|
||||
case value == "pciedevice", strings.HasPrefix(value, "pciedevice_"), strings.HasPrefix(value, "pciedevice "):
|
||||
return true
|
||||
case value == "pciefunction", strings.HasPrefix(value, "pciefunction_"), strings.HasPrefix(value, "pciefunction "):
|
||||
return true
|
||||
case value == "ethernetinterface", strings.HasPrefix(value, "ethernetinterface_"), strings.HasPrefix(value, "ethernetinterface "):
|
||||
return true
|
||||
case value == "networkport", strings.HasPrefix(value, "networkport_"), strings.HasPrefix(value, "networkport "):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// isUnidentifiablePCIeDevice returns true for PCIe topology entries that carry no
|
||||
// useful inventory information: generic class (SingleFunction/MultiFunction), no
|
||||
// resolved model or serial, and no PCI vendor/device IDs for future resolution.
|
||||
@@ -4390,6 +4864,70 @@ func isGenericPCIeClassLabel(v string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func redfishPCIeMatchesChassisDeviceDoc(doc, deviceDoc map[string]interface{}) bool {
|
||||
if len(doc) == 0 || len(deviceDoc) == 0 || redfishChassisDeviceDocLooksEmpty(deviceDoc) {
|
||||
return false
|
||||
}
|
||||
docSerial := normalizeRedfishIdentityField(findFirstNormalizedStringByKeys(doc, "SerialNumber"))
|
||||
deviceSerial := normalizeRedfishIdentityField(findFirstNormalizedStringByKeys(deviceDoc, "SerialNumber"))
|
||||
if docSerial != "" && deviceSerial != "" && strings.EqualFold(docSerial, deviceSerial) {
|
||||
return true
|
||||
}
|
||||
docTokens := redfishPCIeMatchTokens(doc)
|
||||
deviceTokens := redfishPCIeMatchTokens(deviceDoc)
|
||||
if len(docTokens) == 0 || len(deviceTokens) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, token := range docTokens {
|
||||
for _, candidate := range deviceTokens {
|
||||
if strings.EqualFold(token, candidate) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func redfishPCIeMatchTokens(doc map[string]interface{}) []string {
|
||||
if len(doc) == 0 {
|
||||
return nil
|
||||
}
|
||||
rawValues := []string{
|
||||
asString(doc["Name"]),
|
||||
asString(doc["Model"]),
|
||||
asString(doc["PartNumber"]),
|
||||
asString(doc["ProductPartNumber"]),
|
||||
}
|
||||
out := make([]string, 0, len(rawValues))
|
||||
seen := make(map[string]struct{}, len(rawValues))
|
||||
for _, raw := range rawValues {
|
||||
value := normalizeRedfishIdentityField(raw)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(value)
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func redfishChassisDeviceDocLooksEmpty(doc map[string]interface{}) bool {
|
||||
name := strings.ToLower(strings.TrimSpace(asString(doc["Name"])))
|
||||
if strings.HasPrefix(name, "empty slot") {
|
||||
return true
|
||||
}
|
||||
if strings.ToLower(strings.TrimSpace(asString(doc["DeviceType"]))) != "unknown" {
|
||||
return false
|
||||
}
|
||||
return normalizeRedfishIdentityField(asString(doc["PartNumber"])) == "" &&
|
||||
normalizeRedfishIdentityField(asString(doc["ProductPartNumber"])) == "" &&
|
||||
normalizeRedfishIdentityField(findFirstNormalizedStringByKeys(doc, "SerialNumber")) == ""
|
||||
}
|
||||
|
||||
func buildBDFfromOemPublic(doc map[string]interface{}) string {
|
||||
if len(doc) == 0 {
|
||||
return ""
|
||||
@@ -4759,6 +5297,16 @@ func isVirtualStorageDrive(doc map[string]interface{}) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// isAbsentDriveDoc returns true when the drive document represents an empty bay
|
||||
// with no installed media (Status.State == "Absent"). These should be excluded
|
||||
// from the storage inventory.
|
||||
func isAbsentDriveDoc(doc map[string]interface{}) bool {
|
||||
if status, ok := doc["Status"].(map[string]interface{}); ok {
|
||||
return strings.EqualFold(asString(status["State"]), "Absent")
|
||||
}
|
||||
return strings.EqualFold(asString(doc["Status"]), "Absent")
|
||||
}
|
||||
|
||||
func looksLikeDrive(doc map[string]interface{}) bool {
|
||||
if asString(doc["MediaType"]) != "" {
|
||||
return true
|
||||
@@ -4807,6 +5355,9 @@ func classifyStorageType(doc map[string]interface{}) string {
|
||||
}
|
||||
|
||||
func looksLikeVolume(doc map[string]interface{}) bool {
|
||||
if redfishVolumeCapabilitiesDoc(doc) {
|
||||
return false
|
||||
}
|
||||
if asString(doc["RAIDType"]) != "" || asString(doc["VolumeType"]) != "" {
|
||||
return true
|
||||
}
|
||||
@@ -5122,6 +5673,9 @@ func normalizeNetworkAdapterModel(nic models.NetworkAdapter) string {
|
||||
if model == "" {
|
||||
return ""
|
||||
}
|
||||
if isMissingOrRawPCIModel(model) {
|
||||
return ""
|
||||
}
|
||||
slot := strings.TrimSpace(nic.Slot)
|
||||
if slot != "" && strings.EqualFold(slot, model) {
|
||||
return ""
|
||||
@@ -5548,7 +6102,7 @@ func storageControllerFromPath(path string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseFirmware(system, bios, manager, secureBoot, networkProtocol map[string]interface{}) []models.FirmwareInfo {
|
||||
func parseFirmware(system, bios, manager, networkProtocol map[string]interface{}) []models.FirmwareInfo {
|
||||
var out []models.FirmwareInfo
|
||||
|
||||
appendFW := func(name, version string) {
|
||||
@@ -5562,7 +6116,6 @@ func parseFirmware(system, bios, manager, secureBoot, networkProtocol map[string
|
||||
appendFW("BIOS", asString(system["BiosVersion"]))
|
||||
appendFW("BIOS", asString(bios["Version"]))
|
||||
appendFW("BMC", asString(manager["FirmwareVersion"]))
|
||||
appendFW("SecureBoot", asString(secureBoot["SecureBootMode"]))
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
392
internal/collector/redfish_logentries.go
Normal file
392
internal/collector/redfish_logentries.go
Normal file
@@ -0,0 +1,392 @@
|
||||
package collector
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/logpile/internal/models"
|
||||
)
|
||||
|
||||
const (
|
||||
redfishLogEntriesWindow = 7 * 24 * time.Hour
|
||||
redfishLogEntriesMaxTotal = 500
|
||||
redfishLogEntriesMaxPerSvc = 200
|
||||
)
|
||||
|
||||
// collectRedfishLogEntries fetches hardware event log entries from Systems and Managers LogServices.
|
||||
// Only hardware-relevant entries from the last 7 days are returned.
|
||||
// For Systems: all log services except audit/journal/security/debug.
|
||||
// For Managers: only the IPMI SEL service (Id="SEL") — audit and event logs are excluded.
|
||||
func (c *RedfishConnector) collectRedfishLogEntries(ctx context.Context, client *http.Client, req Request, baseURL string, systemPaths, managerPaths []string) []map[string]interface{} {
|
||||
cutoff := time.Now().UTC().Add(-redfishLogEntriesWindow)
|
||||
seen := make(map[string]struct{})
|
||||
var out []map[string]interface{}
|
||||
|
||||
collectFrom := func(logServicesPath string, filter func(map[string]interface{}) bool) {
|
||||
if len(out) >= redfishLogEntriesMaxTotal {
|
||||
return
|
||||
}
|
||||
services, err := c.getCollectionMembers(ctx, client, req, baseURL, logServicesPath)
|
||||
if err != nil || len(services) == 0 {
|
||||
return
|
||||
}
|
||||
for _, svc := range services {
|
||||
if len(out) >= redfishLogEntriesMaxTotal {
|
||||
break
|
||||
}
|
||||
if !filter(svc) {
|
||||
continue
|
||||
}
|
||||
entriesPath := redfishLogServiceEntriesPath(svc)
|
||||
if entriesPath == "" {
|
||||
continue
|
||||
}
|
||||
entries := c.fetchRedfishLogEntriesWithPaging(ctx, client, req, baseURL, entriesPath, cutoff, seen, redfishLogEntriesMaxPerSvc)
|
||||
out = append(out, entries...)
|
||||
}
|
||||
}
|
||||
|
||||
for _, systemPath := range systemPaths {
|
||||
collectFrom(joinPath(systemPath, "/LogServices"), isHardwareLogService)
|
||||
}
|
||||
// Managers hold the IPMI SEL on AMI/MSI BMCs — include only the "SEL" service.
|
||||
for _, managerPath := range managerPaths {
|
||||
collectFrom(joinPath(managerPath, "/LogServices"), isManagerSELService)
|
||||
}
|
||||
|
||||
if len(out) > 0 {
|
||||
log.Printf("redfish: collected %d hardware log entries (Systems+Managers SEL, window=7d)", len(out))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// fetchRedfishLogEntriesWithPaging fetches entries from a LogEntry collection,
|
||||
// following nextLink pages. Stops early when entries older than cutoff are encountered
|
||||
// (assumes BMC returns entries newest-first, which is typical).
|
||||
func (c *RedfishConnector) fetchRedfishLogEntriesWithPaging(ctx context.Context, client *http.Client, req Request, baseURL, entriesPath string, cutoff time.Time, seen map[string]struct{}, limit int) []map[string]interface{} {
|
||||
var out []map[string]interface{}
|
||||
nextPath := entriesPath
|
||||
|
||||
for nextPath != "" && len(out) < limit {
|
||||
collection, err := c.getJSON(ctx, client, req, baseURL, nextPath)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
// Handle both linked members (@odata.id only) and inline members (full objects).
|
||||
rawMembers, _ := collection["Members"].([]interface{})
|
||||
hitOldEntry := false
|
||||
|
||||
for _, rawMember := range rawMembers {
|
||||
if len(out) >= limit {
|
||||
break
|
||||
}
|
||||
memberMap, ok := rawMember.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
var entry map[string]interface{}
|
||||
if _, hasCreated := memberMap["Created"]; hasCreated {
|
||||
// Inline entry — use directly.
|
||||
entry = memberMap
|
||||
} else {
|
||||
// Linked entry — fetch by path.
|
||||
memberPath := normalizeRedfishPath(asString(memberMap["@odata.id"]))
|
||||
if memberPath == "" {
|
||||
continue
|
||||
}
|
||||
entry, err = c.getJSON(ctx, client, req, baseURL, memberPath)
|
||||
if err != nil || len(entry) == 0 {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Dedup by entry Id or path.
|
||||
entryKey := asString(entry["Id"])
|
||||
if entryKey == "" {
|
||||
entryKey = asString(entry["@odata.id"])
|
||||
}
|
||||
if entryKey != "" {
|
||||
if _, dup := seen[entryKey]; dup {
|
||||
continue
|
||||
}
|
||||
seen[entryKey] = struct{}{}
|
||||
}
|
||||
|
||||
// Time filter.
|
||||
created := parseRedfishEntryTime(asString(entry["Created"]))
|
||||
if !created.IsZero() && created.Before(cutoff) {
|
||||
hitOldEntry = true
|
||||
continue
|
||||
}
|
||||
|
||||
// Hardware relevance filter.
|
||||
if !isHardwareLogEntry(entry) {
|
||||
continue
|
||||
}
|
||||
|
||||
out = append(out, entry)
|
||||
}
|
||||
|
||||
// Stop paging once we've seen entries older than the window.
|
||||
if hitOldEntry {
|
||||
break
|
||||
}
|
||||
nextPath = firstNonEmpty(
|
||||
normalizeRedfishPath(asString(collection["Members@odata.nextLink"])),
|
||||
normalizeRedfishPath(asString(collection["@odata.nextLink"])),
|
||||
)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// isManagerSELService returns true only for the IPMI SEL exposed under Managers.
|
||||
// On AMI/MSI BMCs the hardware SEL lives at Managers/{mgr}/LogServices/SEL.
|
||||
// All other Manager log services (AuditLog, EventLog, Journal) are excluded.
|
||||
func isManagerSELService(svc map[string]interface{}) bool {
|
||||
id := strings.ToLower(strings.TrimSpace(asString(svc["Id"])))
|
||||
return id == "sel"
|
||||
}
|
||||
|
||||
// isHardwareLogService returns true if the log service looks like a hardware event log
|
||||
// (SEL, System Event Log) rather than a BMC audit/journal log.
|
||||
func isHardwareLogService(svc map[string]interface{}) bool {
|
||||
id := strings.ToLower(strings.TrimSpace(asString(svc["Id"])))
|
||||
name := strings.ToLower(strings.TrimSpace(asString(svc["Name"])))
|
||||
for _, skip := range []string{"audit", "journal", "bmc", "security", "manager", "debug"} {
|
||||
if strings.Contains(id, skip) || strings.Contains(name, skip) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// redfishLogServiceEntriesPath returns the Entries collection path for a LogService document.
|
||||
func redfishLogServiceEntriesPath(svc map[string]interface{}) string {
|
||||
if entriesLink, ok := svc["Entries"].(map[string]interface{}); ok {
|
||||
if p := normalizeRedfishPath(asString(entriesLink["@odata.id"])); p != "" {
|
||||
return p
|
||||
}
|
||||
}
|
||||
if id := normalizeRedfishPath(asString(svc["@odata.id"])); id != "" {
|
||||
return joinPath(id, "/Entries")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// isHardwareLogEntry returns true if the log entry is hardware-related.
|
||||
// Audit, authentication, and session events are excluded.
|
||||
func isHardwareLogEntry(entry map[string]interface{}) bool {
|
||||
entryType := strings.TrimSpace(asString(entry["EntryType"]))
|
||||
if strings.EqualFold(entryType, "Oem") {
|
||||
return false
|
||||
}
|
||||
|
||||
msgID := strings.ToLower(strings.TrimSpace(asString(entry["MessageId"])))
|
||||
for _, skip := range []string{
|
||||
"user", "account", "password", "login", "logon", "session",
|
||||
"auth", "certificate", "security", "credential", "privilege",
|
||||
} {
|
||||
if strings.Contains(msgID, skip) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// Also check the human-readable message for obvious audit patterns.
|
||||
msg := strings.ToLower(strings.TrimSpace(asString(entry["Message"])))
|
||||
for _, skip := range []string{"logged in", "logged out", "log in", "log out", "sign in", "signed in"} {
|
||||
if strings.Contains(msg, skip) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// parseRedfishEntryTime parses a Redfish LogEntry Created timestamp (ISO 8601 / RFC 3339).
|
||||
func parseRedfishEntryTime(raw string) time.Time {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return time.Time{}
|
||||
}
|
||||
for _, layout := range []string{time.RFC3339, time.RFC3339Nano, "2006-01-02T15:04:05Z07:00"} {
|
||||
if t, err := time.Parse(layout, raw); err == nil {
|
||||
return t.UTC()
|
||||
}
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
// parseRedfishLogEntries converts raw log entries stored in RawPayloads into models.Event slice.
|
||||
// Called during Redfish replay for both live and offline (archive) collections.
|
||||
func parseRedfishLogEntries(rawPayloads map[string]any, collectedAt time.Time) []models.Event {
|
||||
raw, ok := rawPayloads["redfish_log_entries"]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
var entries []map[string]interface{}
|
||||
switch v := raw.(type) {
|
||||
case []map[string]interface{}:
|
||||
entries = v
|
||||
case []interface{}:
|
||||
for _, item := range v {
|
||||
if m, ok := item.(map[string]interface{}); ok {
|
||||
entries = append(entries, m)
|
||||
}
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(entries) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
out := make([]models.Event, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
ev := redfishLogEntryToEvent(entry, collectedAt)
|
||||
if ev == nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, *ev)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// redfishLogEntryToEvent converts a single Redfish LogEntry document to models.Event.
|
||||
func redfishLogEntryToEvent(entry map[string]interface{}, collectedAt time.Time) *models.Event {
|
||||
// Prefer EventTimestamp (actual hardware event time) over Created (Redfish record creation time).
|
||||
ts := parseRedfishEntryTime(asString(entry["EventTimestamp"]))
|
||||
if ts.IsZero() {
|
||||
ts = parseRedfishEntryTime(asString(entry["Created"]))
|
||||
}
|
||||
if ts.IsZero() {
|
||||
ts = collectedAt
|
||||
}
|
||||
|
||||
severity := redfishLogEntrySeverity(entry)
|
||||
sensorType := strings.TrimSpace(asString(entry["SensorType"]))
|
||||
messageID := strings.TrimSpace(asString(entry["MessageId"]))
|
||||
entryType := strings.TrimSpace(asString(entry["EntryType"]))
|
||||
entryCode := strings.TrimSpace(asString(entry["EntryCode"]))
|
||||
|
||||
// SensorName: prefer "Name", fall back to "SensorNumber" + SensorType.
|
||||
sensorName := strings.TrimSpace(asString(entry["Name"]))
|
||||
if sensorName == "" {
|
||||
num := strings.TrimSpace(asString(entry["SensorNumber"]))
|
||||
if num != "" && sensorType != "" {
|
||||
sensorName = sensorType + " " + num
|
||||
}
|
||||
}
|
||||
|
||||
rawMessage := strings.TrimSpace(asString(entry["Message"]))
|
||||
|
||||
// AMI/MSI BMCs dump raw IPMI record fields into Message instead of human-readable text.
|
||||
// Detect this and build a readable description from structured fields instead.
|
||||
description, rawData := redfishDecodeMessage(rawMessage, sensorType, entryCode, entry)
|
||||
if description == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &models.Event{
|
||||
ID: messageID,
|
||||
Timestamp: ts,
|
||||
Source: "redfish",
|
||||
SensorType: sensorType,
|
||||
SensorName: sensorName,
|
||||
EventType: entryType,
|
||||
Severity: severity,
|
||||
Description: description,
|
||||
RawData: rawData,
|
||||
}
|
||||
}
|
||||
|
||||
// redfishDecodeMessage returns a human-readable description and optional raw data.
|
||||
// AMI/MSI BMCs dump raw IPMI record fields into Message as "Key : Value, Key : Value, ..."
|
||||
// instead of a plain human-readable string. We extract the useful decoded fields from it.
|
||||
func redfishDecodeMessage(message, sensorType, entryCode string, entry map[string]interface{}) (description, rawData string) {
|
||||
if !isRawIPMIDump(message) {
|
||||
description = message
|
||||
return
|
||||
}
|
||||
|
||||
rawData = message
|
||||
kv := parseIPMIDumpKV(message)
|
||||
|
||||
// Sensor_Type inside the dump is more specific than the top-level SensorType field.
|
||||
if v := kv["Sensor_Type"]; v != "" {
|
||||
sensorType = v
|
||||
}
|
||||
eventType := kv["Event_Type"] // human-readable IPMI event type, e.g. "Legacy OFF State"
|
||||
|
||||
var parts []string
|
||||
if sensorType != "" {
|
||||
parts = append(parts, sensorType)
|
||||
}
|
||||
if eventType != "" {
|
||||
parts = append(parts, eventType)
|
||||
} else if entryCode != "" {
|
||||
parts = append(parts, entryCode)
|
||||
}
|
||||
description = strings.Join(parts, ": ")
|
||||
return
|
||||
}
|
||||
|
||||
// isRawIPMIDump returns true if the message is an AMI raw IPMI record dump.
|
||||
func isRawIPMIDump(message string) bool {
|
||||
return strings.Contains(message, "Event_Data_1 :") && strings.Contains(message, "Record_Type :")
|
||||
}
|
||||
|
||||
// parseIPMIDumpKV parses the AMI "Key : Value, Key : Value, " format into a map.
|
||||
func parseIPMIDumpKV(message string) map[string]string {
|
||||
out := make(map[string]string)
|
||||
for _, part := range strings.Split(message, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
idx := strings.Index(part, " : ")
|
||||
if idx < 0 {
|
||||
continue
|
||||
}
|
||||
k := strings.TrimSpace(part[:idx])
|
||||
v := strings.TrimSpace(part[idx+3:])
|
||||
if k != "" && v != "" {
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// redfishLogEntrySeverity maps a Redfish LogEntry to models.Severity.
|
||||
// AMI/MSI BMCs often set Severity="OK" on all SEL records regardless of content,
|
||||
// so we fall back to inferring severity from SensorType when the explicit field is unhelpful.
|
||||
func redfishLogEntrySeverity(entry map[string]interface{}) models.Severity {
|
||||
// Newer Redfish uses MessageSeverity; older uses Severity.
|
||||
raw := strings.ToLower(firstNonEmpty(
|
||||
strings.TrimSpace(asString(entry["MessageSeverity"])),
|
||||
strings.TrimSpace(asString(entry["Severity"])),
|
||||
))
|
||||
switch raw {
|
||||
case "critical":
|
||||
return models.SeverityCritical
|
||||
case "warning":
|
||||
return models.SeverityWarning
|
||||
case "ok", "informational", "":
|
||||
// BMC didn't set a meaningful severity — infer from SensorType.
|
||||
return redfishSeverityFromSensorType(strings.TrimSpace(asString(entry["SensorType"])))
|
||||
default:
|
||||
return models.SeverityInfo
|
||||
}
|
||||
}
|
||||
|
||||
// redfishSeverityFromSensorType infers event severity from the IPMI/Redfish SensorType string.
|
||||
func redfishSeverityFromSensorType(sensorType string) models.Severity {
|
||||
switch strings.ToLower(sensorType) {
|
||||
case "critical interrupt", "processor", "memory", "power unit",
|
||||
"power supply", "drive slot", "system firmware progress":
|
||||
return models.SeverityWarning
|
||||
default:
|
||||
return models.SeverityInfo
|
||||
}
|
||||
}
|
||||
@@ -31,8 +31,7 @@ func ReplayRedfishFromRawPayloads(rawPayloads map[string]any, emit ProgressFn) (
|
||||
if emit != nil {
|
||||
emit(Progress{Status: "running", Progress: 10, Message: "Redfish snapshot: replay service root..."})
|
||||
}
|
||||
serviceRootDoc, err := r.getJSON("/redfish/v1")
|
||||
if err != nil {
|
||||
if _, err := r.getJSON("/redfish/v1"); err != nil {
|
||||
log.Printf("redfish replay: service root /redfish/v1 missing from snapshot, continuing with defaults: %v", err)
|
||||
}
|
||||
|
||||
@@ -53,7 +52,7 @@ func ReplayRedfishFromRawPayloads(rawPayloads map[string]any, emit ProgressFn) (
|
||||
chassisDoc, _ := r.getJSON(primaryChassis)
|
||||
managerDoc, _ := r.getJSON(primaryManager)
|
||||
biosDoc, _ := r.getJSON(joinPath(primarySystem, "/Bios"))
|
||||
secureBootDoc, _ := r.getJSON(joinPath(primarySystem, "/SecureBoot"))
|
||||
|
||||
systemFRUDoc, _ := r.getJSON(joinPath(primarySystem, "/Oem/Public/FRU"))
|
||||
chassisFRUDoc, _ := r.getJSON(joinPath(primaryChassis, "/Oem/Public/FRU"))
|
||||
fruDoc := systemFRUDoc
|
||||
@@ -61,8 +60,7 @@ func ReplayRedfishFromRawPayloads(rawPayloads map[string]any, emit ProgressFn) (
|
||||
fruDoc = chassisFRUDoc
|
||||
}
|
||||
boardFallbackDocs := r.collectBoardFallbackDocs(systemPaths, chassisPaths)
|
||||
resourceHints := append(append([]string{}, systemPaths...), append(chassisPaths, managerPaths...)...)
|
||||
profileSignals := redfishprofile.CollectSignals(serviceRootDoc, systemDoc, chassisDoc, managerDoc, resourceHints)
|
||||
profileSignals := redfishprofile.CollectSignalsFromTree(tree)
|
||||
profileMatch := redfishprofile.MatchProfiles(profileSignals)
|
||||
analysisPlan := redfishprofile.ResolveAnalysisPlan(profileMatch, tree, redfishprofile.DiscoveredResources{
|
||||
SystemPaths: systemPaths,
|
||||
@@ -96,19 +94,29 @@ func ReplayRedfishFromRawPayloads(rawPayloads map[string]any, emit ProgressFn) (
|
||||
healthEvents := r.collectHealthSummaryEvents(chassisPaths)
|
||||
driveFetchWarningEvents := buildDriveFetchWarningEvents(rawPayloads)
|
||||
networkProtocolDoc, _ := r.getJSON(joinPath(primaryManager, "/NetworkProtocol"))
|
||||
firmware := parseFirmware(systemDoc, biosDoc, managerDoc, secureBootDoc, networkProtocolDoc)
|
||||
firmware := parseFirmware(systemDoc, biosDoc, managerDoc, networkProtocolDoc)
|
||||
firmware = dedupeFirmwareInfo(append(firmware, r.collectFirmwareInventory()...))
|
||||
boardInfo.BMCMACAddress = r.collectBMCMAC(managerPaths)
|
||||
firmware = filterStorageDriveFirmware(firmware, storageDevices)
|
||||
bmcManagementSummary := r.collectBMCManagementSummary(managerPaths)
|
||||
boardInfo.BMCMACAddress = strings.TrimSpace(firstNonEmpty(
|
||||
asString(bmcManagementSummary["mac_address"]),
|
||||
r.collectBMCMAC(managerPaths),
|
||||
))
|
||||
assemblyFRU := r.collectAssemblyFRU(chassisPaths)
|
||||
collectedAt, sourceTimezone := inferRedfishCollectionTime(managerDoc, rawPayloads)
|
||||
inventoryLastModifiedAt := inferInventoryLastModifiedTime(r.tree)
|
||||
logEntryEvents := parseRedfishLogEntries(rawPayloads, collectedAt)
|
||||
sensorHintSummary, sensorHintEvents := r.collectSensorsListHints(chassisPaths, collectedAt)
|
||||
bmcManagementEvent := buildBMCManagementSummaryEvent(bmcManagementSummary, collectedAt)
|
||||
|
||||
result := &models.AnalysisResult{
|
||||
CollectedAt: collectedAt,
|
||||
SourceTimezone: sourceTimezone,
|
||||
Events: append(append(append(make([]models.Event, 0, len(discreteEvents)+len(healthEvents)+len(driveFetchWarningEvents)+1), healthEvents...), discreteEvents...), driveFetchWarningEvents...),
|
||||
FRU: assemblyFRU,
|
||||
Sensors: dedupeSensorReadings(append(append(thresholdSensors, thermalSensors...), powerSensors...)),
|
||||
RawPayloads: cloneRawPayloads(rawPayloads),
|
||||
CollectedAt: collectedAt,
|
||||
InventoryLastModifiedAt: inventoryLastModifiedAt,
|
||||
SourceTimezone: sourceTimezone,
|
||||
Events: append(append(append(append(append(append(make([]models.Event, 0, len(discreteEvents)+len(healthEvents)+len(driveFetchWarningEvents)+len(logEntryEvents)+len(sensorHintEvents)+2), healthEvents...), discreteEvents...), driveFetchWarningEvents...), logEntryEvents...), sensorHintEvents...), bmcManagementEvent...),
|
||||
FRU: assemblyFRU,
|
||||
Sensors: dedupeSensorReadings(append(append(thresholdSensors, thermalSensors...), powerSensors...)),
|
||||
RawPayloads: cloneRawPayloads(rawPayloads),
|
||||
Hardware: &models.HardwareConfig{
|
||||
BoardInfo: boardInfo,
|
||||
CPUs: processors,
|
||||
@@ -154,6 +162,12 @@ func ReplayRedfishFromRawPayloads(rawPayloads map[string]any, emit ProgressFn) (
|
||||
if strings.TrimSpace(sourceTimezone) != "" {
|
||||
result.RawPayloads["source_timezone"] = sourceTimezone
|
||||
}
|
||||
if len(sensorHintSummary) > 0 {
|
||||
result.RawPayloads["redfish_sensor_hints"] = sensorHintSummary
|
||||
}
|
||||
if len(bmcManagementSummary) > 0 {
|
||||
result.RawPayloads["redfish_bmc_network_summary"] = bmcManagementSummary
|
||||
}
|
||||
appendMissingServerModelWarning(result, systemDoc, joinPath(primarySystem, "/Oem/Public/FRU"), joinPath(primaryChassis, "/Oem/Public/FRU"))
|
||||
return result, nil
|
||||
}
|
||||
@@ -183,6 +197,35 @@ func inferRedfishCollectionTime(managerDoc map[string]interface{}, rawPayloads m
|
||||
return time.Time{}, offset
|
||||
}
|
||||
|
||||
// inferInventoryLastModifiedTime reads InventoryData/Status.InventoryData.LastModifiedTime
|
||||
// from the Redfish snapshot. Returns zero time if not present or unparseable.
|
||||
func inferInventoryLastModifiedTime(snapshot map[string]interface{}) time.Time {
|
||||
docAny, ok := snapshot["/redfish/v1/Oem/Ami/InventoryData/Status"]
|
||||
if !ok {
|
||||
return time.Time{}
|
||||
}
|
||||
doc, ok := docAny.(map[string]interface{})
|
||||
if !ok {
|
||||
return time.Time{}
|
||||
}
|
||||
invData, ok := doc["InventoryData"].(map[string]interface{})
|
||||
if !ok {
|
||||
return time.Time{}
|
||||
}
|
||||
raw := strings.TrimSpace(asString(invData["LastModifiedTime"]))
|
||||
if raw == "" {
|
||||
return time.Time{}
|
||||
}
|
||||
for _, layout := range []string{time.RFC3339, time.RFC3339Nano} {
|
||||
if ts, err := time.Parse(layout, raw); err == nil {
|
||||
t := ts.UTC()
|
||||
log.Printf("redfish replay: inventory last modified at %s (InventoryData/Status.LastModifiedTime)", t.Format(time.RFC3339))
|
||||
return t
|
||||
}
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
func appendMissingServerModelWarning(result *models.AnalysisResult, systemDoc map[string]interface{}, systemFRUPath, chassisFRUPath string) {
|
||||
if result == nil || result.Hardware == nil {
|
||||
return
|
||||
@@ -294,6 +337,153 @@ func buildDriveFetchWarningEvents(rawPayloads map[string]any) []models.Event {
|
||||
}
|
||||
}
|
||||
|
||||
func (r redfishSnapshotReader) collectSensorsListHints(chassisPaths []string, collectedAt time.Time) (map[string]any, []models.Event) {
|
||||
summary := make(map[string]any)
|
||||
var events []models.Event
|
||||
var presentDIMMs []string
|
||||
dimmTotal := 0
|
||||
dimmPresent := 0
|
||||
physicalDriveSlots := 0
|
||||
activePhysicalDriveSlots := 0
|
||||
logicalDriveStatus := ""
|
||||
|
||||
for _, chassisPath := range chassisPaths {
|
||||
doc, err := r.getJSON(joinPath(chassisPath, "/SensorsList"))
|
||||
if err != nil || len(doc) == 0 {
|
||||
continue
|
||||
}
|
||||
sensors, ok := doc["SensorsList"].([]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, item := range sensors {
|
||||
sensor, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(asString(sensor["SensorName"]))
|
||||
sensorType := strings.TrimSpace(asString(sensor["SensorType"]))
|
||||
status := strings.TrimSpace(asString(sensor["Status"]))
|
||||
switch {
|
||||
case strings.HasPrefix(name, "DIMM") && strings.HasSuffix(name, "_Status") && strings.EqualFold(sensorType, "Memory"):
|
||||
dimmTotal++
|
||||
if redfishSlotStatusLooksPresent(status) {
|
||||
dimmPresent++
|
||||
presentDIMMs = append(presentDIMMs, strings.TrimSuffix(name, "_Status"))
|
||||
}
|
||||
case strings.EqualFold(sensorType, "Drive Slot"):
|
||||
if strings.EqualFold(name, "Logical_Drive") {
|
||||
logicalDriveStatus = firstNonEmpty(logicalDriveStatus, status)
|
||||
continue
|
||||
}
|
||||
physicalDriveSlots++
|
||||
if redfishSlotStatusLooksPresent(status) {
|
||||
activePhysicalDriveSlots++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if dimmTotal > 0 {
|
||||
sort.Strings(presentDIMMs)
|
||||
summary["memory_slots"] = map[string]any{
|
||||
"total": dimmTotal,
|
||||
"present_count": dimmPresent,
|
||||
"present_slots": presentDIMMs,
|
||||
"source": "SensorsList",
|
||||
}
|
||||
events = append(events, models.Event{
|
||||
Timestamp: replayEventTimestamp(collectedAt),
|
||||
Source: "Redfish",
|
||||
EventType: "Collection Info",
|
||||
Severity: models.SeverityInfo,
|
||||
Description: fmt.Sprintf("Memory slot sensors report %d populated positions out of %d", dimmPresent, dimmTotal),
|
||||
RawData: firstNonEmpty(strings.Join(presentDIMMs, ", "), "no populated DIMM slots reported"),
|
||||
})
|
||||
}
|
||||
if physicalDriveSlots > 0 || logicalDriveStatus != "" {
|
||||
summary["drive_slots"] = map[string]any{
|
||||
"physical_total": physicalDriveSlots,
|
||||
"physical_active_count": activePhysicalDriveSlots,
|
||||
"logical_drive_status": logicalDriveStatus,
|
||||
"source": "SensorsList",
|
||||
}
|
||||
rawParts := []string{
|
||||
fmt.Sprintf("physical_active=%d/%d", activePhysicalDriveSlots, physicalDriveSlots),
|
||||
}
|
||||
if logicalDriveStatus != "" {
|
||||
rawParts = append(rawParts, "logical_drive="+logicalDriveStatus)
|
||||
}
|
||||
events = append(events, models.Event{
|
||||
Timestamp: replayEventTimestamp(collectedAt),
|
||||
Source: "Redfish",
|
||||
EventType: "Collection Info",
|
||||
Severity: models.SeverityInfo,
|
||||
Description: fmt.Sprintf("Drive slot sensors report %d active physical slots out of %d", activePhysicalDriveSlots, physicalDriveSlots),
|
||||
RawData: strings.Join(rawParts, "; "),
|
||||
})
|
||||
}
|
||||
|
||||
return summary, events
|
||||
}
|
||||
|
||||
func buildBMCManagementSummaryEvent(summary map[string]any, collectedAt time.Time) []models.Event {
|
||||
if len(summary) == 0 {
|
||||
return nil
|
||||
}
|
||||
desc := fmt.Sprintf(
|
||||
"BMC management interface %s link=%s ip=%s",
|
||||
firstNonEmpty(asString(summary["interface_id"]), "unknown"),
|
||||
firstNonEmpty(asString(summary["link_status"]), "unknown"),
|
||||
firstNonEmpty(asString(summary["ipv4_address"]), "n/a"),
|
||||
)
|
||||
rawParts := make([]string, 0, 8)
|
||||
for _, part := range []string{
|
||||
"mac_address=" + strings.TrimSpace(asString(summary["mac_address"])),
|
||||
"speed_mbps=" + strings.TrimSpace(asString(summary["speed_mbps"])),
|
||||
"lldp_chassis_name=" + strings.TrimSpace(asString(summary["lldp_chassis_name"])),
|
||||
"lldp_port_desc=" + strings.TrimSpace(asString(summary["lldp_port_desc"])),
|
||||
"lldp_port_id=" + strings.TrimSpace(asString(summary["lldp_port_id"])),
|
||||
"ipv4_gateway=" + strings.TrimSpace(asString(summary["ipv4_gateway"])),
|
||||
} {
|
||||
if !strings.HasSuffix(part, "=") {
|
||||
rawParts = append(rawParts, part)
|
||||
}
|
||||
}
|
||||
if vlan := asInt(summary["lldp_vlan_id"]); vlan > 0 {
|
||||
rawParts = append(rawParts, fmt.Sprintf("lldp_vlan_id=%d", vlan))
|
||||
}
|
||||
if asBool(summary["ncsi_enabled"]) {
|
||||
rawParts = append(rawParts, "ncsi_enabled=true")
|
||||
}
|
||||
return []models.Event{
|
||||
{
|
||||
Timestamp: replayEventTimestamp(collectedAt),
|
||||
Source: "Redfish",
|
||||
EventType: "Collection Info",
|
||||
Severity: models.SeverityInfo,
|
||||
Description: desc,
|
||||
RawData: strings.Join(rawParts, "; "),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func redfishSlotStatusLooksPresent(status string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(status)) {
|
||||
case "ok", "enabled", "present", "warning", "critical":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func replayEventTimestamp(collectedAt time.Time) time.Time {
|
||||
if !collectedAt.IsZero() {
|
||||
return collectedAt
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
func (r redfishSnapshotReader) collectFirmwareInventory() []models.FirmwareInfo {
|
||||
docs, err := r.getCollectionMembers("/redfish/v1/UpdateService/FirmwareInventory")
|
||||
if err != nil || len(docs) == 0 {
|
||||
@@ -309,6 +499,10 @@ func (r redfishSnapshotReader) collectFirmwareInventory() []models.FirmwareInfo
|
||||
if strings.TrimSpace(version) == "" {
|
||||
continue
|
||||
}
|
||||
// Skip placeholder version strings that carry no useful information.
|
||||
if strings.EqualFold(strings.TrimSpace(version), "N/A") {
|
||||
continue
|
||||
}
|
||||
name := firmwareInventoryDeviceName(doc)
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
@@ -361,6 +555,32 @@ func dedupeFirmwareInfo(items []models.FirmwareInfo) []models.FirmwareInfo {
|
||||
return out
|
||||
}
|
||||
|
||||
// filterStorageDriveFirmware removes from fw any entries whose DeviceName+Version
|
||||
// already appear as a storage drive's Model+Firmware. Drive firmware is already
|
||||
// represented in the Storage section and should not be duplicated in the general
|
||||
// firmware list.
|
||||
func filterStorageDriveFirmware(fw []models.FirmwareInfo, storage []models.Storage) []models.FirmwareInfo {
|
||||
if len(storage) == 0 {
|
||||
return fw
|
||||
}
|
||||
driveFW := make(map[string]struct{}, len(storage))
|
||||
for _, d := range storage {
|
||||
model := strings.ToLower(strings.TrimSpace(d.Model))
|
||||
rev := strings.ToLower(strings.TrimSpace(d.Firmware))
|
||||
if model != "" && rev != "" {
|
||||
driveFW[model+"|"+rev] = struct{}{}
|
||||
}
|
||||
}
|
||||
out := fw[:0:0]
|
||||
for _, f := range fw {
|
||||
key := strings.ToLower(strings.TrimSpace(f.DeviceName)) + "|" + strings.ToLower(strings.TrimSpace(f.Version))
|
||||
if _, skip := driveFW[key]; !skip {
|
||||
out = append(out, f)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (r redfishSnapshotReader) collectThresholdSensors(chassisPaths []string) []models.SensorReading {
|
||||
out := make([]models.SensorReading, 0)
|
||||
seen := make(map[string]struct{})
|
||||
@@ -826,6 +1046,9 @@ func (r redfishSnapshotReader) fallbackCollectionMembers(collectionPath string,
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if redfishFallbackMemberLooksLikePlaceholder(collectionPath, doc) {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(asString(doc["@odata.id"])) == "" {
|
||||
doc["@odata.id"] = normalizeRedfishPath(p)
|
||||
}
|
||||
@@ -834,6 +1057,135 @@ func (r redfishSnapshotReader) fallbackCollectionMembers(collectionPath string,
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func redfishFallbackMemberLooksLikePlaceholder(collectionPath string, doc map[string]interface{}) bool {
|
||||
if len(doc) == 0 {
|
||||
return true
|
||||
}
|
||||
path := normalizeRedfishPath(collectionPath)
|
||||
switch {
|
||||
case strings.HasSuffix(path, "/NetworkAdapters"):
|
||||
return redfishNetworkAdapterPlaceholderDoc(doc)
|
||||
case strings.HasSuffix(path, "/PCIeDevices"):
|
||||
return redfishPCIePlaceholderDoc(doc)
|
||||
case strings.Contains(path, "/Storage"):
|
||||
return redfishStoragePlaceholderDoc(doc)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func redfishNetworkAdapterPlaceholderDoc(doc map[string]interface{}) bool {
|
||||
if normalizeRedfishIdentityField(asString(doc["Model"])) != "" ||
|
||||
normalizeRedfishIdentityField(asString(doc["Manufacturer"])) != "" ||
|
||||
normalizeRedfishIdentityField(asString(doc["SerialNumber"])) != "" ||
|
||||
normalizeRedfishIdentityField(asString(doc["PartNumber"])) != "" ||
|
||||
normalizeRedfishIdentityField(asString(doc["BDF"])) != "" ||
|
||||
asHexOrInt(doc["VendorId"]) != 0 ||
|
||||
asHexOrInt(doc["DeviceId"]) != 0 {
|
||||
return false
|
||||
}
|
||||
return redfishDocHasOnlyAllowedKeys(doc,
|
||||
"@odata.context",
|
||||
"@odata.id",
|
||||
"@odata.type",
|
||||
"Id",
|
||||
"Name",
|
||||
)
|
||||
}
|
||||
|
||||
func redfishPCIePlaceholderDoc(doc map[string]interface{}) bool {
|
||||
if normalizeRedfishIdentityField(asString(doc["Model"])) != "" ||
|
||||
normalizeRedfishIdentityField(asString(doc["Manufacturer"])) != "" ||
|
||||
normalizeRedfishIdentityField(asString(doc["SerialNumber"])) != "" ||
|
||||
normalizeRedfishIdentityField(asString(doc["PartNumber"])) != "" ||
|
||||
normalizeRedfishIdentityField(asString(doc["BDF"])) != "" ||
|
||||
asHexOrInt(doc["VendorId"]) != 0 ||
|
||||
asHexOrInt(doc["DeviceId"]) != 0 {
|
||||
return false
|
||||
}
|
||||
return redfishDocHasOnlyAllowedKeys(doc,
|
||||
"@odata.context",
|
||||
"@odata.id",
|
||||
"@odata.type",
|
||||
"Id",
|
||||
"Name",
|
||||
)
|
||||
}
|
||||
|
||||
func redfishStoragePlaceholderDoc(doc map[string]interface{}) bool {
|
||||
if normalizeRedfishIdentityField(asString(doc["Model"])) != "" ||
|
||||
normalizeRedfishIdentityField(asString(doc["Manufacturer"])) != "" ||
|
||||
normalizeRedfishIdentityField(asString(doc["SerialNumber"])) != "" ||
|
||||
normalizeRedfishIdentityField(asString(doc["PartNumber"])) != "" ||
|
||||
normalizeRedfishIdentityField(asString(doc["BDF"])) != "" ||
|
||||
asHexOrInt(doc["VendorId"]) != 0 ||
|
||||
asHexOrInt(doc["DeviceId"]) != 0 {
|
||||
return false
|
||||
}
|
||||
if !redfishDocHasOnlyAllowedKeys(doc,
|
||||
"@odata.id",
|
||||
"@odata.type",
|
||||
"Drives",
|
||||
"Drives@odata.count",
|
||||
"LogicalDisk",
|
||||
"PhysicalDisk",
|
||||
"Name",
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return redfishFieldIsEmptyCollection(doc["Drives"]) &&
|
||||
redfishFieldIsZeroLike(doc["Drives@odata.count"]) &&
|
||||
redfishFieldIsEmptyCollection(doc["LogicalDisk"]) &&
|
||||
redfishFieldIsEmptyCollection(doc["PhysicalDisk"])
|
||||
}
|
||||
|
||||
func redfishDocHasOnlyAllowedKeys(doc map[string]interface{}, allowed ...string) bool {
|
||||
if len(doc) == 0 {
|
||||
return false
|
||||
}
|
||||
allowedSet := make(map[string]struct{}, len(allowed))
|
||||
for _, key := range allowed {
|
||||
allowedSet[key] = struct{}{}
|
||||
}
|
||||
for key := range doc {
|
||||
if _, ok := allowedSet[key]; !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func redfishFieldIsEmptyCollection(v any) bool {
|
||||
switch x := v.(type) {
|
||||
case nil:
|
||||
return true
|
||||
case []interface{}:
|
||||
return len(x) == 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func redfishFieldIsZeroLike(v any) bool {
|
||||
switch x := v.(type) {
|
||||
case nil:
|
||||
return true
|
||||
case int:
|
||||
return x == 0
|
||||
case int32:
|
||||
return x == 0
|
||||
case int64:
|
||||
return x == 0
|
||||
case float64:
|
||||
return x == 0
|
||||
case string:
|
||||
x = strings.TrimSpace(x)
|
||||
return x == "" || x == "0"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func cloneRawPayloads(src map[string]any) map[string]any {
|
||||
if len(src) == 0 {
|
||||
return nil
|
||||
@@ -892,6 +1244,15 @@ func (r redfishSnapshotReader) getLinkedPCIeFunctions(doc map[string]interface{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
if ref, ok := links["PCIeFunction"].(map[string]interface{}); ok {
|
||||
memberPath := asString(ref["@odata.id"])
|
||||
if memberPath != "" {
|
||||
memberDoc, err := r.getJSON(memberPath)
|
||||
if err == nil {
|
||||
return []map[string]interface{}{memberDoc}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if pcieFunctions, ok := doc["PCIeFunctions"].(map[string]interface{}); ok {
|
||||
if collectionPath := asString(pcieFunctions["@odata.id"]); collectionPath != "" {
|
||||
@@ -904,6 +1265,33 @@ func (r redfishSnapshotReader) getLinkedPCIeFunctions(doc map[string]interface{}
|
||||
return nil
|
||||
}
|
||||
|
||||
func dedupeJSONDocsByPath(docs []map[string]interface{}) []map[string]interface{} {
|
||||
if len(docs) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]struct{}, len(docs))
|
||||
out := make([]map[string]interface{}, 0, len(docs))
|
||||
for _, doc := range docs {
|
||||
if len(doc) == 0 {
|
||||
continue
|
||||
}
|
||||
key := normalizeRedfishPath(asString(doc["@odata.id"]))
|
||||
if key == "" {
|
||||
payload, err := json.Marshal(doc)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
key = string(payload)
|
||||
}
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, doc)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (r redfishSnapshotReader) getLinkedSupplementalDocs(doc map[string]interface{}, keys ...string) []map[string]interface{} {
|
||||
if len(doc) == 0 || len(keys) == 0 {
|
||||
return nil
|
||||
@@ -940,6 +1328,12 @@ func (r redfishSnapshotReader) collectProcessors(systemPath string) []models.CPU
|
||||
!strings.EqualFold(pt, "CPU") && !strings.EqualFold(pt, "General") {
|
||||
continue
|
||||
}
|
||||
// Skip absent processor sockets — empty slots with no CPU installed.
|
||||
if status, ok := doc["Status"].(map[string]interface{}); ok {
|
||||
if strings.EqualFold(asString(status["State"]), "Absent") {
|
||||
continue
|
||||
}
|
||||
}
|
||||
cpu := parseCPUs([]map[string]interface{}{doc})[0]
|
||||
if cpu.Socket == 0 && socketIdx > 0 && strings.TrimSpace(asString(doc["Socket"])) == "" {
|
||||
cpu.Socket = socketIdx
|
||||
@@ -966,6 +1360,10 @@ func (r redfishSnapshotReader) collectMemory(systemPath string) []models.MemoryD
|
||||
out := make([]models.MemoryDIMM, 0, len(memberDocs))
|
||||
for _, doc := range memberDocs {
|
||||
dimm := parseMemory([]map[string]interface{}{doc})[0]
|
||||
// Skip empty DIMM slots — no installed memory.
|
||||
if !dimm.Present {
|
||||
continue
|
||||
}
|
||||
supplementalDocs := r.getLinkedSupplementalDocs(doc, "MemoryMetrics", "EnvironmentMetrics", "Metrics")
|
||||
if len(supplementalDocs) > 0 {
|
||||
dimm.Details = mergeGenericDetails(dimm.Details, redfishMemoryDetailsAcrossDocs(doc, supplementalDocs...))
|
||||
|
||||
@@ -58,6 +58,44 @@ func (r redfishSnapshotReader) collectGPUs(systemPaths, chassisPaths []string, p
|
||||
return out
|
||||
}
|
||||
|
||||
// msiGhostGPUFilter returns true when the GPU chassis for gpuID shows a temperature
|
||||
// of 0 on a powered-on host, which is the reliable MSI/AMI signal that the GPU is
|
||||
// no longer physically installed (stale BMC inventory cache).
|
||||
// It only filters when the system PowerState is "On" — when the host is off, all
|
||||
// temperature readings are 0 and we cannot distinguish absent from idle.
|
||||
func (r redfishSnapshotReader) msiGhostGPUFilter(systemPaths []string, gpuID, chassisPath string) bool {
|
||||
// Require host powered on.
|
||||
for _, sp := range systemPaths {
|
||||
doc, err := r.getJSON(sp)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(asString(doc["PowerState"])), "on") {
|
||||
return false
|
||||
}
|
||||
break
|
||||
}
|
||||
// Read the temperature sensor for this GPU chassis.
|
||||
sensorPath := joinPath(chassisPath, "/Sensors/"+gpuID+"_Temperature")
|
||||
sensorDoc, err := r.getJSON(sensorPath)
|
||||
if err != nil || len(sensorDoc) == 0 {
|
||||
return false
|
||||
}
|
||||
reading, ok := sensorDoc["Reading"]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
switch v := reading.(type) {
|
||||
case float64:
|
||||
return v == 0
|
||||
case int:
|
||||
return v == 0
|
||||
case int64:
|
||||
return v == 0
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// collectGPUsFromProcessors finds GPUs that some BMCs (e.g. MSI) expose as
|
||||
// Processor entries with ProcessorType=GPU rather than as PCIe devices.
|
||||
// It supplements the existing gpus slice (already found via PCIe path),
|
||||
@@ -68,6 +106,7 @@ func (r redfishSnapshotReader) collectGPUsFromProcessors(systemPaths, chassisPat
|
||||
return append([]models.GPU{}, existing...)
|
||||
}
|
||||
chassisByID := make(map[string]map[string]interface{})
|
||||
chassisPathByID := make(map[string]string)
|
||||
for _, cp := range chassisPaths {
|
||||
doc, err := r.getJSON(cp)
|
||||
if err != nil || len(doc) == 0 {
|
||||
@@ -76,6 +115,7 @@ func (r redfishSnapshotReader) collectGPUsFromProcessors(systemPaths, chassisPat
|
||||
id := strings.TrimSpace(asString(doc["Id"]))
|
||||
if id != "" {
|
||||
chassisByID[strings.ToUpper(id)] = doc
|
||||
chassisPathByID[strings.ToUpper(id)] = cp
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +148,13 @@ func (r redfishSnapshotReader) collectGPUsFromProcessors(systemPaths, chassisPat
|
||||
serial = resolveProcessorGPUChassisSerial(chassisByID, gpuID, plan)
|
||||
}
|
||||
|
||||
if plan.Directives.EnableMSIGhostGPUFilter {
|
||||
chassisPath := resolveProcessorGPUChassisPath(chassisPathByID, gpuID, plan)
|
||||
if chassisPath != "" && r.msiGhostGPUFilter(systemPaths, gpuID, chassisPath) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
uuid := strings.TrimSpace(asString(doc["UUID"]))
|
||||
uuidKey := strings.ToUpper(uuid)
|
||||
serialKey := strings.ToUpper(serial)
|
||||
|
||||
@@ -26,6 +26,16 @@ func (r redfishSnapshotReader) enrichNICsFromNetworkInterfaces(nics *[]models.Ne
|
||||
continue
|
||||
}
|
||||
idx, ok := bySlot[strings.ToLower(strings.TrimSpace(slot))]
|
||||
if !ok {
|
||||
// The NetworkInterface Id (e.g. "2") may not match the display slot of
|
||||
// the real NIC that came from Chassis/NetworkAdapters (e.g. "RISER 5
|
||||
// slot 1 (7)"). Try to find the real NIC via the Links.NetworkAdapter
|
||||
// cross-reference before creating a ghost entry.
|
||||
if linkedIdx := r.findNICIndexByLinkedNetworkAdapter(iface, *nics, bySlot); linkedIdx >= 0 {
|
||||
idx = linkedIdx
|
||||
ok = true
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
*nics = append(*nics, models.NetworkAdapter{
|
||||
Slot: slot,
|
||||
@@ -65,28 +75,53 @@ func (r redfishSnapshotReader) collectNICs(chassisPaths []string) []models.Netwo
|
||||
continue
|
||||
}
|
||||
for _, doc := range adapterDocs {
|
||||
nic := parseNIC(doc)
|
||||
for _, pciePath := range networkAdapterPCIeDevicePaths(doc) {
|
||||
pcieDoc, err := r.getJSON(pciePath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
functionDocs := r.getLinkedPCIeFunctions(pcieDoc)
|
||||
supplementalDocs := r.getLinkedSupplementalDocs(pcieDoc, "EnvironmentMetrics", "Metrics")
|
||||
for _, fn := range functionDocs {
|
||||
supplementalDocs = append(supplementalDocs, r.getLinkedSupplementalDocs(fn, "EnvironmentMetrics", "Metrics")...)
|
||||
}
|
||||
enrichNICFromPCIe(&nic, pcieDoc, functionDocs, supplementalDocs)
|
||||
}
|
||||
if len(nic.MACAddresses) == 0 {
|
||||
r.enrichNICMACsFromNetworkDeviceFunctions(&nic, doc)
|
||||
}
|
||||
nics = append(nics, nic)
|
||||
nics = append(nics, r.buildNICFromAdapterDoc(doc))
|
||||
}
|
||||
}
|
||||
return dedupeNetworkAdapters(nics)
|
||||
}
|
||||
|
||||
func (r redfishSnapshotReader) buildNICFromAdapterDoc(adapterDoc map[string]interface{}) models.NetworkAdapter {
|
||||
nic := parseNIC(adapterDoc)
|
||||
adapterFunctionDocs := r.getNetworkAdapterFunctionDocs(adapterDoc)
|
||||
for _, pciePath := range networkAdapterPCIeDevicePaths(adapterDoc) {
|
||||
pcieDoc, err := r.getJSON(pciePath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
functionDocs := r.getLinkedPCIeFunctions(pcieDoc)
|
||||
for _, adapterFnDoc := range adapterFunctionDocs {
|
||||
functionDocs = append(functionDocs, r.getLinkedPCIeFunctions(adapterFnDoc)...)
|
||||
}
|
||||
functionDocs = dedupeJSONDocsByPath(functionDocs)
|
||||
supplementalDocs := r.getLinkedSupplementalDocs(pcieDoc, "EnvironmentMetrics", "Metrics")
|
||||
for _, fn := range functionDocs {
|
||||
supplementalDocs = append(supplementalDocs, r.getLinkedSupplementalDocs(fn, "EnvironmentMetrics", "Metrics")...)
|
||||
}
|
||||
enrichNICFromPCIe(&nic, pcieDoc, functionDocs, supplementalDocs)
|
||||
}
|
||||
if len(nic.MACAddresses) == 0 {
|
||||
r.enrichNICMACsFromNetworkDeviceFunctions(&nic, adapterDoc)
|
||||
}
|
||||
return nic
|
||||
}
|
||||
|
||||
func (r redfishSnapshotReader) getNetworkAdapterFunctionDocs(adapterDoc map[string]interface{}) []map[string]interface{} {
|
||||
ndfCol, ok := adapterDoc["NetworkDeviceFunctions"].(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
colPath := asString(ndfCol["@odata.id"])
|
||||
if colPath == "" {
|
||||
return nil
|
||||
}
|
||||
funcDocs, err := r.getCollectionMembers(colPath)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return funcDocs
|
||||
}
|
||||
|
||||
func (r redfishSnapshotReader) collectPCIeDevices(systemPaths, chassisPaths []string) []models.PCIeDevice {
|
||||
collections := make([]string, 0, len(systemPaths)+len(chassisPaths))
|
||||
for _, systemPath := range systemPaths {
|
||||
@@ -106,13 +141,16 @@ func (r redfishSnapshotReader) collectPCIeDevices(systemPaths, chassisPaths []st
|
||||
if looksLikeGPU(doc, functionDocs) {
|
||||
continue
|
||||
}
|
||||
if replayPCIeDeviceBackedByCanonicalNIC(doc, functionDocs) {
|
||||
continue
|
||||
}
|
||||
supplementalDocs := r.getLinkedSupplementalDocs(doc, "EnvironmentMetrics", "Metrics")
|
||||
supplementalDocs = append(supplementalDocs, r.getChassisScopedPCIeSupplementalDocs(doc)...)
|
||||
for _, fn := range functionDocs {
|
||||
supplementalDocs = append(supplementalDocs, r.getLinkedSupplementalDocs(fn, "EnvironmentMetrics", "Metrics")...)
|
||||
}
|
||||
dev := parsePCIeDeviceWithSupplementalDocs(doc, functionDocs, supplementalDocs)
|
||||
if isUnidentifiablePCIeDevice(dev) {
|
||||
if shouldSkipReplayPCIeDevice(doc, dev) {
|
||||
continue
|
||||
}
|
||||
out = append(out, dev)
|
||||
@@ -126,41 +164,185 @@ func (r redfishSnapshotReader) collectPCIeDevices(systemPaths, chassisPaths []st
|
||||
for idx, fn := range functionDocs {
|
||||
supplementalDocs := r.getLinkedSupplementalDocs(fn, "EnvironmentMetrics", "Metrics")
|
||||
dev := parsePCIeFunctionWithSupplementalDocs(fn, supplementalDocs, idx+1)
|
||||
if shouldSkipReplayPCIeDevice(fn, dev) {
|
||||
continue
|
||||
}
|
||||
out = append(out, dev)
|
||||
}
|
||||
}
|
||||
return dedupePCIeDevices(out)
|
||||
}
|
||||
|
||||
func (r redfishSnapshotReader) getChassisScopedPCIeSupplementalDocs(doc map[string]interface{}) []map[string]interface{} {
|
||||
if !looksLikeNVSwitchPCIeDoc(doc) {
|
||||
return nil
|
||||
func shouldSkipReplayPCIeDevice(doc map[string]interface{}, dev models.PCIeDevice) bool {
|
||||
if isUnidentifiablePCIeDevice(dev) {
|
||||
return true
|
||||
}
|
||||
if replayNetworkFunctionBackedByCanonicalNIC(doc, dev) {
|
||||
return true
|
||||
}
|
||||
if isReplayStorageServiceEndpoint(doc, dev) {
|
||||
return true
|
||||
}
|
||||
if isReplayNoisePCIeClass(dev.DeviceClass) {
|
||||
return true
|
||||
}
|
||||
if isReplayDisplayDeviceDuplicate(doc, dev) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func replayPCIeDeviceBackedByCanonicalNIC(doc map[string]interface{}, functionDocs []map[string]interface{}) bool {
|
||||
if !looksLikeReplayNetworkPCIeDevice(doc, functionDocs) {
|
||||
return false
|
||||
}
|
||||
for _, fn := range functionDocs {
|
||||
if hasRedfishLinkedMember(fn, "NetworkDeviceFunctions") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func replayNetworkFunctionBackedByCanonicalNIC(doc map[string]interface{}, dev models.PCIeDevice) bool {
|
||||
if !looksLikeReplayNetworkClass(dev.DeviceClass) {
|
||||
return false
|
||||
}
|
||||
return hasRedfishLinkedMember(doc, "NetworkDeviceFunctions")
|
||||
}
|
||||
|
||||
func looksLikeReplayNetworkPCIeDevice(doc map[string]interface{}, functionDocs []map[string]interface{}) bool {
|
||||
for _, fn := range functionDocs {
|
||||
if looksLikeReplayNetworkClass(asString(fn["DeviceClass"])) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
joined := strings.ToLower(strings.TrimSpace(strings.Join([]string{
|
||||
asString(doc["DeviceType"]),
|
||||
asString(doc["Description"]),
|
||||
asString(doc["Name"]),
|
||||
asString(doc["Model"]),
|
||||
}, " ")))
|
||||
return strings.Contains(joined, "network")
|
||||
}
|
||||
|
||||
func looksLikeReplayNetworkClass(class string) bool {
|
||||
class = strings.ToLower(strings.TrimSpace(class))
|
||||
return strings.Contains(class, "network") || strings.Contains(class, "ethernet")
|
||||
}
|
||||
|
||||
func isReplayStorageServiceEndpoint(doc map[string]interface{}, dev models.PCIeDevice) bool {
|
||||
class := strings.ToLower(strings.TrimSpace(dev.DeviceClass))
|
||||
if class != "massstoragecontroller" && class != "mass storage controller" {
|
||||
return false
|
||||
}
|
||||
name := strings.ToLower(strings.TrimSpace(firstNonEmpty(
|
||||
dev.PartNumber,
|
||||
asString(doc["PartNumber"]),
|
||||
asString(doc["Description"]),
|
||||
)))
|
||||
if strings.Contains(name, "pcie switch management endpoint") {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(name, "volume management device nvme raid controller") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasRedfishLinkedMember(doc map[string]interface{}, key string) bool {
|
||||
links, ok := doc["Links"].(map[string]interface{})
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if asInt(links[key+"@odata.count"]) > 0 {
|
||||
return true
|
||||
}
|
||||
linked, ok := links[key]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
switch v := linked.(type) {
|
||||
case []interface{}:
|
||||
return len(v) > 0
|
||||
case map[string]interface{}:
|
||||
if asString(v["@odata.id"]) != "" {
|
||||
return true
|
||||
}
|
||||
return len(v) > 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isReplayNoisePCIeClass(class string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(class)) {
|
||||
case "bridge", "processor", "signalprocessingcontroller", "signal processing controller", "serialbuscontroller", "serial bus controller":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isReplayDisplayDeviceDuplicate(doc map[string]interface{}, dev models.PCIeDevice) bool {
|
||||
class := strings.ToLower(strings.TrimSpace(dev.DeviceClass))
|
||||
if class != "displaycontroller" && class != "display controller" {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(strings.TrimSpace(asString(doc["Description"])), "Display Device")
|
||||
}
|
||||
|
||||
func (r redfishSnapshotReader) getChassisScopedPCIeSupplementalDocs(doc map[string]interface{}) []map[string]interface{} {
|
||||
docPath := normalizeRedfishPath(asString(doc["@odata.id"]))
|
||||
chassisPath := chassisPathForPCIeDoc(docPath)
|
||||
if chassisPath == "" {
|
||||
return nil
|
||||
}
|
||||
out := make([]map[string]interface{}, 0, 4)
|
||||
for _, path := range []string{
|
||||
joinPath(chassisPath, "/EnvironmentMetrics"),
|
||||
joinPath(chassisPath, "/ThermalSubsystem/ThermalMetrics"),
|
||||
} {
|
||||
supplementalDoc, err := r.getJSON(path)
|
||||
if err != nil || len(supplementalDoc) == 0 {
|
||||
continue
|
||||
|
||||
out := make([]map[string]interface{}, 0, 6)
|
||||
if looksLikeNVSwitchPCIeDoc(doc) {
|
||||
for _, path := range []string{
|
||||
joinPath(chassisPath, "/EnvironmentMetrics"),
|
||||
joinPath(chassisPath, "/ThermalSubsystem/ThermalMetrics"),
|
||||
} {
|
||||
supplementalDoc, err := r.getJSON(path)
|
||||
if err != nil || len(supplementalDoc) == 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, supplementalDoc)
|
||||
}
|
||||
}
|
||||
deviceDocs, err := r.getCollectionMembers(joinPath(chassisPath, "/Devices"))
|
||||
if err == nil {
|
||||
for _, deviceDoc := range deviceDocs {
|
||||
if !redfishPCIeMatchesChassisDeviceDoc(doc, deviceDoc) {
|
||||
continue
|
||||
}
|
||||
out = append(out, deviceDoc)
|
||||
}
|
||||
out = append(out, supplementalDoc)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// collectBMCMAC returns the MAC address of the first active BMC management
|
||||
// interface found in Managers/*/EthernetInterfaces. Returns empty string if
|
||||
// no MAC is available.
|
||||
// collectBMCMAC returns the MAC address of the best BMC management interface
|
||||
// found in Managers/*/EthernetInterfaces. Prefer an active link with an IP
|
||||
// address over a passive sideband interface.
|
||||
func (r redfishSnapshotReader) collectBMCMAC(managerPaths []string) string {
|
||||
summary := r.collectBMCManagementSummary(managerPaths)
|
||||
if len(summary) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.ToUpper(strings.TrimSpace(asString(summary["mac_address"])))
|
||||
}
|
||||
|
||||
func (r redfishSnapshotReader) collectBMCManagementSummary(managerPaths []string) map[string]any {
|
||||
bestScore := -1
|
||||
var best map[string]any
|
||||
for _, managerPath := range managerPaths {
|
||||
members, err := r.getCollectionMembers(joinPath(managerPath, "/EthernetInterfaces"))
|
||||
collectionPath := joinPath(managerPath, "/EthernetInterfaces")
|
||||
collectionDoc, _ := r.getJSON(collectionPath)
|
||||
ncsiEnabled, lldpMode, lldpByEth := redfishManagerEthernetCollectionHints(collectionDoc)
|
||||
members, err := r.getCollectionMembers(collectionPath)
|
||||
if err != nil || len(members) == 0 {
|
||||
continue
|
||||
}
|
||||
@@ -172,12 +354,214 @@ func (r redfishSnapshotReader) collectBMCMAC(managerPaths []string) string {
|
||||
if mac == "" || strings.EqualFold(mac, "00:00:00:00:00:00") {
|
||||
continue
|
||||
}
|
||||
return strings.ToUpper(mac)
|
||||
ifaceID := strings.TrimSpace(firstNonEmpty(asString(doc["Id"]), asString(doc["Name"])))
|
||||
summary := map[string]any{
|
||||
"manager_path": managerPath,
|
||||
"interface_id": ifaceID,
|
||||
"hostname": strings.TrimSpace(asString(doc["HostName"])),
|
||||
"fqdn": strings.TrimSpace(asString(doc["FQDN"])),
|
||||
"mac_address": strings.ToUpper(mac),
|
||||
"link_status": strings.TrimSpace(asString(doc["LinkStatus"])),
|
||||
"speed_mbps": asInt(doc["SpeedMbps"]),
|
||||
"interface_name": strings.TrimSpace(asString(doc["Name"])),
|
||||
"interface_desc": strings.TrimSpace(asString(doc["Description"])),
|
||||
"ncsi_enabled": ncsiEnabled,
|
||||
"lldp_mode": lldpMode,
|
||||
"ipv4_address": redfishManagerIPv4Field(doc, "Address"),
|
||||
"ipv4_gateway": redfishManagerIPv4Field(doc, "Gateway"),
|
||||
"ipv4_subnet": redfishManagerIPv4Field(doc, "SubnetMask"),
|
||||
"ipv6_address": redfishManagerIPv6Field(doc, "Address"),
|
||||
"link_is_active": strings.EqualFold(strings.TrimSpace(asString(doc["LinkStatus"])), "LinkActive"),
|
||||
"interface_score": 0,
|
||||
}
|
||||
if lldp, ok := lldpByEth[strings.ToLower(ifaceID)]; ok {
|
||||
summary["lldp_chassis_name"] = lldp["ChassisName"]
|
||||
summary["lldp_port_desc"] = lldp["PortDesc"]
|
||||
summary["lldp_port_id"] = lldp["PortId"]
|
||||
if vlan := asInt(lldp["VlanId"]); vlan > 0 {
|
||||
summary["lldp_vlan_id"] = vlan
|
||||
}
|
||||
}
|
||||
score := redfishManagerInterfaceScore(summary)
|
||||
summary["interface_score"] = score
|
||||
if score > bestScore {
|
||||
bestScore = score
|
||||
best = summary
|
||||
}
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func redfishManagerEthernetCollectionHints(collectionDoc map[string]interface{}) (bool, string, map[string]map[string]interface{}) {
|
||||
lldpByEth := make(map[string]map[string]interface{})
|
||||
if len(collectionDoc) == 0 {
|
||||
return false, "", lldpByEth
|
||||
}
|
||||
oem, _ := collectionDoc["Oem"].(map[string]interface{})
|
||||
public, _ := oem["Public"].(map[string]interface{})
|
||||
ncsiEnabled := asBool(public["NcsiEnabled"])
|
||||
lldp, _ := public["LLDP"].(map[string]interface{})
|
||||
lldpMode := strings.TrimSpace(asString(lldp["LLDPMode"]))
|
||||
if members, ok := lldp["Members"].([]interface{}); ok {
|
||||
for _, item := range members {
|
||||
member, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
ethIndex := strings.ToLower(strings.TrimSpace(asString(member["EthIndex"])))
|
||||
if ethIndex == "" {
|
||||
continue
|
||||
}
|
||||
lldpByEth[ethIndex] = member
|
||||
}
|
||||
}
|
||||
return ncsiEnabled, lldpMode, lldpByEth
|
||||
}
|
||||
|
||||
func redfishManagerIPv4Field(doc map[string]interface{}, key string) string {
|
||||
if len(doc) == 0 {
|
||||
return ""
|
||||
}
|
||||
for _, field := range []string{"IPv4Addresses", "IPv4StaticAddresses"} {
|
||||
list, ok := doc[field].([]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, item := range list {
|
||||
entry, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
value := strings.TrimSpace(asString(entry[key]))
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func redfishManagerIPv6Field(doc map[string]interface{}, key string) string {
|
||||
if len(doc) == 0 {
|
||||
return ""
|
||||
}
|
||||
list, ok := doc["IPv6Addresses"].([]interface{})
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
for _, item := range list {
|
||||
entry, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
value := strings.TrimSpace(asString(entry[key]))
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func redfishManagerInterfaceScore(summary map[string]any) int {
|
||||
score := 0
|
||||
if strings.EqualFold(strings.TrimSpace(asString(summary["link_status"])), "LinkActive") {
|
||||
score += 100
|
||||
}
|
||||
if strings.TrimSpace(asString(summary["ipv4_address"])) != "" {
|
||||
score += 40
|
||||
}
|
||||
if strings.TrimSpace(asString(summary["ipv6_address"])) != "" {
|
||||
score += 10
|
||||
}
|
||||
if strings.TrimSpace(asString(summary["mac_address"])) != "" {
|
||||
score += 10
|
||||
}
|
||||
if asInt(summary["speed_mbps"]) > 0 {
|
||||
score += 5
|
||||
}
|
||||
if ifaceID := strings.ToLower(strings.TrimSpace(asString(summary["interface_id"]))); ifaceID != "" && !strings.HasPrefix(ifaceID, "usb") {
|
||||
score += 3
|
||||
}
|
||||
if asBool(summary["ncsi_enabled"]) {
|
||||
score += 1
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
// findNICIndexByLinkedNetworkAdapter resolves a NetworkInterface document to an
|
||||
// existing NIC in bySlot by following Links.NetworkAdapter → the Chassis
|
||||
// NetworkAdapter doc and reconstructing the canonical NIC identity. Returns -1
|
||||
// if no match is found.
|
||||
func (r redfishSnapshotReader) findNICIndexByLinkedNetworkAdapter(iface map[string]interface{}, existing []models.NetworkAdapter, bySlot map[string]int) int {
|
||||
links, ok := iface["Links"].(map[string]interface{})
|
||||
if !ok {
|
||||
return -1
|
||||
}
|
||||
adapterRef, ok := links["NetworkAdapter"].(map[string]interface{})
|
||||
if !ok {
|
||||
return -1
|
||||
}
|
||||
adapterPath := normalizeRedfishPath(asString(adapterRef["@odata.id"]))
|
||||
if adapterPath == "" {
|
||||
return -1
|
||||
}
|
||||
adapterDoc, err := r.getJSON(adapterPath)
|
||||
if err != nil || len(adapterDoc) == 0 {
|
||||
return -1
|
||||
}
|
||||
adapterNIC := r.buildNICFromAdapterDoc(adapterDoc)
|
||||
if serial := normalizeRedfishIdentityField(adapterNIC.SerialNumber); serial != "" {
|
||||
for idx, nic := range existing {
|
||||
if strings.EqualFold(normalizeRedfishIdentityField(nic.SerialNumber), serial) {
|
||||
return idx
|
||||
}
|
||||
}
|
||||
}
|
||||
if bdf := strings.TrimSpace(adapterNIC.BDF); bdf != "" {
|
||||
for idx, nic := range existing {
|
||||
if strings.EqualFold(strings.TrimSpace(nic.BDF), bdf) {
|
||||
return idx
|
||||
}
|
||||
}
|
||||
}
|
||||
if slot := strings.ToLower(strings.TrimSpace(adapterNIC.Slot)); slot != "" {
|
||||
if idx, ok := bySlot[slot]; ok {
|
||||
return idx
|
||||
}
|
||||
}
|
||||
for idx, nic := range existing {
|
||||
if networkAdaptersShareMACs(nic, adapterNIC) {
|
||||
return idx
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func networkAdaptersShareMACs(a, b models.NetworkAdapter) bool {
|
||||
if len(a.MACAddresses) == 0 || len(b.MACAddresses) == 0 {
|
||||
return false
|
||||
}
|
||||
seen := make(map[string]struct{}, len(a.MACAddresses))
|
||||
for _, mac := range a.MACAddresses {
|
||||
normalized := strings.ToUpper(strings.TrimSpace(mac))
|
||||
if normalized == "" {
|
||||
continue
|
||||
}
|
||||
seen[normalized] = struct{}{}
|
||||
}
|
||||
for _, mac := range b.MACAddresses {
|
||||
normalized := strings.ToUpper(strings.TrimSpace(mac))
|
||||
if normalized == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[normalized]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// enrichNICMACsFromNetworkDeviceFunctions reads the NetworkDeviceFunctions
|
||||
// collection linked from a NetworkAdapter document and populates the NIC's
|
||||
// MACAddresses from each function's Ethernet.PermanentMACAddress / MACAddress.
|
||||
|
||||
@@ -45,6 +45,15 @@ func resolveProcessorGPUChassisSerial(chassisByID map[string]map[string]interfac
|
||||
return ""
|
||||
}
|
||||
|
||||
func resolveProcessorGPUChassisPath(chassisPathByID map[string]string, gpuID string, plan redfishprofile.ResolvedAnalysisPlan) string {
|
||||
for _, candidateID := range processorGPUChassisCandidateIDs(gpuID, plan) {
|
||||
if p, ok := chassisPathByID[strings.ToUpper(candidateID)]; ok {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func processorGPUChassisCandidateIDs(gpuID string, plan redfishprofile.ResolvedAnalysisPlan) []string {
|
||||
gpuID = strings.TrimSpace(gpuID)
|
||||
if gpuID == "" {
|
||||
|
||||
@@ -14,13 +14,16 @@ func (r redfishSnapshotReader) collectStorage(systemPath string, plan redfishpro
|
||||
driveDocs, err := r.getCollectionMembers(driveCollectionPath)
|
||||
if err == nil {
|
||||
for _, driveDoc := range driveDocs {
|
||||
if !isVirtualStorageDrive(driveDoc) {
|
||||
if !isAbsentDriveDoc(driveDoc) && !isVirtualStorageDrive(driveDoc) {
|
||||
supplementalDocs := r.getLinkedSupplementalDocs(driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
||||
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
|
||||
}
|
||||
}
|
||||
if len(driveDocs) == 0 {
|
||||
for _, driveDoc := range r.probeDirectDiskBayChildren(driveCollectionPath) {
|
||||
if isAbsentDriveDoc(driveDoc) {
|
||||
continue
|
||||
}
|
||||
supplementalDocs := r.getLinkedSupplementalDocs(driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
||||
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
|
||||
}
|
||||
@@ -43,7 +46,7 @@ func (r redfishSnapshotReader) collectStorage(systemPath string, plan redfishpro
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if !isVirtualStorageDrive(driveDoc) {
|
||||
if !isAbsentDriveDoc(driveDoc) && !isVirtualStorageDrive(driveDoc) {
|
||||
supplementalDocs := r.getLinkedSupplementalDocs(driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
||||
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
|
||||
}
|
||||
@@ -51,7 +54,7 @@ func (r redfishSnapshotReader) collectStorage(systemPath string, plan redfishpro
|
||||
continue
|
||||
}
|
||||
if looksLikeDrive(member) {
|
||||
if isVirtualStorageDrive(member) {
|
||||
if isAbsentDriveDoc(member) || isVirtualStorageDrive(member) {
|
||||
continue
|
||||
}
|
||||
supplementalDocs := r.getLinkedSupplementalDocs(member, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
||||
@@ -63,14 +66,14 @@ func (r redfishSnapshotReader) collectStorage(systemPath string, plan redfishpro
|
||||
driveDocs, err := r.getCollectionMembers(joinPath(enclosurePath, "/Drives"))
|
||||
if err == nil {
|
||||
for _, driveDoc := range driveDocs {
|
||||
if looksLikeDrive(driveDoc) && !isVirtualStorageDrive(driveDoc) {
|
||||
if looksLikeDrive(driveDoc) && !isAbsentDriveDoc(driveDoc) && !isVirtualStorageDrive(driveDoc) {
|
||||
supplementalDocs := r.getLinkedSupplementalDocs(driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
||||
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
|
||||
}
|
||||
}
|
||||
if len(driveDocs) == 0 {
|
||||
for _, driveDoc := range r.probeDirectDiskBayChildren(joinPath(enclosurePath, "/Drives")) {
|
||||
if isVirtualStorageDrive(driveDoc) {
|
||||
if isAbsentDriveDoc(driveDoc) || isVirtualStorageDrive(driveDoc) {
|
||||
continue
|
||||
}
|
||||
out = append(out, parseDrive(driveDoc))
|
||||
@@ -83,7 +86,7 @@ func (r redfishSnapshotReader) collectStorage(systemPath string, plan redfishpro
|
||||
|
||||
if len(plan.KnownStorageDriveCollections) > 0 {
|
||||
for _, driveDoc := range r.collectKnownStorageMembers(systemPath, plan.KnownStorageDriveCollections) {
|
||||
if looksLikeDrive(driveDoc) && !isVirtualStorageDrive(driveDoc) {
|
||||
if looksLikeDrive(driveDoc) && !isAbsentDriveDoc(driveDoc) && !isVirtualStorageDrive(driveDoc) {
|
||||
supplementalDocs := r.getLinkedSupplementalDocs(driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
||||
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
|
||||
}
|
||||
@@ -98,7 +101,7 @@ func (r redfishSnapshotReader) collectStorage(systemPath string, plan redfishpro
|
||||
}
|
||||
for _, devAny := range devices {
|
||||
devDoc, ok := devAny.(map[string]interface{})
|
||||
if !ok || !looksLikeDrive(devDoc) || isVirtualStorageDrive(devDoc) {
|
||||
if !ok || !looksLikeDrive(devDoc) || isAbsentDriveDoc(devDoc) || isVirtualStorageDrive(devDoc) {
|
||||
continue
|
||||
}
|
||||
out = append(out, parseDrive(devDoc))
|
||||
@@ -112,7 +115,7 @@ func (r redfishSnapshotReader) collectStorage(systemPath string, plan redfishpro
|
||||
continue
|
||||
}
|
||||
for _, driveDoc := range driveDocs {
|
||||
if !looksLikeDrive(driveDoc) || isVirtualStorageDrive(driveDoc) {
|
||||
if !looksLikeDrive(driveDoc) || isAbsentDriveDoc(driveDoc) || isVirtualStorageDrive(driveDoc) {
|
||||
continue
|
||||
}
|
||||
out = append(out, parseDrive(driveDoc))
|
||||
@@ -124,7 +127,7 @@ func (r redfishSnapshotReader) collectStorage(systemPath string, plan redfishpro
|
||||
continue
|
||||
}
|
||||
for _, driveDoc := range r.probeSupermicroNVMeDiskBays(chassisPath) {
|
||||
if !looksLikeDrive(driveDoc) || isVirtualStorageDrive(driveDoc) {
|
||||
if !looksLikeDrive(driveDoc) || isAbsentDriveDoc(driveDoc) || isVirtualStorageDrive(driveDoc) {
|
||||
continue
|
||||
}
|
||||
out = append(out, parseDrive(driveDoc))
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -52,7 +52,6 @@ func baselineSeedPaths(discovered DiscoveredResources) []string {
|
||||
for _, p := range discovered.SystemPaths {
|
||||
add(p)
|
||||
add(joinPath(p, "/Bios"))
|
||||
add(joinPath(p, "/SecureBoot"))
|
||||
add(joinPath(p, "/Oem/Public"))
|
||||
add(joinPath(p, "/Oem/Public/FRU"))
|
||||
add(joinPath(p, "/Processors"))
|
||||
|
||||
@@ -10,7 +10,6 @@ func genericProfile() Profile {
|
||||
ensurePrefetchPolicy(plan, AcquisitionPrefetchPolicy{
|
||||
IncludeSuffixes: []string{
|
||||
"/Bios",
|
||||
"/SecureBoot",
|
||||
"/Processors",
|
||||
"/Memory",
|
||||
"/Storage",
|
||||
@@ -47,7 +46,6 @@ func genericProfile() Profile {
|
||||
ensureScopedPathPolicy(plan, AcquisitionScopedPathPolicy{
|
||||
SystemCriticalSuffixes: []string{
|
||||
"/Bios",
|
||||
"/SecureBoot",
|
||||
"/Oem/Public",
|
||||
"/Oem/Public/FRU",
|
||||
"/Processors",
|
||||
@@ -104,6 +102,7 @@ func genericProfile() Profile {
|
||||
ensureRecoveryPolicy(plan, AcquisitionRecoveryPolicy{
|
||||
EnableCriticalCollectionMemberRetry: true,
|
||||
EnableCriticalSlowProbe: true,
|
||||
EnableEmptyCriticalCollectionRetry: true,
|
||||
})
|
||||
ensureRatePolicy(plan, AcquisitionRatePolicy{
|
||||
TargetP95LatencyMS: 900,
|
||||
|
||||
67
internal/collector/redfishprofile/profile_hpe.go
Normal file
67
internal/collector/redfishprofile/profile_hpe.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package redfishprofile
|
||||
|
||||
func hpeProfile() Profile {
|
||||
return staticProfile{
|
||||
name: "hpe",
|
||||
priority: 20,
|
||||
safeForFallback: true,
|
||||
matchFn: func(s MatchSignals) int {
|
||||
score := 0
|
||||
if containsFold(s.SystemManufacturer, "hpe") ||
|
||||
containsFold(s.SystemManufacturer, "hewlett packard") ||
|
||||
containsFold(s.ChassisManufacturer, "hpe") ||
|
||||
containsFold(s.ChassisManufacturer, "hewlett packard") {
|
||||
score += 80
|
||||
}
|
||||
for _, ns := range s.OEMNamespaces {
|
||||
if containsFold(ns, "hpe") {
|
||||
score += 30
|
||||
break
|
||||
}
|
||||
}
|
||||
if containsFold(s.ServiceRootProduct, "ilo") {
|
||||
score += 30
|
||||
}
|
||||
if containsFold(s.ManagerManufacturer, "hpe") || containsFold(s.ManagerManufacturer, "ilo") {
|
||||
score += 20
|
||||
}
|
||||
return min(score, 100)
|
||||
},
|
||||
extendAcquisition: func(plan *AcquisitionPlan, _ MatchSignals) {
|
||||
// HPE ProLiant SmartStorage RAID controller inventory is not reachable
|
||||
// via standard Redfish Storage paths — it requires the HPE OEM SmartStorage tree.
|
||||
ensureScopedPathPolicy(plan, AcquisitionScopedPathPolicy{
|
||||
SystemCriticalSuffixes: []string{
|
||||
"/SmartStorage",
|
||||
"/SmartStorageConfig",
|
||||
},
|
||||
ManagerCriticalSuffixes: []string{
|
||||
"/LicenseService",
|
||||
},
|
||||
})
|
||||
// HPE iLO responds more slowly than average BMCs under load; give the
|
||||
// ETA estimator a realistic baseline so progress reports are accurate.
|
||||
ensureETABaseline(plan, AcquisitionETABaseline{
|
||||
DiscoverySeconds: 12,
|
||||
SnapshotSeconds: 180,
|
||||
PrefetchSeconds: 30,
|
||||
CriticalPlanBSeconds: 40,
|
||||
ProfilePlanBSeconds: 25,
|
||||
})
|
||||
ensureRecoveryPolicy(plan, AcquisitionRecoveryPolicy{
|
||||
EnableProfilePlanB: true,
|
||||
})
|
||||
// HPE iLO starts throttling under high request rates. Setting a higher
|
||||
// latency tolerance prevents the adaptive throttler from treating normal
|
||||
// iLO slowness as a reason to stall the collection.
|
||||
ensureRatePolicy(plan, AcquisitionRatePolicy{
|
||||
TargetP95LatencyMS: 1200,
|
||||
ThrottleP95LatencyMS: 2500,
|
||||
MinSnapshotWorkers: 2,
|
||||
MinPrefetchWorkers: 1,
|
||||
DisablePrefetchOnErrors: true,
|
||||
})
|
||||
addPlanNote(plan, "hpe ilo acquisition extensions enabled")
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package redfishprofile
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
outboardCardHintRe = regexp.MustCompile(`/outboardPCIeCard\d+(?:/|$)`)
|
||||
obDriveHintRe = regexp.MustCompile(`/Drives/OB\d+$`)
|
||||
fpDriveHintRe = regexp.MustCompile(`/Drives/FP00HDD\d+$`)
|
||||
vrFirmwareHintRe = regexp.MustCompile(`^CPU\d+_PVCC.*_VR$`)
|
||||
)
|
||||
|
||||
var inspurGroupOEMFirmwareHints = map[string]struct{}{
|
||||
"Front_HDD_CPLD0": {},
|
||||
"MainBoard0CPLD": {},
|
||||
"MainBoardCPLD": {},
|
||||
"PDBBoardCPLD": {},
|
||||
"SCMCPLD": {},
|
||||
"SWBoardCPLD": {},
|
||||
}
|
||||
|
||||
func inspurGroupOEMPlatformsProfile() Profile {
|
||||
return staticProfile{
|
||||
name: "inspur-group-oem-platforms",
|
||||
priority: 25,
|
||||
safeForFallback: false,
|
||||
matchFn: func(s MatchSignals) int {
|
||||
topologyScore := 0
|
||||
boardScore := 0
|
||||
chassisOutboard := matchedPathTokens(s.ResourceHints, "/redfish/v1/Chassis/", outboardCardHintRe)
|
||||
systemOutboard := matchedPathTokens(s.ResourceHints, "/redfish/v1/Systems/", outboardCardHintRe)
|
||||
obDrives := matchedPathTokens(s.ResourceHints, "", obDriveHintRe)
|
||||
fpDrives := matchedPathTokens(s.ResourceHints, "", fpDriveHintRe)
|
||||
firmwareNames, vrFirmwareNames := inspurGroupOEMFirmwareMatches(s.ResourceHints)
|
||||
|
||||
if len(chassisOutboard) > 0 {
|
||||
topologyScore += 20
|
||||
}
|
||||
if len(systemOutboard) > 0 {
|
||||
topologyScore += 10
|
||||
}
|
||||
switch {
|
||||
case len(obDrives) > 0 && len(fpDrives) > 0:
|
||||
topologyScore += 15
|
||||
}
|
||||
switch {
|
||||
case len(firmwareNames) >= 2:
|
||||
boardScore += 15
|
||||
}
|
||||
switch {
|
||||
case len(vrFirmwareNames) >= 2:
|
||||
boardScore += 10
|
||||
}
|
||||
if anySignalContains(s, "COMMONbAssembly") {
|
||||
boardScore += 12
|
||||
}
|
||||
if anySignalContains(s, "EnvironmentMetrcs") {
|
||||
boardScore += 8
|
||||
}
|
||||
if anySignalContains(s, "GetServerAllUSBStatus") {
|
||||
boardScore += 8
|
||||
}
|
||||
if topologyScore == 0 || boardScore == 0 {
|
||||
return 0
|
||||
}
|
||||
return min(topologyScore+boardScore, 100)
|
||||
},
|
||||
extendAcquisition: func(plan *AcquisitionPlan, _ MatchSignals) {
|
||||
addPlanNote(plan, "Inspur Group OEM platform fingerprint matched")
|
||||
},
|
||||
applyAnalysisDirectives: func(d *AnalysisDirectives, _ MatchSignals) {
|
||||
d.EnableGenericGraphicsControllerDedup = true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func matchedPathTokens(paths []string, requiredPrefix string, re *regexp.Regexp) []string {
|
||||
seen := make(map[string]struct{})
|
||||
for _, rawPath := range paths {
|
||||
path := normalizePath(rawPath)
|
||||
if path == "" || (requiredPrefix != "" && !strings.HasPrefix(path, requiredPrefix)) {
|
||||
continue
|
||||
}
|
||||
token := re.FindString(path)
|
||||
if token == "" {
|
||||
continue
|
||||
}
|
||||
token = strings.Trim(token, "/")
|
||||
if token == "" {
|
||||
continue
|
||||
}
|
||||
seen[token] = struct{}{}
|
||||
}
|
||||
out := make([]string, 0, len(seen))
|
||||
for token := range seen {
|
||||
out = append(out, token)
|
||||
}
|
||||
return dedupeSorted(out)
|
||||
}
|
||||
|
||||
func inspurGroupOEMFirmwareMatches(paths []string) ([]string, []string) {
|
||||
firmwareNames := make(map[string]struct{})
|
||||
vrNames := make(map[string]struct{})
|
||||
for _, rawPath := range paths {
|
||||
path := normalizePath(rawPath)
|
||||
if !strings.HasPrefix(path, "/redfish/v1/UpdateService/FirmwareInventory/") {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(path[strings.LastIndex(path, "/")+1:])
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := inspurGroupOEMFirmwareHints[name]; ok {
|
||||
firmwareNames[name] = struct{}{}
|
||||
}
|
||||
if vrFirmwareHintRe.MatchString(name) {
|
||||
vrNames[name] = struct{}{}
|
||||
}
|
||||
}
|
||||
return mapKeysSorted(firmwareNames), mapKeysSorted(vrNames)
|
||||
}
|
||||
|
||||
func anySignalContains(signals MatchSignals, needle string) bool {
|
||||
needle = strings.TrimSpace(needle)
|
||||
if needle == "" {
|
||||
return false
|
||||
}
|
||||
for _, signal := range signals.ResourceHints {
|
||||
if strings.Contains(signal, needle) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, signal := range signals.DocHints {
|
||||
if strings.Contains(signal, needle) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func mapKeysSorted(items map[string]struct{}) []string {
|
||||
out := make([]string, 0, len(items))
|
||||
for item := range items {
|
||||
out = append(out, item)
|
||||
}
|
||||
return dedupeSorted(out)
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package redfishprofile
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCollectSignalsFromTree_InspurGroupOEMPlatformsSelectsMatchedMode(t *testing.T) {
|
||||
tree := map[string]interface{}{
|
||||
"/redfish/v1": map[string]interface{}{
|
||||
"@odata.id": "/redfish/v1",
|
||||
},
|
||||
"/redfish/v1/Systems": map[string]interface{}{
|
||||
"Members": []interface{}{
|
||||
map[string]interface{}{"@odata.id": "/redfish/v1/Systems/1"},
|
||||
},
|
||||
},
|
||||
"/redfish/v1/Systems/1": map[string]interface{}{
|
||||
"@odata.id": "/redfish/v1/Systems/1",
|
||||
"Oem": map[string]interface{}{
|
||||
"Public": map[string]interface{}{
|
||||
"USB": map[string]interface{}{
|
||||
"@odata.id": "/redfish/v1/Systems/1/Oem/Public/GetServerAllUSBStatus",
|
||||
},
|
||||
},
|
||||
},
|
||||
"NetworkInterfaces": map[string]interface{}{
|
||||
"@odata.id": "/redfish/v1/Systems/1/NetworkInterfaces",
|
||||
},
|
||||
},
|
||||
"/redfish/v1/Systems/1/NetworkInterfaces": map[string]interface{}{
|
||||
"Members": []interface{}{
|
||||
map[string]interface{}{"@odata.id": "/redfish/v1/Systems/1/NetworkInterfaces/outboardPCIeCard0"},
|
||||
map[string]interface{}{"@odata.id": "/redfish/v1/Systems/1/NetworkInterfaces/outboardPCIeCard1"},
|
||||
},
|
||||
},
|
||||
"/redfish/v1/Chassis": map[string]interface{}{
|
||||
"Members": []interface{}{
|
||||
map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1"},
|
||||
},
|
||||
},
|
||||
"/redfish/v1/Chassis/1": map[string]interface{}{
|
||||
"@odata.id": "/redfish/v1/Chassis/1",
|
||||
"Actions": map[string]interface{}{
|
||||
"Oem": map[string]interface{}{
|
||||
"Public": map[string]interface{}{
|
||||
"NvGpuPowerLimitWatts": map[string]interface{}{
|
||||
"target": "/redfish/v1/Chassis/1/GPU/EnvironmentMetrcs",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"Drives": map[string]interface{}{
|
||||
"@odata.id": "/redfish/v1/Chassis/1/Drives",
|
||||
},
|
||||
"NetworkAdapters": map[string]interface{}{
|
||||
"@odata.id": "/redfish/v1/Chassis/1/NetworkAdapters",
|
||||
},
|
||||
},
|
||||
"/redfish/v1/Chassis/1/Drives": map[string]interface{}{
|
||||
"Members": []interface{}{
|
||||
map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/Drives/OB01"},
|
||||
map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/Drives/FP00HDD00"},
|
||||
},
|
||||
},
|
||||
"/redfish/v1/Chassis/1/NetworkAdapters": map[string]interface{}{
|
||||
"Members": []interface{}{
|
||||
map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/NetworkAdapters/outboardPCIeCard0"},
|
||||
map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/NetworkAdapters/outboardPCIeCard1"},
|
||||
},
|
||||
},
|
||||
"/redfish/v1/Chassis/1/Assembly": map[string]interface{}{
|
||||
"Assemblies": []interface{}{
|
||||
map[string]interface{}{
|
||||
"Oem": map[string]interface{}{
|
||||
"COMMONb": map[string]interface{}{
|
||||
"COMMONbAssembly": map[string]interface{}{
|
||||
"@odata.type": "#COMMONbAssembly.v1_0_0.COMMONbAssembly",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/redfish/v1/Managers": map[string]interface{}{
|
||||
"Members": []interface{}{
|
||||
map[string]interface{}{"@odata.id": "/redfish/v1/Managers/1"},
|
||||
},
|
||||
},
|
||||
"/redfish/v1/Managers/1": map[string]interface{}{
|
||||
"Actions": map[string]interface{}{
|
||||
"Oem": map[string]interface{}{
|
||||
"#PublicManager.ExportConfFile": map[string]interface{}{
|
||||
"target": "/redfish/v1/Managers/1/Actions/Oem/Public/ExportConfFile",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/redfish/v1/UpdateService/FirmwareInventory": map[string]interface{}{
|
||||
"Members": []interface{}{
|
||||
map[string]interface{}{"@odata.id": "/redfish/v1/UpdateService/FirmwareInventory/Front_HDD_CPLD0"},
|
||||
map[string]interface{}{"@odata.id": "/redfish/v1/UpdateService/FirmwareInventory/SCMCPLD"},
|
||||
map[string]interface{}{"@odata.id": "/redfish/v1/UpdateService/FirmwareInventory/CPU0_PVCCD_HV_VR"},
|
||||
map[string]interface{}{"@odata.id": "/redfish/v1/UpdateService/FirmwareInventory/CPU1_PVCCIN_VR"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
signals := CollectSignalsFromTree(tree)
|
||||
match := MatchProfiles(signals)
|
||||
|
||||
if match.Mode != ModeMatched {
|
||||
t.Fatalf("expected matched mode, got %q", match.Mode)
|
||||
}
|
||||
assertProfileSelected(t, match, "inspur-group-oem-platforms")
|
||||
}
|
||||
|
||||
func TestCollectSignalsFromTree_InspurGroupOEMPlatformsDoesNotFalsePositiveOnExampleRawExports(t *testing.T) {
|
||||
examples := []string{
|
||||
"2026-03-18 (G5500 V7) - 210619KUGGXGS2000015.zip",
|
||||
"2026-03-11 (SYS-821GE-TNHR) - A514359X5C08846.zip",
|
||||
"2026-03-15 (CG480-S5063) - P5T0006091.zip",
|
||||
"2026-03-18 (CG290-S3063) - PAT0011258.zip",
|
||||
"2024-04-25 (AS -4124GQ-TNMI) - S490387X4418273.zip",
|
||||
}
|
||||
for _, name := range examples {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
tree := loadRawExportTreeFromExampleZip(t, name)
|
||||
match := MatchProfiles(CollectSignalsFromTree(tree))
|
||||
assertProfileNotSelected(t, match, "inspur-group-oem-platforms")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func loadRawExportTreeFromExampleZip(t *testing.T, name string) map[string]interface{} {
|
||||
t.Helper()
|
||||
path := filepath.Join("..", "..", "..", "example", name)
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("open example zip %s: %v", path, err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
t.Fatalf("stat example zip %s: %v", path, err)
|
||||
}
|
||||
|
||||
zr, err := zip.NewReader(f, info.Size())
|
||||
if err != nil {
|
||||
t.Fatalf("read example zip %s: %v", path, err)
|
||||
}
|
||||
for _, file := range zr.File {
|
||||
if file.Name != "raw_export.json" {
|
||||
continue
|
||||
}
|
||||
rc, err := file.Open()
|
||||
if err != nil {
|
||||
t.Fatalf("open %s in %s: %v", file.Name, path, err)
|
||||
}
|
||||
defer rc.Close()
|
||||
var payload struct {
|
||||
Source struct {
|
||||
RawPayloads struct {
|
||||
RedfishTree map[string]interface{} `json:"redfish_tree"`
|
||||
} `json:"raw_payloads"`
|
||||
} `json:"source"`
|
||||
}
|
||||
if err := json.NewDecoder(rc).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode raw_export.json from %s: %v", path, err)
|
||||
}
|
||||
if len(payload.Source.RawPayloads.RedfishTree) == 0 {
|
||||
t.Fatalf("example %s has empty redfish_tree", path)
|
||||
}
|
||||
return payload.Source.RawPayloads.RedfishTree
|
||||
}
|
||||
t.Fatalf("raw_export.json not found in %s", path)
|
||||
return nil
|
||||
}
|
||||
@@ -64,8 +64,10 @@ func msiProfile() Profile {
|
||||
if snapshotHasGPUProcessor(snapshot, discovered.SystemPaths) && snapshotHasPathPrefix(snapshot, "/redfish/v1/Chassis/GPU") {
|
||||
plan.Directives.EnableProcessorGPUFallback = true
|
||||
plan.Directives.EnableMSIProcessorGPUChassisLookup = true
|
||||
plan.Directives.EnableMSIGhostGPUFilter = true
|
||||
addAnalysisLookupMode(plan, "msi-index")
|
||||
addAnalysisNote(plan, "msi analysis enables processor-gpu fallback from discovered GPU chassis")
|
||||
addAnalysisNote(plan, "msi ghost-gpu filter enabled: GPUs with temperature=0 on powered-on host are excluded")
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -55,6 +55,8 @@ func BuiltinProfiles() []Profile {
|
||||
msiProfile(),
|
||||
supermicroProfile(),
|
||||
dellProfile(),
|
||||
hpeProfile(),
|
||||
inspurGroupOEMPlatformsProfile(),
|
||||
hgxProfile(),
|
||||
xfusionProfile(),
|
||||
}
|
||||
@@ -205,6 +207,9 @@ func ensureRecoveryPolicy(plan *AcquisitionPlan, policy AcquisitionRecoveryPolic
|
||||
if policy.EnableProfilePlanB {
|
||||
plan.Tuning.RecoveryPolicy.EnableProfilePlanB = true
|
||||
}
|
||||
if policy.EnableEmptyCriticalCollectionRetry {
|
||||
plan.Tuning.RecoveryPolicy.EnableEmptyCriticalCollectionRetry = true
|
||||
}
|
||||
}
|
||||
|
||||
func ensureScopedPathPolicy(plan *AcquisitionPlan, policy AcquisitionScopedPathPolicy) {
|
||||
|
||||
@@ -2,7 +2,14 @@ package redfishprofile
|
||||
|
||||
import "strings"
|
||||
|
||||
func CollectSignals(serviceRootDoc, systemDoc, chassisDoc, managerDoc map[string]interface{}, resourceHints []string) MatchSignals {
|
||||
func CollectSignals(serviceRootDoc, systemDoc, chassisDoc, managerDoc map[string]interface{}, resourceHints []string, hintDocs ...map[string]interface{}) MatchSignals {
|
||||
resourceHints = append([]string{}, resourceHints...)
|
||||
docHints := make([]string, 0)
|
||||
for _, doc := range append([]map[string]interface{}{serviceRootDoc, systemDoc, chassisDoc, managerDoc}, hintDocs...) {
|
||||
embeddedPaths, embeddedHints := collectDocSignalHints(doc)
|
||||
resourceHints = append(resourceHints, embeddedPaths...)
|
||||
docHints = append(docHints, embeddedHints...)
|
||||
}
|
||||
signals := MatchSignals{
|
||||
ServiceRootVendor: lookupString(serviceRootDoc, "Vendor"),
|
||||
ServiceRootProduct: lookupString(serviceRootDoc, "Product"),
|
||||
@@ -13,6 +20,7 @@ func CollectSignals(serviceRootDoc, systemDoc, chassisDoc, managerDoc map[string
|
||||
ChassisModel: lookupString(chassisDoc, "Model"),
|
||||
ManagerManufacturer: lookupString(managerDoc, "Manufacturer"),
|
||||
ResourceHints: resourceHints,
|
||||
DocHints: docHints,
|
||||
}
|
||||
signals.OEMNamespaces = dedupeSorted(append(
|
||||
oemNamespaces(serviceRootDoc),
|
||||
@@ -50,6 +58,7 @@ func CollectSignalsFromTree(tree map[string]interface{}) MatchSignals {
|
||||
managerPath := memberPath("/redfish/v1/Managers", "/redfish/v1/Managers/1")
|
||||
|
||||
resourceHints := make([]string, 0, len(tree))
|
||||
hintDocs := make([]map[string]interface{}, 0, len(tree))
|
||||
for path := range tree {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
@@ -57,6 +66,13 @@ func CollectSignalsFromTree(tree map[string]interface{}) MatchSignals {
|
||||
}
|
||||
resourceHints = append(resourceHints, path)
|
||||
}
|
||||
for _, v := range tree {
|
||||
doc, ok := v.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
hintDocs = append(hintDocs, doc)
|
||||
}
|
||||
|
||||
return CollectSignals(
|
||||
getDoc("/redfish/v1"),
|
||||
@@ -64,9 +80,72 @@ func CollectSignalsFromTree(tree map[string]interface{}) MatchSignals {
|
||||
getDoc(chassisPath),
|
||||
getDoc(managerPath),
|
||||
resourceHints,
|
||||
hintDocs...,
|
||||
)
|
||||
}
|
||||
|
||||
func collectDocSignalHints(doc map[string]interface{}) ([]string, []string) {
|
||||
if len(doc) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
paths := make([]string, 0)
|
||||
hints := make([]string, 0)
|
||||
var walk func(any)
|
||||
walk = func(v any) {
|
||||
switch x := v.(type) {
|
||||
case map[string]interface{}:
|
||||
for rawKey, child := range x {
|
||||
key := strings.TrimSpace(rawKey)
|
||||
if key != "" {
|
||||
hints = append(hints, key)
|
||||
}
|
||||
if s, ok := child.(string); ok {
|
||||
s = strings.TrimSpace(s)
|
||||
if s != "" {
|
||||
switch key {
|
||||
case "@odata.id", "target":
|
||||
paths = append(paths, s)
|
||||
case "@odata.type":
|
||||
hints = append(hints, s)
|
||||
default:
|
||||
if isInterestingSignalString(s) {
|
||||
hints = append(hints, s)
|
||||
if strings.HasPrefix(s, "/") {
|
||||
paths = append(paths, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(child)
|
||||
}
|
||||
case []interface{}:
|
||||
for _, child := range x {
|
||||
walk(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(doc)
|
||||
return paths, hints
|
||||
}
|
||||
|
||||
func isInterestingSignalString(s string) bool {
|
||||
switch {
|
||||
case strings.HasPrefix(s, "/"):
|
||||
return true
|
||||
case strings.HasPrefix(s, "#"):
|
||||
return true
|
||||
case strings.Contains(s, "COMMONb"):
|
||||
return true
|
||||
case strings.Contains(s, "EnvironmentMetrcs"):
|
||||
return true
|
||||
case strings.Contains(s, "GetServerAllUSBStatus"):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func lookupString(doc map[string]interface{}, key string) string {
|
||||
if len(doc) == 0 {
|
||||
return ""
|
||||
|
||||
@@ -17,6 +17,7 @@ type MatchSignals struct {
|
||||
ManagerManufacturer string
|
||||
OEMNamespaces []string
|
||||
ResourceHints []string
|
||||
DocHints []string
|
||||
}
|
||||
|
||||
type AcquisitionPlan struct {
|
||||
@@ -90,6 +91,7 @@ type AcquisitionRecoveryPolicy struct {
|
||||
EnableCriticalCollectionMemberRetry bool
|
||||
EnableCriticalSlowProbe bool
|
||||
EnableProfilePlanB bool
|
||||
EnableEmptyCriticalCollectionRetry bool
|
||||
}
|
||||
|
||||
type AcquisitionPrefetchPolicy struct {
|
||||
@@ -103,17 +105,18 @@ type AnalysisDirectives struct {
|
||||
EnableProcessorGPUChassisAlias bool
|
||||
EnableGenericGraphicsControllerDedup bool
|
||||
EnableMSIProcessorGPUChassisLookup bool
|
||||
EnableMSIGhostGPUFilter bool
|
||||
EnableStorageEnclosureRecovery bool
|
||||
EnableKnownStorageControllerRecovery bool
|
||||
}
|
||||
|
||||
type ResolvedAnalysisPlan struct {
|
||||
Match MatchResult
|
||||
Directives AnalysisDirectives
|
||||
Notes []string
|
||||
ProcessorGPUChassisLookupModes []string
|
||||
KnownStorageDriveCollections []string
|
||||
KnownStorageVolumeCollections []string
|
||||
Match MatchResult
|
||||
Directives AnalysisDirectives
|
||||
Notes []string
|
||||
ProcessorGPUChassisLookupModes []string
|
||||
KnownStorageDriveCollections []string
|
||||
KnownStorageVolumeCollections []string
|
||||
}
|
||||
|
||||
type Profile interface {
|
||||
@@ -144,6 +147,7 @@ type ProfileScore struct {
|
||||
func normalizeSignals(signals MatchSignals) MatchSignals {
|
||||
signals.OEMNamespaces = dedupeSorted(signals.OEMNamespaces)
|
||||
signals.ResourceHints = dedupeSorted(signals.ResourceHints)
|
||||
signals.DocHints = dedupeSorted(signals.DocHints)
|
||||
return signals
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,9 @@ type Request struct {
|
||||
Password string
|
||||
Token string
|
||||
TLSMode string
|
||||
PowerOnIfHostOff bool
|
||||
PowerOnIfHostOff bool
|
||||
StopHostAfterCollect bool
|
||||
DebugPayloads bool
|
||||
}
|
||||
|
||||
type Progress struct {
|
||||
|
||||
@@ -33,7 +33,7 @@ func ConvertToReanimator(result *models.AnalysisResult) (*ReanimatorExport, erro
|
||||
// Determine target host (optional field)
|
||||
targetHost := inferTargetHost(result.TargetHost, result.Filename)
|
||||
|
||||
collectedAt := formatRFC3339(result.CollectedAt)
|
||||
collectedAt := formatRFC3339(reanimatorCollectedAt(result))
|
||||
devices := canonicalDevicesForExport(result.Hardware)
|
||||
|
||||
export := &ReanimatorExport{
|
||||
@@ -58,6 +58,17 @@ func ConvertToReanimator(result *models.AnalysisResult) (*ReanimatorExport, erro
|
||||
return export, nil
|
||||
}
|
||||
|
||||
// reanimatorCollectedAt returns the best timestamp for Reanimator export collected_at.
|
||||
// Prefers InventoryLastModifiedAt when it is set and no older than 30 days; falls back
|
||||
// to CollectedAt (and ultimately to now via formatRFC3339).
|
||||
func reanimatorCollectedAt(result *models.AnalysisResult) time.Time {
|
||||
inv := result.InventoryLastModifiedAt
|
||||
if !inv.IsZero() && time.Since(inv) <= 30*24*time.Hour {
|
||||
return inv
|
||||
}
|
||||
return result.CollectedAt
|
||||
}
|
||||
|
||||
// formatRFC3339 formats time in RFC3339 format, returns current time if zero
|
||||
func formatRFC3339(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
@@ -347,10 +358,12 @@ func dedupeCanonicalDevices(items []models.HardwareDevice) []models.HardwareDevi
|
||||
prev.score = canonicalScore(prev.item)
|
||||
byKey[key] = prev
|
||||
}
|
||||
// Secondary pass: for items without serial/BDF (noKey), try to merge into an
|
||||
// existing keyed entry with the same model+manufacturer. This handles the case
|
||||
// where a device appears both in PCIeDevices (with BDF) and NetworkAdapters
|
||||
// (without BDF) — e.g. Inspur outboardPCIeCard vs PCIeCard with the same model.
|
||||
// Secondary pass: for PCIe-class items without serial/BDF (noKey), try to merge
|
||||
// into an existing keyed entry with the same model+manufacturer. This handles
|
||||
// the case where a device appears both in PCIeDevices (with BDF) and
|
||||
// NetworkAdapters (without BDF) — e.g. Inspur outboardPCIeCard vs PCIeCard
|
||||
// with the same model. Do not apply this to storage: repeated NVMe slots often
|
||||
// share the same model string and would collapse incorrectly.
|
||||
// deviceIdentity returns the best available model name for secondary matching,
|
||||
// preferring Model over DeviceClass (which may hold a resolved device name).
|
||||
deviceIdentity := func(d models.HardwareDevice) string {
|
||||
@@ -366,6 +379,10 @@ func dedupeCanonicalDevices(items []models.HardwareDevice) []models.HardwareDevi
|
||||
var unmatched []models.HardwareDevice
|
||||
for _, item := range noKey {
|
||||
mergeKind := canonicalMergeKind(item.Kind)
|
||||
if mergeKind != "pcie-class" {
|
||||
unmatched = append(unmatched, item)
|
||||
continue
|
||||
}
|
||||
identity := deviceIdentity(item)
|
||||
mfr := strings.ToLower(strings.TrimSpace(item.Manufacturer))
|
||||
if identity == "" {
|
||||
@@ -658,7 +675,17 @@ func convertMemoryFromDevices(devices []models.HardwareDevice, collectedAt strin
|
||||
}
|
||||
present := boolFromPresentPtr(d.Present, true)
|
||||
status := normalizeStatus(d.Status, true)
|
||||
if !present || d.SizeMB == 0 || status == "Empty" || strings.TrimSpace(d.SerialNumber) == "" {
|
||||
mem := models.MemoryDIMM{
|
||||
Present: present,
|
||||
SizeMB: d.SizeMB,
|
||||
Type: d.Type,
|
||||
Description: stringFromDetailMap(d.Details, "description"),
|
||||
Manufacturer: d.Manufacturer,
|
||||
SerialNumber: d.SerialNumber,
|
||||
PartNumber: d.PartNumber,
|
||||
Status: d.Status,
|
||||
}
|
||||
if !mem.IsInstalledInventory() || status == "Empty" || strings.TrimSpace(d.SerialNumber) == "" {
|
||||
continue
|
||||
}
|
||||
meta := buildStatusMeta(status, d.StatusCheckedAt, d.StatusChangedAt, d.StatusHistory, d.ErrorDescription, collectedAt)
|
||||
@@ -700,18 +727,16 @@ func convertStorageFromDevices(devices []models.HardwareDevice, collectedAt stri
|
||||
if isVirtualExportStorageDevice(d) {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(d.SerialNumber) == "" {
|
||||
continue
|
||||
}
|
||||
present := d.Present == nil || *d.Present
|
||||
if !present {
|
||||
if !shouldExportStorageDevice(d) {
|
||||
continue
|
||||
}
|
||||
present := boolFromPresentPtr(d.Present, true)
|
||||
status := inferStorageStatus(models.Storage{Present: present})
|
||||
if strings.TrimSpace(d.Status) != "" {
|
||||
status = normalizeStatus(d.Status, false)
|
||||
status = normalizeStatus(d.Status, !present)
|
||||
}
|
||||
meta := buildStatusMeta(status, d.StatusCheckedAt, d.StatusChangedAt, d.StatusHistory, d.ErrorDescription, collectedAt)
|
||||
presentValue := present
|
||||
result = append(result, ReanimatorStorage{
|
||||
Slot: d.Slot,
|
||||
Type: d.Type,
|
||||
@@ -721,6 +746,7 @@ func convertStorageFromDevices(devices []models.HardwareDevice, collectedAt stri
|
||||
Manufacturer: d.Manufacturer,
|
||||
Firmware: d.Firmware,
|
||||
Interface: d.Interface,
|
||||
Present: &presentValue,
|
||||
TemperatureC: floatFromDetailMap(d.Details, "temperature_c"),
|
||||
PowerOnHours: int64FromDetailMap(d.Details, "power_on_hours"),
|
||||
PowerCycles: int64FromDetailMap(d.Details, "power_cycles"),
|
||||
@@ -1323,7 +1349,7 @@ func convertMemory(memory []models.MemoryDIMM, collectedAt string) []ReanimatorM
|
||||
|
||||
result := make([]ReanimatorMemory, 0, len(memory))
|
||||
for _, mem := range memory {
|
||||
if !mem.Present || mem.SizeMB == 0 || normalizeStatus(mem.Status, true) == "Empty" || strings.TrimSpace(mem.SerialNumber) == "" {
|
||||
if !mem.IsInstalledInventory() || normalizeStatus(mem.Status, true) == "Empty" || strings.TrimSpace(mem.SerialNumber) == "" {
|
||||
continue
|
||||
}
|
||||
status := normalizeStatus(mem.Status, true)
|
||||
@@ -1365,14 +1391,16 @@ func convertStorage(storage []models.Storage, collectedAt string) []ReanimatorSt
|
||||
|
||||
result := make([]ReanimatorStorage, 0, len(storage))
|
||||
for _, stor := range storage {
|
||||
// Skip storage without serial number
|
||||
if stor.SerialNumber == "" {
|
||||
if isVirtualLegacyStorageDevice(stor) {
|
||||
continue
|
||||
}
|
||||
if !shouldExportLegacyStorage(stor) {
|
||||
continue
|
||||
}
|
||||
|
||||
status := inferStorageStatus(stor)
|
||||
if strings.TrimSpace(stor.Status) != "" {
|
||||
status = normalizeStatus(stor.Status, false)
|
||||
status = normalizeStatus(stor.Status, !stor.Present)
|
||||
}
|
||||
meta := buildStatusMeta(
|
||||
status,
|
||||
@@ -1382,6 +1410,7 @@ func convertStorage(storage []models.Storage, collectedAt string) []ReanimatorSt
|
||||
stor.ErrorDescription,
|
||||
collectedAt,
|
||||
)
|
||||
present := stor.Present
|
||||
|
||||
result = append(result, ReanimatorStorage{
|
||||
Slot: stor.Slot,
|
||||
@@ -1392,6 +1421,7 @@ func convertStorage(storage []models.Storage, collectedAt string) []ReanimatorSt
|
||||
Manufacturer: stor.Manufacturer,
|
||||
Firmware: stor.Firmware,
|
||||
Interface: stor.Interface,
|
||||
Present: &present,
|
||||
RemainingEndurancePct: stor.RemainingEndurancePct,
|
||||
Status: status,
|
||||
StatusCheckedAt: meta.StatusCheckedAt,
|
||||
@@ -1403,6 +1433,53 @@ func convertStorage(storage []models.Storage, collectedAt string) []ReanimatorSt
|
||||
return result
|
||||
}
|
||||
|
||||
func shouldExportStorageDevice(d models.HardwareDevice) bool {
|
||||
if normalizedSerial(d.SerialNumber) != "" {
|
||||
return true
|
||||
}
|
||||
if strings.TrimSpace(d.Slot) != "" {
|
||||
return true
|
||||
}
|
||||
if hasMeaningfulExporterText(d.Model) {
|
||||
return true
|
||||
}
|
||||
if hasMeaningfulExporterText(d.Type) || hasMeaningfulExporterText(d.Interface) {
|
||||
return true
|
||||
}
|
||||
if d.SizeGB > 0 {
|
||||
return true
|
||||
}
|
||||
return d.Present != nil
|
||||
}
|
||||
|
||||
func shouldExportLegacyStorage(stor models.Storage) bool {
|
||||
if normalizedSerial(stor.SerialNumber) != "" {
|
||||
return true
|
||||
}
|
||||
if strings.TrimSpace(stor.Slot) != "" {
|
||||
return true
|
||||
}
|
||||
if hasMeaningfulExporterText(stor.Model) {
|
||||
return true
|
||||
}
|
||||
if hasMeaningfulExporterText(stor.Type) || hasMeaningfulExporterText(stor.Interface) {
|
||||
return true
|
||||
}
|
||||
if stor.SizeGB > 0 {
|
||||
return true
|
||||
}
|
||||
return stor.Present
|
||||
}
|
||||
|
||||
func isVirtualLegacyStorageDevice(stor models.Storage) bool {
|
||||
return isVirtualExportStorageDevice(models.HardwareDevice{
|
||||
Kind: models.DeviceKindStorage,
|
||||
Slot: stor.Slot,
|
||||
Model: stor.Model,
|
||||
Manufacturer: stor.Manufacturer,
|
||||
})
|
||||
}
|
||||
|
||||
// convertPCIeDevices converts PCIe devices, GPUs, and network adapters to Reanimator format
|
||||
func convertPCIeDevices(hw *models.HardwareConfig, collectedAt string) []ReanimatorPCIe {
|
||||
result := make([]ReanimatorPCIe, 0)
|
||||
@@ -2169,10 +2246,8 @@ func normalizePCIeDeviceClass(d models.HardwareDevice) string {
|
||||
|
||||
func normalizeLegacyPCIeDeviceClass(deviceClass string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(deviceClass)) {
|
||||
case "", "network", "network controller", "networkcontroller":
|
||||
case "", "network", "network controller", "networkcontroller", "ethernet", "ethernet controller", "ethernetcontroller":
|
||||
return "NetworkController"
|
||||
case "ethernet", "ethernet controller", "ethernetcontroller":
|
||||
return "EthernetController"
|
||||
case "fibre channel", "fibre channel controller", "fibrechannelcontroller", "fc":
|
||||
return "FibreChannelController"
|
||||
case "display", "displaycontroller", "display controller", "vga":
|
||||
@@ -2193,8 +2268,6 @@ func normalizeLegacyPCIeDeviceClass(deviceClass string) string {
|
||||
func normalizeNetworkDeviceClass(portType, model, description string) string {
|
||||
joined := strings.ToLower(strings.TrimSpace(strings.Join([]string{portType, model, description}, " ")))
|
||||
switch {
|
||||
case strings.Contains(joined, "ethernet"):
|
||||
return "EthernetController"
|
||||
case strings.Contains(joined, "fibre channel") || strings.Contains(joined, " fibrechannel") || strings.Contains(joined, "fc "):
|
||||
return "FibreChannelController"
|
||||
default:
|
||||
|
||||
@@ -259,6 +259,29 @@ func TestConvertMemory(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertMemory_KeepsInstalledDIMMWithUnknownSize(t *testing.T) {
|
||||
memory := []models.MemoryDIMM{
|
||||
{
|
||||
Slot: "PROC 1 DIMM 3",
|
||||
Present: true,
|
||||
SizeMB: 0,
|
||||
Manufacturer: "Hynix",
|
||||
PartNumber: "HMCG88AEBRA115N",
|
||||
SerialNumber: "2B5F92C6",
|
||||
Status: "OK",
|
||||
},
|
||||
}
|
||||
|
||||
result := convertMemory(memory, "2026-03-30T10:00:00Z")
|
||||
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("expected 1 inventory-only DIMM, got %d", len(result))
|
||||
}
|
||||
if result[0].PartNumber != "HMCG88AEBRA115N" || result[0].SerialNumber != "2B5F92C6" || result[0].SizeMB != 0 {
|
||||
t.Fatalf("unexpected converted memory: %+v", result[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertToReanimator_CPUSerialIsNotSynthesizedAndSocketIsDeduped(t *testing.T) {
|
||||
input := &models.AnalysisResult{
|
||||
Filename: "cpu-dedupe.json",
|
||||
@@ -424,20 +447,26 @@ func TestConvertStorage(t *testing.T) {
|
||||
Slot: "OB02",
|
||||
Type: "NVMe",
|
||||
Model: "INTEL SSDPF2KX076T1",
|
||||
SerialNumber: "", // No serial - should be skipped
|
||||
SerialNumber: "",
|
||||
Present: true,
|
||||
},
|
||||
}
|
||||
|
||||
result := convertStorage(storage, "2026-02-10T15:30:00Z")
|
||||
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("expected 1 storage device (skipped one without serial), got %d", len(result))
|
||||
if len(result) != 2 {
|
||||
t.Fatalf("expected both inventory slots to be exported, got %d", len(result))
|
||||
}
|
||||
|
||||
if result[0].Status != "Unknown" {
|
||||
t.Errorf("expected Unknown status, got %q", result[0].Status)
|
||||
}
|
||||
if result[1].SerialNumber != "" {
|
||||
t.Errorf("expected empty serial for second storage slot, got %q", result[1].SerialNumber)
|
||||
}
|
||||
if result[1].Present == nil || !*result[1].Present {
|
||||
t.Fatalf("expected present=true to be preserved for populated slot without serial")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertToReanimator_SkipsAMIVirtualStorageDevices(t *testing.T) {
|
||||
@@ -971,6 +1000,52 @@ func TestConvertToReanimator_StatusFallbackUsesCollectedAt(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertToReanimator_ExportsStorageInventoryWithoutSerial(t *testing.T) {
|
||||
collectedAt := time.Date(2026, 4, 1, 9, 0, 0, 0, time.UTC)
|
||||
input := &models.AnalysisResult{
|
||||
Filename: "nvme-inventory.json",
|
||||
CollectedAt: collectedAt,
|
||||
Hardware: &models.HardwareConfig{
|
||||
BoardInfo: models.BoardInfo{SerialNumber: "BOARD-001"},
|
||||
Storage: []models.Storage{
|
||||
{
|
||||
Slot: "OB01",
|
||||
Type: "NVMe",
|
||||
Model: "PM9A3",
|
||||
SerialNumber: "SSD-001",
|
||||
Present: true,
|
||||
},
|
||||
{
|
||||
Slot: "OB02",
|
||||
Type: "NVMe",
|
||||
Model: "PM9A3",
|
||||
Present: true,
|
||||
},
|
||||
{
|
||||
Slot: "OB03",
|
||||
Type: "NVMe",
|
||||
Model: "PM9A3",
|
||||
Present: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
out, err := ConvertToReanimator(input)
|
||||
if err != nil {
|
||||
t.Fatalf("ConvertToReanimator() failed: %v", err)
|
||||
}
|
||||
if len(out.Hardware.Storage) != 3 {
|
||||
t.Fatalf("expected 3 storage entries including inventory slots without serial, got %d", len(out.Hardware.Storage))
|
||||
}
|
||||
if out.Hardware.Storage[1].Slot != "OB02" || out.Hardware.Storage[1].SerialNumber != "" {
|
||||
t.Fatalf("expected OB02 storage slot without serial to survive export, got %#v", out.Hardware.Storage[1])
|
||||
}
|
||||
if out.Hardware.Storage[2].Present == nil || *out.Hardware.Storage[2].Present {
|
||||
t.Fatalf("expected OB03 to preserve present=false, got %#v", out.Hardware.Storage[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertToReanimator_FirmwareExcludesDeviceBoundEntries(t *testing.T) {
|
||||
input := &models.AnalysisResult{
|
||||
Filename: "fw-filter-test.json",
|
||||
@@ -1658,6 +1733,43 @@ func TestConvertToReanimator_ExportsContractV24Telemetry(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertToReanimator_UnifiesEthernetAndNetworkControllers(t *testing.T) {
|
||||
input := &models.AnalysisResult{
|
||||
Hardware: &models.HardwareConfig{
|
||||
BoardInfo: models.BoardInfo{SerialNumber: "BOARD-123"},
|
||||
Devices: []models.HardwareDevice{
|
||||
{
|
||||
Kind: models.DeviceKindPCIe,
|
||||
Slot: "PCIe1",
|
||||
DeviceClass: "EthernetController",
|
||||
Present: boolPtr(true),
|
||||
SerialNumber: "ETH-001",
|
||||
},
|
||||
{
|
||||
Kind: models.DeviceKindNetwork,
|
||||
Slot: "NIC1",
|
||||
Model: "Ethernet Adapter",
|
||||
Present: boolPtr(true),
|
||||
SerialNumber: "NIC-001",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
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 exports, got %d", len(out.Hardware.PCIeDevices))
|
||||
}
|
||||
for _, dev := range out.Hardware.PCIeDevices {
|
||||
if dev.DeviceClass != "NetworkController" {
|
||||
t.Fatalf("expected unified NetworkController class, got %+v", dev)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertToReanimator_PreservesLegacyStorageAndPSUDetails(t *testing.T) {
|
||||
input := &models.AnalysisResult{
|
||||
Filename: "legacy-details.json",
|
||||
|
||||
29
internal/models/memory.go
Normal file
29
internal/models/memory.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package models
|
||||
|
||||
import "strings"
|
||||
|
||||
// HasInventoryIdentity reports whether the DIMM has enough identifying
|
||||
// inventory data to treat it as a populated module even when size is unknown.
|
||||
func (m MemoryDIMM) HasInventoryIdentity() bool {
|
||||
return strings.TrimSpace(m.SerialNumber) != "" ||
|
||||
strings.TrimSpace(m.PartNumber) != "" ||
|
||||
strings.TrimSpace(m.Type) != "" ||
|
||||
strings.TrimSpace(m.Technology) != "" ||
|
||||
strings.TrimSpace(m.Description) != ""
|
||||
}
|
||||
|
||||
// IsInstalledInventory reports whether the DIMM represents an installed module
|
||||
// that should be kept in canonical inventory and exports.
|
||||
func (m MemoryDIMM) IsInstalledInventory() bool {
|
||||
if !m.Present {
|
||||
return false
|
||||
}
|
||||
|
||||
status := strings.ToLower(strings.TrimSpace(m.Status))
|
||||
switch status {
|
||||
case "empty", "absent", "not installed":
|
||||
return false
|
||||
}
|
||||
|
||||
return m.SizeMB > 0 || m.HasInventoryIdentity()
|
||||
}
|
||||
@@ -14,7 +14,8 @@ type AnalysisResult struct {
|
||||
Protocol string `json:"protocol,omitempty"` // redfish | ipmi
|
||||
TargetHost string `json:"target_host,omitempty"` // BMC host for live collect
|
||||
SourceTimezone string `json:"source_timezone,omitempty"` // Source timezone/offset used during collection (e.g. +08:00)
|
||||
CollectedAt time.Time `json:"collected_at,omitempty"` // Collection/upload timestamp
|
||||
CollectedAt time.Time `json:"collected_at,omitempty"` // Collection/upload timestamp
|
||||
InventoryLastModifiedAt time.Time `json:"inventory_last_modified_at,omitempty"` // Redfish inventory last modified (InventoryData/Status)
|
||||
RawPayloads map[string]any `json:"raw_payloads,omitempty"` // Additional source payloads (e.g. Redfish tree)
|
||||
Events []Event `json:"events"`
|
||||
FRU []FRUInfo `json:"fru"`
|
||||
|
||||
@@ -19,6 +19,7 @@ const maxZipArchiveSize = 50 * 1024 * 1024
|
||||
const maxGzipDecompressedSize = 50 * 1024 * 1024
|
||||
|
||||
var supportedArchiveExt = map[string]struct{}{
|
||||
".ahs": {},
|
||||
".gz": {},
|
||||
".tgz": {},
|
||||
".tar": {},
|
||||
@@ -45,6 +46,8 @@ func ExtractArchive(archivePath string) ([]ExtractedFile, error) {
|
||||
ext := strings.ToLower(filepath.Ext(archivePath))
|
||||
|
||||
switch ext {
|
||||
case ".ahs":
|
||||
return extractSingleFile(archivePath)
|
||||
case ".gz", ".tgz":
|
||||
return extractTarGz(archivePath)
|
||||
case ".tar", ".sds":
|
||||
@@ -66,6 +69,8 @@ func ExtractArchiveFromReader(r io.Reader, filename string) ([]ExtractedFile, er
|
||||
ext := strings.ToLower(filepath.Ext(filename))
|
||||
|
||||
switch ext {
|
||||
case ".ahs":
|
||||
return extractSingleFileFromReader(r, filename)
|
||||
case ".gz", ".tgz":
|
||||
return extractTarGzFromReader(r, filename)
|
||||
case ".tar", ".sds":
|
||||
|
||||
@@ -76,6 +76,7 @@ func TestIsSupportedArchiveFilename(t *testing.T) {
|
||||
name string
|
||||
want bool
|
||||
}{
|
||||
{name: "HPE_CZ2D1X0GS3_20260330.ahs", want: true},
|
||||
{name: "dump.tar.gz", want: true},
|
||||
{name: "nvidia-bug-report-1651124000923.log.gz", want: true},
|
||||
{name: "snapshot.zip", want: true},
|
||||
@@ -124,3 +125,20 @@ func TestExtractArchiveFromReaderSDS(t *testing.T) {
|
||||
t.Fatalf("expected bmc/pack.info, got %q", files[0].Path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractArchiveFromReaderAHS(t *testing.T) {
|
||||
payload := []byte("ABJRtest")
|
||||
files, err := ExtractArchiveFromReader(bytes.NewReader(payload), "sample.ahs")
|
||||
if err != nil {
|
||||
t.Fatalf("extract ahs from reader: %v", err)
|
||||
}
|
||||
if len(files) != 1 {
|
||||
t.Fatalf("expected 1 extracted file, got %d", len(files))
|
||||
}
|
||||
if files[0].Path != "sample.ahs" {
|
||||
t.Fatalf("expected sample.ahs, got %q", files[0].Path)
|
||||
}
|
||||
if string(files[0].Content) != string(payload) {
|
||||
t.Fatalf("content mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
601
internal/parser/vendors/easy_bee/parser.go
vendored
Normal file
601
internal/parser/vendors/easy_bee/parser.go
vendored
Normal file
@@ -0,0 +1,601 @@
|
||||
package easy_bee
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/logpile/internal/models"
|
||||
"git.mchus.pro/mchus/logpile/internal/parser"
|
||||
)
|
||||
|
||||
const parserVersion = "1.0"
|
||||
|
||||
func init() {
|
||||
parser.Register(&Parser{})
|
||||
}
|
||||
|
||||
// Parser imports support bundles produced by reanimator-easy-bee.
|
||||
// These archives embed a ready-to-use hardware snapshot in export/bee-audit.json.
|
||||
type Parser struct{}
|
||||
|
||||
func (p *Parser) Name() string {
|
||||
return "Reanimator Easy Bee Parser"
|
||||
}
|
||||
|
||||
func (p *Parser) Vendor() string {
|
||||
return "easy_bee"
|
||||
}
|
||||
|
||||
func (p *Parser) Version() string {
|
||||
return parserVersion
|
||||
}
|
||||
|
||||
func (p *Parser) Detect(files []parser.ExtractedFile) int {
|
||||
confidence := 0
|
||||
hasManifest := false
|
||||
hasBeeAudit := false
|
||||
hasRuntimeHealth := false
|
||||
hasTechdump := false
|
||||
hasBundlePrefix := false
|
||||
|
||||
for _, f := range files {
|
||||
path := strings.ToLower(strings.TrimSpace(f.Path))
|
||||
content := strings.ToLower(string(f.Content))
|
||||
|
||||
if !hasBundlePrefix && strings.Contains(path, "bee-support-") {
|
||||
hasBundlePrefix = true
|
||||
confidence += 5
|
||||
}
|
||||
|
||||
if (strings.HasSuffix(path, "/manifest.txt") || path == "manifest.txt") &&
|
||||
strings.Contains(content, "bee_version=") {
|
||||
hasManifest = true
|
||||
confidence += 35
|
||||
if strings.Contains(content, "export_dir=") {
|
||||
confidence += 10
|
||||
}
|
||||
}
|
||||
|
||||
if strings.HasSuffix(path, "/export/bee-audit.json") || path == "bee-audit.json" {
|
||||
hasBeeAudit = true
|
||||
confidence += 55
|
||||
}
|
||||
|
||||
if hasBundlePrefix && (strings.HasSuffix(path, "/export/runtime-health.json") || path == "runtime-health.json") {
|
||||
hasRuntimeHealth = true
|
||||
confidence += 10
|
||||
}
|
||||
|
||||
if hasBundlePrefix && !hasTechdump && strings.Contains(path, "/export/techdump/") {
|
||||
hasTechdump = true
|
||||
confidence += 10
|
||||
}
|
||||
}
|
||||
|
||||
if hasManifest && hasBeeAudit {
|
||||
return 100
|
||||
}
|
||||
if hasBeeAudit && (hasRuntimeHealth || hasTechdump) {
|
||||
confidence += 10
|
||||
}
|
||||
if confidence > 100 {
|
||||
return 100
|
||||
}
|
||||
return confidence
|
||||
}
|
||||
|
||||
func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, error) {
|
||||
snapshotFile := findSnapshotFile(files)
|
||||
if snapshotFile == nil {
|
||||
return nil, fmt.Errorf("easy-bee snapshot not found")
|
||||
}
|
||||
|
||||
var snapshot beeSnapshot
|
||||
if err := json.Unmarshal(snapshotFile.Content, &snapshot); err != nil {
|
||||
return nil, fmt.Errorf("decode %s: %w", snapshotFile.Path, err)
|
||||
}
|
||||
|
||||
manifest := parseManifest(files)
|
||||
|
||||
result := &models.AnalysisResult{
|
||||
SourceType: strings.TrimSpace(snapshot.SourceType),
|
||||
Protocol: strings.TrimSpace(snapshot.Protocol),
|
||||
TargetHost: firstNonEmpty(snapshot.TargetHost, manifest.Host),
|
||||
SourceTimezone: strings.TrimSpace(snapshot.SourceTimezone),
|
||||
CollectedAt: chooseCollectedAt(snapshot, manifest),
|
||||
InventoryLastModifiedAt: snapshot.InventoryLastModifiedAt,
|
||||
RawPayloads: snapshot.RawPayloads,
|
||||
Events: make([]models.Event, 0),
|
||||
FRU: append([]models.FRUInfo(nil), snapshot.FRU...),
|
||||
Sensors: make([]models.SensorReading, 0),
|
||||
Hardware: &models.HardwareConfig{
|
||||
Firmware: append([]models.FirmwareInfo(nil), snapshot.Hardware.Firmware...),
|
||||
BoardInfo: snapshot.Hardware.Board,
|
||||
Devices: append([]models.HardwareDevice(nil), snapshot.Hardware.Devices...),
|
||||
CPUs: append([]models.CPU(nil), snapshot.Hardware.CPUs...),
|
||||
Memory: append([]models.MemoryDIMM(nil), snapshot.Hardware.Memory...),
|
||||
Storage: append([]models.Storage(nil), snapshot.Hardware.Storage...),
|
||||
Volumes: append([]models.StorageVolume(nil), snapshot.Hardware.Volumes...),
|
||||
PCIeDevices: normalizePCIeDevices(snapshot.Hardware.PCIeDevices),
|
||||
GPUs: append([]models.GPU(nil), snapshot.Hardware.GPUs...),
|
||||
NetworkCards: append([]models.NIC(nil), snapshot.Hardware.NetworkCards...),
|
||||
NetworkAdapters: normalizeNetworkAdapters(snapshot.Hardware.NetworkAdapters),
|
||||
PowerSupply: append([]models.PSU(nil), snapshot.Hardware.PowerSupply...),
|
||||
},
|
||||
}
|
||||
|
||||
result.Events = append(result.Events, snapshot.Events...)
|
||||
result.Events = append(result.Events, convertRuntimeToEvents(snapshot.Runtime, result.CollectedAt)...)
|
||||
result.Events = append(result.Events, convertEventLogs(snapshot.Hardware.EventLogs)...)
|
||||
|
||||
result.Sensors = append(result.Sensors, snapshot.Sensors...)
|
||||
result.Sensors = append(result.Sensors, flattenSensorGroups(snapshot.Hardware.Sensors)...)
|
||||
|
||||
if len(result.FRU) == 0 {
|
||||
if boardFRU, ok := buildBoardFRU(snapshot.Hardware.Board); ok {
|
||||
result.FRU = append(result.FRU, boardFRU)
|
||||
}
|
||||
}
|
||||
|
||||
if result.Hardware == nil || (result.Hardware.BoardInfo.SerialNumber == "" &&
|
||||
len(result.Hardware.CPUs) == 0 &&
|
||||
len(result.Hardware.Memory) == 0 &&
|
||||
len(result.Hardware.Storage) == 0 &&
|
||||
len(result.Hardware.PCIeDevices) == 0 &&
|
||||
len(result.Hardware.Devices) == 0) {
|
||||
return nil, fmt.Errorf("unsupported easy-bee snapshot format")
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type beeSnapshot struct {
|
||||
SourceType string `json:"source_type,omitempty"`
|
||||
Protocol string `json:"protocol,omitempty"`
|
||||
TargetHost string `json:"target_host,omitempty"`
|
||||
SourceTimezone string `json:"source_timezone,omitempty"`
|
||||
CollectedAt time.Time `json:"collected_at,omitempty"`
|
||||
InventoryLastModifiedAt time.Time `json:"inventory_last_modified_at,omitempty"`
|
||||
RawPayloads map[string]any `json:"raw_payloads,omitempty"`
|
||||
Events []models.Event `json:"events,omitempty"`
|
||||
FRU []models.FRUInfo `json:"fru,omitempty"`
|
||||
Sensors []models.SensorReading `json:"sensors,omitempty"`
|
||||
Hardware beeHardware `json:"hardware"`
|
||||
Runtime beeRuntime `json:"runtime,omitempty"`
|
||||
}
|
||||
|
||||
type beeHardware struct {
|
||||
Board models.BoardInfo `json:"board"`
|
||||
Firmware []models.FirmwareInfo `json:"firmware,omitempty"`
|
||||
Devices []models.HardwareDevice `json:"devices,omitempty"`
|
||||
CPUs []models.CPU `json:"cpus,omitempty"`
|
||||
Memory []models.MemoryDIMM `json:"memory,omitempty"`
|
||||
Storage []models.Storage `json:"storage,omitempty"`
|
||||
Volumes []models.StorageVolume `json:"volumes,omitempty"`
|
||||
PCIeDevices []models.PCIeDevice `json:"pcie_devices,omitempty"`
|
||||
GPUs []models.GPU `json:"gpus,omitempty"`
|
||||
NetworkCards []models.NIC `json:"network_cards,omitempty"`
|
||||
NetworkAdapters []models.NetworkAdapter `json:"network_adapters,omitempty"`
|
||||
PowerSupply []models.PSU `json:"power_supplies,omitempty"`
|
||||
Sensors beeSensorGroups `json:"sensors,omitempty"`
|
||||
EventLogs []beeEventLog `json:"event_logs,omitempty"`
|
||||
}
|
||||
|
||||
type beeSensorGroups struct {
|
||||
Fans []beeFanSensor `json:"fans,omitempty"`
|
||||
Power []beePowerSensor `json:"power,omitempty"`
|
||||
Temperatures []beeTemperatureSensor `json:"temperatures,omitempty"`
|
||||
Other []beeOtherSensor `json:"other,omitempty"`
|
||||
}
|
||||
|
||||
type beeFanSensor struct {
|
||||
Name string `json:"name"`
|
||||
Location string `json:"location,omitempty"`
|
||||
RPM int `json:"rpm,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
type beePowerSensor 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 beeTemperatureSensor 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 beeOtherSensor 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"`
|
||||
}
|
||||
|
||||
type beeRuntime struct {
|
||||
Status string `json:"status,omitempty"`
|
||||
CheckedAt time.Time `json:"checked_at,omitempty"`
|
||||
NetworkStatus string `json:"network_status,omitempty"`
|
||||
Issues []beeRuntimeIssue `json:"issues,omitempty"`
|
||||
Services []beeRuntimeStatus `json:"services,omitempty"`
|
||||
Interfaces []beeInterface `json:"interfaces,omitempty"`
|
||||
}
|
||||
|
||||
type beeRuntimeIssue struct {
|
||||
Code string `json:"code,omitempty"`
|
||||
Severity string `json:"severity,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type beeRuntimeStatus struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
type beeInterface struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
State string `json:"state,omitempty"`
|
||||
IPv4 []string `json:"ipv4,omitempty"`
|
||||
Outcome string `json:"outcome,omitempty"`
|
||||
}
|
||||
|
||||
type beeEventLog struct {
|
||||
Source string `json:"source,omitempty"`
|
||||
EventTime string `json:"event_time,omitempty"`
|
||||
Severity string `json:"severity,omitempty"`
|
||||
MessageID string `json:"message_id,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
RawPayload map[string]any `json:"raw_payload,omitempty"`
|
||||
}
|
||||
|
||||
type manifestMetadata struct {
|
||||
Host string
|
||||
GeneratedAtUTC time.Time
|
||||
}
|
||||
|
||||
func findSnapshotFile(files []parser.ExtractedFile) *parser.ExtractedFile {
|
||||
for i := range files {
|
||||
path := strings.ToLower(strings.TrimSpace(files[i].Path))
|
||||
if strings.HasSuffix(path, "/export/bee-audit.json") || path == "bee-audit.json" {
|
||||
return &files[i]
|
||||
}
|
||||
}
|
||||
for i := range files {
|
||||
path := strings.ToLower(strings.TrimSpace(files[i].Path))
|
||||
if strings.HasSuffix(path, ".json") && strings.Contains(path, "reanimator") {
|
||||
return &files[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseManifest(files []parser.ExtractedFile) manifestMetadata {
|
||||
var meta manifestMetadata
|
||||
|
||||
for _, f := range files {
|
||||
path := strings.ToLower(strings.TrimSpace(f.Path))
|
||||
if !(strings.HasSuffix(path, "/manifest.txt") || path == "manifest.txt") {
|
||||
continue
|
||||
}
|
||||
|
||||
lines := strings.Split(string(f.Content), "\n")
|
||||
for _, line := range lines {
|
||||
key, value, ok := strings.Cut(strings.TrimSpace(line), "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch strings.TrimSpace(key) {
|
||||
case "host":
|
||||
meta.Host = strings.TrimSpace(value)
|
||||
case "generated_at_utc":
|
||||
if ts, err := time.Parse(time.RFC3339, strings.TrimSpace(value)); err == nil {
|
||||
meta.GeneratedAtUTC = ts.UTC()
|
||||
}
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
return meta
|
||||
}
|
||||
|
||||
func chooseCollectedAt(snapshot beeSnapshot, manifest manifestMetadata) time.Time {
|
||||
switch {
|
||||
case !snapshot.CollectedAt.IsZero():
|
||||
return snapshot.CollectedAt.UTC()
|
||||
case !snapshot.Runtime.CheckedAt.IsZero():
|
||||
return snapshot.Runtime.CheckedAt.UTC()
|
||||
case !manifest.GeneratedAtUTC.IsZero():
|
||||
return manifest.GeneratedAtUTC.UTC()
|
||||
default:
|
||||
return time.Time{}
|
||||
}
|
||||
}
|
||||
|
||||
func convertRuntimeToEvents(runtime beeRuntime, fallback time.Time) []models.Event {
|
||||
events := make([]models.Event, 0)
|
||||
ts := runtime.CheckedAt
|
||||
if ts.IsZero() {
|
||||
ts = fallback
|
||||
}
|
||||
|
||||
if status := strings.TrimSpace(runtime.Status); status != "" {
|
||||
desc := "Bee runtime status: " + status
|
||||
if networkStatus := strings.TrimSpace(runtime.NetworkStatus); networkStatus != "" {
|
||||
desc += " (network: " + networkStatus + ")"
|
||||
}
|
||||
events = append(events, models.Event{
|
||||
Timestamp: ts,
|
||||
Source: "Bee Runtime",
|
||||
EventType: "Runtime Status",
|
||||
Severity: mapSeverity(status),
|
||||
Description: desc,
|
||||
})
|
||||
}
|
||||
|
||||
for _, issue := range runtime.Issues {
|
||||
desc := strings.TrimSpace(issue.Description)
|
||||
if desc == "" {
|
||||
desc = "Bee runtime issue"
|
||||
}
|
||||
events = append(events, models.Event{
|
||||
Timestamp: ts,
|
||||
Source: "Bee Runtime",
|
||||
EventType: "Runtime Issue",
|
||||
Severity: mapSeverity(issue.Severity),
|
||||
Description: desc,
|
||||
RawData: strings.TrimSpace(issue.Code),
|
||||
})
|
||||
}
|
||||
|
||||
for _, svc := range runtime.Services {
|
||||
status := strings.TrimSpace(svc.Status)
|
||||
if status == "" || strings.EqualFold(status, "active") {
|
||||
continue
|
||||
}
|
||||
events = append(events, models.Event{
|
||||
Timestamp: ts,
|
||||
Source: "systemd",
|
||||
EventType: "Service Status",
|
||||
Severity: mapSeverity(status),
|
||||
Description: fmt.Sprintf("%s is %s", strings.TrimSpace(svc.Name), status),
|
||||
})
|
||||
}
|
||||
|
||||
for _, iface := range runtime.Interfaces {
|
||||
state := strings.TrimSpace(iface.State)
|
||||
outcome := strings.TrimSpace(iface.Outcome)
|
||||
if state == "" && outcome == "" {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(state, "up") && strings.EqualFold(outcome, "lease_acquired") {
|
||||
continue
|
||||
}
|
||||
desc := fmt.Sprintf("interface %s state=%s outcome=%s", strings.TrimSpace(iface.Name), state, outcome)
|
||||
events = append(events, models.Event{
|
||||
Timestamp: ts,
|
||||
Source: "network",
|
||||
EventType: "Interface Status",
|
||||
Severity: models.SeverityWarning,
|
||||
Description: strings.TrimSpace(desc),
|
||||
})
|
||||
}
|
||||
|
||||
return events
|
||||
}
|
||||
|
||||
func convertEventLogs(items []beeEventLog) []models.Event {
|
||||
events := make([]models.Event, 0, len(items))
|
||||
for _, item := range items {
|
||||
message := strings.TrimSpace(item.Message)
|
||||
if message == "" {
|
||||
continue
|
||||
}
|
||||
ts := parseEventTime(item.EventTime)
|
||||
rawData := strings.TrimSpace(item.MessageID)
|
||||
events = append(events, models.Event{
|
||||
Timestamp: ts,
|
||||
Source: firstNonEmpty(strings.TrimSpace(item.Source), "Reanimator"),
|
||||
EventType: "Event Log",
|
||||
Severity: mapSeverity(item.Severity),
|
||||
Description: message,
|
||||
RawData: rawData,
|
||||
})
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
func parseEventTime(raw string) time.Time {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return time.Time{}
|
||||
}
|
||||
layouts := []string{time.RFC3339Nano, time.RFC3339}
|
||||
for _, layout := range layouts {
|
||||
if ts, err := time.Parse(layout, raw); err == nil {
|
||||
return ts.UTC()
|
||||
}
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
func flattenSensorGroups(groups beeSensorGroups) []models.SensorReading {
|
||||
result := make([]models.SensorReading, 0, len(groups.Fans)+len(groups.Power)+len(groups.Temperatures)+len(groups.Other))
|
||||
|
||||
for _, fan := range groups.Fans {
|
||||
result = append(result, models.SensorReading{
|
||||
Name: sensorName(fan.Name, fan.Location),
|
||||
Type: "fan",
|
||||
Value: float64(fan.RPM),
|
||||
Unit: "RPM",
|
||||
Status: strings.TrimSpace(fan.Status),
|
||||
})
|
||||
}
|
||||
|
||||
for _, power := range groups.Power {
|
||||
name := sensorName(power.Name, power.Location)
|
||||
status := strings.TrimSpace(power.Status)
|
||||
if power.PowerW != 0 {
|
||||
result = append(result, models.SensorReading{
|
||||
Name: name,
|
||||
Type: "power",
|
||||
Value: power.PowerW,
|
||||
Unit: "W",
|
||||
Status: status,
|
||||
})
|
||||
}
|
||||
if power.VoltageV != 0 {
|
||||
result = append(result, models.SensorReading{
|
||||
Name: name + " Voltage",
|
||||
Type: "voltage",
|
||||
Value: power.VoltageV,
|
||||
Unit: "V",
|
||||
Status: status,
|
||||
})
|
||||
}
|
||||
if power.CurrentA != 0 {
|
||||
result = append(result, models.SensorReading{
|
||||
Name: name + " Current",
|
||||
Type: "current",
|
||||
Value: power.CurrentA,
|
||||
Unit: "A",
|
||||
Status: status,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for _, temp := range groups.Temperatures {
|
||||
result = append(result, models.SensorReading{
|
||||
Name: sensorName(temp.Name, temp.Location),
|
||||
Type: "temperature",
|
||||
Value: temp.Celsius,
|
||||
Unit: "C",
|
||||
Status: strings.TrimSpace(temp.Status),
|
||||
})
|
||||
}
|
||||
|
||||
for _, other := range groups.Other {
|
||||
result = append(result, models.SensorReading{
|
||||
Name: sensorName(other.Name, other.Location),
|
||||
Type: "other",
|
||||
Value: other.Value,
|
||||
Unit: strings.TrimSpace(other.Unit),
|
||||
Status: strings.TrimSpace(other.Status),
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func sensorName(name, location string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
location = strings.TrimSpace(location)
|
||||
if name == "" {
|
||||
return location
|
||||
}
|
||||
if location == "" {
|
||||
return name
|
||||
}
|
||||
return name + " [" + location + "]"
|
||||
}
|
||||
|
||||
func normalizePCIeDevices(items []models.PCIeDevice) []models.PCIeDevice {
|
||||
out := append([]models.PCIeDevice(nil), items...)
|
||||
for i := range out {
|
||||
slot := strings.TrimSpace(out[i].Slot)
|
||||
if out[i].BDF == "" && looksLikeBDF(slot) {
|
||||
out[i].BDF = slot
|
||||
}
|
||||
if out[i].Slot == "" && out[i].BDF != "" {
|
||||
out[i].Slot = out[i].BDF
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeNetworkAdapters(items []models.NetworkAdapter) []models.NetworkAdapter {
|
||||
out := append([]models.NetworkAdapter(nil), items...)
|
||||
for i := range out {
|
||||
slot := strings.TrimSpace(out[i].Slot)
|
||||
if out[i].BDF == "" && looksLikeBDF(slot) {
|
||||
out[i].BDF = slot
|
||||
}
|
||||
if out[i].Slot == "" && out[i].BDF != "" {
|
||||
out[i].Slot = out[i].BDF
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func looksLikeBDF(value string) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) != len("0000:00:00.0") {
|
||||
return false
|
||||
}
|
||||
for i, r := range value {
|
||||
switch i {
|
||||
case 4, 7:
|
||||
if r != ':' {
|
||||
return false
|
||||
}
|
||||
case 10:
|
||||
if r != '.' {
|
||||
return false
|
||||
}
|
||||
default:
|
||||
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func buildBoardFRU(board models.BoardInfo) (models.FRUInfo, bool) {
|
||||
if strings.TrimSpace(board.SerialNumber) == "" &&
|
||||
strings.TrimSpace(board.Manufacturer) == "" &&
|
||||
strings.TrimSpace(board.ProductName) == "" &&
|
||||
strings.TrimSpace(board.PartNumber) == "" {
|
||||
return models.FRUInfo{}, false
|
||||
}
|
||||
|
||||
return models.FRUInfo{
|
||||
Description: "System Board",
|
||||
Manufacturer: strings.TrimSpace(board.Manufacturer),
|
||||
ProductName: strings.TrimSpace(board.ProductName),
|
||||
SerialNumber: strings.TrimSpace(board.SerialNumber),
|
||||
PartNumber: strings.TrimSpace(board.PartNumber),
|
||||
}, true
|
||||
}
|
||||
|
||||
func mapSeverity(raw string) models.Severity {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "critical", "crit", "error", "failed", "failure":
|
||||
return models.SeverityCritical
|
||||
case "warning", "warn", "partial", "degraded", "inactive", "activating", "deactivating":
|
||||
return models.SeverityWarning
|
||||
default:
|
||||
return models.SeverityInfo
|
||||
}
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
219
internal/parser/vendors/easy_bee/parser_test.go
vendored
Normal file
219
internal/parser/vendors/easy_bee/parser_test.go
vendored
Normal file
@@ -0,0 +1,219 @@
|
||||
package easy_bee
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/logpile/internal/parser"
|
||||
)
|
||||
|
||||
func TestDetectBeeSupportArchive(t *testing.T) {
|
||||
p := &Parser{}
|
||||
files := []parser.ExtractedFile{
|
||||
{
|
||||
Path: "bee-support-debian-20260325-162030/manifest.txt",
|
||||
Content: []byte("bee_version=1.0.0\nhost=debian\ngenerated_at_utc=2026-03-25T16:20:30Z\nexport_dir=/appdata/bee/export\n"),
|
||||
},
|
||||
{
|
||||
Path: "bee-support-debian-20260325-162030/export/bee-audit.json",
|
||||
Content: []byte(`{"hardware":{"board":{"serial_number":"SN-BEE-001"}}}`),
|
||||
},
|
||||
{
|
||||
Path: "bee-support-debian-20260325-162030/export/runtime-health.json",
|
||||
Content: []byte(`{"status":"PARTIAL"}`),
|
||||
},
|
||||
}
|
||||
|
||||
if got := p.Detect(files); got < 90 {
|
||||
t.Fatalf("expected high confidence detect score, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectRejectsNonBeeArchive(t *testing.T) {
|
||||
p := &Parser{}
|
||||
files := []parser.ExtractedFile{
|
||||
{
|
||||
Path: "random/manifest.txt",
|
||||
Content: []byte("host=test\n"),
|
||||
},
|
||||
{
|
||||
Path: "random/export/runtime-health.json",
|
||||
Content: []byte(`{"status":"OK"}`),
|
||||
},
|
||||
}
|
||||
|
||||
if got := p.Detect(files); got != 0 {
|
||||
t.Fatalf("expected detect score 0, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBeeAuditSnapshot(t *testing.T) {
|
||||
p := &Parser{}
|
||||
files := []parser.ExtractedFile{
|
||||
{
|
||||
Path: "bee-support-debian-20260325-162030/manifest.txt",
|
||||
Content: []byte("bee_version=1.0.0\nhost=debian\ngenerated_at_utc=2026-03-25T16:20:30Z\nexport_dir=/appdata/bee/export\n"),
|
||||
},
|
||||
{
|
||||
Path: "bee-support-debian-20260325-162030/export/bee-audit.json",
|
||||
Content: []byte(`{
|
||||
"source_type": "manual",
|
||||
"target_host": "debian",
|
||||
"collected_at": "2026-03-25T16:08:09Z",
|
||||
"runtime": {
|
||||
"status": "PARTIAL",
|
||||
"checked_at": "2026-03-25T16:07:56Z",
|
||||
"network_status": "OK",
|
||||
"issues": [
|
||||
{
|
||||
"code": "nvidia_kernel_module_missing",
|
||||
"severity": "warning",
|
||||
"description": "NVIDIA kernel module is not loaded."
|
||||
}
|
||||
],
|
||||
"services": [
|
||||
{
|
||||
"name": "bee-web",
|
||||
"status": "inactive"
|
||||
}
|
||||
]
|
||||
},
|
||||
"hardware": {
|
||||
"board": {
|
||||
"manufacturer": "Supermicro",
|
||||
"product_name": "AS-4124GQ-TNMI",
|
||||
"serial_number": "S490387X4418273",
|
||||
"part_number": "H12DGQ-NT6",
|
||||
"uuid": "d868ae00-a61f-11ee-8000-7cc255e10309"
|
||||
},
|
||||
"firmware": [
|
||||
{
|
||||
"device_name": "BIOS",
|
||||
"version": "2.8"
|
||||
}
|
||||
],
|
||||
"cpus": [
|
||||
{
|
||||
"status": "OK",
|
||||
"status_checked_at": "2026-03-25T16:08:09Z",
|
||||
"socket": 1,
|
||||
"model": "AMD EPYC 7763 64-Core Processor",
|
||||
"cores": 64,
|
||||
"threads": 128,
|
||||
"frequency_mhz": 2450,
|
||||
"max_frequency_mhz": 3525
|
||||
}
|
||||
],
|
||||
"memory": [
|
||||
{
|
||||
"status": "OK",
|
||||
"status_checked_at": "2026-03-25T16:08:09Z",
|
||||
"slot": "P1-DIMMA1",
|
||||
"location": "P0_Node0_Channel0_Dimm0",
|
||||
"present": true,
|
||||
"size_mb": 32768,
|
||||
"type": "DDR4",
|
||||
"max_speed_mhz": 3200,
|
||||
"current_speed_mhz": 2933,
|
||||
"manufacturer": "SK Hynix",
|
||||
"serial_number": "80AD01224887286666",
|
||||
"part_number": "HMA84GR7DJR4N-XN"
|
||||
}
|
||||
],
|
||||
"storage": [
|
||||
{
|
||||
"status": "Unknown",
|
||||
"status_checked_at": "2026-03-25T16:08:09Z",
|
||||
"slot": "nvme0n1",
|
||||
"type": "NVMe",
|
||||
"model": "KCD6XLUL960G",
|
||||
"serial_number": "2470A00XT5M8",
|
||||
"interface": "NVMe",
|
||||
"present": true
|
||||
}
|
||||
],
|
||||
"pcie_devices": [
|
||||
{
|
||||
"status": "OK",
|
||||
"status_checked_at": "2026-03-25T16:08:09Z",
|
||||
"slot": "0000:05:00.0",
|
||||
"vendor_id": 5555,
|
||||
"device_id": 4123,
|
||||
"device_class": "EthernetController",
|
||||
"manufacturer": "Mellanox Technologies",
|
||||
"model": "MT28908 Family [ConnectX-6]",
|
||||
"link_width": 16,
|
||||
"link_speed": "Gen4",
|
||||
"max_link_width": 16,
|
||||
"max_link_speed": "Gen4",
|
||||
"mac_addresses": ["94:6d:ae:9a:75:4a"],
|
||||
"present": true
|
||||
}
|
||||
],
|
||||
"sensors": {
|
||||
"power": [
|
||||
{
|
||||
"name": "PPT",
|
||||
"location": "amdgpu-pci-1100",
|
||||
"power_w": 95
|
||||
}
|
||||
],
|
||||
"temperatures": [
|
||||
{
|
||||
"name": "Composite",
|
||||
"location": "nvme-pci-0600",
|
||||
"celsius": 28.85,
|
||||
"threshold_warning_celsius": 72.85,
|
||||
"threshold_critical_celsius": 81.85,
|
||||
"status": "OK"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}`),
|
||||
},
|
||||
}
|
||||
|
||||
result, err := p.Parse(files)
|
||||
if err != nil {
|
||||
t.Fatalf("parse failed: %v", err)
|
||||
}
|
||||
|
||||
if result.Hardware == nil {
|
||||
t.Fatal("expected hardware to be populated")
|
||||
}
|
||||
if result.TargetHost != "debian" {
|
||||
t.Fatalf("expected target host debian, got %q", result.TargetHost)
|
||||
}
|
||||
wantCollectedAt := time.Date(2026, 3, 25, 16, 8, 9, 0, time.UTC)
|
||||
if !result.CollectedAt.Equal(wantCollectedAt) {
|
||||
t.Fatalf("expected collected_at %s, got %s", wantCollectedAt, result.CollectedAt)
|
||||
}
|
||||
if result.Hardware.BoardInfo.SerialNumber != "S490387X4418273" {
|
||||
t.Fatalf("unexpected board serial %q", result.Hardware.BoardInfo.SerialNumber)
|
||||
}
|
||||
if len(result.Hardware.CPUs) != 1 {
|
||||
t.Fatalf("expected 1 cpu, got %d", len(result.Hardware.CPUs))
|
||||
}
|
||||
if len(result.Hardware.Memory) != 1 {
|
||||
t.Fatalf("expected 1 dimm, got %d", len(result.Hardware.Memory))
|
||||
}
|
||||
if len(result.Hardware.Storage) != 1 {
|
||||
t.Fatalf("expected 1 storage device, got %d", len(result.Hardware.Storage))
|
||||
}
|
||||
if len(result.Hardware.PCIeDevices) != 1 {
|
||||
t.Fatalf("expected 1 pcie device, got %d", len(result.Hardware.PCIeDevices))
|
||||
}
|
||||
if result.Hardware.PCIeDevices[0].BDF != "0000:05:00.0" {
|
||||
t.Fatalf("expected BDF to be normalized from slot, got %q", result.Hardware.PCIeDevices[0].BDF)
|
||||
}
|
||||
if len(result.Sensors) != 2 {
|
||||
t.Fatalf("expected 2 flattened sensors, got %d", len(result.Sensors))
|
||||
}
|
||||
if len(result.Events) < 3 {
|
||||
t.Fatalf("expected runtime events to be created, got %d", len(result.Events))
|
||||
}
|
||||
if len(result.FRU) == 0 {
|
||||
t.Fatal("expected board FRU fallback to be populated")
|
||||
}
|
||||
}
|
||||
1706
internal/parser/vendors/hpe_ilo_ahs/parser.go
vendored
Normal file
1706
internal/parser/vendors/hpe_ilo_ahs/parser.go
vendored
Normal file
File diff suppressed because it is too large
Load Diff
316
internal/parser/vendors/hpe_ilo_ahs/parser_test.go
vendored
Normal file
316
internal/parser/vendors/hpe_ilo_ahs/parser_test.go
vendored
Normal file
@@ -0,0 +1,316 @@
|
||||
package hpe_ilo_ahs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/binary"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.mchus.pro/mchus/logpile/internal/parser"
|
||||
)
|
||||
|
||||
func TestDetectAHS(t *testing.T) {
|
||||
p := &Parser{}
|
||||
score := p.Detect([]parser.ExtractedFile{{
|
||||
Path: "HPE_CZ2D1X0GS3_20260330.ahs",
|
||||
Content: makeAHSArchive(t, []ahsTestEntry{{Name: "CUST_INFO.DAT", Payload: []byte("x")}}),
|
||||
}})
|
||||
if score < 80 {
|
||||
t.Fatalf("expected high confidence detect, got %d", score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAHSInventory(t *testing.T) {
|
||||
p := &Parser{}
|
||||
content := makeAHSArchive(t, []ahsTestEntry{
|
||||
{Name: "CUST_INFO.DAT", Payload: make([]byte, 16)},
|
||||
{Name: "0000088-2026-03-30.zbb", Payload: gzipBytes(t, []byte(sampleInventoryBlob()))},
|
||||
{Name: "bcert.pkg", Payload: []byte(sampleBCertBlob())},
|
||||
})
|
||||
|
||||
result, err := p.Parse([]parser.ExtractedFile{{
|
||||
Path: "HPE_CZ2D1X0GS3_20260330.ahs",
|
||||
Content: content,
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("parse failed: %v", err)
|
||||
}
|
||||
if result.Hardware == nil {
|
||||
t.Fatalf("expected hardware section")
|
||||
}
|
||||
|
||||
board := result.Hardware.BoardInfo
|
||||
if board.Manufacturer != "HPE" {
|
||||
t.Fatalf("unexpected board manufacturer: %q", board.Manufacturer)
|
||||
}
|
||||
if board.ProductName != "ProLiant DL380 Gen11" {
|
||||
t.Fatalf("unexpected board product: %q", board.ProductName)
|
||||
}
|
||||
if board.SerialNumber != "CZ2D1X0GS3" {
|
||||
t.Fatalf("unexpected board serial: %q", board.SerialNumber)
|
||||
}
|
||||
if board.PartNumber != "P52560-421" {
|
||||
t.Fatalf("unexpected board part number: %q", board.PartNumber)
|
||||
}
|
||||
|
||||
if len(result.Hardware.CPUs) != 1 || result.Hardware.CPUs[0].Model != "Intel(R) Xeon(R) Gold 6444Y" {
|
||||
t.Fatalf("unexpected CPUs: %+v", result.Hardware.CPUs)
|
||||
}
|
||||
if len(result.Hardware.Memory) != 1 {
|
||||
t.Fatalf("expected one DIMM, got %d", len(result.Hardware.Memory))
|
||||
}
|
||||
if result.Hardware.Memory[0].PartNumber != "HMCG88AEBRA115N" {
|
||||
t.Fatalf("unexpected DIMM part number: %q", result.Hardware.Memory[0].PartNumber)
|
||||
}
|
||||
|
||||
if len(result.Hardware.NetworkAdapters) != 2 {
|
||||
t.Fatalf("expected two network adapters, got %d", len(result.Hardware.NetworkAdapters))
|
||||
}
|
||||
if len(result.Hardware.PowerSupply) != 1 {
|
||||
t.Fatalf("expected one PSU, got %d", len(result.Hardware.PowerSupply))
|
||||
}
|
||||
if result.Hardware.PowerSupply[0].SerialNumber != "5XUWB0C4DJG4BV" {
|
||||
t.Fatalf("unexpected PSU serial: %q", result.Hardware.PowerSupply[0].SerialNumber)
|
||||
}
|
||||
if result.Hardware.PowerSupply[0].Firmware != "2.00" {
|
||||
t.Fatalf("unexpected PSU firmware: %q", result.Hardware.PowerSupply[0].Firmware)
|
||||
}
|
||||
|
||||
if len(result.Hardware.Storage) != 1 {
|
||||
t.Fatalf("expected one physical drive, got %d", len(result.Hardware.Storage))
|
||||
}
|
||||
drive := result.Hardware.Storage[0]
|
||||
if drive.Model != "SAMSUNGMZ7L3480HCHQ-00A07" {
|
||||
t.Fatalf("unexpected drive model: %q", drive.Model)
|
||||
}
|
||||
if drive.SerialNumber != "S664NC0Y502720" {
|
||||
t.Fatalf("unexpected drive serial: %q", drive.SerialNumber)
|
||||
}
|
||||
if drive.SizeGB != 480 {
|
||||
t.Fatalf("unexpected drive size: %d", drive.SizeGB)
|
||||
}
|
||||
|
||||
if len(result.Hardware.Firmware) == 0 {
|
||||
t.Fatalf("expected firmware inventory")
|
||||
}
|
||||
foundILO := false
|
||||
foundControllerFW := false
|
||||
foundNICFW := false
|
||||
foundBackplaneFW := false
|
||||
for _, item := range result.Hardware.Firmware {
|
||||
if item.DeviceName == "iLO 6" && item.Version == "v1.63p20" {
|
||||
foundILO = true
|
||||
}
|
||||
if item.DeviceName == "HPE MR408i-o Gen11" && item.Version == "52.26.3-5379" {
|
||||
foundControllerFW = true
|
||||
}
|
||||
if item.DeviceName == "BCM 5719 1Gb 4p BASE-T OCP Adptr" && item.Version == "20.28.41" {
|
||||
foundNICFW = true
|
||||
}
|
||||
if item.DeviceName == "8 SFF 24G x1NVMe/SAS UBM3 BC BP" && item.Version == "1.24" {
|
||||
foundBackplaneFW = true
|
||||
}
|
||||
}
|
||||
if !foundILO {
|
||||
t.Fatalf("expected iLO firmware entry")
|
||||
}
|
||||
if !foundControllerFW {
|
||||
t.Fatalf("expected controller firmware entry")
|
||||
}
|
||||
if !foundNICFW {
|
||||
t.Fatalf("expected broadcom firmware entry")
|
||||
}
|
||||
if !foundBackplaneFW {
|
||||
t.Fatalf("expected backplane firmware entry")
|
||||
}
|
||||
|
||||
broadcomFound := false
|
||||
backplaneFound := false
|
||||
for _, nic := range result.Hardware.NetworkAdapters {
|
||||
if nic.SerialNumber == "1CH0150001" && nic.Firmware == "20.28.41" {
|
||||
broadcomFound = true
|
||||
}
|
||||
}
|
||||
for _, dev := range result.Hardware.Devices {
|
||||
if dev.DeviceClass == "storage_backplane" && dev.Firmware == "1.24" {
|
||||
backplaneFound = true
|
||||
}
|
||||
}
|
||||
if !broadcomFound {
|
||||
t.Fatalf("expected broadcom adapter firmware to be enriched")
|
||||
}
|
||||
if !backplaneFound {
|
||||
t.Fatalf("expected backplane canonical device")
|
||||
}
|
||||
|
||||
if len(result.Hardware.Devices) < 6 {
|
||||
t.Fatalf("expected canonical devices, got %d", len(result.Hardware.Devices))
|
||||
}
|
||||
if len(result.Events) == 0 {
|
||||
t.Fatalf("expected parsed events")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExampleAHS(t *testing.T) {
|
||||
path := filepath.Join("..", "..", "..", "..", "example", "HPE_CZ2D1X0GS3_20260330.ahs")
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Skipf("example fixture unavailable: %v", err)
|
||||
}
|
||||
|
||||
p := &Parser{}
|
||||
result, err := p.Parse([]parser.ExtractedFile{{
|
||||
Path: filepath.Base(path),
|
||||
Content: content,
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("parse example failed: %v", err)
|
||||
}
|
||||
if result.Hardware == nil {
|
||||
t.Fatalf("expected hardware section")
|
||||
}
|
||||
|
||||
board := result.Hardware.BoardInfo
|
||||
if board.ProductName != "ProLiant DL380 Gen11" {
|
||||
t.Fatalf("unexpected board product: %q", board.ProductName)
|
||||
}
|
||||
if board.SerialNumber != "CZ2D1X0GS3" {
|
||||
t.Fatalf("unexpected board serial: %q", board.SerialNumber)
|
||||
}
|
||||
|
||||
if len(result.Hardware.Storage) < 2 {
|
||||
t.Fatalf("expected at least two drives, got %d", len(result.Hardware.Storage))
|
||||
}
|
||||
if len(result.Hardware.PowerSupply) != 2 {
|
||||
t.Fatalf("expected exactly two PSUs, got %d: %+v", len(result.Hardware.PowerSupply), result.Hardware.PowerSupply)
|
||||
}
|
||||
|
||||
foundController := false
|
||||
foundBackplaneFW := false
|
||||
foundNICFW := false
|
||||
for _, device := range result.Hardware.Devices {
|
||||
if device.Model == "HPE MR408i-o Gen11" && device.SerialNumber == "PXSFQ0BBIJY3B3" {
|
||||
foundController = true
|
||||
}
|
||||
if device.DeviceClass == "storage_backplane" && device.Firmware == "1.24" {
|
||||
foundBackplaneFW = true
|
||||
}
|
||||
}
|
||||
if !foundController {
|
||||
t.Fatalf("expected MR408i-o controller in canonical devices")
|
||||
}
|
||||
for _, fw := range result.Hardware.Firmware {
|
||||
if fw.DeviceName == "BCM 5719 1Gb 4p BASE-T OCP Adptr" && fw.Version == "20.28.41" {
|
||||
foundNICFW = true
|
||||
}
|
||||
}
|
||||
if !foundBackplaneFW {
|
||||
t.Fatalf("expected backplane device in canonical devices")
|
||||
}
|
||||
if !foundNICFW {
|
||||
t.Fatalf("expected broadcom firmware from bcert/pkg lockdown")
|
||||
}
|
||||
}
|
||||
|
||||
type ahsTestEntry struct {
|
||||
Name string
|
||||
Payload []byte
|
||||
Flag uint32
|
||||
}
|
||||
|
||||
func makeAHSArchive(t *testing.T, entries []ahsTestEntry) []byte {
|
||||
t.Helper()
|
||||
|
||||
var buf bytes.Buffer
|
||||
for _, entry := range entries {
|
||||
header := make([]byte, ahsHeaderSize)
|
||||
copy(header[:4], []byte("ABJR"))
|
||||
binary.LittleEndian.PutUint16(header[4:6], 0x0300)
|
||||
binary.LittleEndian.PutUint16(header[6:8], 0x0002)
|
||||
binary.LittleEndian.PutUint32(header[8:12], uint32(len(entry.Payload)))
|
||||
flag := entry.Flag
|
||||
if flag == 0 {
|
||||
flag = 0x80000002
|
||||
if len(entry.Payload) >= 2 && entry.Payload[0] == 0x1f && entry.Payload[1] == 0x8b {
|
||||
flag = 0x80000001
|
||||
}
|
||||
}
|
||||
binary.LittleEndian.PutUint32(header[16:20], flag)
|
||||
copy(header[20:52], []byte(entry.Name))
|
||||
buf.Write(header)
|
||||
buf.Write(entry.Payload)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func gzipBytes(t *testing.T, payload []byte) []byte {
|
||||
t.Helper()
|
||||
|
||||
var buf bytes.Buffer
|
||||
zw := gzip.NewWriter(&buf)
|
||||
if _, err := zw.Write(payload); err != nil {
|
||||
t.Fatalf("gzip payload: %v", err)
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatalf("close gzip writer: %v", err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func sampleInventoryBlob() string {
|
||||
return stringsJoin(
|
||||
"iLO 6 v1.63p20 built on Sep 13 2024",
|
||||
"HPE",
|
||||
"ProLiant DL380 Gen11",
|
||||
"CZ2D1X0GS3",
|
||||
"P52560-421",
|
||||
"Proc 1",
|
||||
"Intel(R) Corporation",
|
||||
"Intel(R) Xeon(R) Gold 6444Y",
|
||||
"PROC 1 DIMM 3",
|
||||
"Hynix",
|
||||
"HMCG88AEBRA115N",
|
||||
"2B5F92C6",
|
||||
"Power Supply 1",
|
||||
"5XUWB0C4DJG4BV",
|
||||
"P03178-B21",
|
||||
"PciRoot(0x1)/Pci(0x5,0x0)/Pci(0x0,0x0)",
|
||||
"NIC.Slot.1.1",
|
||||
"Network Controller",
|
||||
"Slot 1",
|
||||
"MCX512A-ACAT",
|
||||
"MT2230478382",
|
||||
"PciRoot(0x3)/Pci(0x1,0x0)/Pci(0x0,0x0)",
|
||||
"OCP.Slot.15.1",
|
||||
"Broadcom NetXtreme Gigabit Ethernet - NIC",
|
||||
"OCP Slot 15",
|
||||
"P51183-001",
|
||||
"1CH0150001",
|
||||
"20.28.41",
|
||||
"System ROM",
|
||||
"v2.22 (06/19/2024)",
|
||||
"03/30/2026 09:47:33",
|
||||
"iLO network link down.",
|
||||
`{"@odata.id":"/redfish/v1/Systems/1/Storage/DE00A000/Controllers/0","@odata.type":"#StorageController.v1_7_0.StorageController","Id":"0","Name":"HPE MR408i-o Gen11","FirmwareVersion":"52.26.3-5379","Manufacturer":"HPE","Model":"HPE MR408i-o Gen11","PartNumber":"P58543-001","SKU":"P58335-B21","SerialNumber":"PXSFQ0BBIJY3B3","Status":{"State":"Enabled","Health":"OK"},"Location":{"PartLocation":{"ServiceLabel":"Slot=14","LocationType":"Slot","LocationOrdinalValue":14}},"PCIeInterface":{"PCIeType":"Gen4","LanesInUse":8}}`,
|
||||
`{"@odata.id":"/redfish/v1/Fabrics/DE00A000","@odata.type":"#Fabric.v1_3_0.Fabric","Id":"DE00A000","Name":"8 SFF 24G x1NVMe/SAS UBM3 BC BP","FabricType":"MultiProtocol"}`,
|
||||
`{"@odata.id":"/redfish/v1/Fabrics/DE00A000/Switches/1","@odata.type":"#Switch.v1_9_1.Switch","Id":"1","Name":"Direct Attached","Model":"UBM3","FirmwareVersion":"1.24","SupportedProtocols":["SAS","SATA","NVMe"],"SwitchType":"MultiProtocol","Status":{"State":"Enabled","Health":"OK"}}`,
|
||||
`{"@odata.id":"/redfish/v1/Chassis/DE00A000/Drives/0","@odata.type":"#Drive.v1_17_0.Drive","Id":"0","Name":"480GB 6G SATA SSD","Status":{"State":"StandbyOffline","Health":"OK"},"PhysicalLocation":{"PartLocation":{"ServiceLabel":"Slot=14:Port=1:Box=3:Bay=1","LocationType":"Bay","LocationOrdinalValue":1}},"CapacityBytes":480103981056,"MediaType":"SSD","Model":"SAMSUNGMZ7L3480HCHQ-00A07","Protocol":"SATA","Revision":"JXTC604Q","SerialNumber":"S664NC0Y502720","PredictedMediaLifeLeftPercent":100}`,
|
||||
`{"@odata.id":"/redfish/v1/Chassis/DE00A000/Drives/64515","@odata.type":"#Drive.v1_17_0.Drive","Id":"64515","Name":"Empty Bay","Status":{"State":"Absent","Health":"OK"}}`,
|
||||
)
|
||||
}
|
||||
|
||||
func sampleBCertBlob() string {
|
||||
return `<BC><MfgRecord><PowerSupplySlot id="0"><Present>Yes</Present><SerialNumber>5XUWB0C4DJG4BV</SerialNumber><FirmwareVersion>2.00</FirmwareVersion><SparePartNumber>P44412-001</SparePartNumber></PowerSupplySlot><FirmwareLockdown><SystemProgrammableLogicDevice>0x12</SystemProgrammableLogicDevice><ServerPlatformServicesSPSFirmware>6.1.4.47</ServerPlatformServicesSPSFirmware><STMicroGen11TPM>1.512</STMicroGen11TPM><HPEMR408i-oGen11>52.26.3-5379</HPEMR408i-oGen11><UBM3>UBM3/1.24</UBM3><BCM57191Gb4pBASE-TOCP3>20.28.41</BCM57191Gb4pBASE-TOCP3></FirmwareLockdown></MfgRecord></BC>`
|
||||
}
|
||||
|
||||
func stringsJoin(parts ...string) string {
|
||||
return string(bytes.Join(func() [][]byte {
|
||||
out := make([][]byte, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
out = append(out, []byte(part))
|
||||
}
|
||||
return out
|
||||
}(), []byte{0}))
|
||||
}
|
||||
2
internal/parser/vendors/vendors.go
vendored
2
internal/parser/vendors/vendors.go
vendored
@@ -5,7 +5,9 @@ package vendors
|
||||
import (
|
||||
// Import vendor modules to trigger their init() registration
|
||||
_ "git.mchus.pro/mchus/logpile/internal/parser/vendors/dell"
|
||||
_ "git.mchus.pro/mchus/logpile/internal/parser/vendors/easy_bee"
|
||||
_ "git.mchus.pro/mchus/logpile/internal/parser/vendors/h3c"
|
||||
_ "git.mchus.pro/mchus/logpile/internal/parser/vendors/hpe_ilo_ahs"
|
||||
_ "git.mchus.pro/mchus/logpile/internal/parser/vendors/inspur"
|
||||
_ "git.mchus.pro/mchus/logpile/internal/parser/vendors/nvidia"
|
||||
_ "git.mchus.pro/mchus/logpile/internal/parser/vendors/nvidia_bug_report"
|
||||
|
||||
450
internal/parser/vendors/xfusion/hardware.go
vendored
450
internal/parser/vendors/xfusion/hardware.go
vendored
@@ -10,6 +10,33 @@ import (
|
||||
"git.mchus.pro/mchus/logpile/internal/parser"
|
||||
)
|
||||
|
||||
type xfusionNICCard struct {
|
||||
Slot string
|
||||
Model string
|
||||
ProductName string
|
||||
Vendor string
|
||||
VendorID int
|
||||
DeviceID int
|
||||
BDF string
|
||||
SerialNumber string
|
||||
PartNumber string
|
||||
}
|
||||
|
||||
type xfusionNetcardPort struct {
|
||||
BDF string
|
||||
MAC string
|
||||
ActualMAC string
|
||||
}
|
||||
|
||||
type xfusionNetcardSnapshot struct {
|
||||
Timestamp time.Time
|
||||
Slot string
|
||||
ProductName string
|
||||
Manufacturer string
|
||||
Firmware string
|
||||
Ports []xfusionNetcardPort
|
||||
}
|
||||
|
||||
// ── FRU ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
// parseFRUInfo parses fruinfo.txt and populates result.FRU and result.Hardware.BoardInfo.
|
||||
@@ -232,15 +259,15 @@ func parseCPUInfo(content []byte) []models.CPU {
|
||||
}
|
||||
|
||||
cpus = append(cpus, models.CPU{
|
||||
Socket: socketNum,
|
||||
Model: model,
|
||||
Cores: cores,
|
||||
Threads: threads,
|
||||
L1CacheKB: l1,
|
||||
L2CacheKB: l2,
|
||||
L3CacheKB: l3,
|
||||
Socket: socketNum,
|
||||
Model: model,
|
||||
Cores: cores,
|
||||
Threads: threads,
|
||||
L1CacheKB: l1,
|
||||
L2CacheKB: l2,
|
||||
L3CacheKB: l3,
|
||||
SerialNumber: sn,
|
||||
Status: "ok",
|
||||
Status: "ok",
|
||||
})
|
||||
}
|
||||
return cpus
|
||||
@@ -338,9 +365,9 @@ func parseMemInfo(content []byte) []models.MemoryDIMM {
|
||||
|
||||
// ── Card Info (GPU + NIC) ─────────────────────────────────────────────────────
|
||||
|
||||
// parseCardInfo parses card_info file, extracting GPU and NIC entries.
|
||||
// parseCardInfo parses card_info file, extracting GPU and OCP NIC card inventory.
|
||||
// The file has named sections ("GPU Card Info", "OCP Card Info", etc.) each with a pipe-table.
|
||||
func parseCardInfo(content []byte) (gpus []models.GPU, nics []models.NIC) {
|
||||
func parseCardInfo(content []byte) (gpus []models.GPU, nicCards []xfusionNICCard) {
|
||||
sections := splitPipeSections(content)
|
||||
|
||||
// Build BDF and VendorID/DeviceID map from PCIe Card Info: slot → info
|
||||
@@ -396,17 +423,22 @@ func parseCardInfo(content []byte) (gpus []models.GPU, nics []models.NIC) {
|
||||
}
|
||||
|
||||
// OCP Card Info: NIC cards
|
||||
for i, row := range sections["ocp card info"] {
|
||||
desc := strings.TrimSpace(row["card desc"])
|
||||
sn := strings.TrimSpace(row["serialnumber"])
|
||||
nics = append(nics, models.NIC{
|
||||
Name: fmt.Sprintf("OCP%d", i+1),
|
||||
Model: desc,
|
||||
SerialNumber: sn,
|
||||
for _, row := range sections["ocp card info"] {
|
||||
slot := strings.TrimSpace(row["slot"])
|
||||
pcie := slotPCIe[slot]
|
||||
nicCards = append(nicCards, xfusionNICCard{
|
||||
Slot: slot,
|
||||
Model: strings.TrimSpace(row["card desc"]),
|
||||
ProductName: strings.TrimSpace(row["card desc"]),
|
||||
VendorID: parseHexInt(row["vender id"]),
|
||||
DeviceID: parseHexInt(row["device id"]),
|
||||
BDF: pcie.bdf,
|
||||
SerialNumber: strings.TrimSpace(row["serialnumber"]),
|
||||
PartNumber: strings.TrimSpace(row["partnum"]),
|
||||
})
|
||||
}
|
||||
|
||||
return gpus, nics
|
||||
return gpus, nicCards
|
||||
}
|
||||
|
||||
// splitPipeSections parses a multi-section file where each section starts with a
|
||||
@@ -462,6 +494,301 @@ func parseHexInt(s string) int {
|
||||
return int(n)
|
||||
}
|
||||
|
||||
func parseNetcardInfo(content []byte) []xfusionNetcardSnapshot {
|
||||
if len(content) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var snapshots []xfusionNetcardSnapshot
|
||||
var current *xfusionNetcardSnapshot
|
||||
var currentPort *xfusionNetcardPort
|
||||
|
||||
flushPort := func() {
|
||||
if current == nil || currentPort == nil {
|
||||
return
|
||||
}
|
||||
current.Ports = append(current.Ports, *currentPort)
|
||||
currentPort = nil
|
||||
}
|
||||
flushSnapshot := func() {
|
||||
if current == nil || !current.hasData() {
|
||||
return
|
||||
}
|
||||
flushPort()
|
||||
snapshots = append(snapshots, *current)
|
||||
current = nil
|
||||
}
|
||||
|
||||
for _, rawLine := range strings.Split(string(content), "\n") {
|
||||
line := strings.TrimSpace(rawLine)
|
||||
if line == "" {
|
||||
flushPort()
|
||||
continue
|
||||
}
|
||||
if ts, ok := parseXFusionUTCTimestamp(line); ok {
|
||||
if current == nil {
|
||||
current = &xfusionNetcardSnapshot{Timestamp: ts}
|
||||
continue
|
||||
}
|
||||
if current.hasData() {
|
||||
flushSnapshot()
|
||||
current = &xfusionNetcardSnapshot{Timestamp: ts}
|
||||
continue
|
||||
}
|
||||
current.Timestamp = ts
|
||||
continue
|
||||
}
|
||||
if current == nil {
|
||||
current = &xfusionNetcardSnapshot{}
|
||||
}
|
||||
if port := parseNetcardPortHeader(line); port != nil {
|
||||
flushPort()
|
||||
currentPort = port
|
||||
continue
|
||||
}
|
||||
if currentPort != nil {
|
||||
if value, ok := parseSimpleKV(line, "MacAddr"); ok {
|
||||
currentPort.MAC = value
|
||||
continue
|
||||
}
|
||||
if value, ok := parseSimpleKV(line, "ActualMac"); ok {
|
||||
currentPort.ActualMAC = value
|
||||
continue
|
||||
}
|
||||
}
|
||||
if value, ok := parseSimpleKV(line, "ProductName"); ok {
|
||||
current.ProductName = value
|
||||
continue
|
||||
}
|
||||
if value, ok := parseSimpleKV(line, "Manufacture"); ok {
|
||||
current.Manufacturer = value
|
||||
continue
|
||||
}
|
||||
if value, ok := parseSimpleKV(line, "FirmwareVersion"); ok {
|
||||
current.Firmware = value
|
||||
continue
|
||||
}
|
||||
if value, ok := parseSimpleKV(line, "SlotId"); ok {
|
||||
current.Slot = value
|
||||
}
|
||||
}
|
||||
flushSnapshot()
|
||||
|
||||
bestIndexBySlot := make(map[string]int)
|
||||
for i, snapshot := range snapshots {
|
||||
slot := strings.TrimSpace(snapshot.Slot)
|
||||
if slot == "" {
|
||||
continue
|
||||
}
|
||||
prevIdx, exists := bestIndexBySlot[slot]
|
||||
if !exists || snapshot.isBetterThan(snapshots[prevIdx]) {
|
||||
bestIndexBySlot[slot] = i
|
||||
}
|
||||
}
|
||||
|
||||
ordered := make([]xfusionNetcardSnapshot, 0, len(bestIndexBySlot))
|
||||
for i, snapshot := range snapshots {
|
||||
slot := strings.TrimSpace(snapshot.Slot)
|
||||
bestIdx, ok := bestIndexBySlot[slot]
|
||||
if !ok || bestIdx != i {
|
||||
continue
|
||||
}
|
||||
ordered = append(ordered, snapshot)
|
||||
delete(bestIndexBySlot, slot)
|
||||
}
|
||||
return ordered
|
||||
}
|
||||
|
||||
func mergeNetworkAdapters(cards []xfusionNICCard, snapshots []xfusionNetcardSnapshot) ([]models.NetworkAdapter, []models.NIC) {
|
||||
bySlotCard := make(map[string]xfusionNICCard, len(cards))
|
||||
bySlotSnapshot := make(map[string]xfusionNetcardSnapshot, len(snapshots))
|
||||
orderedSlots := make([]string, 0, len(cards)+len(snapshots))
|
||||
seenSlots := make(map[string]struct{}, len(cards)+len(snapshots))
|
||||
|
||||
for _, card := range cards {
|
||||
slot := strings.TrimSpace(card.Slot)
|
||||
if slot == "" {
|
||||
continue
|
||||
}
|
||||
bySlotCard[slot] = card
|
||||
if _, seen := seenSlots[slot]; !seen {
|
||||
orderedSlots = append(orderedSlots, slot)
|
||||
seenSlots[slot] = struct{}{}
|
||||
}
|
||||
}
|
||||
for _, snapshot := range snapshots {
|
||||
slot := strings.TrimSpace(snapshot.Slot)
|
||||
if slot == "" {
|
||||
continue
|
||||
}
|
||||
bySlotSnapshot[slot] = snapshot
|
||||
if _, seen := seenSlots[slot]; !seen {
|
||||
orderedSlots = append(orderedSlots, slot)
|
||||
seenSlots[slot] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
adapters := make([]models.NetworkAdapter, 0, len(orderedSlots))
|
||||
legacyNICs := make([]models.NIC, 0, len(orderedSlots))
|
||||
for _, slot := range orderedSlots {
|
||||
card := bySlotCard[slot]
|
||||
snapshot := bySlotSnapshot[slot]
|
||||
|
||||
model := firstNonEmpty(card.Model, snapshot.ProductName)
|
||||
description := ""
|
||||
if !strings.EqualFold(strings.TrimSpace(model), strings.TrimSpace(snapshot.ProductName)) {
|
||||
description = strings.TrimSpace(snapshot.ProductName)
|
||||
}
|
||||
macs := snapshot.macAddresses()
|
||||
bdf := firstNonEmpty(snapshot.primaryBDF(), card.BDF)
|
||||
firmware := normalizeXFusionValue(snapshot.Firmware)
|
||||
manufacturer := firstNonEmpty(snapshot.Manufacturer, card.Vendor)
|
||||
portCount := len(snapshot.Ports)
|
||||
if portCount == 0 && len(macs) > 0 {
|
||||
portCount = len(macs)
|
||||
}
|
||||
if portCount == 0 {
|
||||
portCount = 1
|
||||
}
|
||||
|
||||
adapters = append(adapters, models.NetworkAdapter{
|
||||
Slot: slot,
|
||||
Location: "OCP",
|
||||
Present: true,
|
||||
BDF: bdf,
|
||||
Model: model,
|
||||
Description: description,
|
||||
Vendor: manufacturer,
|
||||
VendorID: card.VendorID,
|
||||
DeviceID: card.DeviceID,
|
||||
SerialNumber: card.SerialNumber,
|
||||
PartNumber: card.PartNumber,
|
||||
Firmware: firmware,
|
||||
PortCount: portCount,
|
||||
PortType: "ethernet",
|
||||
MACAddresses: macs,
|
||||
Status: "ok",
|
||||
})
|
||||
legacyNICs = append(legacyNICs, models.NIC{
|
||||
Name: fmt.Sprintf("OCP%s", slot),
|
||||
Model: model,
|
||||
Description: description,
|
||||
MACAddress: firstNonEmpty(macs...),
|
||||
SerialNumber: card.SerialNumber,
|
||||
})
|
||||
}
|
||||
|
||||
return adapters, legacyNICs
|
||||
}
|
||||
|
||||
func parseXFusionUTCTimestamp(line string) (time.Time, bool) {
|
||||
ts, err := time.Parse("2006-01-02 15:04:05 MST", strings.TrimSpace(line))
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return ts, true
|
||||
}
|
||||
|
||||
func parseNetcardPortHeader(line string) *xfusionNetcardPort {
|
||||
fields := strings.Fields(strings.TrimSpace(line))
|
||||
if len(fields) < 2 || !strings.HasPrefix(strings.ToLower(fields[0]), "port") {
|
||||
return nil
|
||||
}
|
||||
joined := strings.Join(fields[1:], " ")
|
||||
if !strings.HasPrefix(strings.ToLower(joined), "bdf:") {
|
||||
return nil
|
||||
}
|
||||
return &xfusionNetcardPort{BDF: strings.TrimSpace(joined[len("BDF:"):])}
|
||||
}
|
||||
|
||||
func parseSimpleKV(line, key string) (string, bool) {
|
||||
idx := strings.Index(line, ":")
|
||||
if idx < 0 {
|
||||
return "", false
|
||||
}
|
||||
gotKey := strings.TrimSpace(line[:idx])
|
||||
if !strings.EqualFold(gotKey, key) {
|
||||
return "", false
|
||||
}
|
||||
return strings.TrimSpace(line[idx+1:]), true
|
||||
}
|
||||
|
||||
func normalizeXFusionValue(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
switch strings.ToUpper(value) {
|
||||
case "", "N/A", "NA", "UNKNOWN":
|
||||
return ""
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
func (s xfusionNetcardSnapshot) hasData() bool {
|
||||
return strings.TrimSpace(s.Slot) != "" ||
|
||||
strings.TrimSpace(s.ProductName) != "" ||
|
||||
strings.TrimSpace(s.Manufacturer) != "" ||
|
||||
strings.TrimSpace(s.Firmware) != "" ||
|
||||
len(s.Ports) > 0
|
||||
}
|
||||
|
||||
func (s xfusionNetcardSnapshot) score() int {
|
||||
score := len(s.Ports)
|
||||
if normalizeXFusionValue(s.Firmware) != "" {
|
||||
score += 10
|
||||
}
|
||||
score += len(s.macAddresses()) * 2
|
||||
return score
|
||||
}
|
||||
|
||||
func (s xfusionNetcardSnapshot) isBetterThan(other xfusionNetcardSnapshot) bool {
|
||||
if s.score() != other.score() {
|
||||
return s.score() > other.score()
|
||||
}
|
||||
if !s.Timestamp.Equal(other.Timestamp) {
|
||||
return s.Timestamp.After(other.Timestamp)
|
||||
}
|
||||
return len(s.Ports) > len(other.Ports)
|
||||
}
|
||||
|
||||
func (s xfusionNetcardSnapshot) primaryBDF() string {
|
||||
for _, port := range s.Ports {
|
||||
if bdf := strings.TrimSpace(port.BDF); bdf != "" {
|
||||
return bdf
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s xfusionNetcardSnapshot) macAddresses() []string {
|
||||
out := make([]string, 0, len(s.Ports))
|
||||
seen := make(map[string]struct{}, len(s.Ports))
|
||||
for _, port := range s.Ports {
|
||||
for _, candidate := range []string{port.ActualMAC, port.MAC} {
|
||||
mac := normalizeMAC(candidate)
|
||||
if mac == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[mac]; exists {
|
||||
continue
|
||||
}
|
||||
seen[mac] = struct{}{}
|
||||
out = append(out, mac)
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeMAC(value string) string {
|
||||
value = strings.ToUpper(strings.TrimSpace(value))
|
||||
switch value {
|
||||
case "", "N/A", "NA", "UNKNOWN", "00:00:00:00:00:00":
|
||||
return ""
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
// ── PSU ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
// parsePSUInfo parses the pipe-delimited psu_info.txt.
|
||||
@@ -525,6 +852,11 @@ func parsePSUInfo(content []byte) []models.PSU {
|
||||
func parseStorageControllerInfo(content []byte, result *models.AnalysisResult) {
|
||||
// File may contain multiple controller blocks; parse key:value pairs from each.
|
||||
// We only look at the first occurrence of each key (first controller).
|
||||
seen := make(map[string]struct{}, len(result.Hardware.Firmware))
|
||||
for _, fw := range result.Hardware.Firmware {
|
||||
key := strings.ToLower(strings.TrimSpace(fw.DeviceName + "\x00" + fw.Version + "\x00" + fw.Description))
|
||||
seen[key] = struct{}{}
|
||||
}
|
||||
text := string(content)
|
||||
blocks := strings.Split(text, "RAID Controller #")
|
||||
for _, block := range blocks[1:] { // skip pre-block preamble
|
||||
@@ -532,7 +864,7 @@ func parseStorageControllerInfo(content []byte, result *models.AnalysisResult) {
|
||||
name := firstNonEmpty(fields["Component Name"], fields["Controller Name"], fields["Controller Type"])
|
||||
firmware := fields["Firmware Version"]
|
||||
if name != "" && firmware != "" {
|
||||
result.Hardware.Firmware = append(result.Hardware.Firmware, models.FirmwareInfo{
|
||||
appendXFusionFirmware(result, seen, models.FirmwareInfo{
|
||||
DeviceName: name,
|
||||
Description: fields["Controller Name"],
|
||||
Version: firmware,
|
||||
@@ -541,6 +873,86 @@ func parseStorageControllerInfo(content []byte, result *models.AnalysisResult) {
|
||||
}
|
||||
}
|
||||
|
||||
func parseAppRevision(content []byte, result *models.AnalysisResult) {
|
||||
type firmwareLine struct {
|
||||
deviceName string
|
||||
description string
|
||||
buildKey string
|
||||
}
|
||||
|
||||
known := map[string]firmwareLine{
|
||||
"Active iBMC Version": {deviceName: "iBMC", description: "active iBMC", buildKey: "Active iBMC Built"},
|
||||
"Active BIOS Version": {deviceName: "BIOS", description: "active BIOS", buildKey: "Active BIOS Built"},
|
||||
"CPLD Version": {deviceName: "CPLD", description: "mainboard CPLD"},
|
||||
"SDK Version": {deviceName: "SDK", description: "iBMC SDK", buildKey: "SDK Built"},
|
||||
"Active Uboot Version": {deviceName: "U-Boot", description: "active U-Boot"},
|
||||
"Active Secure Bootloader Version": {deviceName: "Secure Bootloader", description: "active secure bootloader"},
|
||||
"Active Secure Firmware Version": {deviceName: "Secure Firmware", description: "active secure firmware"},
|
||||
}
|
||||
|
||||
values := parseAlignedKeyValues(content)
|
||||
if result.Hardware.BoardInfo.ProductName == "" {
|
||||
if productName := values["Product Name"]; productName != "" {
|
||||
result.Hardware.BoardInfo.ProductName = productName
|
||||
}
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, len(result.Hardware.Firmware))
|
||||
for _, fw := range result.Hardware.Firmware {
|
||||
key := strings.ToLower(strings.TrimSpace(fw.DeviceName + "\x00" + fw.Version + "\x00" + fw.Description))
|
||||
seen[key] = struct{}{}
|
||||
}
|
||||
|
||||
for key, meta := range known {
|
||||
version := normalizeXFusionValue(values[key])
|
||||
if version == "" {
|
||||
continue
|
||||
}
|
||||
appendXFusionFirmware(result, seen, models.FirmwareInfo{
|
||||
DeviceName: meta.deviceName,
|
||||
Description: meta.description,
|
||||
Version: version,
|
||||
BuildTime: normalizeXFusionValue(values[meta.buildKey]),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func parseAlignedKeyValues(content []byte) map[string]string {
|
||||
values := make(map[string]string)
|
||||
for _, rawLine := range strings.Split(string(content), "\n") {
|
||||
line := strings.TrimRight(rawLine, "\r")
|
||||
if !strings.Contains(line, ":") {
|
||||
continue
|
||||
}
|
||||
idx := strings.Index(line, ":")
|
||||
if idx < 0 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimRight(line[:idx], " \t")
|
||||
value := strings.TrimSpace(line[idx+1:])
|
||||
if key == "" || value == "" || values[key] != "" {
|
||||
continue
|
||||
}
|
||||
values[key] = value
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func appendXFusionFirmware(result *models.AnalysisResult, seen map[string]struct{}, fw models.FirmwareInfo) {
|
||||
if result == nil || result.Hardware == nil {
|
||||
return
|
||||
}
|
||||
key := strings.ToLower(strings.TrimSpace(fw.DeviceName + "\x00" + fw.Version + "\x00" + fw.Description))
|
||||
if key == "" {
|
||||
return
|
||||
}
|
||||
if _, exists := seen[key]; exists {
|
||||
return
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
result.Hardware.Firmware = append(result.Hardware.Firmware, fw)
|
||||
}
|
||||
|
||||
// parseDiskInfo parses a single PhysicalDrivesInfo/DiskN/disk_info file.
|
||||
func parseDiskInfo(content []byte) *models.Storage {
|
||||
fields := parseKeyValueBlock(content)
|
||||
|
||||
57
internal/parser/vendors/xfusion/parser.go
vendored
57
internal/parser/vendors/xfusion/parser.go
vendored
@@ -13,7 +13,7 @@ import (
|
||||
"git.mchus.pro/mchus/logpile/internal/parser"
|
||||
)
|
||||
|
||||
const parserVersion = "1.0"
|
||||
const parserVersion = "1.1"
|
||||
|
||||
func init() {
|
||||
parser.Register(&Parser{})
|
||||
@@ -34,11 +34,15 @@ func (p *Parser) Detect(files []parser.ExtractedFile) int {
|
||||
path := strings.ToLower(f.Path)
|
||||
switch {
|
||||
case strings.Contains(path, "appdump/frudata/fruinfo.txt"):
|
||||
confidence += 60
|
||||
confidence += 50
|
||||
case strings.Contains(path, "rtosdump/versioninfo/app_revision.txt"):
|
||||
confidence += 30
|
||||
case strings.Contains(path, "appdump/sensor_alarm/sensor_info.txt"):
|
||||
confidence += 20
|
||||
confidence += 10
|
||||
case strings.Contains(path, "appdump/card_manage/card_info"):
|
||||
confidence += 20
|
||||
case strings.Contains(path, "logdump/netcard/netcard_info.txt"):
|
||||
confidence += 20
|
||||
}
|
||||
if confidence >= 100 {
|
||||
return 100
|
||||
@@ -54,17 +58,21 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er
|
||||
FRU: make([]models.FRUInfo, 0),
|
||||
Sensors: make([]models.SensorReading, 0),
|
||||
Hardware: &models.HardwareConfig{
|
||||
CPUs: make([]models.CPU, 0),
|
||||
Memory: make([]models.MemoryDIMM, 0),
|
||||
Storage: make([]models.Storage, 0),
|
||||
GPUs: make([]models.GPU, 0),
|
||||
NetworkCards: make([]models.NIC, 0),
|
||||
PowerSupply: make([]models.PSU, 0),
|
||||
Firmware: make([]models.FirmwareInfo, 0),
|
||||
Firmware: make([]models.FirmwareInfo, 0),
|
||||
Devices: make([]models.HardwareDevice, 0),
|
||||
CPUs: make([]models.CPU, 0),
|
||||
Memory: make([]models.MemoryDIMM, 0),
|
||||
Storage: make([]models.Storage, 0),
|
||||
Volumes: make([]models.StorageVolume, 0),
|
||||
PCIeDevices: make([]models.PCIeDevice, 0),
|
||||
GPUs: make([]models.GPU, 0),
|
||||
NetworkCards: make([]models.NIC, 0),
|
||||
NetworkAdapters: make([]models.NetworkAdapter, 0),
|
||||
PowerSupply: make([]models.PSU, 0),
|
||||
},
|
||||
}
|
||||
|
||||
if f := findByPath(files, "appdump/frudata/fruinfo.txt"); f != nil {
|
||||
if f := findByAnyPath(files, "appdump/frudata/fruinfo.txt", "rtosdump/versioninfo/fruinfo.txt"); f != nil {
|
||||
parseFRUInfo(f.Content, result)
|
||||
}
|
||||
if f := findByPath(files, "appdump/sensor_alarm/sensor_info.txt"); f != nil {
|
||||
@@ -76,10 +84,20 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er
|
||||
if f := findByPath(files, "appdump/cpumem/mem_info"); f != nil {
|
||||
result.Hardware.Memory = parseMemInfo(f.Content)
|
||||
}
|
||||
var nicCards []xfusionNICCard
|
||||
if f := findByPath(files, "appdump/card_manage/card_info"); f != nil {
|
||||
gpus, nics := parseCardInfo(f.Content)
|
||||
gpus, cards := parseCardInfo(f.Content)
|
||||
result.Hardware.GPUs = gpus
|
||||
result.Hardware.NetworkCards = nics
|
||||
nicCards = cards
|
||||
}
|
||||
if f := findByPath(files, "logdump/netcard/netcard_info.txt"); f != nil || len(nicCards) > 0 {
|
||||
var content []byte
|
||||
if f != nil {
|
||||
content = f.Content
|
||||
}
|
||||
adapters, legacyNICs := mergeNetworkAdapters(nicCards, parseNetcardInfo(content))
|
||||
result.Hardware.NetworkAdapters = adapters
|
||||
result.Hardware.NetworkCards = legacyNICs
|
||||
}
|
||||
if f := findByPath(files, "appdump/bmc/psu_info.txt"); f != nil {
|
||||
result.Hardware.PowerSupply = parsePSUInfo(f.Content)
|
||||
@@ -87,6 +105,9 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er
|
||||
if f := findByPath(files, "appdump/storagemgnt/raid_controller_info.txt"); f != nil {
|
||||
parseStorageControllerInfo(f.Content, result)
|
||||
}
|
||||
if f := findByPath(files, "rtosdump/versioninfo/app_revision.txt"); f != nil {
|
||||
parseAppRevision(f.Content, result)
|
||||
}
|
||||
for _, f := range findDiskInfoFiles(files) {
|
||||
disk := parseDiskInfo(f.Content)
|
||||
if disk != nil {
|
||||
@@ -99,6 +120,7 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er
|
||||
|
||||
result.Protocol = "ipmi"
|
||||
result.SourceType = models.SourceTypeArchive
|
||||
parser.ApplyManufacturedYearWeekFromFRU(result.FRU, result.Hardware)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -113,6 +135,15 @@ func findByPath(files []parser.ExtractedFile, substring string) *parser.Extracte
|
||||
return nil
|
||||
}
|
||||
|
||||
func findByAnyPath(files []parser.ExtractedFile, substrings ...string) *parser.ExtractedFile {
|
||||
for _, substring := range substrings {
|
||||
if f := findByPath(files, substring); f != nil {
|
||||
return f
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// findDiskInfoFiles returns all PhysicalDrivesInfo disk_info files.
|
||||
func findDiskInfoFiles(files []parser.ExtractedFile) []parser.ExtractedFile {
|
||||
var out []parser.ExtractedFile
|
||||
|
||||
113
internal/parser/vendors/xfusion/parser_test.go
vendored
113
internal/parser/vendors/xfusion/parser_test.go
vendored
@@ -1,8 +1,10 @@
|
||||
package xfusion
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.mchus.pro/mchus/logpile/internal/models"
|
||||
"git.mchus.pro/mchus/logpile/internal/parser"
|
||||
)
|
||||
|
||||
@@ -26,6 +28,29 @@ func TestDetect_G5500V7(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetect_ServerFileExportMarkers(t *testing.T) {
|
||||
p := &Parser{}
|
||||
score := p.Detect([]parser.ExtractedFile{
|
||||
{Path: "dump_info/RTOSDump/versioninfo/app_revision.txt", Content: []byte("Product Name: G5500 V7")},
|
||||
{Path: "dump_info/LogDump/netcard/netcard_info.txt", Content: []byte("2026-02-04 03:54:06 UTC")},
|
||||
{Path: "dump_info/AppDump/card_manage/card_info", Content: []byte("OCP Card Info")},
|
||||
})
|
||||
if score < 70 {
|
||||
t.Fatalf("expected Detect score >= 70 for xFusion file export markers, got %d", score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetect_Negative(t *testing.T) {
|
||||
p := &Parser{}
|
||||
score := p.Detect([]parser.ExtractedFile{
|
||||
{Path: "logs/messages.txt", Content: []byte("plain text")},
|
||||
{Path: "inventory.json", Content: []byte(`{"vendor":"other"}`)},
|
||||
})
|
||||
if score != 0 {
|
||||
t.Fatalf("expected Detect score 0 for non-xFusion input, got %d", score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_G5500V7_BoardInfo(t *testing.T) {
|
||||
files := loadTestArchive(t, "../../../../example/G5500V7_210619KUGGXGS2000015_20260318-1128.tar.gz")
|
||||
p := &Parser{}
|
||||
@@ -126,6 +151,94 @@ func TestParse_G5500V7_NICs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_ServerFileExport_NetworkAdaptersAndFirmware(t *testing.T) {
|
||||
p := &Parser{}
|
||||
files := []parser.ExtractedFile{
|
||||
{
|
||||
Path: "dump_info/AppDump/card_manage/card_info",
|
||||
Content: []byte(strings.TrimSpace(`
|
||||
Pcie Card Info
|
||||
Slot | Vender Id | Device Id | Sub Vender Id | Sub Device Id | Segment Number | Bus Number | Device Number | Function Number | Card Desc | Board Id | PCB Version | CPLD Version | Sub Card Bom Id | PartNum | SerialNumber | OriginalPartNum
|
||||
1 | 0x15b3 | 0x101f | 0x1f24 | 0x2011 | 0x00 | 0x27 | 0x00 | 0x00 | MT2894 Family [ConnectX-6 Lx] | N/A | N/A | N/A | N/A | 0302Y238 | 02Y238X6RC000058 |
|
||||
|
||||
OCP Card Info
|
||||
Slot | Vender Id | Device Id | Sub Vender Id | Sub Device Id | Segment Number | Bus Number | Device Number | Function Number | Card Desc | Board Id | PCB Version | CPLD Version | Sub Card Bom Id | PartNum | SerialNumber | OriginalPartNum
|
||||
1 | 0x15b3 | 0x101f | 0x1f24 | 0x2011 | 0x00 | 0x27 | 0x00 | 0x00 | MT2894 Family [ConnectX-6 Lx] | N/A | N/A | N/A | N/A | 0302Y238 | 02Y238X6RC000058 |
|
||||
`)),
|
||||
},
|
||||
{
|
||||
Path: "dump_info/LogDump/netcard/netcard_info.txt",
|
||||
Content: []byte(strings.TrimSpace(`
|
||||
2026-02-04 03:54:06 UTC
|
||||
ProductName :XC385
|
||||
Manufacture :XFUSION
|
||||
FirmwareVersion :26.39.2048
|
||||
SlotId :1
|
||||
Port0 BDF:0000:27:00.0
|
||||
MacAddr:44:1A:4C:16:E8:03
|
||||
ActualMac:44:1A:4C:16:E8:03
|
||||
Port1 BDF:0000:27:00.1
|
||||
MacAddr:00:00:00:00:00:00
|
||||
ActualMac:44:1A:4C:16:E8:04
|
||||
`)),
|
||||
},
|
||||
{
|
||||
Path: "dump_info/RTOSDump/versioninfo/app_revision.txt",
|
||||
Content: []byte(strings.TrimSpace(`
|
||||
------------------- iBMC INFO -------------------
|
||||
Active iBMC Version: (U68)3.08.05.85
|
||||
Active iBMC Built: 16:46:26 Jan 4 2026
|
||||
SDK Version: 13.16.30.16
|
||||
SDK Built: 07:55:18 Dec 12 2025
|
||||
Active BIOS Version: (U6216)01.02.08.17
|
||||
Active BIOS Built: 00:00:00 Jan 05 2026
|
||||
Product Name: G5500 V7
|
||||
`)),
|
||||
},
|
||||
}
|
||||
|
||||
result, err := p.Parse(files)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
if result.Protocol != "ipmi" || result.SourceType != models.SourceTypeArchive {
|
||||
t.Fatalf("unexpected source metadata: protocol=%q source_type=%q", result.Protocol, result.SourceType)
|
||||
}
|
||||
if result.Hardware == nil {
|
||||
t.Fatal("Hardware is nil")
|
||||
}
|
||||
if len(result.Hardware.NetworkAdapters) != 1 {
|
||||
t.Fatalf("expected 1 network adapter, got %d", len(result.Hardware.NetworkAdapters))
|
||||
}
|
||||
adapter := result.Hardware.NetworkAdapters[0]
|
||||
if adapter.BDF != "0000:27:00.0" {
|
||||
t.Fatalf("expected network adapter BDF 0000:27:00.0, got %q", adapter.BDF)
|
||||
}
|
||||
if adapter.Firmware != "26.39.2048" {
|
||||
t.Fatalf("expected network adapter firmware 26.39.2048, got %q", adapter.Firmware)
|
||||
}
|
||||
if adapter.SerialNumber != "02Y238X6RC000058" {
|
||||
t.Fatalf("expected network adapter serial from card_info, got %q", adapter.SerialNumber)
|
||||
}
|
||||
if len(adapter.MACAddresses) != 2 || adapter.MACAddresses[0] != "44:1A:4C:16:E8:03" || adapter.MACAddresses[1] != "44:1A:4C:16:E8:04" {
|
||||
t.Fatalf("unexpected MAC addresses: %#v", adapter.MACAddresses)
|
||||
}
|
||||
|
||||
fwByDevice := make(map[string]models.FirmwareInfo)
|
||||
for _, fw := range result.Hardware.Firmware {
|
||||
fwByDevice[fw.DeviceName] = fw
|
||||
}
|
||||
if fwByDevice["iBMC"].Version != "(U68)3.08.05.85" {
|
||||
t.Fatalf("expected iBMC firmware from app_revision.txt, got %#v", fwByDevice["iBMC"])
|
||||
}
|
||||
if fwByDevice["BIOS"].Version != "(U6216)01.02.08.17" {
|
||||
t.Fatalf("expected BIOS firmware from app_revision.txt, got %#v", fwByDevice["BIOS"])
|
||||
}
|
||||
if result.Hardware.BoardInfo.ProductName != "G5500 V7" {
|
||||
t.Fatalf("expected board product fallback from app_revision.txt, got %q", result.Hardware.BoardInfo.ProductName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_G5500V7_PSUs(t *testing.T) {
|
||||
files := loadTestArchive(t, "../../../../example/G5500V7_210619KUGGXGS2000015_20260318-1128.tar.gz")
|
||||
p := &Parser{}
|
||||
|
||||
@@ -44,6 +44,9 @@ func TestParserParseExample(t *testing.T) {
|
||||
examplePath := filepath.Join("..", "..", "..", "..", "example", "xigmanas.txt")
|
||||
raw, err := os.ReadFile(examplePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
t.Skipf("example file %s not present", examplePath)
|
||||
}
|
||||
t.Fatalf("read example file: %v", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ package server
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -29,7 +31,17 @@ func TestCollectProbe(t *testing.T) {
|
||||
_, ts := newCollectTestServer()
|
||||
defer ts.Close()
|
||||
|
||||
body := `{"host":"bmc-off.local","protocol":"redfish","port":443,"username":"admin","auth_type":"password","password":"secret","tls_mode":"strict"}`
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen probe target: %v", err)
|
||||
}
|
||||
defer ln.Close()
|
||||
addr, ok := ln.Addr().(*net.TCPAddr)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected listener address type: %T", ln.Addr())
|
||||
}
|
||||
|
||||
body := fmt.Sprintf(`{"host":"127.0.0.1","protocol":"redfish","port":%d,"username":"admin-off","auth_type":"password","password":"secret","tls_mode":"strict"}`, addr.Port)
|
||||
resp, err := http.Post(ts.URL+"/api/collect/probe", "application/json", bytes.NewBufferString(body))
|
||||
if err != nil {
|
||||
t.Fatalf("post collect probe failed: %v", err)
|
||||
|
||||
@@ -21,11 +21,15 @@ func (c *mockConnector) Probe(ctx context.Context, req collector.Request) (*coll
|
||||
if strings.Contains(strings.ToLower(req.Host), "fail") {
|
||||
return nil, context.DeadlineExceeded
|
||||
}
|
||||
hostPoweredOn := true
|
||||
if strings.Contains(strings.ToLower(req.Host), "off") || strings.Contains(strings.ToLower(req.Username), "off") {
|
||||
hostPoweredOn = false
|
||||
}
|
||||
return &collector.ProbeResult{
|
||||
Reachable: true,
|
||||
Protocol: c.protocol,
|
||||
HostPowerState: map[bool]string{true: "On", false: "Off"}[!strings.Contains(strings.ToLower(req.Host), "off")],
|
||||
HostPoweredOn: !strings.Contains(strings.ToLower(req.Host), "off"),
|
||||
HostPowerState: map[bool]string{true: "On", false: "Off"}[hostPoweredOn],
|
||||
HostPoweredOn: hostPoweredOn,
|
||||
PowerControlAvailable: true,
|
||||
SystemPath: "/redfish/v1/Systems/1",
|
||||
}, nil
|
||||
|
||||
@@ -19,7 +19,9 @@ type CollectRequest struct {
|
||||
Password string `json:"password,omitempty"`
|
||||
Token string `json:"token,omitempty"`
|
||||
TLSMode string `json:"tls_mode"`
|
||||
PowerOnIfHostOff bool `json:"power_on_if_host_off,omitempty"`
|
||||
PowerOnIfHostOff bool `json:"power_on_if_host_off,omitempty"`
|
||||
StopHostAfterCollect bool `json:"stop_host_after_collect,omitempty"`
|
||||
DebugPayloads bool `json:"debug_payloads,omitempty"`
|
||||
}
|
||||
|
||||
type CollectProbeResponse struct {
|
||||
|
||||
@@ -81,7 +81,7 @@ func BuildHardwareDevices(hw *models.HardwareConfig) []models.HardwareDevice {
|
||||
}
|
||||
|
||||
for _, mem := range hw.Memory {
|
||||
if !mem.Present || mem.SizeMB == 0 {
|
||||
if !mem.IsInstalledInventory() {
|
||||
continue
|
||||
}
|
||||
present := mem.Present
|
||||
@@ -243,6 +243,8 @@ func BuildHardwareDevices(hw *models.HardwareConfig) []models.HardwareDevice {
|
||||
Source: "network_adapters",
|
||||
Slot: nic.Slot,
|
||||
Location: nic.Location,
|
||||
BDF: nic.BDF,
|
||||
DeviceClass: "NetworkController",
|
||||
VendorID: nic.VendorID,
|
||||
DeviceID: nic.DeviceID,
|
||||
Model: nic.Model,
|
||||
@@ -253,6 +255,11 @@ func BuildHardwareDevices(hw *models.HardwareConfig) []models.HardwareDevice {
|
||||
PortCount: nic.PortCount,
|
||||
PortType: nic.PortType,
|
||||
MACAddresses: nic.MACAddresses,
|
||||
LinkWidth: nic.LinkWidth,
|
||||
LinkSpeed: nic.LinkSpeed,
|
||||
MaxLinkWidth: nic.MaxLinkWidth,
|
||||
MaxLinkSpeed: nic.MaxLinkSpeed,
|
||||
NUMANode: nic.NUMANode,
|
||||
Present: &present,
|
||||
Status: nic.Status,
|
||||
StatusCheckedAt: nic.StatusCheckedAt,
|
||||
|
||||
@@ -90,6 +90,98 @@ func TestBuildHardwareDevices_MemorySameSerialDifferentSlots_NotDeduped(t *testi
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHardwareDevices_ZeroSizeMemoryWithInventoryIsIncluded(t *testing.T) {
|
||||
hw := &models.HardwareConfig{
|
||||
Memory: []models.MemoryDIMM{
|
||||
{
|
||||
Slot: "PROC 1 DIMM 3",
|
||||
Location: "PROC 1 DIMM 3",
|
||||
Present: true,
|
||||
SizeMB: 0,
|
||||
Manufacturer: "Hynix",
|
||||
SerialNumber: "2B5F92C6",
|
||||
PartNumber: "HMCG88AEBRA115N",
|
||||
Status: "ok",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
devices := BuildHardwareDevices(hw)
|
||||
memoryCount := 0
|
||||
for _, d := range devices {
|
||||
if d.Kind != models.DeviceKindMemory {
|
||||
continue
|
||||
}
|
||||
memoryCount++
|
||||
if d.Slot != "PROC 1 DIMM 3" || d.PartNumber != "HMCG88AEBRA115N" || d.SerialNumber != "2B5F92C6" {
|
||||
t.Fatalf("unexpected memory device: %+v", d)
|
||||
}
|
||||
}
|
||||
if memoryCount != 1 {
|
||||
t.Fatalf("expected 1 installed zero-size memory record, got %d", memoryCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHardwareDevices_NetworkAdapterPreservesPCIeMetadata(t *testing.T) {
|
||||
hw := &models.HardwareConfig{
|
||||
NetworkAdapters: []models.NetworkAdapter{
|
||||
{
|
||||
Slot: "1",
|
||||
Location: "OCP",
|
||||
Present: true,
|
||||
BDF: "0000:27:00.0",
|
||||
Model: "ConnectX-6 Lx",
|
||||
VendorID: 0x15b3,
|
||||
DeviceID: 0x101f,
|
||||
SerialNumber: "NIC-001",
|
||||
Firmware: "26.39.2048",
|
||||
MACAddresses: []string{"44:1A:4C:16:E8:03", "44:1A:4C:16:E8:04"},
|
||||
LinkWidth: 16,
|
||||
LinkSpeed: "32 GT/s",
|
||||
NUMANode: 1,
|
||||
Status: "ok",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
devices := BuildHardwareDevices(hw)
|
||||
for _, d := range devices {
|
||||
if d.Kind != models.DeviceKindNetwork {
|
||||
continue
|
||||
}
|
||||
if d.BDF != "0000:27:00.0" || d.LinkWidth != 16 || d.LinkSpeed != "32 GT/s" || d.NUMANode != 1 {
|
||||
t.Fatalf("expected network PCIe metadata to be preserved, got %+v", d)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatal("expected network device in canonical inventory")
|
||||
}
|
||||
|
||||
func TestBuildSpecification_ZeroSizeMemoryWithInventoryIsShown(t *testing.T) {
|
||||
hw := &models.HardwareConfig{
|
||||
Memory: []models.MemoryDIMM{
|
||||
{
|
||||
Slot: "PROC 1 DIMM 3",
|
||||
Present: true,
|
||||
SizeMB: 0,
|
||||
Manufacturer: "Hynix",
|
||||
PartNumber: "HMCG88AEBRA115N",
|
||||
SerialNumber: "2B5F92C6",
|
||||
Status: "ok",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
spec := buildSpecification(hw)
|
||||
for _, line := range spec {
|
||||
if line.Category == "Память" && line.Name == "Hynix HMCG88AEBRA115N (size unknown)" && line.Quantity == 1 {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
t.Fatalf("expected memory spec line for zero-size identified DIMM, got %+v", spec)
|
||||
}
|
||||
|
||||
func TestBuildHardwareDevices_DuplicateSerials_AreAnnotated(t *testing.T) {
|
||||
hw := &models.HardwareConfig{
|
||||
Memory: []models.MemoryDIMM{
|
||||
@@ -166,6 +258,31 @@ func TestBuildHardwareDevices_SkipsFirmwareOnlyNumericSlots(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHardwareDevices_NetworkDevicesUseUnifiedControllerClass(t *testing.T) {
|
||||
hw := &models.HardwareConfig{
|
||||
NetworkAdapters: []models.NetworkAdapter{
|
||||
{
|
||||
Slot: "NIC1",
|
||||
Model: "Ethernet Adapter",
|
||||
Vendor: "Intel",
|
||||
Present: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
devices := BuildHardwareDevices(hw)
|
||||
for _, d := range devices {
|
||||
if d.Kind != models.DeviceKindNetwork {
|
||||
continue
|
||||
}
|
||||
if d.DeviceClass != "NetworkController" {
|
||||
t.Fatalf("expected unified network controller class, got %+v", d)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatalf("expected one canonical network device")
|
||||
}
|
||||
|
||||
func TestHandleGetConfig_ReturnsCanonicalHardware(t *testing.T) {
|
||||
srv := &Server{}
|
||||
srv.SetResult(&models.AnalysisResult{
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -17,6 +18,7 @@ import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/logpile/internal/collector"
|
||||
@@ -46,7 +48,10 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
tmpl.Execute(w, nil)
|
||||
tmpl.Execute(w, map[string]string{
|
||||
"AppVersion": s.config.AppVersion,
|
||||
"AppCommit": s.config.AppCommit,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleChartCurrent(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -525,11 +530,21 @@ func buildSpecification(hw *models.HardwareConfig) []SpecLine {
|
||||
continue
|
||||
}
|
||||
present := mem.Present != nil && *mem.Present
|
||||
// Skip empty slots (not present or 0 size)
|
||||
if !present || mem.SizeMB == 0 {
|
||||
if !present {
|
||||
continue
|
||||
}
|
||||
// Include frequency if available
|
||||
|
||||
if mem.SizeMB == 0 {
|
||||
name := strings.TrimSpace(strings.Join(nonEmptyStrings(mem.Manufacturer, mem.PartNumber, mem.Type), " "))
|
||||
if name == "" {
|
||||
name = "Installed DIMM (size unknown)"
|
||||
} else {
|
||||
name += " (size unknown)"
|
||||
}
|
||||
memGroups[name]++
|
||||
continue
|
||||
}
|
||||
|
||||
key := ""
|
||||
currentSpeed := intFromDetails(mem.Details, "current_speed_mhz")
|
||||
if currentSpeed > 0 {
|
||||
@@ -621,6 +636,18 @@ func buildSpecification(hw *models.HardwareConfig) []SpecLine {
|
||||
return spec
|
||||
}
|
||||
|
||||
func nonEmptyStrings(values ...string) []string {
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) handleGetSerials(w http.ResponseWriter, r *http.Request) {
|
||||
result := s.GetResult()
|
||||
if result == nil {
|
||||
@@ -712,6 +739,19 @@ func hasUsableSerial(serial string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func hasUsableFirmwareVersion(version string) bool {
|
||||
v := strings.TrimSpace(version)
|
||||
if v == "" {
|
||||
return false
|
||||
}
|
||||
switch strings.ToUpper(v) {
|
||||
case "N/A", "NA", "NONE", "NULL", "UNKNOWN", "-":
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleGetFirmware(w http.ResponseWriter, r *http.Request) {
|
||||
result := s.GetResult()
|
||||
if result == nil || result.Hardware == nil {
|
||||
@@ -939,7 +979,7 @@ func buildFirmwareEntries(hw *models.HardwareConfig) []firmwareEntry {
|
||||
component = strings.TrimSpace(component)
|
||||
model = strings.TrimSpace(model)
|
||||
version = strings.TrimSpace(version)
|
||||
if component == "" || version == "" {
|
||||
if component == "" || !hasUsableFirmwareVersion(version) {
|
||||
return
|
||||
}
|
||||
if model == "" {
|
||||
@@ -1571,6 +1611,32 @@ func (s *Server) handleCollectStart(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(job.toJobResponse("Collection job accepted"))
|
||||
}
|
||||
|
||||
// pingHost dials host:port up to total times with 2s timeout each, returns true if
|
||||
// at least need attempts succeeded.
|
||||
func pingHost(host string, port int, total, need int) (bool, string) {
|
||||
addr := fmt.Sprintf("%s:%d", host, port)
|
||||
var successes atomic.Int32
|
||||
done := make(chan struct{}, total)
|
||||
for i := 0; i < total; i++ {
|
||||
go func() {
|
||||
defer func() { done <- struct{}{} }()
|
||||
conn, err := net.DialTimeout("tcp", addr, 2*time.Second)
|
||||
if err == nil {
|
||||
conn.Close()
|
||||
successes.Add(1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
for i := 0; i < total; i++ {
|
||||
<-done
|
||||
}
|
||||
n := int(successes.Load())
|
||||
if n < need {
|
||||
return false, fmt.Sprintf("Хост недоступен: только %d из %d попыток подключения к %s прошли успешно (требуется минимум %d)", n, total, addr, need)
|
||||
}
|
||||
return true, ""
|
||||
}
|
||||
|
||||
func (s *Server) handleCollectProbe(w http.ResponseWriter, r *http.Request) {
|
||||
var req CollectRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
@@ -1592,6 +1658,11 @@ func (s *Server) handleCollectProbe(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if ok, msg := pingHost(req.Host, req.Port, 10, 3); !ok {
|
||||
jsonError(w, msg, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -1956,15 +2027,17 @@ func applyCollectSourceMetadata(result *models.AnalysisResult, req CollectReques
|
||||
|
||||
func toCollectorRequest(req CollectRequest) collector.Request {
|
||||
return collector.Request{
|
||||
Host: req.Host,
|
||||
Protocol: req.Protocol,
|
||||
Port: req.Port,
|
||||
Username: req.Username,
|
||||
AuthType: req.AuthType,
|
||||
Password: req.Password,
|
||||
Token: req.Token,
|
||||
TLSMode: req.TLSMode,
|
||||
PowerOnIfHostOff: req.PowerOnIfHostOff,
|
||||
Host: req.Host,
|
||||
Protocol: req.Protocol,
|
||||
Port: req.Port,
|
||||
Username: req.Username,
|
||||
AuthType: req.AuthType,
|
||||
Password: req.Password,
|
||||
Token: req.Token,
|
||||
TLSMode: req.TLSMode,
|
||||
PowerOnIfHostOff: req.PowerOnIfHostOff,
|
||||
StopHostAfterCollect: req.StopHostAfterCollect,
|
||||
DebugPayloads: req.DebugPayloads,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -62,3 +62,22 @@ func TestBuildFirmwareEntries_IncludesGPUFirmwareFallback(t *testing.T) {
|
||||
t.Fatalf("expected GPU firmware entry from hardware.gpus fallback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFirmwareEntries_SkipsPlaceholderVersions(t *testing.T) {
|
||||
hw := &models.HardwareConfig{
|
||||
Firmware: []models.FirmwareInfo{
|
||||
{DeviceName: "BMC", Version: "3.13.42P13"},
|
||||
{DeviceName: "Front_BP_1", Version: "NA"},
|
||||
{DeviceName: "Rear_BP_0", Version: "N/A"},
|
||||
{DeviceName: "HDD_BP", Version: "-"},
|
||||
},
|
||||
}
|
||||
|
||||
entries := buildFirmwareEntries(hw)
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("expected only usable firmware entries, got %#v", entries)
|
||||
}
|
||||
if entries[0].Component != "BMC" || entries[0].Version != "3.13.42P13" {
|
||||
t.Fatalf("unexpected remaining firmware entry: %#v", entries[0])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,7 +210,6 @@ main {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#api-check-btn,
|
||||
#api-connect-btn,
|
||||
#api-power-on-collect-btn,
|
||||
#api-collect-off-btn,
|
||||
@@ -229,7 +228,6 @@ main {
|
||||
transition: background-color 0.2s ease, opacity 0.2s ease;
|
||||
}
|
||||
|
||||
#api-check-btn:hover,
|
||||
#api-connect-btn:hover,
|
||||
#api-power-on-collect-btn:hover,
|
||||
#api-collect-off-btn:hover,
|
||||
@@ -242,7 +240,6 @@ main {
|
||||
|
||||
#convert-run-btn:disabled,
|
||||
#convert-folder-btn:disabled,
|
||||
#api-check-btn:disabled,
|
||||
#api-connect-btn:disabled,
|
||||
#api-power-on-collect-btn:disabled,
|
||||
#api-collect-off-btn:disabled,
|
||||
@@ -252,6 +249,127 @@ main {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
#api-collect-btn {
|
||||
background: #1f8f4c;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 0.6rem 1.25rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease, opacity 0.2s ease;
|
||||
}
|
||||
|
||||
#api-collect-btn:hover {
|
||||
background: #176e3a;
|
||||
}
|
||||
|
||||
#api-collect-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.api-probe-options {
|
||||
margin-top: 0.9rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.api-form-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.api-form-checkbox input[type="checkbox"] {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.api-form-checkbox input[type="checkbox"]:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.api-form-checkbox span {
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.api-form-checkbox-sub {
|
||||
padding-left: 0.25rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.api-probe-options-separator {
|
||||
margin: 0.5rem 0;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.api-confirm-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.api-confirm-modal {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 1.5rem 1.75rem;
|
||||
max-width: 380px;
|
||||
width: 90%;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.18);
|
||||
}
|
||||
|
||||
.api-confirm-modal p {
|
||||
margin-bottom: 1.1rem;
|
||||
font-size: 0.95rem;
|
||||
color: #333;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.api-confirm-modal-actions {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.api-confirm-modal-actions button {
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.api-confirm-modal-actions .btn-cancel {
|
||||
background: #e2e8f0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.api-confirm-modal-actions .btn-cancel:hover {
|
||||
background: #cbd5e1;
|
||||
}
|
||||
|
||||
.api-confirm-modal-actions .btn-confirm {
|
||||
background: #dc3545;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.api-confirm-modal-actions .btn-confirm:hover {
|
||||
background: #b02a37;
|
||||
}
|
||||
|
||||
.api-connect-status {
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
|
||||
@@ -91,14 +91,18 @@ function initApiSource() {
|
||||
}
|
||||
|
||||
const cancelJobButton = document.getElementById('cancel-job-btn');
|
||||
const checkButton = document.getElementById('api-check-btn');
|
||||
const collectOffButton = document.getElementById('api-collect-off-btn');
|
||||
const powerOnCollectButton = document.getElementById('api-power-on-collect-btn');
|
||||
const connectButton = document.getElementById('api-connect-btn');
|
||||
const collectButton = document.getElementById('api-collect-btn');
|
||||
const powerOffCheckbox = document.getElementById('api-power-off');
|
||||
const fieldNames = ['host', 'port', 'username', 'password'];
|
||||
|
||||
apiForm.addEventListener('submit', (event) => {
|
||||
event.preventDefault();
|
||||
startCollectionFromCurrentProbe(false);
|
||||
if (apiProbeResult && apiProbeResult.reachable) {
|
||||
startCollectionWithOptions();
|
||||
} else {
|
||||
startApiProbe();
|
||||
}
|
||||
});
|
||||
|
||||
if (cancelJobButton) {
|
||||
@@ -106,21 +110,29 @@ function initApiSource() {
|
||||
cancelCollectionJob();
|
||||
});
|
||||
}
|
||||
if (checkButton) {
|
||||
checkButton.addEventListener('click', () => {
|
||||
if (connectButton) {
|
||||
connectButton.addEventListener('click', () => {
|
||||
startApiProbe();
|
||||
});
|
||||
}
|
||||
if (collectOffButton) {
|
||||
collectOffButton.addEventListener('click', () => {
|
||||
clearApiPowerDecisionTimer();
|
||||
startCollectionFromCurrentProbe(false);
|
||||
if (collectButton) {
|
||||
collectButton.addEventListener('click', () => {
|
||||
startCollectionWithOptions();
|
||||
});
|
||||
}
|
||||
if (powerOnCollectButton) {
|
||||
powerOnCollectButton.addEventListener('click', () => {
|
||||
clearApiPowerDecisionTimer();
|
||||
startCollectionFromCurrentProbe(true);
|
||||
if (powerOffCheckbox) {
|
||||
powerOffCheckbox.addEventListener('change', () => {
|
||||
if (!powerOffCheckbox.checked) {
|
||||
return;
|
||||
}
|
||||
// If host was already on when probed, warn before enabling shutdown
|
||||
if (apiProbeResult && apiProbeResult.host_powered_on) {
|
||||
showConfirmModal(
|
||||
'Хост был включён до начала сбора. Вы уверены, что хотите выключить его после завершения сбора?',
|
||||
() => { /* confirmed — leave checked */ },
|
||||
() => { powerOffCheckbox.checked = false; }
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -151,11 +163,42 @@ function initApiSource() {
|
||||
renderCollectionJob();
|
||||
}
|
||||
|
||||
function showConfirmModal(message, onConfirm, onCancel) {
|
||||
const backdrop = document.createElement('div');
|
||||
backdrop.className = 'api-confirm-modal-backdrop';
|
||||
backdrop.innerHTML = `
|
||||
<div class="api-confirm-modal" role="dialog" aria-modal="true">
|
||||
<p>${escapeHtml(message)}</p>
|
||||
<div class="api-confirm-modal-actions">
|
||||
<button class="btn-cancel">Отмена</button>
|
||||
<button class="btn-confirm">Да, выключить</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(backdrop);
|
||||
|
||||
const close = () => document.body.removeChild(backdrop);
|
||||
backdrop.querySelector('.btn-cancel').addEventListener('click', () => {
|
||||
close();
|
||||
if (onCancel) onCancel();
|
||||
});
|
||||
backdrop.querySelector('.btn-confirm').addEventListener('click', () => {
|
||||
close();
|
||||
if (onConfirm) onConfirm();
|
||||
});
|
||||
backdrop.addEventListener('click', (e) => {
|
||||
if (e.target === backdrop) {
|
||||
close();
|
||||
if (onCancel) onCancel();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function startApiProbe() {
|
||||
const { isValid, payload, errors } = validateCollectForm();
|
||||
renderFormErrors(errors);
|
||||
if (!isValid) {
|
||||
renderApiConnectStatus(false, null);
|
||||
renderApiConnectStatus(false);
|
||||
resetApiProbeState();
|
||||
return;
|
||||
}
|
||||
@@ -163,7 +206,7 @@ function startApiProbe() {
|
||||
apiConnectPayload = payload;
|
||||
resetApiProbeState();
|
||||
setApiFormBlocked(true);
|
||||
renderApiConnectStatus(true, { ...payload, password: '***' });
|
||||
renderApiConnectStatus(true);
|
||||
|
||||
fetch('/api/collect/probe', {
|
||||
method: 'POST',
|
||||
@@ -181,7 +224,7 @@ function startApiProbe() {
|
||||
})
|
||||
.catch((err) => {
|
||||
resetApiProbeState();
|
||||
renderApiConnectStatus(false, null);
|
||||
renderApiConnectStatus(false);
|
||||
const status = document.getElementById('api-connect-status');
|
||||
if (status) {
|
||||
status.textContent = err.message || 'Проверка подключения не удалась';
|
||||
@@ -195,12 +238,11 @@ function startApiProbe() {
|
||||
});
|
||||
}
|
||||
|
||||
function startCollectionFromCurrentProbe(powerOnIfHostOff) {
|
||||
function startCollectionWithOptions() {
|
||||
const { isValid, payload, errors } = validateCollectForm();
|
||||
renderFormErrors(errors);
|
||||
if (!isValid) {
|
||||
renderApiConnectStatus(false, null);
|
||||
resetApiProbeState();
|
||||
renderApiConnectStatus(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -213,71 +255,78 @@ function startCollectionFromCurrentProbe(powerOnIfHostOff) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearApiPowerDecisionTimer();
|
||||
payload.power_on_if_host_off = Boolean(powerOnIfHostOff);
|
||||
const powerOnCheckbox = document.getElementById('api-power-on');
|
||||
const powerOffCheckbox = document.getElementById('api-power-off');
|
||||
const debugPayloads = document.getElementById('api-debug-payloads');
|
||||
payload.power_on_if_host_off = powerOnCheckbox ? powerOnCheckbox.checked : false;
|
||||
payload.stop_host_after_collect = powerOffCheckbox ? powerOffCheckbox.checked : false;
|
||||
payload.debug_payloads = debugPayloads ? debugPayloads.checked : false;
|
||||
startCollectionJob(payload);
|
||||
}
|
||||
|
||||
function renderApiProbeState() {
|
||||
const collectButton = document.getElementById('api-connect-btn');
|
||||
const connectButton = document.getElementById('api-connect-btn');
|
||||
const probeOptions = document.getElementById('api-probe-options');
|
||||
const status = document.getElementById('api-connect-status');
|
||||
const decision = document.getElementById('api-power-decision');
|
||||
const decisionText = document.getElementById('api-power-decision-text');
|
||||
if (!collectButton || !status || !decision || !decisionText) {
|
||||
const powerOnCheckbox = document.getElementById('api-power-on');
|
||||
const powerOffCheckbox = document.getElementById('api-power-off');
|
||||
if (!connectButton || !probeOptions || !status) {
|
||||
return;
|
||||
}
|
||||
|
||||
decision.classList.add('hidden');
|
||||
clearApiPowerDecisionTimer();
|
||||
collectButton.disabled = !apiProbeResult || !apiProbeResult.reachable;
|
||||
|
||||
if (!apiProbeResult || !apiProbeResult.reachable) {
|
||||
status.textContent = 'Проверка подключения не пройдена.';
|
||||
status.className = 'api-connect-status error';
|
||||
probeOptions.classList.add('hidden');
|
||||
connectButton.textContent = 'Подключиться';
|
||||
return;
|
||||
}
|
||||
|
||||
if (apiProbeResult.host_powered_on) {
|
||||
status.textContent = apiProbeResult.message || 'Связь с BMC есть, host включен.';
|
||||
const hostOn = apiProbeResult.host_powered_on;
|
||||
const powerControlAvailable = apiProbeResult.power_control_available;
|
||||
|
||||
if (hostOn) {
|
||||
status.textContent = apiProbeResult.message || 'Связь с BMC есть, host включён.';
|
||||
status.className = 'api-connect-status success';
|
||||
collectButton.disabled = false;
|
||||
return;
|
||||
} else {
|
||||
status.textContent = apiProbeResult.message || 'Связь с BMC есть, host выключен.';
|
||||
status.className = 'api-connect-status warning';
|
||||
}
|
||||
|
||||
status.textContent = apiProbeResult.message || 'Связь с BMC есть, host выключен.';
|
||||
status.className = 'api-connect-status warning';
|
||||
if (!apiProbeResult.power_control_available) {
|
||||
collectButton.disabled = false;
|
||||
return;
|
||||
}
|
||||
probeOptions.classList.remove('hidden');
|
||||
|
||||
decision.classList.remove('hidden');
|
||||
let secondsLeft = 5;
|
||||
const updateDecisionText = () => {
|
||||
decisionText.textContent = `Если не выбрать действие, сбор начнется без включения через ${secondsLeft} сек.`;
|
||||
};
|
||||
updateDecisionText();
|
||||
apiPowerDecisionTimer = window.setInterval(() => {
|
||||
secondsLeft -= 1;
|
||||
if (secondsLeft <= 0) {
|
||||
clearApiPowerDecisionTimer();
|
||||
startCollectionFromCurrentProbe(false);
|
||||
return;
|
||||
// "Включить" checkbox
|
||||
if (powerOnCheckbox) {
|
||||
if (hostOn) {
|
||||
// Host already on — checkbox is checked and disabled
|
||||
powerOnCheckbox.checked = true;
|
||||
powerOnCheckbox.disabled = true;
|
||||
} else {
|
||||
// Host off — default: checked (will power on), enabled
|
||||
powerOnCheckbox.checked = true;
|
||||
powerOnCheckbox.disabled = !powerControlAvailable;
|
||||
}
|
||||
updateDecisionText();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// "Выключить" checkbox — default: unchecked
|
||||
if (powerOffCheckbox) {
|
||||
powerOffCheckbox.checked = false;
|
||||
powerOffCheckbox.disabled = !powerControlAvailable;
|
||||
}
|
||||
|
||||
connectButton.textContent = 'Переподключиться';
|
||||
}
|
||||
|
||||
function resetApiProbeState() {
|
||||
apiProbeResult = null;
|
||||
clearApiPowerDecisionTimer();
|
||||
const collectButton = document.getElementById('api-connect-btn');
|
||||
const decision = document.getElementById('api-power-decision');
|
||||
if (collectButton) {
|
||||
collectButton.disabled = true;
|
||||
const connectButton = document.getElementById('api-connect-btn');
|
||||
const probeOptions = document.getElementById('api-probe-options');
|
||||
if (connectButton) {
|
||||
connectButton.textContent = 'Подключиться';
|
||||
}
|
||||
if (decision) {
|
||||
decision.classList.add('hidden');
|
||||
if (probeOptions) {
|
||||
probeOptions.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,7 +417,7 @@ function renderFormErrors(errors) {
|
||||
summary.innerHTML = `<strong>Исправьте ошибки в форме:</strong><ul>${messages.map(msg => `<li>${escapeHtml(msg)}</li>`).join('')}</ul>`;
|
||||
}
|
||||
|
||||
function renderApiConnectStatus(isValid, payload) {
|
||||
function renderApiConnectStatus(isValid) {
|
||||
const status = document.getElementById('api-connect-status');
|
||||
if (!status) {
|
||||
return;
|
||||
@@ -380,16 +429,8 @@ function renderApiConnectStatus(isValid, payload) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payloadPreview = { ...payload };
|
||||
if (payloadPreview.password) {
|
||||
payloadPreview.password = '***';
|
||||
}
|
||||
if (payloadPreview.token) {
|
||||
payloadPreview.token = '***';
|
||||
}
|
||||
|
||||
status.textContent = `Payload сформирован: ${JSON.stringify(payloadPreview)}`;
|
||||
status.className = 'api-connect-status success';
|
||||
status.textContent = 'Подключение...';
|
||||
status.className = 'api-connect-status info';
|
||||
}
|
||||
|
||||
function clearApiConnectStatus() {
|
||||
@@ -440,7 +481,7 @@ function startCollectionJob(payload) {
|
||||
.catch((err) => {
|
||||
setApiFormBlocked(false);
|
||||
clearApiConnectStatus();
|
||||
renderApiConnectStatus(false, null);
|
||||
renderApiConnectStatus(false);
|
||||
const status = document.getElementById('api-connect-status');
|
||||
if (status) {
|
||||
status.textContent = err.message || 'Ошибка запуска задачи';
|
||||
@@ -523,14 +564,61 @@ function appendJobLog(message) {
|
||||
return;
|
||||
}
|
||||
|
||||
const time = new Date().toLocaleTimeString('ru-RU', { hour12: false });
|
||||
const parsed = parseServerLogLine(message);
|
||||
if (isCollectLogNoise(parsed.message)) {
|
||||
// Still count toward log length so syncServerLogs offset stays correct,
|
||||
// but mark as hidden so renderCollectionJob skips it.
|
||||
collectionJob.logs.push({
|
||||
id: ++collectionJobLogCounter,
|
||||
time: parsed.time || new Date().toLocaleTimeString('ru-RU', { hour12: false }),
|
||||
message: parsed.message,
|
||||
hidden: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
collectionJob.logs.push({
|
||||
id: ++collectionJobLogCounter,
|
||||
time,
|
||||
message
|
||||
time: parsed.time || new Date().toLocaleTimeString('ru-RU', { hour12: false }),
|
||||
message: humanizeCollectLogMessage(parsed.message)
|
||||
});
|
||||
}
|
||||
|
||||
// Transform technical log messages into human-readable form for the UI.
|
||||
// The original messages are preserved in collect.log / raw_export.
|
||||
function humanizeCollectLogMessage(msg) {
|
||||
// "Redfish snapshot: документов=520, ETA≈16s, корни=Chassis(294), Systems(114), последний=/redfish/v1/..."
|
||||
// → "Snapshot: /Chassis/Self/PCIeDevices/00_34_04"
|
||||
let m = msg.match(/snapshot:\s+документов=\d+[^,]*,.*последний=(\S+)/i);
|
||||
if (m) {
|
||||
const path = m[1].replace(/^\.\.\./, '').replace(/^\/redfish\/v1/, '') || m[1];
|
||||
return `Snapshot: ${path}`;
|
||||
}
|
||||
|
||||
// "Redfish snapshot: собрано N документов"
|
||||
m = msg.match(/snapshot:\s+собрано\s+(\d+)\s+документов/i);
|
||||
if (m) {
|
||||
return `Snapshot: итого ${m[1]} документов`;
|
||||
}
|
||||
|
||||
// "Redfish: plan-B завершен за 30s (targets=18, recovered=0)"
|
||||
m = msg.match(/plan-B завершен за ([^(]+)\(targets=(\d+),\s*recovered=(\d+)\)/i);
|
||||
if (m) {
|
||||
const recovered = parseInt(m[3], 10);
|
||||
const suffix = recovered > 0 ? `, восстановлено ${m[3]}` : '';
|
||||
return `Plan-B: завершен за ${m[1].trim()}${suffix}`;
|
||||
}
|
||||
|
||||
// "Redfish: prefetch критичных endpoint (адаптивно 9/72)..."
|
||||
m = msg.match(/prefetch критичных endpoint[^(]*\(([^)]+)\)/i);
|
||||
if (m) {
|
||||
return `Prefetch критичных endpoint (${m[1]})`;
|
||||
}
|
||||
|
||||
// Strip "Redfish: " / "Redfish snapshot: " prefix — redundant in context
|
||||
return msg.replace(/^Redfish(?:\s+snapshot)?:\s+/i, '');
|
||||
}
|
||||
|
||||
function renderCollectionJob() {
|
||||
const jobStatusBlock = document.getElementById('api-job-status');
|
||||
const jobIdValue = document.getElementById('job-id-value');
|
||||
@@ -576,9 +664,11 @@ function renderCollectionJob() {
|
||||
renderJobActiveModules(activeModulesBlock, activeModulesList);
|
||||
renderJobDebugInfo(debugInfoBlock, debugSummary, phaseTelemetryNode);
|
||||
|
||||
logsList.innerHTML = [...collectionJob.logs].reverse().map((log) => (
|
||||
`<li><span class="log-time">${escapeHtml(log.time)}</span><span class="log-message">${escapeHtml(log.message)}</span></li>`
|
||||
)).join('');
|
||||
logsList.innerHTML = [...collectionJob.logs].reverse()
|
||||
.filter((log) => !log.hidden)
|
||||
.map((log) => (
|
||||
`<li><span class="log-time">${escapeHtml(log.time)}</span><span class="log-message">${escapeHtml(log.message)}</span></li>`
|
||||
)).join('');
|
||||
|
||||
cancelButton.disabled = isTerminal;
|
||||
setApiFormBlocked(!isTerminal);
|
||||
@@ -668,6 +758,38 @@ function syncServerLogs(logs) {
|
||||
}
|
||||
}
|
||||
|
||||
// Patterns for log lines that are internal debug noise and should not be shown in the UI.
|
||||
const _collectLogNoisePatterns = [
|
||||
/plan-B \(\d+\/\d+/, // individual plan-B step lines
|
||||
/plan-B топ веток/,
|
||||
/snapshot: heartbeat/,
|
||||
/snapshot: post-probe коллекций \(/,
|
||||
/snapshot: топ веток/,
|
||||
/prefetch завершен/,
|
||||
/cooldown перед повторным добором/,
|
||||
/Redfish telemetry:/,
|
||||
/redfish-postprobe-metrics:/,
|
||||
/redfish-prefetch-metrics:/,
|
||||
/redfish-collect:/,
|
||||
/redfish-profile-plan:/,
|
||||
/redfish replay:/,
|
||||
];
|
||||
|
||||
function isCollectLogNoise(message) {
|
||||
return _collectLogNoisePatterns.some((re) => re.test(message));
|
||||
}
|
||||
|
||||
// Strip the server-side RFC3339Nano timestamp prefix from a log line and return {time, message}.
|
||||
function parseServerLogLine(raw) {
|
||||
const m = String(raw).match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z)\s+(.*)/s);
|
||||
if (!m) {
|
||||
return { time: null, message: String(raw).trim() };
|
||||
}
|
||||
const d = new Date(m[1]);
|
||||
const time = isNaN(d) ? null : d.toLocaleTimeString('ru-RU', { hour12: false });
|
||||
return { time, message: m[2].trim() };
|
||||
}
|
||||
|
||||
function normalizeJobStatus(status) {
|
||||
return String(status || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
@@ -36,9 +36,9 @@
|
||||
<div id="archive-source-content">
|
||||
<div class="upload-area" id="drop-zone">
|
||||
<p>Перетащите архив, TXT/LOG или JSON snapshot сюда</p>
|
||||
<input type="file" id="file-input" accept="application/gzip,application/x-gzip,application/x-tar,application/zip,application/json,text/plain,.json,.tar,.tar.gz,.tgz,.sds,.zip,.txt,.log" hidden>
|
||||
<input type="file" id="file-input" accept="application/gzip,application/x-gzip,application/x-tar,application/zip,application/json,text/plain,.ahs,.json,.tar,.tar.gz,.tgz,.sds,.zip,.txt,.log" hidden>
|
||||
<button type="button" onclick="document.getElementById('file-input').click()">Выберите файл</button>
|
||||
<p class="hint">Поддерживаемые форматы: tar.gz, tar, tgz, sds, zip, json, txt, log</p>
|
||||
<p class="hint">Поддерживаемые форматы: ahs, tar.gz, tar, tgz, sds, zip, json, txt, log</p>
|
||||
</div>
|
||||
<div id="upload-status"></div>
|
||||
<div id="parsers-info" class="parsers-info"></div>
|
||||
@@ -76,16 +76,25 @@
|
||||
</div>
|
||||
|
||||
<div class="api-form-actions">
|
||||
<button id="api-check-btn" type="button">Проверить</button>
|
||||
<button id="api-connect-btn" type="submit" disabled>Собрать</button>
|
||||
<button id="api-connect-btn" type="button">Подключиться</button>
|
||||
</div>
|
||||
<div id="api-connect-status" class="api-connect-status"></div>
|
||||
<div id="api-power-decision" class="api-connect-status hidden">
|
||||
<strong>Host выключен.</strong>
|
||||
<p id="api-power-decision-text">Если не выбрать действие, сбор начнется без включения через 5 секунд.</p>
|
||||
<div id="api-probe-options" class="api-probe-options hidden">
|
||||
<label class="api-form-checkbox" for="api-power-on">
|
||||
<input id="api-power-on" name="power_on_if_host_off" type="checkbox">
|
||||
<span>Включить перед сбором</span>
|
||||
</label>
|
||||
<label class="api-form-checkbox" for="api-power-off">
|
||||
<input id="api-power-off" name="stop_host_after_collect" type="checkbox">
|
||||
<span>Выключить после сбора</span>
|
||||
</label>
|
||||
<div class="api-probe-options-separator"></div>
|
||||
<label class="api-form-checkbox" for="api-debug-payloads">
|
||||
<input id="api-debug-payloads" name="debug_payloads" type="checkbox">
|
||||
<span>Сбор расширенных метрик для отладки</span>
|
||||
</label>
|
||||
<div class="api-form-actions">
|
||||
<button id="api-power-on-collect-btn" type="button">Включить и собрать</button>
|
||||
<button id="api-collect-off-btn" type="button">Собирать выключенный</button>
|
||||
<button id="api-collect-btn" type="submit">Собрать</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@@ -165,7 +174,7 @@
|
||||
<div class="footer-buttons">
|
||||
</div>
|
||||
<div class="footer-info">
|
||||
<p>Автор: <a href="https://mchus.pro" target="_blank">mchus.pro</a> | <a href="https://git.mchus.pro/mchus/logpile" target="_blank">Git Repository</a></p>
|
||||
<p>Автор: <a href="https://mchus.pro" target="_blank">mchus.pro</a> | <a href="https://git.mchus.pro/mchus/logpile" target="_blank">Git Repository</a>{{if .AppVersion}} | v{{.AppVersion}}{{end}}</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user