export: align reanimator contract v2.7
This commit is contained in:
@@ -55,6 +55,23 @@ Responses:
|
|||||||
- `400` on malformed JSON
|
- `400` on malformed JSON
|
||||||
- `422` on validation errors
|
- `422` on validation errors
|
||||||
|
|
||||||
|
Optional request field:
|
||||||
|
- `power_on_if_host_off`: when `true`, Redfish collection may power on the host before collection if preflight found it powered off
|
||||||
|
|
||||||
|
### `POST /api/collect/probe`
|
||||||
|
|
||||||
|
Checks that live API connectivity works and returns host power state before collection starts.
|
||||||
|
|
||||||
|
Typical request body is the same as `POST /api/collect`.
|
||||||
|
|
||||||
|
Typical response fields:
|
||||||
|
- `reachable`
|
||||||
|
- `protocol`
|
||||||
|
- `host_power_state`
|
||||||
|
- `host_powered_on`
|
||||||
|
- `power_control_available`
|
||||||
|
- `message`
|
||||||
|
|
||||||
### `GET /api/collect/{id}`
|
### `GET /api/collect/{id}`
|
||||||
|
|
||||||
Returns async collection job status, progress, timestamps, and accumulated logs.
|
Returns async collection job status, progress, timestamps, and accumulated logs.
|
||||||
|
|||||||
@@ -20,12 +20,28 @@ Request fields passed from the server:
|
|||||||
- `auth_type`
|
- `auth_type`
|
||||||
- credential field (`password` or token)
|
- credential field (`password` or token)
|
||||||
- `tls_mode`
|
- `tls_mode`
|
||||||
|
- optional `power_on_if_host_off`
|
||||||
|
|
||||||
### Core rule
|
### Core rule
|
||||||
|
|
||||||
Live collection and replay must stay behaviorally aligned.
|
Live collection and replay must stay behaviorally aligned.
|
||||||
If the collector adds a fallback, probe, or normalization rule, replay must mirror it.
|
If the collector adds a fallback, probe, or normalization rule, replay must mirror it.
|
||||||
|
|
||||||
|
### Preflight and host power
|
||||||
|
|
||||||
|
- `Probe()` may be used before collection to verify API connectivity and current host `PowerState`
|
||||||
|
- if the host is off and the user chose power-on, the collector may issue `ComputerSystem.Reset`
|
||||||
|
with `ResetType=On`
|
||||||
|
- power-on attempts are bounded and logged
|
||||||
|
- after a successful power-on, the collector waits an extra stabilization window, then checks
|
||||||
|
`PowerState` again and only starts collection if the host is still on
|
||||||
|
- if the collector powered on the host itself for collection, it must attempt to power it back off
|
||||||
|
after collection completes
|
||||||
|
- if the host was already on before collection, the collector must not power it off afterward
|
||||||
|
- if power-on fails, collection still continues against the powered-off host
|
||||||
|
- all power-control decisions and attempts must be visible in the collection log so they are
|
||||||
|
preserved in raw-export bundles
|
||||||
|
|
||||||
### Discovery model
|
### Discovery model
|
||||||
|
|
||||||
The collector does not rely on one fixed vendor tree.
|
The collector does not rely on one fixed vendor tree.
|
||||||
|
|||||||
@@ -35,25 +35,34 @@ Implementation files:
|
|||||||
Conversion rules:
|
Conversion rules:
|
||||||
- canonical source is merged canonical inventory derived from `hardware.devices` plus legacy hardware slices
|
- canonical source is merged canonical inventory derived from `hardware.devices` plus legacy hardware slices
|
||||||
- output must conform to the strict Reanimator ingest contract in `docs/hardware-ingest-contract.md`
|
- output must conform to the strict Reanimator ingest contract in `docs/hardware-ingest-contract.md`
|
||||||
|
- local mirror currently tracks upstream contract `v2.7`
|
||||||
- timestamps are RFC3339
|
- timestamps are RFC3339
|
||||||
- status is normalized to Reanimator-friendly values
|
- status is normalized to Reanimator-friendly values
|
||||||
- missing PCIe serials may be generated from board serial + slot
|
- missing component serial numbers must stay absent; LOGPile must not synthesize fake serials for Reanimator export
|
||||||
- missing CPU serials may be generated from board serial + socket
|
|
||||||
- CPU `firmware` field means CPU microcode, not generic processor firmware inventory
|
- CPU `firmware` field means CPU microcode, not generic processor firmware inventory
|
||||||
- `NULL`-style board manufacturer/product values are treated as absent
|
- `NULL`-style board manufacturer/product values are treated as absent
|
||||||
- optional component telemetry/health fields are exported when LOGPile already has the data
|
- optional component telemetry/health fields are exported when LOGPile already has the data
|
||||||
- partial `hardware.devices` must not suppress components still present only in legacy parser/collector fields
|
- partial `hardware.devices` must not suppress components still present only in legacy parser/collector fields
|
||||||
- `present` is not serialized for exported components; presence is expressed by the existence of the component record itself
|
- `present` is not serialized for exported components; presence is expressed by the existence of the component record itself
|
||||||
|
- Reanimator ingest may apply its own server-side fallback serial rules for CPU and PCIe when LOGPile leaves serials absent
|
||||||
|
|
||||||
## Inclusion rules
|
## Inclusion rules
|
||||||
|
|
||||||
Included:
|
Included:
|
||||||
- PCIe-class devices even when serial must be synthesized
|
- PCIe-class devices when the component itself is present, even if serial number is missing
|
||||||
- contract `v2.4` component telemetry and health fields when source data exists
|
- contract `v2.7` component telemetry and health fields when source data exists
|
||||||
- hardware sensors grouped into `fans`, `power`, `temperatures`, `other`
|
- hardware sensors grouped into `fans`, `power`, `temperatures`, `other` only when the sensor has a real numeric reading
|
||||||
|
- sensor `location` is not exported; LOGPile keeps only sensor `name` plus measured values and status
|
||||||
- Redfish linked metric docs that carry component telemetry: `ProcessorMetrics`, `MemoryMetrics`, `DriveMetrics`, `EnvironmentMetrics`, `Metrics`
|
- Redfish linked metric docs that carry component telemetry: `ProcessorMetrics`, `MemoryMetrics`, `DriveMetrics`, `EnvironmentMetrics`, `Metrics`
|
||||||
|
- `pcie_devices.slot` is treated as the canonical PCIe address; `bdf` is used only as an internal fallback/dedupe key and is not serialized in the payload
|
||||||
|
- `event_logs` are exported only from normalized parser/collector events that can be mapped to contract sources `host` / `bmc` / `redfish` without synthesizing content
|
||||||
|
- `manufactured_year_week` is exported only as a reliable passthrough when the parser/collector already extracted a valid `YYYY-Www` value
|
||||||
|
|
||||||
Excluded:
|
Excluded:
|
||||||
|
- storage endpoints from `pcie_devices`; disks and NVMe drives export only through `hardware.storage`
|
||||||
|
- fake serial numbers for PCIe-class devices; any fallback serial generation belongs to Reanimator ingest, not LOGPile
|
||||||
|
- sensors without a real numeric reading
|
||||||
|
- events with internal-only or unmappable sources such as LOGPile internal warnings
|
||||||
- memory with missing serial number
|
- memory with missing serial number
|
||||||
- memory with `present=false` or `status=Empty`
|
- memory with `present=false` or `status=Empty`
|
||||||
- CPUs with `present=false`
|
- CPUs with `present=false`
|
||||||
|
|||||||
@@ -455,3 +455,129 @@ live collection and raw snapshot replay still underreported component health fie
|
|||||||
component record.
|
component record.
|
||||||
|
|
||||||
<!-- Add new decisions below this line using the format above -->
|
<!-- Add new decisions below this line using the format above -->
|
||||||
|
|
||||||
|
## ADL-025 — Missing serial numbers must remain absent in Reanimator export
|
||||||
|
|
||||||
|
**Date:** 2026-03-15
|
||||||
|
**Context:**
|
||||||
|
LOGPile previously generated synthetic serial numbers for components that had no real serial in
|
||||||
|
source data, especially CPUs and PCIe-class devices. This made the payload look richer, but the
|
||||||
|
serials were not authoritative and could mislead downstream consumers. Reanimator can already
|
||||||
|
accept missing serials and generate its own internal fallback identifiers when needed.
|
||||||
|
|
||||||
|
**Decision:**
|
||||||
|
- Do not synthesize fake serial numbers in LOGPile's Reanimator export.
|
||||||
|
- If a component has no real serial in parsed source data, export the serial field as absent.
|
||||||
|
- This applies to CPUs, PCIe devices, GPUs, NICs, and any other component class unless an
|
||||||
|
upstream contract explicitly requires a deterministic exporter-generated identifier.
|
||||||
|
- Any fallback serial generation defined by the upstream contract is ingest-side Reanimator behavior,
|
||||||
|
not LOGPile exporter behavior.
|
||||||
|
|
||||||
|
**Consequences:**
|
||||||
|
- Exported payloads carry only source-backed serial numbers.
|
||||||
|
- Fake identifiers such as `BOARD-...-CPU-...` or synthetic PCIe serials are no longer considered
|
||||||
|
acceptable exporter behavior.
|
||||||
|
- Any future attempt to reintroduce generated serials requires an explicit contract review and a
|
||||||
|
new ADL entry.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ADL-026 — Live Redfish collection uses explicit preflight host-power confirmation
|
||||||
|
|
||||||
|
**Date:** 2026-03-15
|
||||||
|
**Context:**
|
||||||
|
Live Redfish inventory can be incomplete when the managed host is powered off. At the same time,
|
||||||
|
LOGPile must not silently power on a host without explicit user choice. The collection workflow
|
||||||
|
therefore needs a preflight step that verifies connectivity, shows current host power state to the
|
||||||
|
user, and only powers on the host when the user explicitly chose that path.
|
||||||
|
|
||||||
|
**Decision:**
|
||||||
|
- Add a dedicated live preflight API step before collection starts.
|
||||||
|
- UI first runs connectivity and power-state check, then offers:
|
||||||
|
- collect as-is
|
||||||
|
- power on and collect
|
||||||
|
- if the host is off and the user does not answer within 5 seconds, default to collecting without
|
||||||
|
powering the host on
|
||||||
|
- Redfish collection may power on the host only when the request explicitly sets
|
||||||
|
`power_on_if_host_off=true`
|
||||||
|
- when LOGPile powers on the host for collection, it must try to power the host back off after
|
||||||
|
collection completes
|
||||||
|
- if LOGPile did not power the host on itself, it must never power the host off
|
||||||
|
- all preflight and power-control steps must be logged into the collection log and therefore into
|
||||||
|
the raw-export bundle
|
||||||
|
|
||||||
|
**Consequences:**
|
||||||
|
- Live collection becomes a two-step UX: probe first, collect second.
|
||||||
|
- Raw bundles preserve operator-visible evidence of power-state decisions and power-control attempts.
|
||||||
|
- Power-on failures do not block collection entirely; they only downgrade completeness expectations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ADL-027 — Sensors without numeric readings are not exported
|
||||||
|
|
||||||
|
**Date:** 2026-03-15
|
||||||
|
**Context:**
|
||||||
|
Some parsed sensor records carry only a name, unit, or status, but no actual numeric reading. Such
|
||||||
|
records are not useful as telemetry in Reanimator export and create noisy, low-value sensor lists.
|
||||||
|
|
||||||
|
**Decision:**
|
||||||
|
- Do not export temperature, power, fan, or other sensor records unless they carry a real numeric
|
||||||
|
measurement value.
|
||||||
|
- Presence of a sensor name or health/status alone is not sufficient for export.
|
||||||
|
|
||||||
|
**Consequences:**
|
||||||
|
- Exported sensor groups contain only actionable telemetry.
|
||||||
|
- Parsers and collectors may still keep non-numeric sensor artifacts internally for diagnostics, but
|
||||||
|
Reanimator export must filter them out.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ADL-028 — Reanimator PCIe export excludes storage endpoints and synthetic serials
|
||||||
|
|
||||||
|
**Date:** 2026-03-15
|
||||||
|
**Context:**
|
||||||
|
Some Redfish and archive sources expose NVMe drives both as storage inventory and as PCIe-visible
|
||||||
|
endpoints. Exporting such drives in both `hardware.storage` and `hardware.pcie_devices` creates
|
||||||
|
duplicates without adding useful topology value. At the same time, PCIe-class export still had old
|
||||||
|
fallback behavior that generated synthetic serial numbers when source serials were absent.
|
||||||
|
|
||||||
|
**Decision:**
|
||||||
|
- Export disks and NVMe drives only through `hardware.storage`.
|
||||||
|
- Do not export storage endpoints as `hardware.pcie_devices`, even if the source inventory exposes
|
||||||
|
them as PCIe/NVMe devices.
|
||||||
|
- Keep real PCIe storage controllers such as RAID and HBA adapters in `hardware.pcie_devices`.
|
||||||
|
- Do not synthesize PCIe/GPU/NIC serial numbers in LOGPile; missing serials stay absent.
|
||||||
|
- Treat placeholder names such as `Network Device View` as non-authoritative and prefer resolved
|
||||||
|
device names when stronger data exists.
|
||||||
|
|
||||||
|
**Consequences:**
|
||||||
|
- Reanimator payloads no longer duplicate NVMe drives between storage and PCIe sections.
|
||||||
|
- PCIe export remains topology-focused while storage export remains component-focused.
|
||||||
|
- Missing PCIe-class serials no longer produce fake `BOARD-...-PCIE-...` identifiers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ADL-029 — Local exporter guidance tracks upstream contract v2.7 terminology
|
||||||
|
|
||||||
|
**Date:** 2026-03-15
|
||||||
|
**Context:**
|
||||||
|
The upstream Reanimator hardware ingest contract moved to `v2.7` and clarified several points that
|
||||||
|
matter for LOGPile documentation: ingest-side serial fallback rules, canonical PCIe addressing via
|
||||||
|
`slot`, the optional `event_logs` section, and the shared `manufactured_year_week` field.
|
||||||
|
|
||||||
|
**Decision:**
|
||||||
|
- Keep the local mirrored contract file as an exact copy of the upstream `v2.7` document.
|
||||||
|
- Describe CPU/PCIe serial fallback as Reanimator ingest behavior, not LOGPile exporter behavior.
|
||||||
|
- Treat `pcie_devices.slot` as the canonical address on the LOGPile side as well; `bdf` may remain
|
||||||
|
an internal fallback/dedupe key but is not serialized in the payload.
|
||||||
|
- Export `event_logs` only from normalized parser/collector events that can be mapped to contract
|
||||||
|
sources `host` / `bmc` / `redfish` without synthesizing message content.
|
||||||
|
- Export `manufactured_year_week` only as a reliable passthrough when a parser/collector already
|
||||||
|
extracted a valid `YYYY-Www` value.
|
||||||
|
|
||||||
|
**Consequences:**
|
||||||
|
- Local bible wording no longer conflicts with upstream contract terminology.
|
||||||
|
- Reanimator payloads use contract-native PCIe addressing and no longer expose `bdf` as a parallel
|
||||||
|
coordinate.
|
||||||
|
- LOGPile event export remains strictly source-derived; internal warnings such as LOGPile analysis
|
||||||
|
notes do not leak into Reanimator `event_logs`.
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+534
-17
@@ -69,6 +69,32 @@ func (c *RedfishConnector) Protocol() string {
|
|||||||
return "redfish"
|
return "redfish"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *RedfishConnector) Probe(ctx context.Context, req Request) (*ProbeResult, error) {
|
||||||
|
baseURL, err := c.baseURL(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
client := c.httpClientWithTimeout(req, c.timeout)
|
||||||
|
if _, err := c.getJSON(ctx, client, req, baseURL, "/redfish/v1"); err != nil {
|
||||||
|
return nil, fmt.Errorf("redfish service root: %w", err)
|
||||||
|
}
|
||||||
|
systemPaths := c.discoverMemberPaths(ctx, client, req, baseURL, "/redfish/v1/Systems", "/redfish/v1/Systems/1")
|
||||||
|
primarySystem := firstNonEmptyPath(systemPaths, "/redfish/v1/Systems/1")
|
||||||
|
systemDoc, err := c.getJSON(ctx, client, req, baseURL, primarySystem)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("redfish system: %w", err)
|
||||||
|
}
|
||||||
|
powerState := strings.TrimSpace(asString(systemDoc["PowerState"]))
|
||||||
|
return &ProbeResult{
|
||||||
|
Reachable: true,
|
||||||
|
Protocol: "redfish",
|
||||||
|
HostPowerState: powerState,
|
||||||
|
HostPoweredOn: isRedfishHostPoweredOn(powerState),
|
||||||
|
PowerControlAvailable: redfishResetActionTarget(systemDoc) != "",
|
||||||
|
SystemPath: primarySystem,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *RedfishConnector) debugf(format string, args ...interface{}) {
|
func (c *RedfishConnector) debugf(format string, args ...interface{}) {
|
||||||
if !c.debug {
|
if !c.debug {
|
||||||
return
|
return
|
||||||
@@ -102,6 +128,21 @@ func (c *RedfishConnector) Collect(ctx context.Context, req Request, emit Progre
|
|||||||
}
|
}
|
||||||
|
|
||||||
systemPaths := c.discoverMemberPaths(ctx, snapshotClient, req, baseURL, "/redfish/v1/Systems", "/redfish/v1/Systems/1")
|
systemPaths := c.discoverMemberPaths(ctx, 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if !poweredOnByCollector || primarySystem == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
c.restoreHostPowerAfterCollection(shutdownCtx, snapshotClient, req, baseURL, primarySystem, emit)
|
||||||
|
}()
|
||||||
chassisPaths := c.discoverMemberPaths(ctx, snapshotClient, req, baseURL, "/redfish/v1/Chassis", "/redfish/v1/Chassis/1")
|
chassisPaths := c.discoverMemberPaths(ctx, snapshotClient, req, baseURL, "/redfish/v1/Chassis", "/redfish/v1/Chassis/1")
|
||||||
managerPaths := c.discoverMemberPaths(ctx, snapshotClient, req, baseURL, "/redfish/v1/Managers", "/redfish/v1/Managers/1")
|
managerPaths := c.discoverMemberPaths(ctx, snapshotClient, req, baseURL, "/redfish/v1/Managers", "/redfish/v1/Managers/1")
|
||||||
criticalPaths := redfishCriticalEndpoints(systemPaths, chassisPaths, managerPaths)
|
criticalPaths := redfishCriticalEndpoints(systemPaths, chassisPaths, managerPaths)
|
||||||
@@ -186,6 +227,257 @@ func (c *RedfishConnector) Collect(ctx context.Context, req Request, emit Progre
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *RedfishConnector) ensureHostPowerForCollection(ctx context.Context, client *http.Client, req Request, baseURL, systemPath string, emit ProgressFn) (hostOn bool, poweredOnByCollector bool) {
|
||||||
|
systemDoc, err := c.getJSON(ctx, client, req, baseURL, systemPath)
|
||||||
|
if err != nil {
|
||||||
|
if emit != nil {
|
||||||
|
emit(Progress{Status: "running", Progress: 18, Message: "Redfish: не удалось проверить PowerState host, сбор продолжается без power-control"})
|
||||||
|
}
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
|
||||||
|
powerState := strings.TrimSpace(asString(systemDoc["PowerState"]))
|
||||||
|
if isRedfishHostPoweredOn(powerState) {
|
||||||
|
if emit != nil {
|
||||||
|
emit(Progress{Status: "running", Progress: 18, Message: fmt.Sprintf("Redfish: host включен (%s)", firstNonEmpty(powerState, "On"))})
|
||||||
|
}
|
||||||
|
return true, false
|
||||||
|
}
|
||||||
|
|
||||||
|
if emit != nil {
|
||||||
|
emit(Progress{Status: "running", Progress: 18, Message: fmt.Sprintf("Redfish: host выключен (%s)", firstNonEmpty(powerState, "Off"))})
|
||||||
|
}
|
||||||
|
if !req.PowerOnIfHostOff {
|
||||||
|
if emit != nil {
|
||||||
|
emit(Progress{Status: "running", Progress: 19, Message: "Redfish: включение host не запрошено, сбор продолжается на выключенном host"})
|
||||||
|
}
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
|
||||||
|
resetTarget := redfishResetActionTarget(systemDoc)
|
||||||
|
resetType := redfishPickResetType(systemDoc, "On", "ForceOn")
|
||||||
|
if resetTarget == "" || resetType == "" {
|
||||||
|
if emit != nil {
|
||||||
|
emit(Progress{Status: "running", Progress: 19, Message: "Redfish: action ComputerSystem.Reset недоступен, сбор продолжается на выключенном host"})
|
||||||
|
}
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
|
||||||
|
waitWindows := []time.Duration{5 * time.Second, 10 * time.Second, 30 * time.Second}
|
||||||
|
for i, waitFor := range waitWindows {
|
||||||
|
if emit != nil {
|
||||||
|
emit(Progress{Status: "running", Progress: 19, Message: fmt.Sprintf("Redfish: попытка включения host (%d/%d), ожидание %s", i+1, len(waitWindows), waitFor)})
|
||||||
|
}
|
||||||
|
if err := c.postJSON(ctx, client, req, baseURL, resetTarget, map[string]any{"ResetType": resetType}); err != nil {
|
||||||
|
if emit != nil {
|
||||||
|
emit(Progress{Status: "running", Progress: 19, Message: fmt.Sprintf("Redfish: включение host не удалось (%v)", err)})
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c.waitForHostPowerState(ctx, client, req, baseURL, systemPath, true, waitFor) {
|
||||||
|
if !c.waitForStablePoweredOnHost(ctx, client, req, baseURL, systemPath, emit) {
|
||||||
|
if emit != nil {
|
||||||
|
emit(Progress{Status: "running", Progress: 20, Message: "Redfish: host включился, но не подтвердил стабильное состояние; сбор продолжается на выключенном host"})
|
||||||
|
}
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
if emit != nil {
|
||||||
|
emit(Progress{Status: "running", Progress: 20, Message: "Redfish: host успешно включен и стабилен перед сбором"})
|
||||||
|
}
|
||||||
|
return true, true
|
||||||
|
}
|
||||||
|
if emit != nil {
|
||||||
|
emit(Progress{Status: "running", Progress: 20, Message: fmt.Sprintf("Redfish: host не включился за %s", waitFor)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if emit != nil {
|
||||||
|
emit(Progress{Status: "running", Progress: 20, Message: "Redfish: host не удалось включить, сбор продолжается на выключенном host"})
|
||||||
|
}
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RedfishConnector) waitForStablePoweredOnHost(ctx context.Context, client *http.Client, req Request, baseURL, systemPath string, emit ProgressFn) bool {
|
||||||
|
stabilizationDelay := redfishPowerOnStabilizationDelay()
|
||||||
|
if stabilizationDelay > 0 {
|
||||||
|
if emit != nil {
|
||||||
|
emit(Progress{
|
||||||
|
Status: "running",
|
||||||
|
Progress: 20,
|
||||||
|
Message: fmt.Sprintf("Redfish: host включен, ожидание стабилизации %s перед началом сбора", stabilizationDelay),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
timer := time.NewTimer(stabilizationDelay)
|
||||||
|
defer timer.Stop()
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return false
|
||||||
|
case <-timer.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if emit != nil {
|
||||||
|
emit(Progress{
|
||||||
|
Status: "running",
|
||||||
|
Progress: 20,
|
||||||
|
Message: "Redfish: повторная проверка PowerState после стабилизации host",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return c.waitForHostPowerState(ctx, client, req, baseURL, systemPath, true, 5*time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RedfishConnector) restoreHostPowerAfterCollection(ctx context.Context, client *http.Client, req Request, baseURL, systemPath string, emit ProgressFn) {
|
||||||
|
systemDoc, err := c.getJSON(ctx, client, req, baseURL, systemPath)
|
||||||
|
if err != nil {
|
||||||
|
if emit != nil {
|
||||||
|
emit(Progress{Status: "running", Progress: 100, Message: "Redfish: не удалось повторно прочитать system перед выключением host"})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resetTarget := redfishResetActionTarget(systemDoc)
|
||||||
|
resetType := redfishPickResetType(systemDoc, "GracefulShutdown", "ForceOff", "PushPowerButton")
|
||||||
|
if resetTarget == "" || resetType == "" {
|
||||||
|
if emit != nil {
|
||||||
|
emit(Progress{Status: "running", Progress: 100, Message: "Redfish: выключение host после сбора недоступно"})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if emit != nil {
|
||||||
|
emit(Progress{Status: "running", Progress: 100, Message: "Redfish: выключаем host после завершения сбора"})
|
||||||
|
}
|
||||||
|
if err := c.postJSON(ctx, client, req, baseURL, resetTarget, map[string]any{"ResetType": resetType}); err != nil {
|
||||||
|
if emit != nil {
|
||||||
|
emit(Progress{Status: "running", Progress: 100, Message: fmt.Sprintf("Redfish: не удалось выключить host после сбора (%v)", err)})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if c.waitForHostPowerState(ctx, client, req, baseURL, systemPath, false, 20*time.Second) {
|
||||||
|
if emit != nil {
|
||||||
|
emit(Progress{Status: "running", Progress: 100, Message: "Redfish: host выключен после завершения сбора"})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if emit != nil {
|
||||||
|
emit(Progress{Status: "running", Progress: 100, Message: "Redfish: не удалось подтвердить выключение host после сбора"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return false
|
||||||
|
case <-time.After(1 * time.Second):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmptyPath(paths []string, fallback string) string {
|
||||||
|
for _, p := range paths {
|
||||||
|
if strings.TrimSpace(p) != "" {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func isRedfishHostPoweredOn(state string) bool {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(state)) {
|
||||||
|
case "on", "poweringon":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func redfishResetActionTarget(systemDoc map[string]interface{}) string {
|
||||||
|
if systemDoc == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
actions, _ := systemDoc["Actions"].(map[string]interface{})
|
||||||
|
reset, _ := actions["#ComputerSystem.Reset"].(map[string]interface{})
|
||||||
|
target := strings.TrimSpace(asString(reset["target"]))
|
||||||
|
if target != "" {
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
odataID := strings.TrimSpace(asString(systemDoc["@odata.id"]))
|
||||||
|
if odataID == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return joinPath(odataID, "/Actions/ComputerSystem.Reset")
|
||||||
|
}
|
||||||
|
|
||||||
|
func redfishPickResetType(systemDoc map[string]interface{}, preferred ...string) string {
|
||||||
|
actions, _ := systemDoc["Actions"].(map[string]interface{})
|
||||||
|
reset, _ := actions["#ComputerSystem.Reset"].(map[string]interface{})
|
||||||
|
allowedRaw, _ := reset["ResetType@Redfish.AllowableValues"].([]interface{})
|
||||||
|
if len(allowedRaw) == 0 {
|
||||||
|
if len(preferred) > 0 {
|
||||||
|
return preferred[0]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
allowed := make([]string, 0, len(allowedRaw))
|
||||||
|
for _, item := range allowedRaw {
|
||||||
|
if v := strings.TrimSpace(asString(item)); v != "" {
|
||||||
|
allowed = append(allowed, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, want := range preferred {
|
||||||
|
for _, have := range allowed {
|
||||||
|
if strings.EqualFold(want, have) {
|
||||||
|
return have
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RedfishConnector) postJSON(ctx context.Context, client *http.Client, req Request, baseURL, resourcePath string, payload map[string]any) error {
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
target := strings.TrimSpace(resourcePath)
|
||||||
|
if !strings.HasPrefix(strings.ToLower(target), "http://") && !strings.HasPrefix(strings.ToLower(target), "https://") {
|
||||||
|
target = baseURL + normalizeRedfishPath(target)
|
||||||
|
}
|
||||||
|
u, err := url.Parse(target)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), strings.NewReader(string(body)))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
switch req.AuthType {
|
||||||
|
case "password":
|
||||||
|
httpReq.SetBasicAuth(req.Username, req.Password)
|
||||||
|
case "token":
|
||||||
|
httpReq.Header.Set("Authorization", "Bearer "+req.Token)
|
||||||
|
}
|
||||||
|
resp, err := client.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("status %d from %s: %s", resp.StatusCode, resourcePath, strings.TrimSpace(string(respBody)))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *RedfishConnector) prefetchCriticalRedfishDocs(
|
func (c *RedfishConnector) prefetchCriticalRedfishDocs(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
client *http.Client,
|
client *http.Client,
|
||||||
@@ -417,11 +709,17 @@ func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Clie
|
|||||||
driveDocs, err := c.getCollectionMembers(ctx, client, req, baseURL, driveCollectionPath)
|
driveDocs, err := c.getCollectionMembers(ctx, client, req, baseURL, driveCollectionPath)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
for _, driveDoc := range driveDocs {
|
for _, driveDoc := range driveDocs {
|
||||||
|
if isVirtualStorageDrive(driveDoc) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
||||||
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
|
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
|
||||||
}
|
}
|
||||||
if len(driveDocs) == 0 {
|
if len(driveDocs) == 0 {
|
||||||
for _, driveDoc := range c.probeDirectDiskBayChildren(ctx, client, req, baseURL, driveCollectionPath) {
|
for _, driveDoc := range c.probeDirectDiskBayChildren(ctx, client, req, baseURL, driveCollectionPath) {
|
||||||
|
if isVirtualStorageDrive(driveDoc) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
||||||
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
|
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
|
||||||
}
|
}
|
||||||
@@ -444,6 +742,9 @@ func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Clie
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if isVirtualStorageDrive(driveDoc) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
||||||
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
|
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
|
||||||
}
|
}
|
||||||
@@ -452,6 +753,9 @@ func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Clie
|
|||||||
|
|
||||||
// Some implementations return drive fields right in storage member object.
|
// Some implementations return drive fields right in storage member object.
|
||||||
if looksLikeDrive(member) {
|
if looksLikeDrive(member) {
|
||||||
|
if isVirtualStorageDrive(member) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, member, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, member, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
||||||
out = append(out, parseDriveWithSupplementalDocs(member, supplementalDocs...))
|
out = append(out, parseDriveWithSupplementalDocs(member, supplementalDocs...))
|
||||||
}
|
}
|
||||||
@@ -462,13 +766,16 @@ func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Clie
|
|||||||
driveDocs, err := c.getCollectionMembers(ctx, client, req, baseURL, joinPath(enclosurePath, "/Drives"))
|
driveDocs, err := c.getCollectionMembers(ctx, client, req, baseURL, joinPath(enclosurePath, "/Drives"))
|
||||||
if err == nil {
|
if err == nil {
|
||||||
for _, driveDoc := range driveDocs {
|
for _, driveDoc := range driveDocs {
|
||||||
if looksLikeDrive(driveDoc) {
|
if looksLikeDrive(driveDoc) && !isVirtualStorageDrive(driveDoc) {
|
||||||
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
||||||
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
|
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(driveDocs) == 0 {
|
if len(driveDocs) == 0 {
|
||||||
for _, driveDoc := range c.probeDirectDiskBayChildren(ctx, client, req, baseURL, joinPath(enclosurePath, "/Drives")) {
|
for _, driveDoc := range c.probeDirectDiskBayChildren(ctx, client, req, baseURL, joinPath(enclosurePath, "/Drives")) {
|
||||||
|
if isVirtualStorageDrive(driveDoc) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
out = append(out, parseDrive(driveDoc))
|
out = append(out, parseDrive(driveDoc))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -481,7 +788,7 @@ func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Clie
|
|||||||
"/Storage/IntelVROC/Drives",
|
"/Storage/IntelVROC/Drives",
|
||||||
"/Storage/IntelVROC/Controllers/1/Drives",
|
"/Storage/IntelVROC/Controllers/1/Drives",
|
||||||
}) {
|
}) {
|
||||||
if looksLikeDrive(driveDoc) {
|
if looksLikeDrive(driveDoc) && !isVirtualStorageDrive(driveDoc) {
|
||||||
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
||||||
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
|
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
|
||||||
}
|
}
|
||||||
@@ -496,7 +803,7 @@ func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Clie
|
|||||||
}
|
}
|
||||||
for _, devAny := range devices {
|
for _, devAny := range devices {
|
||||||
devDoc, ok := devAny.(map[string]interface{})
|
devDoc, ok := devAny.(map[string]interface{})
|
||||||
if !ok || !looksLikeDrive(devDoc) {
|
if !ok || !looksLikeDrive(devDoc) || isVirtualStorageDrive(devDoc) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
out = append(out, parseDriveWithSupplementalDocs(devDoc))
|
out = append(out, parseDriveWithSupplementalDocs(devDoc))
|
||||||
@@ -511,7 +818,7 @@ func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Clie
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, driveDoc := range driveDocs {
|
for _, driveDoc := range driveDocs {
|
||||||
if !looksLikeDrive(driveDoc) {
|
if !looksLikeDrive(driveDoc) || isVirtualStorageDrive(driveDoc) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
||||||
@@ -523,7 +830,7 @@ func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Clie
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, driveDoc := range c.probeSupermicroNVMeDiskBays(ctx, client, req, baseURL, chassisPath) {
|
for _, driveDoc := range c.probeSupermicroNVMeDiskBays(ctx, client, req, baseURL, chassisPath) {
|
||||||
if !looksLikeDrive(driveDoc) {
|
if !looksLikeDrive(driveDoc) || isVirtualStorageDrive(driveDoc) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
||||||
@@ -773,6 +1080,7 @@ func (c *RedfishConnector) collectPCIeDevices(ctx context.Context, client *http.
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, doc, "EnvironmentMetrics", "Metrics")
|
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, doc, "EnvironmentMetrics", "Metrics")
|
||||||
|
supplementalDocs = append(supplementalDocs, c.getChassisScopedPCIeSupplementalDocs(ctx, client, req, baseURL, doc)...)
|
||||||
for _, fn := range functionDocs {
|
for _, fn := range functionDocs {
|
||||||
supplementalDocs = append(supplementalDocs, c.getLinkedSupplementalDocs(ctx, client, req, baseURL, fn, "EnvironmentMetrics", "Metrics")...)
|
supplementalDocs = append(supplementalDocs, c.getLinkedSupplementalDocs(ctx, client, req, baseURL, fn, "EnvironmentMetrics", "Metrics")...)
|
||||||
}
|
}
|
||||||
@@ -800,6 +1108,39 @@ func (c *RedfishConnector) collectPCIeDevices(ctx context.Context, client *http.
|
|||||||
return dedupePCIeDevices(out)
|
return dedupePCIeDevices(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
add := func(path string) {
|
||||||
|
path = normalizeRedfishPath(path)
|
||||||
|
if path == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, ok := seen[path]; ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
supplementalDoc, err := c.getJSON(ctx, client, req, baseURL, path)
|
||||||
|
if err != nil || len(supplementalDoc) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[path] = struct{}{}
|
||||||
|
out = append(out, supplementalDoc)
|
||||||
|
}
|
||||||
|
|
||||||
|
add(joinPath(chassisPath, "/EnvironmentMetrics"))
|
||||||
|
add(joinPath(chassisPath, "/ThermalSubsystem/ThermalMetrics"))
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func (c *RedfishConnector) discoverMemberPaths(ctx context.Context, client *http.Client, req Request, baseURL, collectionPath, fallbackPath string) []string {
|
func (c *RedfishConnector) discoverMemberPaths(ctx context.Context, client *http.Client, req Request, baseURL, collectionPath, fallbackPath string) []string {
|
||||||
collection, err := c.getJSON(ctx, client, req, baseURL, collectionPath)
|
collection, err := c.getJSON(ctx, client, req, baseURL, collectionPath)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -1810,6 +2151,15 @@ func redfishCriticalSlowGap() time.Duration {
|
|||||||
return 1200 * time.Millisecond
|
return 1200 * time.Millisecond
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func redfishPowerOnStabilizationDelay() time.Duration {
|
||||||
|
if v := strings.TrimSpace(os.Getenv("LOGPILE_REDFISH_POWERON_STABILIZATION")); v != "" {
|
||||||
|
if d, err := time.ParseDuration(v); err == nil && d >= 0 {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 60 * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
func redfishSnapshotMemoryRequestTimeout() time.Duration {
|
func redfishSnapshotMemoryRequestTimeout() time.Duration {
|
||||||
if v := strings.TrimSpace(os.Getenv("LOGPILE_REDFISH_MEMORY_TIMEOUT")); v != "" {
|
if v := strings.TrimSpace(os.Getenv("LOGPILE_REDFISH_MEMORY_TIMEOUT")); v != "" {
|
||||||
if d, err := time.ParseDuration(v); err == nil && d > 0 {
|
if d, err := time.ParseDuration(v); err == nil && d > 0 {
|
||||||
@@ -1984,11 +2334,22 @@ func looksLikeCanonicalBDF(bdf string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sanitizeRedfishBDF(bdf string) string {
|
||||||
|
bdf = strings.TrimSpace(bdf)
|
||||||
|
if looksLikeCanonicalBDF(bdf) {
|
||||||
|
return bdf
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func shouldCrawlPath(path string) bool {
|
func shouldCrawlPath(path string) bool {
|
||||||
if path == "" {
|
if path == "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
normalized := normalizeRedfishPath(path)
|
normalized := normalizeRedfishPath(path)
|
||||||
|
if isAllowedNVSwitchFabricPath(normalized) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
if strings.Contains(normalized, "/Chassis/") &&
|
if strings.Contains(normalized, "/Chassis/") &&
|
||||||
strings.Contains(normalized, "/PCIeDevices/") &&
|
strings.Contains(normalized, "/PCIeDevices/") &&
|
||||||
strings.Contains(normalized, "/PCIeFunctions/") {
|
strings.Contains(normalized, "/PCIeFunctions/") {
|
||||||
@@ -2079,6 +2440,22 @@ func shouldCrawlPath(path string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isAllowedNVSwitchFabricPath(path string) bool {
|
||||||
|
if !strings.HasPrefix(path, "/redfish/v1/Fabrics/") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !strings.Contains(path, "/Switches/NVSwitch_") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(path, "/Switches") || strings.Contains(path, "/Ports/NVLink_") || strings.HasSuffix(path, "/Ports") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.Contains(path, "/Switches/NVSwitch_") && !strings.Contains(path, "/Ports/") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func isRedfishMemoryMemberPath(path string) bool {
|
func isRedfishMemoryMemberPath(path string) bool {
|
||||||
normalized := normalizeRedfishPath(path)
|
normalized := normalizeRedfishPath(path)
|
||||||
if !strings.Contains(normalized, "/Systems/") {
|
if !strings.Contains(normalized, "/Systems/") {
|
||||||
@@ -2880,23 +3257,38 @@ func parseNIC(doc map[string]interface{}) models.NetworkAdapter {
|
|||||||
vendor = pciids.VendorName(vendorID)
|
vendor = pciids.VendorName(vendorID)
|
||||||
}
|
}
|
||||||
location := redfishLocationLabel(doc["Location"])
|
location := redfishLocationLabel(doc["Location"])
|
||||||
|
slot := firstNonEmpty(redfishLocationLabel(doc["Location"]), asString(doc["Id"]), asString(doc["Name"]))
|
||||||
var firmware string
|
var firmware string
|
||||||
var portCount int
|
var portCount int
|
||||||
|
var linkWidth int
|
||||||
|
var maxLinkWidth int
|
||||||
|
var linkSpeed string
|
||||||
|
var maxLinkSpeed string
|
||||||
if controllers, ok := doc["Controllers"].([]interface{}); ok && len(controllers) > 0 {
|
if controllers, ok := doc["Controllers"].([]interface{}); ok && len(controllers) > 0 {
|
||||||
if ctrl, ok := controllers[0].(map[string]interface{}); ok {
|
if ctrl, ok := controllers[0].(map[string]interface{}); ok {
|
||||||
location = firstNonEmpty(location, redfishLocationLabel(ctrl["Location"]))
|
ctrlLocation := redfishLocationLabel(ctrl["Location"])
|
||||||
|
location = firstNonEmpty(location, ctrlLocation)
|
||||||
|
if isWeakRedfishNICSlotLabel(slot) {
|
||||||
|
slot = firstNonEmpty(ctrlLocation, slot)
|
||||||
|
}
|
||||||
firmware = asString(ctrl["FirmwarePackageVersion"])
|
firmware = asString(ctrl["FirmwarePackageVersion"])
|
||||||
if caps, ok := ctrl["ControllerCapabilities"].(map[string]interface{}); ok {
|
if caps, ok := ctrl["ControllerCapabilities"].(map[string]interface{}); ok {
|
||||||
portCount = sanitizeNetworkPortCount(asInt(caps["NetworkPortCount"]))
|
portCount = sanitizeNetworkPortCount(asInt(caps["NetworkPortCount"]))
|
||||||
}
|
}
|
||||||
|
if pcieIf, ok := ctrl["PCIeInterface"].(map[string]interface{}); ok {
|
||||||
|
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"]))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return models.NetworkAdapter{
|
return models.NetworkAdapter{
|
||||||
Slot: firstNonEmpty(asString(doc["Id"]), asString(doc["Name"])),
|
Slot: slot,
|
||||||
Location: location,
|
Location: location,
|
||||||
Present: !strings.EqualFold(mapStatus(doc["Status"]), "Absent"),
|
Present: !strings.EqualFold(mapStatus(doc["Status"]), "Absent"),
|
||||||
BDF: asString(doc["BDF"]),
|
BDF: sanitizeRedfishBDF(asString(doc["BDF"])),
|
||||||
Model: strings.TrimSpace(model),
|
Model: strings.TrimSpace(model),
|
||||||
Vendor: strings.TrimSpace(vendor),
|
Vendor: strings.TrimSpace(vendor),
|
||||||
VendorID: vendorID,
|
VendorID: vendorID,
|
||||||
@@ -2905,11 +3297,29 @@ func parseNIC(doc map[string]interface{}) models.NetworkAdapter {
|
|||||||
PartNumber: asString(doc["PartNumber"]),
|
PartNumber: asString(doc["PartNumber"]),
|
||||||
Firmware: firmware,
|
Firmware: firmware,
|
||||||
PortCount: portCount,
|
PortCount: portCount,
|
||||||
|
LinkWidth: linkWidth,
|
||||||
|
LinkSpeed: linkSpeed,
|
||||||
|
MaxLinkWidth: maxLinkWidth,
|
||||||
|
MaxLinkSpeed: maxLinkSpeed,
|
||||||
Status: mapStatus(doc["Status"]),
|
Status: mapStatus(doc["Status"]),
|
||||||
Details: redfishPCIeDetails(doc, nil),
|
Details: redfishPCIeDetails(doc, nil),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isWeakRedfishNICSlotLabel(slot string) bool {
|
||||||
|
slot = strings.TrimSpace(slot)
|
||||||
|
if slot == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if isNumericString(slot) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.EqualFold(slot, "nic") || strings.HasPrefix(strings.ToLower(slot), "nic") && !strings.Contains(strings.ToLower(slot), "slot") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func networkAdapterPCIeDevicePaths(doc map[string]interface{}) []string {
|
func networkAdapterPCIeDevicePaths(doc map[string]interface{}) []string {
|
||||||
var out []string
|
var out []string
|
||||||
if controllers, ok := doc["Controllers"].([]interface{}); ok {
|
if controllers, ok := doc["Controllers"].([]interface{}); ok {
|
||||||
@@ -2967,7 +3377,7 @@ func enrichNICFromPCIe(nic *models.NetworkAdapter, pcieDoc map[string]interface{
|
|||||||
}
|
}
|
||||||
for _, fn := range functionDocs {
|
for _, fn := range functionDocs {
|
||||||
if strings.TrimSpace(nic.BDF) == "" {
|
if strings.TrimSpace(nic.BDF) == "" {
|
||||||
nic.BDF = asString(fn["FunctionId"])
|
nic.BDF = sanitizeRedfishBDF(asString(fn["FunctionId"]))
|
||||||
}
|
}
|
||||||
if nic.VendorID == 0 {
|
if nic.VendorID == 0 {
|
||||||
nic.VendorID = asHexOrInt(fn["VendorId"])
|
nic.VendorID = asHexOrInt(fn["VendorId"])
|
||||||
@@ -3132,9 +3542,13 @@ func redfishPCIeDetailsWithSupplementalDocs(doc map[string]interface{}, function
|
|||||||
lookupDocs = append(lookupDocs, functionDocs...)
|
lookupDocs = append(lookupDocs, functionDocs...)
|
||||||
lookupDocs = append(lookupDocs, supplementalDocs...)
|
lookupDocs = append(lookupDocs, supplementalDocs...)
|
||||||
details := make(map[string]any)
|
details := make(map[string]any)
|
||||||
addFloatDetail(details, "temperature_c", redfishFirstNumericAcrossDocs(lookupDocs,
|
temperatureC := redfishFirstNumericAcrossDocs(lookupDocs,
|
||||||
"TemperatureCelsius", "TemperatureC", "Temperature",
|
"TemperatureCelsius", "TemperatureC", "Temperature",
|
||||||
))
|
)
|
||||||
|
if temperatureC == 0 {
|
||||||
|
temperatureC = redfishSupplementalThermalMetricForPCIe(doc, supplementalDocs)
|
||||||
|
}
|
||||||
|
addFloatDetail(details, "temperature_c", temperatureC)
|
||||||
addFloatDetail(details, "power_w", redfishFirstNumericAcrossDocs(lookupDocs,
|
addFloatDetail(details, "power_w", redfishFirstNumericAcrossDocs(lookupDocs,
|
||||||
"PowerConsumedWatts", "PowerWatts", "PowerConsumptionWatts",
|
"PowerConsumedWatts", "PowerWatts", "PowerConsumptionWatts",
|
||||||
))
|
))
|
||||||
@@ -3189,6 +3603,89 @@ func redfishPCIeDetailsWithSupplementalDocs(doc map[string]interface{}, function
|
|||||||
return details
|
return details
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func redfishSupplementalThermalMetricForPCIe(doc map[string]interface{}, supplementalDocs []map[string]interface{}) float64 {
|
||||||
|
if len(supplementalDocs) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
deviceNames := redfishPCIeSupplementalDeviceNames(doc)
|
||||||
|
if len(deviceNames) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
for _, supplemental := range supplementalDocs {
|
||||||
|
readings, ok := supplemental["TemperatureReadingsCelsius"].([]interface{})
|
||||||
|
if !ok || len(readings) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, readingAny := range readings {
|
||||||
|
reading, ok := readingAny.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
deviceName := strings.TrimSpace(asString(reading["DeviceName"]))
|
||||||
|
if deviceName == "" || !matchesAnyFold(deviceName, deviceNames) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if value := asFloat(reading["Reading"]); value != 0 {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func redfishPCIeSupplementalDeviceNames(doc map[string]interface{}) []string {
|
||||||
|
names := make([]string, 0, 3)
|
||||||
|
for _, raw := range []string{
|
||||||
|
asString(doc["Id"]),
|
||||||
|
asString(doc["Name"]),
|
||||||
|
asString(doc["Model"]),
|
||||||
|
} {
|
||||||
|
name := strings.TrimSpace(raw)
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !matchesAnyFold(name, names) {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchesAnyFold(value string, candidates []string) bool {
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(value), strings.TrimSpace(candidate)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func looksLikeNVSwitchPCIeDoc(doc map[string]interface{}) bool {
|
||||||
|
joined := strings.ToLower(strings.TrimSpace(strings.Join([]string{
|
||||||
|
asString(doc["Id"]),
|
||||||
|
asString(doc["Name"]),
|
||||||
|
asString(doc["Model"]),
|
||||||
|
asString(doc["Manufacturer"]),
|
||||||
|
}, " ")))
|
||||||
|
return strings.Contains(joined, "nvswitch")
|
||||||
|
}
|
||||||
|
|
||||||
|
func chassisPathForPCIeDoc(docPath string) string {
|
||||||
|
docPath = normalizeRedfishPath(docPath)
|
||||||
|
if !strings.Contains(docPath, "/PCIeDevices/") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
idx := strings.Index(docPath, "/PCIeDevices/")
|
||||||
|
if idx <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
chassisPath := docPath[:idx]
|
||||||
|
if !strings.HasPrefix(chassisPath, "/redfish/v1/Chassis/") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return chassisPath
|
||||||
|
}
|
||||||
|
|
||||||
func redfishFirstNumeric(doc map[string]interface{}, keys ...string) float64 {
|
func redfishFirstNumeric(doc map[string]interface{}, keys ...string) float64 {
|
||||||
for _, key := range keys {
|
for _, key := range keys {
|
||||||
if v, ok := redfishLookupValue(doc, key); ok {
|
if v, ok := redfishLookupValue(doc, key); ok {
|
||||||
@@ -3392,7 +3889,7 @@ func parseGPUWithSupplementalDocs(doc map[string]interface{}, functionDocs []map
|
|||||||
Details: redfishPCIeDetailsWithSupplementalDocs(doc, functionDocs, supplementalDocs),
|
Details: redfishPCIeDetailsWithSupplementalDocs(doc, functionDocs, supplementalDocs),
|
||||||
}
|
}
|
||||||
|
|
||||||
if bdf := asString(doc["BDF"]); bdf != "" {
|
if bdf := sanitizeRedfishBDF(asString(doc["BDF"])); bdf != "" {
|
||||||
gpu.BDF = bdf
|
gpu.BDF = bdf
|
||||||
}
|
}
|
||||||
if gpu.BDF == "" {
|
if gpu.BDF == "" {
|
||||||
@@ -3407,7 +3904,7 @@ func parseGPUWithSupplementalDocs(doc map[string]interface{}, functionDocs []map
|
|||||||
|
|
||||||
for _, fn := range functionDocs {
|
for _, fn := range functionDocs {
|
||||||
if gpu.BDF == "" {
|
if gpu.BDF == "" {
|
||||||
gpu.BDF = asString(fn["FunctionId"])
|
gpu.BDF = sanitizeRedfishBDF(asString(fn["FunctionId"]))
|
||||||
}
|
}
|
||||||
if gpu.VendorID == 0 {
|
if gpu.VendorID == 0 {
|
||||||
gpu.VendorID = asHexOrInt(fn["VendorId"])
|
gpu.VendorID = asHexOrInt(fn["VendorId"])
|
||||||
@@ -3448,7 +3945,7 @@ 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 {
|
func parsePCIeDeviceWithSupplementalDocs(doc map[string]interface{}, functionDocs []map[string]interface{}, supplementalDocs []map[string]interface{}) models.PCIeDevice {
|
||||||
dev := models.PCIeDevice{
|
dev := models.PCIeDevice{
|
||||||
Slot: firstNonEmpty(redfishLocationLabel(doc["Slot"]), redfishLocationLabel(doc["Location"]), asString(doc["Name"]), asString(doc["Id"])),
|
Slot: firstNonEmpty(redfishLocationLabel(doc["Slot"]), redfishLocationLabel(doc["Location"]), asString(doc["Name"]), asString(doc["Id"])),
|
||||||
BDF: asString(doc["BDF"]),
|
BDF: sanitizeRedfishBDF(asString(doc["BDF"])),
|
||||||
DeviceClass: asString(doc["DeviceType"]),
|
DeviceClass: asString(doc["DeviceType"]),
|
||||||
Manufacturer: asString(doc["Manufacturer"]),
|
Manufacturer: asString(doc["Manufacturer"]),
|
||||||
PartNumber: asString(doc["PartNumber"]),
|
PartNumber: asString(doc["PartNumber"]),
|
||||||
@@ -3463,7 +3960,7 @@ func parsePCIeDeviceWithSupplementalDocs(doc map[string]interface{}, functionDoc
|
|||||||
|
|
||||||
for _, fn := range functionDocs {
|
for _, fn := range functionDocs {
|
||||||
if dev.BDF == "" {
|
if dev.BDF == "" {
|
||||||
dev.BDF = asString(fn["FunctionId"])
|
dev.BDF = sanitizeRedfishBDF(asString(fn["FunctionId"]))
|
||||||
}
|
}
|
||||||
if dev.DeviceClass == "" || isGenericPCIeClassLabel(dev.DeviceClass) {
|
if dev.DeviceClass == "" || isGenericPCIeClassLabel(dev.DeviceClass) {
|
||||||
dev.DeviceClass = firstNonEmpty(asString(fn["DeviceClass"]), asString(fn["ClassCode"]))
|
dev.DeviceClass = firstNonEmpty(asString(fn["DeviceClass"]), asString(fn["ClassCode"]))
|
||||||
@@ -3522,7 +4019,7 @@ func parsePCIeFunctionWithSupplementalDocs(doc map[string]interface{}, supplemen
|
|||||||
|
|
||||||
dev := models.PCIeDevice{
|
dev := models.PCIeDevice{
|
||||||
Slot: slot,
|
Slot: slot,
|
||||||
BDF: asString(doc["FunctionId"]),
|
BDF: sanitizeRedfishBDF(asString(doc["BDF"])),
|
||||||
VendorID: asHexOrInt(doc["VendorId"]),
|
VendorID: asHexOrInt(doc["VendorId"]),
|
||||||
DeviceID: asHexOrInt(doc["DeviceId"]),
|
DeviceID: asHexOrInt(doc["DeviceId"]),
|
||||||
DeviceClass: firstNonEmpty(asString(doc["DeviceClass"]), asString(doc["ClassCode"]), "PCIe device"),
|
DeviceClass: firstNonEmpty(asString(doc["DeviceClass"]), asString(doc["ClassCode"]), "PCIe device"),
|
||||||
@@ -3534,6 +4031,9 @@ func parsePCIeFunctionWithSupplementalDocs(doc map[string]interface{}, supplemen
|
|||||||
MaxLinkSpeed: firstNonEmpty(asString(doc["MaxLinkSpeedGTs"]), asString(doc["MaxLinkSpeed"])),
|
MaxLinkSpeed: firstNonEmpty(asString(doc["MaxLinkSpeedGTs"]), asString(doc["MaxLinkSpeed"])),
|
||||||
Details: redfishPCIeDetailsWithSupplementalDocs(doc, nil, supplementalDocs),
|
Details: redfishPCIeDetailsWithSupplementalDocs(doc, nil, supplementalDocs),
|
||||||
}
|
}
|
||||||
|
if dev.BDF == "" {
|
||||||
|
dev.BDF = firstNonEmpty(buildBDFfromOemPublic(doc), sanitizeRedfishBDF(asString(doc["FunctionId"])))
|
||||||
|
}
|
||||||
if isGenericPCIeClassLabel(dev.DeviceClass) {
|
if isGenericPCIeClassLabel(dev.DeviceClass) {
|
||||||
if resolved := pciids.DeviceName(dev.VendorID, dev.DeviceID); resolved != "" {
|
if resolved := pciids.DeviceName(dev.VendorID, dev.DeviceID); resolved != "" {
|
||||||
dev.DeviceClass = resolved
|
dev.DeviceClass = resolved
|
||||||
@@ -3951,7 +4451,24 @@ func isVirtualStorageDrive(doc map[string]interface{}) bool {
|
|||||||
}
|
}
|
||||||
mfr := strings.ToUpper(strings.TrimSpace(asString(doc["Manufacturer"])))
|
mfr := strings.ToUpper(strings.TrimSpace(asString(doc["Manufacturer"])))
|
||||||
model := strings.ToUpper(strings.TrimSpace(asString(doc["Model"])))
|
model := strings.ToUpper(strings.TrimSpace(asString(doc["Model"])))
|
||||||
if strings.Contains(mfr, "AMI") && strings.Contains(model, "VIRTUAL") {
|
name := strings.ToUpper(strings.TrimSpace(asString(doc["Name"])))
|
||||||
|
joined := strings.Join([]string{mfr, model, name}, " ")
|
||||||
|
if strings.Contains(mfr, "AMI") && strings.Contains(joined, "VIRTUAL") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, marker := range []string{
|
||||||
|
"VIRTUAL CDROM",
|
||||||
|
"VIRTUAL CD/DVD",
|
||||||
|
"VIRTUAL FLOPPY",
|
||||||
|
"VIRTUAL FDD",
|
||||||
|
"VIRTUAL USB",
|
||||||
|
"VIRTUAL MEDIA",
|
||||||
|
} {
|
||||||
|
if strings.Contains(joined, marker) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(mfr, "AMERICAN MEGATRENDS") && (strings.Contains(joined, "CDROM") || strings.Contains(joined, "FLOPPY") || strings.Contains(joined, "FDD")) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -273,11 +273,7 @@ func (r redfishSnapshotReader) collectFirmwareInventory() []models.FirmwareInfo
|
|||||||
if strings.TrimSpace(version) == "" {
|
if strings.TrimSpace(version) == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
name := firstNonEmpty(
|
name := firmwareInventoryDeviceName(doc)
|
||||||
asString(doc["DeviceName"]),
|
|
||||||
asString(doc["Name"]),
|
|
||||||
asString(doc["Id"]),
|
|
||||||
)
|
|
||||||
name = strings.TrimSpace(name)
|
name = strings.TrimSpace(name)
|
||||||
if name == "" {
|
if name == "" {
|
||||||
continue
|
continue
|
||||||
@@ -291,6 +287,25 @@ func (r redfishSnapshotReader) collectFirmwareInventory() []models.FirmwareInfo
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func firmwareInventoryDeviceName(doc map[string]interface{}) string {
|
||||||
|
name := strings.TrimSpace(asString(doc["DeviceName"]))
|
||||||
|
if name != "" {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
id := strings.TrimSpace(asString(doc["Id"]))
|
||||||
|
rawName := strings.TrimSpace(asString(doc["Name"]))
|
||||||
|
if strings.EqualFold(rawName, "Software Inventory") || strings.EqualFold(rawName, "Firmware Inventory") {
|
||||||
|
if id != "" {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rawName != "" {
|
||||||
|
return rawName
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
func dedupeFirmwareInfo(items []models.FirmwareInfo) []models.FirmwareInfo {
|
func dedupeFirmwareInfo(items []models.FirmwareInfo) []models.FirmwareInfo {
|
||||||
seen := make(map[string]struct{}, len(items))
|
seen := make(map[string]struct{}, len(items))
|
||||||
out := make([]models.FirmwareInfo, 0, len(items))
|
out := make([]models.FirmwareInfo, 0, len(items))
|
||||||
@@ -1094,6 +1109,9 @@ func (r redfishSnapshotReader) collectStorage(systemPath string) []models.Storag
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if looksLikeDrive(member) {
|
if looksLikeDrive(member) {
|
||||||
|
if isVirtualStorageDrive(member) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
supplementalDocs := r.getLinkedSupplementalDocs(member, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
supplementalDocs := r.getLinkedSupplementalDocs(member, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
||||||
out = append(out, parseDriveWithSupplementalDocs(member, supplementalDocs...))
|
out = append(out, parseDriveWithSupplementalDocs(member, supplementalDocs...))
|
||||||
}
|
}
|
||||||
@@ -1102,13 +1120,16 @@ func (r redfishSnapshotReader) collectStorage(systemPath string) []models.Storag
|
|||||||
driveDocs, err := r.getCollectionMembers(joinPath(enclosurePath, "/Drives"))
|
driveDocs, err := r.getCollectionMembers(joinPath(enclosurePath, "/Drives"))
|
||||||
if err == nil {
|
if err == nil {
|
||||||
for _, driveDoc := range driveDocs {
|
for _, driveDoc := range driveDocs {
|
||||||
if looksLikeDrive(driveDoc) {
|
if looksLikeDrive(driveDoc) && !isVirtualStorageDrive(driveDoc) {
|
||||||
supplementalDocs := r.getLinkedSupplementalDocs(driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
supplementalDocs := r.getLinkedSupplementalDocs(driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
||||||
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
|
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(driveDocs) == 0 {
|
if len(driveDocs) == 0 {
|
||||||
for _, driveDoc := range r.probeDirectDiskBayChildren(joinPath(enclosurePath, "/Drives")) {
|
for _, driveDoc := range r.probeDirectDiskBayChildren(joinPath(enclosurePath, "/Drives")) {
|
||||||
|
if isVirtualStorageDrive(driveDoc) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
out = append(out, parseDrive(driveDoc))
|
out = append(out, parseDrive(driveDoc))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1120,7 +1141,7 @@ func (r redfishSnapshotReader) collectStorage(systemPath string) []models.Storag
|
|||||||
"/Storage/IntelVROC/Drives",
|
"/Storage/IntelVROC/Drives",
|
||||||
"/Storage/IntelVROC/Controllers/1/Drives",
|
"/Storage/IntelVROC/Controllers/1/Drives",
|
||||||
}) {
|
}) {
|
||||||
if looksLikeDrive(driveDoc) {
|
if looksLikeDrive(driveDoc) && !isVirtualStorageDrive(driveDoc) {
|
||||||
supplementalDocs := r.getLinkedSupplementalDocs(driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
supplementalDocs := r.getLinkedSupplementalDocs(driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
||||||
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
|
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
|
||||||
}
|
}
|
||||||
@@ -1134,7 +1155,7 @@ func (r redfishSnapshotReader) collectStorage(systemPath string) []models.Storag
|
|||||||
}
|
}
|
||||||
for _, devAny := range devices {
|
for _, devAny := range devices {
|
||||||
devDoc, ok := devAny.(map[string]interface{})
|
devDoc, ok := devAny.(map[string]interface{})
|
||||||
if !ok || !looksLikeDrive(devDoc) {
|
if !ok || !looksLikeDrive(devDoc) || isVirtualStorageDrive(devDoc) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
out = append(out, parseDrive(devDoc))
|
out = append(out, parseDrive(devDoc))
|
||||||
@@ -1148,7 +1169,7 @@ func (r redfishSnapshotReader) collectStorage(systemPath string) []models.Storag
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, driveDoc := range driveDocs {
|
for _, driveDoc := range driveDocs {
|
||||||
if !looksLikeDrive(driveDoc) {
|
if !looksLikeDrive(driveDoc) || isVirtualStorageDrive(driveDoc) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
out = append(out, parseDrive(driveDoc))
|
out = append(out, parseDrive(driveDoc))
|
||||||
@@ -1159,7 +1180,7 @@ func (r redfishSnapshotReader) collectStorage(systemPath string) []models.Storag
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, driveDoc := range r.probeSupermicroNVMeDiskBays(chassisPath) {
|
for _, driveDoc := range r.probeSupermicroNVMeDiskBays(chassisPath) {
|
||||||
if !looksLikeDrive(driveDoc) {
|
if !looksLikeDrive(driveDoc) || isVirtualStorageDrive(driveDoc) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
out = append(out, parseDrive(driveDoc))
|
out = append(out, parseDrive(driveDoc))
|
||||||
@@ -1353,6 +1374,7 @@ func (r redfishSnapshotReader) collectPCIeDevices(systemPaths, chassisPaths []st
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
supplementalDocs := r.getLinkedSupplementalDocs(doc, "EnvironmentMetrics", "Metrics")
|
supplementalDocs := r.getLinkedSupplementalDocs(doc, "EnvironmentMetrics", "Metrics")
|
||||||
|
supplementalDocs = append(supplementalDocs, r.getChassisScopedPCIeSupplementalDocs(doc)...)
|
||||||
for _, fn := range functionDocs {
|
for _, fn := range functionDocs {
|
||||||
supplementalDocs = append(supplementalDocs, r.getLinkedSupplementalDocs(fn, "EnvironmentMetrics", "Metrics")...)
|
supplementalDocs = append(supplementalDocs, r.getLinkedSupplementalDocs(fn, "EnvironmentMetrics", "Metrics")...)
|
||||||
}
|
}
|
||||||
@@ -1377,6 +1399,29 @@ func (r redfishSnapshotReader) collectPCIeDevices(systemPaths, chassisPaths []st
|
|||||||
return dedupePCIeDevices(out)
|
return dedupePCIeDevices(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r redfishSnapshotReader) getChassisScopedPCIeSupplementalDocs(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)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func stringsTrimTrailingSlash(s string) string {
|
func stringsTrimTrailingSlash(s string) string {
|
||||||
for len(s) > 1 && s[len(s)-1] == '/' {
|
for len(s) > 1 && s[len(s)-1] == '/' {
|
||||||
s = s[:len(s)-1]
|
s = s[:len(s)-1]
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -206,6 +207,177 @@ func TestRedfishConnectorCollect(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRedfishConnectorProbe(t *testing.T) {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
register := func(path string, payload interface{}) {
|
||||||
|
mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(payload)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
register("/redfish/v1", map[string]interface{}{"Name": "ServiceRoot"})
|
||||||
|
register("/redfish/v1/Systems/1", map[string]interface{}{
|
||||||
|
"@odata.id": "/redfish/v1/Systems/1",
|
||||||
|
"PowerState": "Off",
|
||||||
|
"Actions": map[string]interface{}{
|
||||||
|
"#ComputerSystem.Reset": map[string]interface{}{
|
||||||
|
"target": "/redfish/v1/Systems/1/Actions/ComputerSystem.Reset",
|
||||||
|
"ResetType@Redfish.AllowableValues": []interface{}{"On", "ForceOff"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
ts := httptest.NewTLSServer(mux)
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
connector := NewRedfishConnector()
|
||||||
|
port := 443
|
||||||
|
host := ""
|
||||||
|
if u, err := url.Parse(ts.URL); err == nil {
|
||||||
|
host = u.Hostname()
|
||||||
|
if p := u.Port(); p != "" {
|
||||||
|
fmt.Sscanf(p, "%d", &port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
got, err := connector.Probe(context.Background(), Request{
|
||||||
|
Host: host,
|
||||||
|
Protocol: "redfish",
|
||||||
|
Port: port,
|
||||||
|
Username: "admin",
|
||||||
|
AuthType: "password",
|
||||||
|
Password: "secret",
|
||||||
|
TLSMode: "insecure",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("probe failed: %v", err)
|
||||||
|
}
|
||||||
|
if got == nil || !got.Reachable {
|
||||||
|
t.Fatalf("expected reachable probe result, got %+v", got)
|
||||||
|
}
|
||||||
|
if got.HostPoweredOn {
|
||||||
|
t.Fatalf("expected powered off host")
|
||||||
|
}
|
||||||
|
if got.HostPowerState != "Off" {
|
||||||
|
t.Fatalf("expected power state Off, got %q", got.HostPowerState)
|
||||||
|
}
|
||||||
|
if !got.PowerControlAvailable {
|
||||||
|
t.Fatalf("expected power control available")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureHostPowerForCollection_WaitsForStablePowerOn(t *testing.T) {
|
||||||
|
t.Setenv("LOGPILE_REDFISH_POWERON_STABILIZATION", "1ms")
|
||||||
|
|
||||||
|
powerState := "Off"
|
||||||
|
resetCalls := 0
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/redfish/v1/Systems/1", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"@odata.id": "/redfish/v1/Systems/1",
|
||||||
|
"PowerState": powerState,
|
||||||
|
"Actions": map[string]interface{}{
|
||||||
|
"#ComputerSystem.Reset": map[string]interface{}{
|
||||||
|
"target": "/redfish/v1/Systems/1/Actions/ComputerSystem.Reset",
|
||||||
|
"ResetType@Redfish.AllowableValues": []interface{}{"On"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/redfish/v1/Systems/1/Actions/ComputerSystem.Reset", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
resetCalls++
|
||||||
|
powerState = "On"
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
ts := httptest.NewTLSServer(mux)
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
u, err := url.Parse(ts.URL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse server url: %v", err)
|
||||||
|
}
|
||||||
|
port := 443
|
||||||
|
if u.Port() != "" {
|
||||||
|
fmt.Sscanf(u.Port(), "%d", &port)
|
||||||
|
}
|
||||||
|
|
||||||
|
c := NewRedfishConnector()
|
||||||
|
hostOn, changed := c.ensureHostPowerForCollection(context.Background(), c.httpClientWithTimeout(Request{TLSMode: "insecure"}, 5*time.Second), Request{
|
||||||
|
Host: u.Hostname(),
|
||||||
|
Protocol: "redfish",
|
||||||
|
Port: port,
|
||||||
|
Username: "admin",
|
||||||
|
AuthType: "password",
|
||||||
|
Password: "secret",
|
||||||
|
TLSMode: "insecure",
|
||||||
|
PowerOnIfHostOff: true,
|
||||||
|
}, ts.URL, "/redfish/v1/Systems/1", nil)
|
||||||
|
if !hostOn || !changed {
|
||||||
|
t.Fatalf("expected stable power-on result, got hostOn=%v changed=%v", hostOn, changed)
|
||||||
|
}
|
||||||
|
if resetCalls != 1 {
|
||||||
|
t.Fatalf("expected one reset call, got %d", resetCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureHostPowerForCollection_FailsIfHostDoesNotStayOnAfterStabilization(t *testing.T) {
|
||||||
|
t.Setenv("LOGPILE_REDFISH_POWERON_STABILIZATION", "1ms")
|
||||||
|
|
||||||
|
powerState := "Off"
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/redfish/v1/Systems/1", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
current := powerState
|
||||||
|
if powerState == "On" {
|
||||||
|
powerState = "Off"
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"@odata.id": "/redfish/v1/Systems/1",
|
||||||
|
"PowerState": current,
|
||||||
|
"Actions": map[string]interface{}{
|
||||||
|
"#ComputerSystem.Reset": map[string]interface{}{
|
||||||
|
"target": "/redfish/v1/Systems/1/Actions/ComputerSystem.Reset",
|
||||||
|
"ResetType@Redfish.AllowableValues": []interface{}{"On"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/redfish/v1/Systems/1/Actions/ComputerSystem.Reset", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
powerState = "On"
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
ts := httptest.NewTLSServer(mux)
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
u, err := url.Parse(ts.URL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse server url: %v", err)
|
||||||
|
}
|
||||||
|
port := 443
|
||||||
|
if u.Port() != "" {
|
||||||
|
fmt.Sscanf(u.Port(), "%d", &port)
|
||||||
|
}
|
||||||
|
|
||||||
|
c := NewRedfishConnector()
|
||||||
|
hostOn, changed := c.ensureHostPowerForCollection(context.Background(), c.httpClientWithTimeout(Request{TLSMode: "insecure"}, 5*time.Second), Request{
|
||||||
|
Host: u.Hostname(),
|
||||||
|
Protocol: "redfish",
|
||||||
|
Port: port,
|
||||||
|
Username: "admin",
|
||||||
|
AuthType: "password",
|
||||||
|
Password: "secret",
|
||||||
|
TLSMode: "insecure",
|
||||||
|
PowerOnIfHostOff: true,
|
||||||
|
}, ts.URL, "/redfish/v1/Systems/1", nil)
|
||||||
|
if hostOn || changed {
|
||||||
|
t.Fatalf("expected unstable power-on result to fail, got hostOn=%v changed=%v", hostOn, changed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestParsePCIeDeviceSlot_FromNestedRedfishSlotLocation(t *testing.T) {
|
func TestParsePCIeDeviceSlot_FromNestedRedfishSlotLocation(t *testing.T) {
|
||||||
doc := map[string]interface{}{
|
doc := map[string]interface{}{
|
||||||
"Id": "NIC1",
|
"Id": "NIC1",
|
||||||
@@ -629,6 +801,42 @@ func TestParseNIC_PortCountFromControllerCapabilities(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseNIC_PrefersControllerSlotLabelAndPCIeInterface(t *testing.T) {
|
||||||
|
nic := parseNIC(map[string]interface{}{
|
||||||
|
"Id": "1",
|
||||||
|
"Model": "MCX75310AAS-NEAT",
|
||||||
|
"Manufacturer": "Supermicro",
|
||||||
|
"Controllers": []interface{}{
|
||||||
|
map[string]interface{}{
|
||||||
|
"Location": map[string]interface{}{
|
||||||
|
"PartLocation": map[string]interface{}{
|
||||||
|
"ServiceLabel": "PCIe Slot 1 (1)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"PCIeInterface": map[string]interface{}{
|
||||||
|
"LanesInUse": 16,
|
||||||
|
"MaxLanes": 16,
|
||||||
|
"PCIeType": "Gen5",
|
||||||
|
"MaxPCIeType": "Gen5",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if nic.Slot != "PCIe Slot 1 (1)" {
|
||||||
|
t.Fatalf("expected slot from controller location, got %q", nic.Slot)
|
||||||
|
}
|
||||||
|
if nic.Location != "PCIe Slot 1 (1)" {
|
||||||
|
t.Fatalf("expected location from controller location, got %q", nic.Location)
|
||||||
|
}
|
||||||
|
if nic.LinkWidth != 16 || nic.MaxLinkWidth != 16 {
|
||||||
|
t.Fatalf("expected link widths from controller PCIeInterface, got current=%d max=%d", nic.LinkWidth, nic.MaxLinkWidth)
|
||||||
|
}
|
||||||
|
if nic.LinkSpeed != "Gen5" || nic.MaxLinkSpeed != "Gen5" {
|
||||||
|
t.Fatalf("expected link speeds from controller PCIeInterface, got current=%q max=%q", nic.LinkSpeed, nic.MaxLinkSpeed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseNIC_DropsUnrealisticPortCount(t *testing.T) {
|
func TestParseNIC_DropsUnrealisticPortCount(t *testing.T) {
|
||||||
nic := parseNIC(map[string]interface{}{
|
nic := parseNIC(map[string]interface{}{
|
||||||
"Id": "1",
|
"Id": "1",
|
||||||
@@ -670,6 +878,48 @@ func TestParsePCIeDevice_PrefersFunctionClassOverDeviceType(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParsePCIeComponents_DoNotTreatNumericFunctionIDAsBDF(t *testing.T) {
|
||||||
|
pcieFn := parsePCIeFunction(map[string]interface{}{
|
||||||
|
"Id": "1",
|
||||||
|
"FunctionId": "1",
|
||||||
|
"DeviceClass": "NetworkController",
|
||||||
|
"VendorId": "0x15b3",
|
||||||
|
"DeviceId": "0x1021",
|
||||||
|
}, 1)
|
||||||
|
if pcieFn.BDF != "" {
|
||||||
|
t.Fatalf("expected empty BDF for numeric FunctionId, got %q", pcieFn.BDF)
|
||||||
|
}
|
||||||
|
|
||||||
|
gpu := parseGPU(map[string]interface{}{
|
||||||
|
"Id": "GPU1",
|
||||||
|
"Name": "GPU1",
|
||||||
|
"PCIeFunctions": map[string]interface{}{
|
||||||
|
"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/GPU1/PCIeFunctions",
|
||||||
|
},
|
||||||
|
}, []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"FunctionId": "1",
|
||||||
|
"VendorId": "0x10de",
|
||||||
|
"DeviceId": "0x2331",
|
||||||
|
},
|
||||||
|
}, 1)
|
||||||
|
if gpu.BDF != "" {
|
||||||
|
t.Fatalf("expected GPU BDF to stay empty when only numeric FunctionId exists, got %q", gpu.BDF)
|
||||||
|
}
|
||||||
|
|
||||||
|
nic := parseNIC(map[string]interface{}{"Id": "1"})
|
||||||
|
enrichNICFromPCIe(&nic, map[string]interface{}{}, []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"FunctionId": "1",
|
||||||
|
"VendorId": "0x15b3",
|
||||||
|
"DeviceId": "0x1021",
|
||||||
|
},
|
||||||
|
}, nil)
|
||||||
|
if nic.BDF != "" {
|
||||||
|
t.Fatalf("expected NIC BDF to stay empty when only numeric FunctionId exists, got %q", nic.BDF)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseComponents_UseNestedSerialNumberFallback(t *testing.T) {
|
func TestParseComponents_UseNestedSerialNumberFallback(t *testing.T) {
|
||||||
doc := map[string]interface{}{
|
doc := map[string]interface{}{
|
||||||
"Name": "dev0",
|
"Name": "dev0",
|
||||||
@@ -2206,6 +2456,48 @@ func TestLooksLikeGPU_NVSwitchExcluded(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFirmwareInventoryDeviceName_PrefersIDForGenericSoftwareInventory(t *testing.T) {
|
||||||
|
doc := map[string]interface{}{
|
||||||
|
"Id": "HGX_FW_NVSwitch_0",
|
||||||
|
"Name": "Software Inventory",
|
||||||
|
"Version": "96.10.73.00.01",
|
||||||
|
}
|
||||||
|
|
||||||
|
got := firmwareInventoryDeviceName(doc)
|
||||||
|
if got != "HGX_FW_NVSwitch_0" {
|
||||||
|
t.Fatalf("expected firmware inventory id to be used, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParsePCIeDeviceWithSupplementalDocs_NVSwitchThermalMetrics(t *testing.T) {
|
||||||
|
doc := map[string]interface{}{
|
||||||
|
"@odata.id": "/redfish/v1/Chassis/HGX_NVSwitch_0/PCIeDevices/NVSwitch_0",
|
||||||
|
"Id": "NVSwitch_0",
|
||||||
|
"Model": "NVSwitch",
|
||||||
|
"Manufacturer": "NVIDIA",
|
||||||
|
"DeviceType": "SingleFunction",
|
||||||
|
}
|
||||||
|
|
||||||
|
supplementalDocs := []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"TemperatureReadingsCelsius": []interface{}{
|
||||||
|
map[string]interface{}{
|
||||||
|
"DeviceName": "NVSwitch_0",
|
||||||
|
"Reading": "31.593750",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := parsePCIeDeviceWithSupplementalDocs(doc, nil, supplementalDocs)
|
||||||
|
if got.Details == nil {
|
||||||
|
t.Fatalf("expected NVSwitch details to be populated")
|
||||||
|
}
|
||||||
|
if temp := got.Details["temperature_c"]; temp != 31.59375 {
|
||||||
|
t.Fatalf("expected NVSwitch thermal metric, got %#v", got.Details)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestShouldCrawlPath_MemoryAndProcessorMetricsAreAllowed(t *testing.T) {
|
func TestShouldCrawlPath_MemoryAndProcessorMetricsAreAllowed(t *testing.T) {
|
||||||
if !shouldCrawlPath("/redfish/v1/Systems/1/Memory/CPU0_C0D0") {
|
if !shouldCrawlPath("/redfish/v1/Systems/1/Memory/CPU0_C0D0") {
|
||||||
t.Fatalf("expected direct DIMM resource to be crawlable")
|
t.Fatalf("expected direct DIMM resource to be crawlable")
|
||||||
@@ -2222,6 +2514,12 @@ func TestShouldCrawlPath_MemoryAndProcessorMetricsAreAllowed(t *testing.T) {
|
|||||||
if shouldCrawlPath("/redfish/v1/Chassis/1/PCIeDevices/0/PCIeFunctions/1") {
|
if shouldCrawlPath("/redfish/v1/Chassis/1/PCIeDevices/0/PCIeFunctions/1") {
|
||||||
t.Fatalf("expected noisy chassis pciefunctions branch to be skipped")
|
t.Fatalf("expected noisy chassis pciefunctions branch to be skipped")
|
||||||
}
|
}
|
||||||
|
if !shouldCrawlPath("/redfish/v1/Fabrics/HGX_NVLinkFabric_0/Switches/NVSwitch_0") {
|
||||||
|
t.Fatalf("expected NVSwitch fabric resource to be crawlable")
|
||||||
|
}
|
||||||
|
if !shouldCrawlPath("/redfish/v1/Fabrics/HGX_NVLinkFabric_0/Switches/NVSwitch_0/Ports/NVLink_0/Metrics") {
|
||||||
|
t.Fatalf("expected NVLink port metrics to be crawlable")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIsRedfishMemoryMemberPath(t *testing.T) {
|
func TestIsRedfishMemoryMemberPath(t *testing.T) {
|
||||||
@@ -2531,3 +2829,19 @@ func TestNVMePostProbeSkipsNonStorageChassis(t *testing.T) {
|
|||||||
t.Fatalf("expected StorageBackplane to be selected, got %q", selected[0])
|
t.Fatalf("expected StorageBackplane to be selected, got %q", selected[0])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIsVirtualStorageDrive_AMIVirtualMedia(t *testing.T) {
|
||||||
|
doc := map[string]interface{}{
|
||||||
|
"Id": "USB_Device1_Port4",
|
||||||
|
"Name": "Virtual Cdrom Device",
|
||||||
|
"Model": "Virtual Cdrom Device",
|
||||||
|
"Manufacturer": "American Megatrends Inc.",
|
||||||
|
"Protocol": "USB",
|
||||||
|
"CapacityBytes": 0,
|
||||||
|
"SerialNumber": "AAAABBBBCCCC1",
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isVirtualStorageDrive(doc) {
|
||||||
|
t.Fatalf("expected AMI virtual media to be filtered")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ type Request struct {
|
|||||||
Password string
|
Password string
|
||||||
Token string
|
Token string
|
||||||
TLSMode string
|
TLSMode string
|
||||||
|
PowerOnIfHostOff bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type Progress struct {
|
type Progress struct {
|
||||||
@@ -25,7 +26,20 @@ type Progress struct {
|
|||||||
|
|
||||||
type ProgressFn func(Progress)
|
type ProgressFn func(Progress)
|
||||||
|
|
||||||
|
type ProbeResult struct {
|
||||||
|
Reachable bool
|
||||||
|
Protocol string
|
||||||
|
HostPowerState string
|
||||||
|
HostPoweredOn bool
|
||||||
|
PowerControlAvailable bool
|
||||||
|
SystemPath string
|
||||||
|
}
|
||||||
|
|
||||||
type Connector interface {
|
type Connector interface {
|
||||||
Protocol() string
|
Protocol() string
|
||||||
Collect(ctx context.Context, req Request, emit ProgressFn) (*models.AnalysisResult, error)
|
Collect(ctx context.Context, req Request, emit ProgressFn) (*models.AnalysisResult, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Prober interface {
|
||||||
|
Probe(ctx context.Context, req Request) (*ProbeResult, error)
|
||||||
|
}
|
||||||
|
|||||||
@@ -45,12 +45,13 @@ func ConvertToReanimator(result *models.AnalysisResult) (*ReanimatorExport, erro
|
|||||||
Hardware: ReanimatorHardware{
|
Hardware: ReanimatorHardware{
|
||||||
Board: convertBoard(result.Hardware.BoardInfo),
|
Board: convertBoard(result.Hardware.BoardInfo),
|
||||||
Firmware: dedupeFirmware(convertFirmware(result.Hardware.Firmware)),
|
Firmware: dedupeFirmware(convertFirmware(result.Hardware.Firmware)),
|
||||||
CPUs: convertCPUsFromDevices(devices, collectedAt, result.Hardware.BoardInfo.SerialNumber, buildCPUMicrocodeBySocket(result.Hardware.Firmware)),
|
CPUs: dedupeCPUs(convertCPUsFromDevices(devices, collectedAt, result.Hardware.BoardInfo.SerialNumber, buildCPUMicrocodeBySocket(result.Hardware.Firmware))),
|
||||||
Memory: convertMemoryFromDevices(devices, collectedAt),
|
Memory: dedupeMemory(convertMemoryFromDevices(devices, collectedAt)),
|
||||||
Storage: convertStorageFromDevices(devices, collectedAt),
|
Storage: dedupeStorage(convertStorageFromDevices(devices, collectedAt)),
|
||||||
PCIeDevices: convertPCIeFromDevices(devices, collectedAt, result.Hardware.BoardInfo.SerialNumber),
|
PCIeDevices: dedupePCIe(convertPCIeFromDevices(devices, collectedAt)),
|
||||||
PowerSupplies: convertPSUsFromDevices(devices, collectedAt),
|
PowerSupplies: dedupePSUs(convertPSUsFromDevices(devices, collectedAt)),
|
||||||
Sensors: convertSensors(result.Sensors),
|
Sensors: convertSensors(result.Sensors),
|
||||||
|
EventLogs: convertEventLogs(result.Events, collectedAt),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,6 +92,7 @@ func buildDevicesFromLegacy(hw *models.HardwareConfig) []models.HardwareDevice {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
all := make([]models.HardwareDevice, 0, len(hw.CPUs)+len(hw.Memory)+len(hw.Storage)+len(hw.PCIeDevices)+len(hw.GPUs)+len(hw.NetworkAdapters)+len(hw.PowerSupply))
|
all := make([]models.HardwareDevice, 0, len(hw.CPUs)+len(hw.Memory)+len(hw.Storage)+len(hw.PCIeDevices)+len(hw.GPUs)+len(hw.NetworkAdapters)+len(hw.PowerSupply))
|
||||||
|
nvswitchFirmwareBySlot := buildNVSwitchFirmwareBySlot(hw.Firmware)
|
||||||
appendDevice := func(d models.HardwareDevice) {
|
appendDevice := func(d models.HardwareDevice) {
|
||||||
all = append(all, d)
|
all = append(all, d)
|
||||||
}
|
}
|
||||||
@@ -175,6 +177,16 @@ func buildDevicesFromLegacy(hw *models.HardwareConfig) []models.HardwareDevice {
|
|||||||
if pcieModel == "" {
|
if pcieModel == "" {
|
||||||
pcieModel = pcie.Description
|
pcieModel = pcie.Description
|
||||||
}
|
}
|
||||||
|
details := mergeDetailMaps(nil, pcie.Details)
|
||||||
|
pcieFirmware := stringFromDetailMap(details, "firmware")
|
||||||
|
if pcieFirmware == "" && isNVSwitchPCIeDevice(pcie) {
|
||||||
|
pcieFirmware = nvswitchFirmwareBySlot[normalizeNVSwitchSlotForLookup(pcie.Slot)]
|
||||||
|
if pcieFirmware != "" {
|
||||||
|
details = mergeDetailMaps(details, map[string]any{
|
||||||
|
"firmware": pcieFirmware,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
appendDevice(models.HardwareDevice{
|
appendDevice(models.HardwareDevice{
|
||||||
Kind: models.DeviceKindPCIe,
|
Kind: models.DeviceKindPCIe,
|
||||||
Slot: pcie.Slot,
|
Slot: pcie.Slot,
|
||||||
@@ -197,7 +209,7 @@ func buildDevicesFromLegacy(hw *models.HardwareConfig) []models.HardwareDevice {
|
|||||||
StatusAtCollect: pcie.StatusAtCollect,
|
StatusAtCollect: pcie.StatusAtCollect,
|
||||||
StatusHistory: pcie.StatusHistory,
|
StatusHistory: pcie.StatusHistory,
|
||||||
ErrorDescription: pcie.ErrorDescription,
|
ErrorDescription: pcie.ErrorDescription,
|
||||||
Details: mergeDetailMaps(nil, pcie.Details),
|
Details: details,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
for _, gpu := range hw.GPUs {
|
for _, gpu := range hw.GPUs {
|
||||||
@@ -353,6 +365,7 @@ func dedupeCanonicalDevices(items []models.HardwareDevice) []models.HardwareDevi
|
|||||||
|
|
||||||
var unmatched []models.HardwareDevice
|
var unmatched []models.HardwareDevice
|
||||||
for _, item := range noKey {
|
for _, item := range noKey {
|
||||||
|
mergeKind := canonicalMergeKind(item.Kind)
|
||||||
identity := deviceIdentity(item)
|
identity := deviceIdentity(item)
|
||||||
mfr := strings.ToLower(strings.TrimSpace(item.Manufacturer))
|
mfr := strings.ToLower(strings.TrimSpace(item.Manufacturer))
|
||||||
if identity == "" {
|
if identity == "" {
|
||||||
@@ -363,7 +376,8 @@ func dedupeCanonicalDevices(items []models.HardwareDevice) []models.HardwareDevi
|
|||||||
matchCount := 0
|
matchCount := 0
|
||||||
for _, k := range order {
|
for _, k := range order {
|
||||||
existing := byKey[k].item
|
existing := byKey[k].item
|
||||||
if deviceIdentity(existing) == identity &&
|
if canonicalMergeKind(existing.Kind) == mergeKind &&
|
||||||
|
deviceIdentity(existing) == identity &&
|
||||||
strings.ToLower(strings.TrimSpace(existing.Manufacturer)) == mfr {
|
strings.ToLower(strings.TrimSpace(existing.Manufacturer)) == mfr {
|
||||||
matchKey = k
|
matchKey = k
|
||||||
matchCount++
|
matchCount++
|
||||||
@@ -507,32 +521,43 @@ func mergeDetailMaps(primary, secondary map[string]any) map[string]any {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func canonicalKey(item models.HardwareDevice) string {
|
func canonicalKey(item models.HardwareDevice) string {
|
||||||
|
kind := canonicalMergeKind(item.Kind)
|
||||||
if sn := normalizedSerial(item.SerialNumber); sn != "" {
|
if sn := normalizedSerial(item.SerialNumber); sn != "" {
|
||||||
return "sn:" + strings.ToLower(sn)
|
return kind + "|sn:" + strings.ToLower(sn)
|
||||||
}
|
}
|
||||||
if bdf := strings.ToLower(strings.TrimSpace(item.BDF)); bdf != "" {
|
if bdf := strings.ToLower(strings.TrimSpace(item.BDF)); bdf != "" {
|
||||||
return "bdf:" + bdf
|
return kind + "|bdf:" + bdf
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func canonicalLooseKey(item models.HardwareDevice) string {
|
func canonicalLooseKey(item models.HardwareDevice) string {
|
||||||
|
kind := canonicalMergeKind(item.Kind)
|
||||||
slot := strings.ToLower(strings.TrimSpace(item.Slot))
|
slot := strings.ToLower(strings.TrimSpace(item.Slot))
|
||||||
model := strings.ToLower(strings.TrimSpace(item.Model))
|
model := strings.ToLower(strings.TrimSpace(item.Model))
|
||||||
part := strings.ToLower(strings.TrimSpace(item.PartNumber))
|
part := strings.ToLower(strings.TrimSpace(item.PartNumber))
|
||||||
mfr := strings.ToLower(strings.TrimSpace(item.Manufacturer))
|
mfr := strings.ToLower(strings.TrimSpace(item.Manufacturer))
|
||||||
if item.VendorID != 0 && item.DeviceID != 0 && slot != "" {
|
if item.VendorID != 0 && item.DeviceID != 0 && slot != "" {
|
||||||
return fmt.Sprintf("slotid:%s|%d|%d", slot, item.VendorID, item.DeviceID)
|
return fmt.Sprintf("%s|slotid:%s|%d|%d", kind, slot, item.VendorID, item.DeviceID)
|
||||||
}
|
}
|
||||||
if slot != "" && model != "" && mfr != "" {
|
if slot != "" && model != "" && mfr != "" {
|
||||||
return "slotmodel:" + slot + "|" + model + "|" + mfr
|
return kind + "|slotmodel:" + slot + "|" + model + "|" + mfr
|
||||||
}
|
}
|
||||||
if slot != "" && part != "" && mfr != "" {
|
if slot != "" && part != "" && mfr != "" {
|
||||||
return "slotpart:" + slot + "|" + part + "|" + mfr
|
return kind + "|slotpart:" + slot + "|" + part + "|" + mfr
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func canonicalMergeKind(kind string) string {
|
||||||
|
switch kind {
|
||||||
|
case models.DeviceKindPCIe, models.DeviceKindGPU, models.DeviceKindNetwork:
|
||||||
|
return "pcie-class"
|
||||||
|
default:
|
||||||
|
return strings.TrimSpace(kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func canonicalScore(item models.HardwareDevice) int {
|
func canonicalScore(item models.HardwareDevice) int {
|
||||||
score := 0
|
score := 0
|
||||||
if normalizedSerial(item.SerialNumber) != "" {
|
if normalizedSerial(item.SerialNumber) != "" {
|
||||||
@@ -607,7 +632,7 @@ func convertCPUsFromDevices(devices []models.HardwareDevice, collectedAt, boardS
|
|||||||
UncorrectableErrorCount: int64FromDetailMap(d.Details, "uncorrectable_error_count"),
|
UncorrectableErrorCount: int64FromDetailMap(d.Details, "uncorrectable_error_count"),
|
||||||
LifeRemainingPct: floatFromDetailMap(d.Details, "life_remaining_pct"),
|
LifeRemainingPct: floatFromDetailMap(d.Details, "life_remaining_pct"),
|
||||||
LifeUsedPct: floatFromDetailMap(d.Details, "life_used_pct"),
|
LifeUsedPct: floatFromDetailMap(d.Details, "life_used_pct"),
|
||||||
SerialNumber: generatedCPUSerial(d.SerialNumber, boardSerial, socket),
|
SerialNumber: strings.TrimSpace(d.SerialNumber),
|
||||||
Firmware: firstNonEmptyString(
|
Firmware: firstNonEmptyString(
|
||||||
stringFromDetailMap(d.Details, "microcode"),
|
stringFromDetailMap(d.Details, "microcode"),
|
||||||
microcodeBySocket[socket],
|
microcodeBySocket[socket],
|
||||||
@@ -617,6 +642,7 @@ func convertCPUsFromDevices(devices []models.HardwareDevice, collectedAt, boardS
|
|||||||
Status: cpuStatus,
|
Status: cpuStatus,
|
||||||
StatusCheckedAt: meta.StatusCheckedAt,
|
StatusCheckedAt: meta.StatusCheckedAt,
|
||||||
StatusChangedAt: meta.StatusChangedAt,
|
StatusChangedAt: meta.StatusChangedAt,
|
||||||
|
ManufacturedYearWeek: manufacturedYearWeekFromDetails(d.Details),
|
||||||
StatusHistory: meta.StatusHistory,
|
StatusHistory: meta.StatusHistory,
|
||||||
ErrorDescription: meta.ErrorDescription,
|
ErrorDescription: meta.ErrorDescription,
|
||||||
})
|
})
|
||||||
@@ -657,6 +683,7 @@ func convertMemoryFromDevices(devices []models.HardwareDevice, collectedAt strin
|
|||||||
Status: status,
|
Status: status,
|
||||||
StatusCheckedAt: meta.StatusCheckedAt,
|
StatusCheckedAt: meta.StatusCheckedAt,
|
||||||
StatusChangedAt: meta.StatusChangedAt,
|
StatusChangedAt: meta.StatusChangedAt,
|
||||||
|
ManufacturedYearWeek: manufacturedYearWeekFromDetails(d.Details),
|
||||||
StatusHistory: meta.StatusHistory,
|
StatusHistory: meta.StatusHistory,
|
||||||
ErrorDescription: meta.ErrorDescription,
|
ErrorDescription: meta.ErrorDescription,
|
||||||
})
|
})
|
||||||
@@ -670,6 +697,9 @@ func convertStorageFromDevices(devices []models.HardwareDevice, collectedAt stri
|
|||||||
if d.Kind != models.DeviceKindStorage {
|
if d.Kind != models.DeviceKindStorage {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if isVirtualExportStorageDevice(d) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if strings.TrimSpace(d.SerialNumber) == "" {
|
if strings.TrimSpace(d.SerialNumber) == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -709,6 +739,7 @@ func convertStorageFromDevices(devices []models.HardwareDevice, collectedAt stri
|
|||||||
Status: status,
|
Status: status,
|
||||||
StatusCheckedAt: meta.StatusCheckedAt,
|
StatusCheckedAt: meta.StatusCheckedAt,
|
||||||
StatusChangedAt: meta.StatusChangedAt,
|
StatusChangedAt: meta.StatusChangedAt,
|
||||||
|
ManufacturedYearWeek: manufacturedYearWeekFromDetails(d.Details),
|
||||||
StatusHistory: meta.StatusHistory,
|
StatusHistory: meta.StatusHistory,
|
||||||
ErrorDescription: meta.ErrorDescription,
|
ErrorDescription: meta.ErrorDescription,
|
||||||
})
|
})
|
||||||
@@ -716,19 +747,25 @@ func convertStorageFromDevices(devices []models.HardwareDevice, collectedAt stri
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func convertPCIeFromDevices(devices []models.HardwareDevice, collectedAt, boardSerial string) []ReanimatorPCIe {
|
func convertPCIeFromDevices(devices []models.HardwareDevice, collectedAt string) []ReanimatorPCIe {
|
||||||
result := make([]ReanimatorPCIe, 0)
|
result := make([]ReanimatorPCIe, 0)
|
||||||
for _, d := range devices {
|
for _, d := range devices {
|
||||||
if d.Kind != models.DeviceKindPCIe && d.Kind != models.DeviceKindGPU && d.Kind != models.DeviceKindNetwork {
|
if d.Kind != models.DeviceKindPCIe && d.Kind != models.DeviceKindGPU && d.Kind != models.DeviceKindNetwork {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if isStorageEndpointPCIeDevice(d) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if isPlaceholderPCIeExportDevice(d) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if d.Present != nil && !*d.Present {
|
if d.Present != nil && !*d.Present {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
deviceClass := normalizePCIeDeviceClass(d)
|
deviceClass := normalizePCIeDeviceClass(d)
|
||||||
model := d.Model
|
model := normalizePlaceholderDeviceModel(d.Model)
|
||||||
if model == "" {
|
if model == "" {
|
||||||
model = d.PartNumber
|
model = normalizePlaceholderDeviceModel(d.PartNumber)
|
||||||
}
|
}
|
||||||
// General rule: if model not found in source data but PCI IDs are known, resolve from pci.ids.
|
// General rule: if model not found in source data but PCI IDs are known, resolve from pci.ids.
|
||||||
if model == "" && d.VendorID != 0 && d.DeviceID != 0 {
|
if model == "" && d.VendorID != 0 && d.DeviceID != 0 {
|
||||||
@@ -749,8 +786,9 @@ func convertPCIeFromDevices(devices []models.HardwareDevice, collectedAt, boardS
|
|||||||
)
|
)
|
||||||
status := normalizeStatus(d.Status, false)
|
status := normalizeStatus(d.Status, false)
|
||||||
meta := buildStatusMeta(status, d.StatusCheckedAt, d.StatusChangedAt, d.StatusHistory, d.ErrorDescription, collectedAt)
|
meta := buildStatusMeta(status, d.StatusCheckedAt, d.StatusChangedAt, d.StatusHistory, d.ErrorDescription, collectedAt)
|
||||||
|
slot := firstNonEmptyString(d.Slot, d.BDF)
|
||||||
result = append(result, ReanimatorPCIe{
|
result = append(result, ReanimatorPCIe{
|
||||||
Slot: d.Slot,
|
Slot: slot,
|
||||||
VendorID: d.VendorID,
|
VendorID: d.VendorID,
|
||||||
DeviceID: d.DeviceID,
|
DeviceID: d.DeviceID,
|
||||||
NUMANode: d.NUMANode,
|
NUMANode: d.NUMANode,
|
||||||
@@ -780,11 +818,12 @@ func convertPCIeFromDevices(devices []models.HardwareDevice, collectedAt, boardS
|
|||||||
MaxLinkWidth: d.MaxLinkWidth,
|
MaxLinkWidth: d.MaxLinkWidth,
|
||||||
MaxLinkSpeed: d.MaxLinkSpeed,
|
MaxLinkSpeed: d.MaxLinkSpeed,
|
||||||
MACAddresses: append([]string(nil), d.MACAddresses...),
|
MACAddresses: append([]string(nil), d.MACAddresses...),
|
||||||
SerialNumber: generatedPCIeSerial(d.SerialNumber, boardSerial, d.Slot),
|
SerialNumber: strings.TrimSpace(d.SerialNumber),
|
||||||
Firmware: d.Firmware,
|
Firmware: firstNonEmptyString(d.Firmware, stringFromDetailMap(d.Details, "firmware")),
|
||||||
Status: status,
|
Status: status,
|
||||||
StatusCheckedAt: meta.StatusCheckedAt,
|
StatusCheckedAt: meta.StatusCheckedAt,
|
||||||
StatusChangedAt: meta.StatusChangedAt,
|
StatusChangedAt: meta.StatusChangedAt,
|
||||||
|
ManufacturedYearWeek: manufacturedYearWeekFromDetails(d.Details),
|
||||||
StatusHistory: meta.StatusHistory,
|
StatusHistory: meta.StatusHistory,
|
||||||
ErrorDescription: meta.ErrorDescription,
|
ErrorDescription: meta.ErrorDescription,
|
||||||
})
|
})
|
||||||
@@ -792,6 +831,96 @@ func convertPCIeFromDevices(devices []models.HardwareDevice, collectedAt, boardS
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isStorageEndpointPCIeDevice(d models.HardwareDevice) bool {
|
||||||
|
if d.Kind != models.DeviceKindPCIe {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
class := strings.ToLower(strings.TrimSpace(d.DeviceClass))
|
||||||
|
if !strings.Contains(class, "storage") && !strings.Contains(class, "nonvolatile") && !strings.Contains(class, "nvme") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
joined := strings.ToLower(strings.TrimSpace(strings.Join([]string{
|
||||||
|
d.Slot,
|
||||||
|
d.Model,
|
||||||
|
d.PartNumber,
|
||||||
|
d.Manufacturer,
|
||||||
|
stringFromDetailMap(d.Details, "description"),
|
||||||
|
}, " ")))
|
||||||
|
|
||||||
|
if strings.Contains(joined, "raid") || strings.Contains(joined, "hba") || strings.Contains(joined, "controller") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Contains(joined, "nvme") ||
|
||||||
|
strings.Contains(joined, "ssd") ||
|
||||||
|
strings.Contains(joined, "u.2") ||
|
||||||
|
strings.Contains(joined, "e1.s") ||
|
||||||
|
strings.Contains(joined, "e3.s") ||
|
||||||
|
strings.Contains(joined, "disk") ||
|
||||||
|
strings.Contains(joined, "drive")
|
||||||
|
}
|
||||||
|
|
||||||
|
func isVirtualExportStorageDevice(d models.HardwareDevice) bool {
|
||||||
|
if d.Kind != models.DeviceKindStorage {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
mfr := strings.ToUpper(strings.TrimSpace(d.Manufacturer))
|
||||||
|
model := strings.ToUpper(strings.TrimSpace(d.Model))
|
||||||
|
slot := strings.ToUpper(strings.TrimSpace(d.Slot))
|
||||||
|
if strings.Contains(mfr, "AMERICAN MEGATRENDS") || strings.Contains(mfr, "AMI") {
|
||||||
|
joined := strings.Join([]string{mfr, model, slot}, " ")
|
||||||
|
for _, marker := range []string{
|
||||||
|
"VIRTUAL CDROM",
|
||||||
|
"VIRTUAL CD/DVD",
|
||||||
|
"VIRTUAL FLOPPY",
|
||||||
|
"VIRTUAL FDD",
|
||||||
|
"VIRTUAL MEDIA",
|
||||||
|
"USB_DEVICE",
|
||||||
|
} {
|
||||||
|
if strings.Contains(joined, marker) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPlaceholderPCIeExportDevice(d models.HardwareDevice) bool {
|
||||||
|
if d.Kind != models.DeviceKindPCIe && d.Kind != models.DeviceKindNetwork {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(d.BDF) != "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if d.VendorID != 0 || d.DeviceID != 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if normalizedSerial(d.SerialNumber) != "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(d.MACAddresses) > 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(d.Firmware) != "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if d.LinkWidth != 0 || d.MaxLinkWidth != 0 || strings.TrimSpace(d.LinkSpeed) != "" || strings.TrimSpace(d.MaxLinkSpeed) != "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if hasMeaningfulExporterText(d.Model) || hasMeaningfulExporterText(d.PartNumber) || hasMeaningfulExporterText(d.Manufacturer) || hasMeaningfulExporterText(stringFromDetailMap(d.Details, "description")) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
class := strings.ToLower(strings.TrimSpace(d.DeviceClass))
|
||||||
|
if class != "" && class != "unknown" && class != "other" && class != "pcie device" && class != "network" && class != "network controller" && class != "networkcontroller" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return isNumericExporterSlot(d.Slot)
|
||||||
|
}
|
||||||
|
|
||||||
func convertPSUsFromDevices(devices []models.HardwareDevice, collectedAt string) []ReanimatorPSU {
|
func convertPSUsFromDevices(devices []models.HardwareDevice, collectedAt string) []ReanimatorPSU {
|
||||||
result := make([]ReanimatorPSU, 0)
|
result := make([]ReanimatorPSU, 0)
|
||||||
for _, d := range devices {
|
for _, d := range devices {
|
||||||
@@ -822,6 +951,7 @@ func convertPSUsFromDevices(devices []models.HardwareDevice, collectedAt string)
|
|||||||
LifeUsedPct: floatFromDetailMap(d.Details, "life_used_pct"),
|
LifeUsedPct: floatFromDetailMap(d.Details, "life_used_pct"),
|
||||||
StatusCheckedAt: meta.StatusCheckedAt,
|
StatusCheckedAt: meta.StatusCheckedAt,
|
||||||
StatusChangedAt: meta.StatusChangedAt,
|
StatusChangedAt: meta.StatusChangedAt,
|
||||||
|
ManufacturedYearWeek: manufacturedYearWeekFromDetails(d.Details),
|
||||||
StatusHistory: meta.StatusHistory,
|
StatusHistory: meta.StatusHistory,
|
||||||
ErrorDescription: meta.ErrorDescription,
|
ErrorDescription: meta.ErrorDescription,
|
||||||
})
|
})
|
||||||
@@ -829,6 +959,41 @@ func convertPSUsFromDevices(devices []models.HardwareDevice, collectedAt string)
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func convertEventLogs(events []models.Event, collectedAt string) []ReanimatorEventLog {
|
||||||
|
if len(events) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]ReanimatorEventLog, 0, len(events))
|
||||||
|
for _, event := range events {
|
||||||
|
source := normalizeEventLogSource(event.Source)
|
||||||
|
message := strings.TrimSpace(event.Description)
|
||||||
|
if source == "" || message == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
item := ReanimatorEventLog{
|
||||||
|
Source: source,
|
||||||
|
EventTime: formatEventLogTime(event.Timestamp, collectedAt),
|
||||||
|
Severity: normalizeEventLogSeverity(event.Severity),
|
||||||
|
MessageID: strings.TrimSpace(event.ID),
|
||||||
|
Message: message,
|
||||||
|
ComponentRef: firstNonEmptyString(strings.TrimSpace(event.SensorName), strings.TrimSpace(event.SensorType)),
|
||||||
|
}
|
||||||
|
if raw := strings.TrimSpace(event.RawData); raw != "" {
|
||||||
|
item.RawPayload = map[string]any{
|
||||||
|
"raw_data": raw,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func convertSensors(sensors []models.SensorReading) *ReanimatorSensors {
|
func convertSensors(sensors []models.SensorReading) *ReanimatorSensors {
|
||||||
if len(sensors) == 0 {
|
if len(sensors) == 0 {
|
||||||
return nil
|
return nil
|
||||||
@@ -836,7 +1001,7 @@ func convertSensors(sensors []models.SensorReading) *ReanimatorSensors {
|
|||||||
|
|
||||||
out := &ReanimatorSensors{}
|
out := &ReanimatorSensors{}
|
||||||
seenFans := map[string]struct{}{}
|
seenFans := map[string]struct{}{}
|
||||||
seenPower := map[string]struct{}{}
|
powerIndex := map[string]int{}
|
||||||
seenTemps := map[string]struct{}{}
|
seenTemps := map[string]struct{}{}
|
||||||
seenOther := map[string]struct{}{}
|
seenOther := map[string]struct{}{}
|
||||||
|
|
||||||
@@ -845,10 +1010,12 @@ func convertSensors(sensors []models.SensorReading) *ReanimatorSensors {
|
|||||||
if name == "" {
|
if name == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if !sensorHasNumericReading(s) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
status := normalizeSensorStatus(s.Status)
|
status := normalizeSensorStatus(s.Status)
|
||||||
sType := strings.ToLower(strings.TrimSpace(s.Type))
|
sType := strings.ToLower(strings.TrimSpace(s.Type))
|
||||||
unit := strings.TrimSpace(s.Unit)
|
unit := strings.TrimSpace(s.Unit)
|
||||||
location := inferSensorLocation(name)
|
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case sType == "fan" || strings.EqualFold(unit, "RPM"):
|
case sType == "fan" || strings.EqualFold(unit, "RPM"):
|
||||||
@@ -857,27 +1024,21 @@ func convertSensors(sensors []models.SensorReading) *ReanimatorSensors {
|
|||||||
}
|
}
|
||||||
out.Fans = append(out.Fans, ReanimatorFanSensor{
|
out.Fans = append(out.Fans, ReanimatorFanSensor{
|
||||||
Name: name,
|
Name: name,
|
||||||
Location: location,
|
|
||||||
RPM: int(s.Value),
|
RPM: int(s.Value),
|
||||||
Status: status,
|
Status: status,
|
||||||
})
|
})
|
||||||
case sType == "power" || sType == "voltage" || sType == "current" || strings.EqualFold(unit, "V") || strings.EqualFold(unit, "A") || strings.EqualFold(unit, "W"):
|
case sType == "power" || sType == "voltage" || sType == "current" || strings.EqualFold(unit, "V") || strings.EqualFold(unit, "A") || strings.EqualFold(unit, "W"):
|
||||||
if seenFirst(seenPower, name) {
|
baseName := groupedPowerSensorName(name)
|
||||||
|
if idx, ok := powerIndex[baseName]; ok {
|
||||||
|
mergePowerSensorReading(&out.Power[idx], sType, unit, s.Value, status)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
item := ReanimatorPowerSensor{
|
item := ReanimatorPowerSensor{
|
||||||
Name: name,
|
Name: baseName,
|
||||||
Location: location,
|
|
||||||
Status: status,
|
Status: status,
|
||||||
}
|
}
|
||||||
switch {
|
mergePowerSensorReading(&item, sType, unit, s.Value, status)
|
||||||
case sType == "current" || strings.EqualFold(unit, "A"):
|
powerIndex[baseName] = len(out.Power)
|
||||||
item.CurrentA = s.Value
|
|
||||||
case sType == "power" || strings.EqualFold(unit, "W"):
|
|
||||||
item.PowerW = s.Value
|
|
||||||
default:
|
|
||||||
item.VoltageV = s.Value
|
|
||||||
}
|
|
||||||
out.Power = append(out.Power, item)
|
out.Power = append(out.Power, item)
|
||||||
case sType == "temperature" || strings.EqualFold(unit, "C") || strings.EqualFold(unit, "°C"):
|
case sType == "temperature" || strings.EqualFold(unit, "C") || strings.EqualFold(unit, "°C"):
|
||||||
if seenFirst(seenTemps, name) {
|
if seenFirst(seenTemps, name) {
|
||||||
@@ -885,7 +1046,6 @@ func convertSensors(sensors []models.SensorReading) *ReanimatorSensors {
|
|||||||
}
|
}
|
||||||
out.Temperatures = append(out.Temperatures, ReanimatorTemperatureSensor{
|
out.Temperatures = append(out.Temperatures, ReanimatorTemperatureSensor{
|
||||||
Name: name,
|
Name: name,
|
||||||
Location: location,
|
|
||||||
Celsius: s.Value,
|
Celsius: s.Value,
|
||||||
Status: status,
|
Status: status,
|
||||||
})
|
})
|
||||||
@@ -895,7 +1055,6 @@ func convertSensors(sensors []models.SensorReading) *ReanimatorSensors {
|
|||||||
}
|
}
|
||||||
out.Other = append(out.Other, ReanimatorOtherSensor{
|
out.Other = append(out.Other, ReanimatorOtherSensor{
|
||||||
Name: name,
|
Name: name,
|
||||||
Location: location,
|
|
||||||
Value: s.Value,
|
Value: s.Value,
|
||||||
Unit: unit,
|
Unit: unit,
|
||||||
Status: status,
|
Status: status,
|
||||||
@@ -909,6 +1068,69 @@ func convertSensors(sensors []models.SensorReading) *ReanimatorSensors {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func groupedPowerSensorName(name string) string {
|
||||||
|
trimmed := strings.TrimSpace(name)
|
||||||
|
lower := strings.ToLower(trimmed)
|
||||||
|
for _, suffix := range []string{"_inputpower", "_inputvoltage", "_inputcurrent"} {
|
||||||
|
if strings.HasSuffix(lower, suffix) {
|
||||||
|
return strings.TrimSpace(trimmed[:len(trimmed)-len(suffix)])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergePowerSensorReading(item *ReanimatorPowerSensor, sType, unit string, value float64, status string) {
|
||||||
|
if item == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case sType == "current" || strings.EqualFold(unit, "A"):
|
||||||
|
item.CurrentA = value
|
||||||
|
case sType == "power" || strings.EqualFold(unit, "W"):
|
||||||
|
item.PowerW = value
|
||||||
|
default:
|
||||||
|
item.VoltageV = value
|
||||||
|
}
|
||||||
|
item.Status = mergeSensorStatus(item.Status, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeSensorStatus(current, incoming string) string {
|
||||||
|
current = strings.TrimSpace(current)
|
||||||
|
incoming = strings.TrimSpace(incoming)
|
||||||
|
if current == "" {
|
||||||
|
return incoming
|
||||||
|
}
|
||||||
|
if incoming == "" {
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
if sensorStatusRank(incoming) > sensorStatusRank(current) {
|
||||||
|
return incoming
|
||||||
|
}
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
|
func sensorStatusRank(status string) int {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(status)) {
|
||||||
|
case "critical":
|
||||||
|
return 3
|
||||||
|
case "warning":
|
||||||
|
return 2
|
||||||
|
case "ok":
|
||||||
|
return 1
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sensorHasNumericReading(s models.SensorReading) bool {
|
||||||
|
if strings.TrimSpace(s.RawValue) != "" {
|
||||||
|
if _, err := strconv.ParseFloat(strings.TrimSpace(s.RawValue), 64); err == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s.Value != 0
|
||||||
|
}
|
||||||
|
|
||||||
func isDeviceBoundFirmwareName(name string) bool {
|
func isDeviceBoundFirmwareName(name string) bool {
|
||||||
n := strings.TrimSpace(strings.ToLower(name))
|
n := strings.TrimSpace(strings.ToLower(name))
|
||||||
if n == "" {
|
if n == "" {
|
||||||
@@ -937,6 +1159,7 @@ func isDeviceBoundFirmwareName(name string) bool {
|
|||||||
// HGX baseboard firmware inventory IDs for device-bound components
|
// HGX baseboard firmware inventory IDs for device-bound components
|
||||||
strings.Contains(n, "_fw_gpu_") ||
|
strings.Contains(n, "_fw_gpu_") ||
|
||||||
strings.Contains(n, "_fw_nvswitch_") ||
|
strings.Contains(n, "_fw_nvswitch_") ||
|
||||||
|
strings.Contains(n, "_fw_erot_") ||
|
||||||
strings.Contains(n, "_inforom_gpu_") {
|
strings.Contains(n, "_inforom_gpu_") {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -944,6 +1167,60 @@ func isDeviceBoundFirmwareName(name string) bool {
|
|||||||
return cpuMicrocodeFirmwareRegex.MatchString(strings.TrimSpace(name))
|
return cpuMicrocodeFirmwareRegex.MatchString(strings.TrimSpace(name))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeEventLogSource(source string) string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(source)) {
|
||||||
|
case "redfish":
|
||||||
|
return "redfish"
|
||||||
|
case "sel", "bmc", "ipmi", "idrac", "lifecycle controller", "lifecyclecontroller":
|
||||||
|
return "bmc"
|
||||||
|
case "system", "syslog", "smart", "zfs", "file", "gpu", "dmi", "nvidia driver", "gpu field diagnostics", "fan", "memory", "host":
|
||||||
|
return "host"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeEventLogSeverity(severity models.Severity) string {
|
||||||
|
switch severity {
|
||||||
|
case models.SeverityCritical:
|
||||||
|
return "Critical"
|
||||||
|
case models.SeverityWarning:
|
||||||
|
return "Warning"
|
||||||
|
case models.SeverityInfo:
|
||||||
|
return "Info"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatEventLogTime(ts time.Time, collectedAt string) string {
|
||||||
|
if !ts.IsZero() {
|
||||||
|
return ts.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(collectedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func manufacturedYearWeekFromDetails(details map[string]any) string {
|
||||||
|
if details == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
value := normalizeManufacturedYearWeek(stringFromDetailMap(details, "manufactured_year_week"))
|
||||||
|
if value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return normalizeManufacturedYearWeek(stringFromDetailMap(details, "mfg_date"))
|
||||||
|
}
|
||||||
|
|
||||||
|
var manufacturedYearWeekRegex = regexp.MustCompile(`^\d{4}-W\d{2}$`)
|
||||||
|
|
||||||
|
func normalizeManufacturedYearWeek(value string) string {
|
||||||
|
value = strings.TrimSpace(strings.ToUpper(value))
|
||||||
|
if manufacturedYearWeekRegex.MatchString(value) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// isDeviceBoundFirmwareFQDD returns true if the description looks like a device-bound FQDD
|
// isDeviceBoundFirmwareFQDD returns true if the description looks like a device-bound FQDD
|
||||||
// (e.g. NIC.Integrated.1-1-1, PSU.Slot.1, Disk.Bay.0:..., RAID.SL.3-1, InfiniBand.Slot.1-1).
|
// (e.g. NIC.Integrated.1-1-1, PSU.Slot.1, Disk.Bay.0:..., RAID.SL.3-1, InfiniBand.Slot.1-1).
|
||||||
// These firmware entries are already embedded in the device itself and must not appear
|
// These firmware entries are already embedded in the device itself and must not appear
|
||||||
@@ -989,7 +1266,7 @@ func buildCPUMicrocodeBySocket(firmware []models.FirmwareInfo) map[int]string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// convertCPUs converts CPU information to Reanimator format
|
// convertCPUs converts CPU information to Reanimator format
|
||||||
func convertCPUs(cpus []models.CPU, collectedAt, boardSerial string) []ReanimatorCPU {
|
func convertCPUs(cpus []models.CPU, collectedAt string) []ReanimatorCPU {
|
||||||
if len(cpus) == 0 {
|
if len(cpus) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -1018,7 +1295,7 @@ func convertCPUs(cpus []models.CPU, collectedAt, boardSerial string) []Reanimato
|
|||||||
Threads: cpu.Threads,
|
Threads: cpu.Threads,
|
||||||
FrequencyMHz: cpu.FrequencyMHz,
|
FrequencyMHz: cpu.FrequencyMHz,
|
||||||
MaxFrequencyMHz: cpu.MaxFreqMHz,
|
MaxFrequencyMHz: cpu.MaxFreqMHz,
|
||||||
SerialNumber: generatedCPUSerial(cpu.SerialNumber, boardSerial, cpu.Socket),
|
SerialNumber: strings.TrimSpace(cpu.SerialNumber),
|
||||||
Firmware: "",
|
Firmware: "",
|
||||||
Manufacturer: manufacturer,
|
Manufacturer: manufacturer,
|
||||||
Status: cpuStatus,
|
Status: cpuStatus,
|
||||||
@@ -1120,7 +1397,7 @@ func convertStorage(storage []models.Storage, collectedAt string) []ReanimatorSt
|
|||||||
}
|
}
|
||||||
|
|
||||||
// convertPCIeDevices converts PCIe devices, GPUs, and network adapters to Reanimator format
|
// convertPCIeDevices converts PCIe devices, GPUs, and network adapters to Reanimator format
|
||||||
func convertPCIeDevices(hw *models.HardwareConfig, collectedAt, boardSerial string) []ReanimatorPCIe {
|
func convertPCIeDevices(hw *models.HardwareConfig, collectedAt string) []ReanimatorPCIe {
|
||||||
result := make([]ReanimatorPCIe, 0)
|
result := make([]ReanimatorPCIe, 0)
|
||||||
gpuSlots := make(map[string]struct{}, len(hw.GPUs))
|
gpuSlots := make(map[string]struct{}, len(hw.GPUs))
|
||||||
nvswitchFirmwareBySlot := buildNVSwitchFirmwareBySlot(hw.Firmware)
|
nvswitchFirmwareBySlot := buildNVSwitchFirmwareBySlot(hw.Firmware)
|
||||||
@@ -1140,12 +1417,23 @@ func convertPCIeDevices(hw *models.HardwareConfig, collectedAt, boardSerial stri
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
serialNumber := generatedPCIeSerial(pcie.SerialNumber, boardSerial, pcie.Slot)
|
if isStorageEndpointPCIeDevice(models.HardwareDevice{
|
||||||
|
Kind: models.DeviceKindPCIe,
|
||||||
|
Slot: pcie.Slot,
|
||||||
|
DeviceClass: pcie.DeviceClass,
|
||||||
|
Model: pcie.Description,
|
||||||
|
PartNumber: pcie.PartNumber,
|
||||||
|
Manufacturer: pcie.Manufacturer,
|
||||||
|
}) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
serialNumber := strings.TrimSpace(pcie.SerialNumber)
|
||||||
|
|
||||||
// Determine model: PartNumber > Description (chip name) > DeviceClass (bus width fallback)
|
// Determine model: PartNumber > Description (chip name) > DeviceClass (bus width fallback)
|
||||||
model := pcie.PartNumber
|
model := normalizePlaceholderDeviceModel(pcie.PartNumber)
|
||||||
if model == "" {
|
if model == "" {
|
||||||
model = pcie.Description
|
model = normalizePlaceholderDeviceModel(pcie.Description)
|
||||||
}
|
}
|
||||||
if model == "" {
|
if model == "" {
|
||||||
model = pcie.DeviceClass
|
model = pcie.DeviceClass
|
||||||
@@ -1191,7 +1479,7 @@ func convertPCIeDevices(hw *models.HardwareConfig, collectedAt, boardSerial stri
|
|||||||
|
|
||||||
// Convert GPUs as PCIe devices
|
// Convert GPUs as PCIe devices
|
||||||
for _, gpu := range hw.GPUs {
|
for _, gpu := range hw.GPUs {
|
||||||
serialNumber := generatedPCIeSerial(gpu.SerialNumber, boardSerial, gpu.Slot)
|
serialNumber := strings.TrimSpace(gpu.SerialNumber)
|
||||||
|
|
||||||
status := normalizeStatus(gpu.Status, false)
|
status := normalizeStatus(gpu.Status, false)
|
||||||
meta := buildStatusMeta(
|
meta := buildStatusMeta(
|
||||||
@@ -1233,7 +1521,7 @@ func convertPCIeDevices(hw *models.HardwareConfig, collectedAt, boardSerial stri
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
serialNumber := generatedPCIeSerial(nic.SerialNumber, boardSerial, nic.Slot)
|
serialNumber := strings.TrimSpace(nic.SerialNumber)
|
||||||
|
|
||||||
status := normalizeStatus(nic.Status, false)
|
status := normalizeStatus(nic.Status, false)
|
||||||
meta := buildStatusMeta(
|
meta := buildStatusMeta(
|
||||||
@@ -1285,19 +1573,25 @@ func buildNVSwitchFirmwareBySlot(firmware []models.FirmwareInfo) map[string]stri
|
|||||||
result := make(map[string]string)
|
result := make(map[string]string)
|
||||||
for _, fw := range firmware {
|
for _, fw := range firmware {
|
||||||
name := strings.TrimSpace(fw.DeviceName)
|
name := strings.TrimSpace(fw.DeviceName)
|
||||||
if !strings.HasPrefix(strings.ToUpper(name), "NVSWITCH ") {
|
if strings.HasPrefix(strings.ToUpper(name), "HGX_FW_EROT_") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
slot := ""
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(strings.ToUpper(name), "NVSWITCH "):
|
||||||
rest := strings.TrimSpace(name[len("NVSwitch "):])
|
rest := strings.TrimSpace(name[len("NVSwitch "):])
|
||||||
if rest == "" {
|
if rest == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
slot = rest
|
||||||
slot := rest
|
|
||||||
if idx := strings.Index(rest, " ("); idx > 0 {
|
if idx := strings.Index(rest, " ("); idx > 0 {
|
||||||
slot = strings.TrimSpace(rest[:idx])
|
slot = strings.TrimSpace(rest[:idx])
|
||||||
}
|
}
|
||||||
|
case strings.HasPrefix(strings.ToUpper(name), "HGX_FW_NVSWITCH_"):
|
||||||
|
slot = strings.TrimPrefix(strings.ToUpper(name), "HGX_FW_")
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
slot = normalizeNVSwitchSlotForLookup(slot)
|
slot = normalizeNVSwitchSlotForLookup(slot)
|
||||||
if slot == "" {
|
if slot == "" {
|
||||||
continue
|
continue
|
||||||
@@ -1388,28 +1682,6 @@ func seenFirst(seen map[string]struct{}, key string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func inferSensorLocation(name string) string {
|
|
||||||
lower := strings.ToLower(strings.TrimSpace(name))
|
|
||||||
switch {
|
|
||||||
case strings.Contains(lower, "front"):
|
|
||||||
return "Front"
|
|
||||||
case strings.Contains(lower, "rear"):
|
|
||||||
return "Rear"
|
|
||||||
case strings.Contains(lower, "inlet"):
|
|
||||||
return "Front"
|
|
||||||
case strings.Contains(lower, "cpu0"):
|
|
||||||
return "CPU0"
|
|
||||||
case strings.Contains(lower, "cpu1"):
|
|
||||||
return "CPU1"
|
|
||||||
case strings.Contains(lower, "psu0"):
|
|
||||||
return "PSU0"
|
|
||||||
case strings.Contains(lower, "psu1"):
|
|
||||||
return "PSU1"
|
|
||||||
default:
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeSensorStatus(status string) string {
|
func normalizeSensorStatus(status string) string {
|
||||||
return normalizeStatus(status, false)
|
return normalizeStatus(status, false)
|
||||||
}
|
}
|
||||||
@@ -1829,29 +2101,6 @@ func boolFromPresentPtr(v *bool, defaultValue bool) bool {
|
|||||||
return *v
|
return *v
|
||||||
}
|
}
|
||||||
|
|
||||||
func generatedCPUSerial(serial, boardSerial string, socket int) string {
|
|
||||||
if normalized := normalizedSerial(serial); normalized != "" {
|
|
||||||
return normalized
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(boardSerial) != "" {
|
|
||||||
return fmt.Sprintf("%s-CPU-%d", strings.TrimSpace(boardSerial), socket)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("CPU-%d", socket)
|
|
||||||
}
|
|
||||||
|
|
||||||
func generatedPCIeSerial(serial, boardSerial, slot string) string {
|
|
||||||
if normalized := normalizedSerial(serial); normalized != "" {
|
|
||||||
return normalized
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(slot) == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(boardSerial) != "" {
|
|
||||||
return fmt.Sprintf("%s-PCIE-%s", strings.TrimSpace(boardSerial), strings.TrimSpace(slot))
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("PCIE-%s", strings.TrimSpace(slot))
|
|
||||||
}
|
|
||||||
|
|
||||||
func floatFromDetailMap(details map[string]any, key string) float64 {
|
func floatFromDetailMap(details map[string]any, key string) float64 {
|
||||||
if details == nil {
|
if details == nil {
|
||||||
return 0
|
return 0
|
||||||
@@ -1946,6 +2195,42 @@ func normalizeNetworkDeviceClass(portType, model, description string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizePlaceholderDeviceModel(model string) string {
|
||||||
|
trimmed := strings.TrimSpace(model)
|
||||||
|
switch strings.ToLower(trimmed) {
|
||||||
|
case "", "network device view", "pci device view", "pcie device view", "storage device view":
|
||||||
|
return ""
|
||||||
|
default:
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasMeaningfulExporterText(v string) bool {
|
||||||
|
s := strings.ToLower(strings.TrimSpace(v))
|
||||||
|
if s == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch s {
|
||||||
|
case "-", "n/a", "na", "none", "null", "unknown", "network device view", "pci device view", "pcie device view", "storage device view":
|
||||||
|
return false
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isNumericExporterSlot(slot string) bool {
|
||||||
|
slot = strings.TrimSpace(slot)
|
||||||
|
if slot == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range slot {
|
||||||
|
if r < '0' || r > '9' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// inferStorageStatus determines storage device status
|
// inferStorageStatus determines storage device status
|
||||||
func inferStorageStatus(stor models.Storage) string {
|
func inferStorageStatus(stor models.Storage) string {
|
||||||
if !stor.Present {
|
if !stor.Present {
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ func TestConvertCPUs(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
result := convertCPUs(cpus, "2026-02-10T15:30:00Z", "BOARD-001")
|
result := convertCPUs(cpus, "2026-02-10T15:30:00Z")
|
||||||
|
|
||||||
if len(result) != 2 {
|
if len(result) != 2 {
|
||||||
t.Fatalf("expected 2 CPUs, got %d", len(result))
|
t.Fatalf("expected 2 CPUs, got %d", len(result))
|
||||||
@@ -227,8 +227,8 @@ func TestConvertCPUs(t *testing.T) {
|
|||||||
if result[0].Status != "Unknown" {
|
if result[0].Status != "Unknown" {
|
||||||
t.Errorf("expected Unknown status, got %q", result[0].Status)
|
t.Errorf("expected Unknown status, got %q", result[0].Status)
|
||||||
}
|
}
|
||||||
if result[0].SerialNumber != "BOARD-001-CPU-0" {
|
if result[0].SerialNumber != "" {
|
||||||
t.Errorf("expected generated CPU serial, got %q", result[0].SerialNumber)
|
t.Errorf("expected empty CPU serial when source serial is absent, got %q", result[0].SerialNumber)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,6 +259,158 @@ func TestConvertMemory(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestConvertToReanimator_CPUSerialIsNotSynthesizedAndSocketIsDeduped(t *testing.T) {
|
||||||
|
input := &models.AnalysisResult{
|
||||||
|
Filename: "cpu-dedupe.json",
|
||||||
|
Hardware: &models.HardwareConfig{
|
||||||
|
BoardInfo: models.BoardInfo{SerialNumber: "BOARD-001"},
|
||||||
|
Devices: []models.HardwareDevice{
|
||||||
|
{
|
||||||
|
Kind: models.DeviceKindCPU,
|
||||||
|
Slot: "CPU1",
|
||||||
|
Model: "Xeon Platinum",
|
||||||
|
Cores: 56,
|
||||||
|
Status: "OK",
|
||||||
|
Details: map[string]any{
|
||||||
|
"socket": 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
CPUs: []models.CPU{
|
||||||
|
{Socket: 1, Model: "Xeon Platinum", Cores: 56, Status: "OK"},
|
||||||
|
{Socket: 2, Model: "Xeon Platinum", Cores: 56, Status: "OK"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := ConvertToReanimator(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ConvertToReanimator() failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(out.Hardware.CPUs) != 2 {
|
||||||
|
t.Fatalf("expected exactly two CPUs after socket dedupe, got %d", len(out.Hardware.CPUs))
|
||||||
|
}
|
||||||
|
for _, cpu := range out.Hardware.CPUs {
|
||||||
|
if cpu.SerialNumber != "" {
|
||||||
|
t.Fatalf("expected CPU serial to stay empty when source serial is absent, got %q", cpu.SerialNumber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertToReanimator_ExportsEventLogsAndOmitsPCIeBDFJSON(t *testing.T) {
|
||||||
|
input := &models.AnalysisResult{
|
||||||
|
Filename: "events.json",
|
||||||
|
CollectedAt: time.Date(2026, 3, 15, 12, 0, 0, 0, time.UTC),
|
||||||
|
Events: []models.Event{
|
||||||
|
{
|
||||||
|
ID: "0x0042",
|
||||||
|
Timestamp: time.Date(2026, 3, 15, 11, 59, 0, 0, time.UTC),
|
||||||
|
Source: "SEL",
|
||||||
|
SensorName: "CPU0_C0D0",
|
||||||
|
Severity: models.SeverityWarning,
|
||||||
|
Description: "Correctable ECC error threshold exceeded",
|
||||||
|
RawData: "sel_record_id=42",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Source: "LOGPile",
|
||||||
|
Severity: models.SeverityWarning,
|
||||||
|
Description: "internal warning should not leak to event_logs",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Hardware: &models.HardwareConfig{
|
||||||
|
BoardInfo: models.BoardInfo{SerialNumber: "BOARD-001"},
|
||||||
|
Devices: []models.HardwareDevice{
|
||||||
|
{
|
||||||
|
Kind: models.DeviceKindPCIe,
|
||||||
|
Slot: "",
|
||||||
|
BDF: "0000:18:00.0",
|
||||||
|
DeviceClass: "NetworkController",
|
||||||
|
Manufacturer: "Mellanox",
|
||||||
|
Model: "ConnectX-6",
|
||||||
|
Status: "OK",
|
||||||
|
Details: map[string]any{
|
||||||
|
"manufactured_year_week": "2024-W07",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := ConvertToReanimator(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ConvertToReanimator() failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(out.Hardware.EventLogs) != 1 {
|
||||||
|
t.Fatalf("expected 1 exported event log, got %d", len(out.Hardware.EventLogs))
|
||||||
|
}
|
||||||
|
log := out.Hardware.EventLogs[0]
|
||||||
|
if log.Source != "bmc" {
|
||||||
|
t.Fatalf("expected SEL source to map to bmc, got %#v", log)
|
||||||
|
}
|
||||||
|
if log.ComponentRef != "CPU0_C0D0" {
|
||||||
|
t.Fatalf("expected sensor name to map to component_ref, got %#v", log)
|
||||||
|
}
|
||||||
|
if len(out.Hardware.PCIeDevices) != 1 {
|
||||||
|
t.Fatalf("expected 1 pcie device, got %d", len(out.Hardware.PCIeDevices))
|
||||||
|
}
|
||||||
|
if out.Hardware.PCIeDevices[0].Slot != "0000:18:00.0" {
|
||||||
|
t.Fatalf("expected slot to fall back to BDF, got %#v", out.Hardware.PCIeDevices[0])
|
||||||
|
}
|
||||||
|
if out.Hardware.PCIeDevices[0].ManufacturedYearWeek != "2024-W07" {
|
||||||
|
t.Fatalf("expected manufactured_year_week to be exported, got %#v", out.Hardware.PCIeDevices[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := json.Marshal(out)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("json.Marshal() failed: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(payload), `"bdf"`) {
|
||||||
|
t.Fatalf("expected pcie bdf field to stay out of JSON payload: %s", payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertToReanimator_EventLogSourceMappingSupportsDellAndHostSyslog(t *testing.T) {
|
||||||
|
input := &models.AnalysisResult{
|
||||||
|
Filename: "event-source-map.json",
|
||||||
|
CollectedAt: time.Date(2026, 3, 15, 12, 0, 0, 0, time.UTC),
|
||||||
|
Events: []models.Event{
|
||||||
|
{
|
||||||
|
ID: "SYS1001",
|
||||||
|
Timestamp: time.Date(2026, 3, 15, 11, 58, 0, 0, time.UTC),
|
||||||
|
Source: "iDRAC",
|
||||||
|
SensorName: "NIC.Slot.1-1-1",
|
||||||
|
Severity: models.SeverityWarning,
|
||||||
|
Description: "Link is down",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "syslog_1",
|
||||||
|
Timestamp: time.Date(2026, 3, 15, 11, 59, 0, 0, time.UTC),
|
||||||
|
Source: "syslog",
|
||||||
|
SensorName: "systemd[1]",
|
||||||
|
Severity: models.SeverityInfo,
|
||||||
|
Description: "Started Example Service",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Hardware: &models.HardwareConfig{
|
||||||
|
BoardInfo: models.BoardInfo{SerialNumber: "BOARD-001"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := ConvertToReanimator(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ConvertToReanimator() failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(out.Hardware.EventLogs) != 2 {
|
||||||
|
t.Fatalf("expected 2 event logs, got %d", len(out.Hardware.EventLogs))
|
||||||
|
}
|
||||||
|
if out.Hardware.EventLogs[0].Source != "bmc" {
|
||||||
|
t.Fatalf("expected iDRAC event to map to bmc, got %#v", out.Hardware.EventLogs[0])
|
||||||
|
}
|
||||||
|
if out.Hardware.EventLogs[1].Source != "host" {
|
||||||
|
t.Fatalf("expected syslog event to map to host, got %#v", out.Hardware.EventLogs[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestConvertStorage(t *testing.T) {
|
func TestConvertStorage(t *testing.T) {
|
||||||
storage := []models.Storage{
|
storage := []models.Storage{
|
||||||
{
|
{
|
||||||
@@ -288,6 +440,46 @@ func TestConvertStorage(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestConvertToReanimator_SkipsAMIVirtualStorageDevices(t *testing.T) {
|
||||||
|
input := &models.AnalysisResult{
|
||||||
|
Filename: "virtual-media.json",
|
||||||
|
Hardware: &models.HardwareConfig{
|
||||||
|
BoardInfo: models.BoardInfo{SerialNumber: "BOARD-001"},
|
||||||
|
Storage: []models.Storage{
|
||||||
|
{
|
||||||
|
Slot: "USB_Device1_Port4",
|
||||||
|
Type: "HDD",
|
||||||
|
Model: "Virtual Cdrom Device",
|
||||||
|
SerialNumber: "AAAABBBBCCCC1",
|
||||||
|
Manufacturer: "American Megatrends Inc.",
|
||||||
|
Interface: "USB",
|
||||||
|
Present: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Slot: "OB01",
|
||||||
|
Type: "NVMe",
|
||||||
|
Model: "Memblaze PBlaze7",
|
||||||
|
SerialNumber: "REAL-NVME-001",
|
||||||
|
Manufacturer: "Memblaze",
|
||||||
|
Interface: "NVMe",
|
||||||
|
Present: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := ConvertToReanimator(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ConvertToReanimator() failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(out.Hardware.Storage) != 1 {
|
||||||
|
t.Fatalf("expected only one real storage device to remain, got %d", len(out.Hardware.Storage))
|
||||||
|
}
|
||||||
|
if out.Hardware.Storage[0].SerialNumber != "REAL-NVME-001" {
|
||||||
|
t.Fatalf("expected virtual AMI storage to be skipped, got %#v", out.Hardware.Storage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestConvertStorage_RemainingEndurance(t *testing.T) {
|
func TestConvertStorage_RemainingEndurance(t *testing.T) {
|
||||||
pct100 := 100
|
pct100 := 100
|
||||||
pct3 := 3
|
pct3 := 3
|
||||||
@@ -370,16 +562,16 @@ func TestConvertPCIeDevices(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
result := convertPCIeDevices(hw, "2026-02-10T15:30:00Z", "BOARD-001")
|
result := convertPCIeDevices(hw, "2026-02-10T15:30:00Z")
|
||||||
|
|
||||||
// Should have: 2 PCIe devices + 1 GPU + 1 NIC = 4 total
|
// Should have: 2 PCIe devices + 1 GPU + 1 NIC = 4 total
|
||||||
if len(result) != 4 {
|
if len(result) != 4 {
|
||||||
t.Fatalf("expected 4 PCIe devices total, got %d", len(result))
|
t.Fatalf("expected 4 PCIe devices total, got %d", len(result))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check that serial is generated for second PCIe device
|
// Missing serials must remain absent.
|
||||||
if result[1].SerialNumber != "BOARD-001-PCIE-PCIeCard2" {
|
if result[1].SerialNumber != "" {
|
||||||
t.Errorf("expected generated serial for missing device serial, got %q", result[1].SerialNumber)
|
t.Errorf("expected empty serial for missing device serial, got %q", result[1].SerialNumber)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check GPU was included
|
// Check GPU was included
|
||||||
@@ -416,20 +608,71 @@ func TestConvertPCIeDevices_NVSwitchWithoutSerialRemainsEmpty(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
result := convertPCIeDevices(hw, "2026-02-10T15:30:00Z", "BOARD-001")
|
result := convertPCIeDevices(hw, "2026-02-10T15:30:00Z")
|
||||||
|
|
||||||
if len(result) != 1 {
|
if len(result) != 1 {
|
||||||
t.Fatalf("expected 1 PCIe device, got %d", len(result))
|
t.Fatalf("expected 1 PCIe device, got %d", len(result))
|
||||||
}
|
}
|
||||||
|
|
||||||
if result[0].SerialNumber != "BOARD-001-PCIE-NVSWITCH1" {
|
if result[0].SerialNumber != "" {
|
||||||
t.Fatalf("expected generated NVSwitch serial, got %q", result[0].SerialNumber)
|
t.Fatalf("expected empty NVSwitch serial, got %q", result[0].SerialNumber)
|
||||||
}
|
}
|
||||||
if result[0].Firmware != "96.10.6D.00.01" {
|
if result[0].Firmware != "96.10.6D.00.01" {
|
||||||
t.Fatalf("expected NVSwitch firmware 96.10.6D.00.01, got %q", result[0].Firmware)
|
t.Fatalf("expected NVSwitch firmware 96.10.6D.00.01, got %q", result[0].Firmware)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestConvertToReanimator_MapsHGXNVSwitchFirmwareToPCIeDevice(t *testing.T) {
|
||||||
|
input := &models.AnalysisResult{
|
||||||
|
Hardware: &models.HardwareConfig{
|
||||||
|
BoardInfo: models.BoardInfo{SerialNumber: "BOARD-001"},
|
||||||
|
Firmware: []models.FirmwareInfo{
|
||||||
|
{DeviceName: "HGX_FW_ERoT_NVSwitch_0", Version: "00.02.0192.0000_n00"},
|
||||||
|
{DeviceName: "HGX_FW_NVSwitch_0", Version: "96.10.73.00.01"},
|
||||||
|
},
|
||||||
|
PCIeDevices: []models.PCIeDevice{
|
||||||
|
{
|
||||||
|
Slot: "NVSwitch_0",
|
||||||
|
DeviceClass: "NVSwitch",
|
||||||
|
Manufacturer: "NVIDIA",
|
||||||
|
Details: map[string]any{
|
||||||
|
"temperature_c": 31.59375,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := ConvertToReanimator(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ConvertToReanimator() failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(out.Hardware.PCIeDevices) != 1 {
|
||||||
|
t.Fatalf("expected one NVSwitch PCIe device, got %d", len(out.Hardware.PCIeDevices))
|
||||||
|
}
|
||||||
|
got := out.Hardware.PCIeDevices[0]
|
||||||
|
if got.Firmware != "96.10.73.00.01" {
|
||||||
|
t.Fatalf("expected HGX NVSwitch firmware to map to device, got %#v", got)
|
||||||
|
}
|
||||||
|
if got.TemperatureC != 31.59375 {
|
||||||
|
t.Fatalf("expected NVSwitch temperature to be exported, got %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildNVSwitchFirmwareBySlot_SkipsERoTFirmware(t *testing.T) {
|
||||||
|
got := buildNVSwitchFirmwareBySlot([]models.FirmwareInfo{
|
||||||
|
{DeviceName: "HGX_FW_ERoT_NVSwitch_0", Version: "00.02.0192.0000_n00"},
|
||||||
|
{DeviceName: "HGX_FW_NVSwitch_0", Version: "96.10.73.00.01"},
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("expected only main NVSwitch firmware to remain, got %#v", got)
|
||||||
|
}
|
||||||
|
if got["NVSWITCH_0"] != "96.10.73.00.01" {
|
||||||
|
t.Fatalf("expected main NVSwitch firmware, got %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestConvertPCIeDevices_SkipsDisplayControllerDuplicates(t *testing.T) {
|
func TestConvertPCIeDevices_SkipsDisplayControllerDuplicates(t *testing.T) {
|
||||||
hw := &models.HardwareConfig{
|
hw := &models.HardwareConfig{
|
||||||
PCIeDevices: []models.PCIeDevice{
|
PCIeDevices: []models.PCIeDevice{
|
||||||
@@ -449,7 +692,7 @@ func TestConvertPCIeDevices_SkipsDisplayControllerDuplicates(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
result := convertPCIeDevices(hw, "2026-02-10T15:30:00Z", "BOARD-001")
|
result := convertPCIeDevices(hw, "2026-02-10T15:30:00Z")
|
||||||
if len(result) != 1 {
|
if len(result) != 1 {
|
||||||
t.Fatalf("expected only dedicated GPU record without duplicate display PCIe, got %d", len(result))
|
t.Fatalf("expected only dedicated GPU record without duplicate display PCIe, got %d", len(result))
|
||||||
}
|
}
|
||||||
@@ -482,7 +725,7 @@ func TestConvertPCIeDevices_MapsGPUStatusHistory(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
result := convertPCIeDevices(hw, "2026-02-10T15:30:00Z", "BOARD-001")
|
result := convertPCIeDevices(hw, "2026-02-10T15:30:00Z")
|
||||||
if len(result) != 1 {
|
if len(result) != 1 {
|
||||||
t.Fatalf("expected 1 converted GPU, got %d", len(result))
|
t.Fatalf("expected 1 converted GPU, got %d", len(result))
|
||||||
}
|
}
|
||||||
@@ -667,11 +910,11 @@ func TestConvertToReanimator_DeduplicatesAllSections(t *testing.T) {
|
|||||||
if len(out.Hardware.Firmware) != 1 {
|
if len(out.Hardware.Firmware) != 1 {
|
||||||
t.Fatalf("expected deduped firmware len=1, got %d", len(out.Hardware.Firmware))
|
t.Fatalf("expected deduped firmware len=1, got %d", len(out.Hardware.Firmware))
|
||||||
}
|
}
|
||||||
if len(out.Hardware.CPUs) != 2 {
|
if len(out.Hardware.CPUs) != 1 {
|
||||||
t.Fatalf("expected cpus len=2 (no serial/bdf dedupe), got %d", len(out.Hardware.CPUs))
|
t.Fatalf("expected cpus len=1 after socket dedupe, got %d", len(out.Hardware.CPUs))
|
||||||
}
|
}
|
||||||
if len(out.Hardware.Memory) != 2 {
|
if len(out.Hardware.Memory) != 1 {
|
||||||
t.Fatalf("expected memory len=2 (different serials), got %d", len(out.Hardware.Memory))
|
t.Fatalf("expected memory len=1 after slot dedupe, got %d", len(out.Hardware.Memory))
|
||||||
}
|
}
|
||||||
if len(out.Hardware.Storage) != 1 {
|
if len(out.Hardware.Storage) != 1 {
|
||||||
t.Fatalf("expected deduped storage len=1, got %d", len(out.Hardware.Storage))
|
t.Fatalf("expected deduped storage len=1, got %d", len(out.Hardware.Storage))
|
||||||
@@ -679,8 +922,8 @@ func TestConvertToReanimator_DeduplicatesAllSections(t *testing.T) {
|
|||||||
if len(out.Hardware.PowerSupplies) != 1 {
|
if len(out.Hardware.PowerSupplies) != 1 {
|
||||||
t.Fatalf("expected deduped psu len=1, got %d", len(out.Hardware.PowerSupplies))
|
t.Fatalf("expected deduped psu len=1, got %d", len(out.Hardware.PowerSupplies))
|
||||||
}
|
}
|
||||||
if len(out.Hardware.PCIeDevices) != 4 {
|
if len(out.Hardware.PCIeDevices) != 2 {
|
||||||
t.Fatalf("expected pcie len=4 with serial->bdf dedupe, got %d", len(out.Hardware.PCIeDevices))
|
t.Fatalf("expected pcie len=2 after final pcie-class dedupe, got %d", len(out.Hardware.PCIeDevices))
|
||||||
}
|
}
|
||||||
|
|
||||||
gpuCount := 0
|
gpuCount := 0
|
||||||
@@ -689,8 +932,8 @@ func TestConvertToReanimator_DeduplicatesAllSections(t *testing.T) {
|
|||||||
gpuCount++
|
gpuCount++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if gpuCount != 2 {
|
if gpuCount != 1 {
|
||||||
t.Fatalf("expected two #GPU0 records (pcie+gpu kinds), got %d", gpuCount)
|
t.Fatalf("expected one merged #GPU0 record, got %d", gpuCount)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -914,6 +1157,143 @@ func TestConvertToReanimator_MergesCanonicalAndLegacyDevices(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestConvertToReanimator_DoesNotMergeStorageIntoPCIeBySharedSerial(t *testing.T) {
|
||||||
|
input := &models.AnalysisResult{
|
||||||
|
Filename: "nvme-redfish.json",
|
||||||
|
Hardware: &models.HardwareConfig{
|
||||||
|
BoardInfo: models.BoardInfo{SerialNumber: "BOARD-001"},
|
||||||
|
Storage: []models.Storage{
|
||||||
|
{
|
||||||
|
Slot: "Disk.Bay.0",
|
||||||
|
Type: "NVMe",
|
||||||
|
Model: "MZQL21T9HCJR-00A07",
|
||||||
|
SerialNumber: "S64GNNFX612200",
|
||||||
|
Manufacturer: "Samsung",
|
||||||
|
Firmware: "GDC5A02Q",
|
||||||
|
Present: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
PCIeDevices: []models.PCIeDevice{
|
||||||
|
{
|
||||||
|
Slot: "NVMeSSD1",
|
||||||
|
BDF: "0000:81:00.0",
|
||||||
|
DeviceClass: "MassStorageController",
|
||||||
|
Description: "MZQL21T9HCJR-00A07",
|
||||||
|
SerialNumber: "S64GNNFX612200",
|
||||||
|
Manufacturer: "Samsung",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := ConvertToReanimator(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ConvertToReanimator() failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(out.Hardware.Storage) != 1 {
|
||||||
|
t.Fatalf("expected storage record to survive shared-serial canonical merge, got %d", len(out.Hardware.Storage))
|
||||||
|
}
|
||||||
|
if out.Hardware.Storage[0].Slot != "Disk.Bay.0" {
|
||||||
|
t.Fatalf("expected storage slot Disk.Bay.0, got %q", out.Hardware.Storage[0].Slot)
|
||||||
|
}
|
||||||
|
if len(out.Hardware.PCIeDevices) != 0 {
|
||||||
|
t.Fatalf("expected NVMe storage endpoint to be excluded from pcie export, got %d records", len(out.Hardware.PCIeDevices))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertToReanimator_LeavesStorageControllersInPCIe(t *testing.T) {
|
||||||
|
input := &models.AnalysisResult{
|
||||||
|
Hardware: &models.HardwareConfig{
|
||||||
|
BoardInfo: models.BoardInfo{SerialNumber: "BOARD-123"},
|
||||||
|
PCIeDevices: []models.PCIeDevice{
|
||||||
|
{
|
||||||
|
Slot: "PCIe Slot 3",
|
||||||
|
BDF: "0000:5e:00.0",
|
||||||
|
DeviceClass: "MassStorageController",
|
||||||
|
Description: "MegaRAID Controller",
|
||||||
|
PartNumber: "PERC H755",
|
||||||
|
SerialNumber: "RAID-001",
|
||||||
|
Manufacturer: "Dell",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := ConvertToReanimator(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ConvertToReanimator() failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(out.Hardware.PCIeDevices) != 1 {
|
||||||
|
t.Fatalf("expected RAID controller to remain in pcie export, got %d", len(out.Hardware.PCIeDevices))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertToReanimator_PCIePlaceholderModelFallsBackToPCIIDs(t *testing.T) {
|
||||||
|
input := &models.AnalysisResult{
|
||||||
|
Hardware: &models.HardwareConfig{
|
||||||
|
BoardInfo: models.BoardInfo{SerialNumber: "BOARD-123"},
|
||||||
|
Devices: []models.HardwareDevice{
|
||||||
|
{
|
||||||
|
Kind: models.DeviceKindNetwork,
|
||||||
|
Slot: "NIC1",
|
||||||
|
Model: "Network Device View",
|
||||||
|
VendorID: 0x15b3,
|
||||||
|
DeviceID: 0x101d,
|
||||||
|
Manufacturer: "Mellanox",
|
||||||
|
Present: boolPtr(true),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := ConvertToReanimator(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ConvertToReanimator() failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(out.Hardware.PCIeDevices) != 1 {
|
||||||
|
t.Fatalf("expected one pcie export, got %d", len(out.Hardware.PCIeDevices))
|
||||||
|
}
|
||||||
|
if strings.EqualFold(out.Hardware.PCIeDevices[0].Model, "Network Device View") {
|
||||||
|
t.Fatalf("expected placeholder model to be replaced, got %q", out.Hardware.PCIeDevices[0].Model)
|
||||||
|
}
|
||||||
|
if out.Hardware.PCIeDevices[0].SerialNumber != "" {
|
||||||
|
t.Fatalf("expected missing pcie serial to stay empty, got %q", out.Hardware.PCIeDevices[0].SerialNumber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertToReanimator_SkipsPlaceholderNetworkPCIeRecords(t *testing.T) {
|
||||||
|
input := &models.AnalysisResult{
|
||||||
|
Hardware: &models.HardwareConfig{
|
||||||
|
BoardInfo: models.BoardInfo{SerialNumber: "BOARD-123"},
|
||||||
|
Devices: []models.HardwareDevice{
|
||||||
|
{
|
||||||
|
Kind: models.DeviceKindNetwork,
|
||||||
|
Slot: "1",
|
||||||
|
Status: "Unknown",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Kind: models.DeviceKindNetwork,
|
||||||
|
Slot: "NIC2",
|
||||||
|
Model: "ConnectX-7",
|
||||||
|
Manufacturer: "NVIDIA",
|
||||||
|
Present: boolPtr(true),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := ConvertToReanimator(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ConvertToReanimator() failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(out.Hardware.PCIeDevices) != 1 {
|
||||||
|
t.Fatalf("expected only one meaningful pcie-class device, got %d", len(out.Hardware.PCIeDevices))
|
||||||
|
}
|
||||||
|
if out.Hardware.PCIeDevices[0].Slot != "NIC2" {
|
||||||
|
t.Fatalf("expected placeholder numeric-slot NIC to be skipped, got %+v", out.Hardware.PCIeDevices)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestConvertToReanimator_ExportsSensorsAndPSUTelemetry(t *testing.T) {
|
func TestConvertToReanimator_ExportsSensorsAndPSUTelemetry(t *testing.T) {
|
||||||
input := &models.AnalysisResult{
|
input := &models.AnalysisResult{
|
||||||
Filename: "vitals.json",
|
Filename: "vitals.json",
|
||||||
@@ -987,6 +1367,60 @@ func TestConvertToReanimator_ExportsSensorsAndPSUTelemetry(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestConvertToReanimator_SkipsSensorsWithoutNumericReadings(t *testing.T) {
|
||||||
|
input := &models.AnalysisResult{
|
||||||
|
Filename: "sensor-gaps.json",
|
||||||
|
Sensors: []models.SensorReading{
|
||||||
|
{Name: "CPU0 Temp", Type: "temperature", Status: "OK", RawValue: "N/A"},
|
||||||
|
{Name: "PSU1 Power", Type: "power", Status: "OK", RawValue: ""},
|
||||||
|
{Name: "Fan1", Type: "fan", Status: "OK", RawValue: "not present"},
|
||||||
|
{Name: "Humidity", Type: "humidity", Status: "OK", RawValue: "unknown"},
|
||||||
|
},
|
||||||
|
Hardware: &models.HardwareConfig{
|
||||||
|
BoardInfo: models.BoardInfo{SerialNumber: "BOARD-001"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := ConvertToReanimator(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ConvertToReanimator() failed: %v", err)
|
||||||
|
}
|
||||||
|
if out.Hardware.Sensors != nil {
|
||||||
|
t.Fatalf("expected sensors to be omitted when all readings are non-numeric, got %+v", out.Hardware.Sensors)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertToReanimator_MergesSiblingPowerSensors(t *testing.T) {
|
||||||
|
input := &models.AnalysisResult{
|
||||||
|
Filename: "power-sensors.json",
|
||||||
|
Sensors: []models.SensorReading{
|
||||||
|
{Name: "Power Supply Bay 8_InputPower", Type: "power", Value: 231, Unit: "W", Status: "OK"},
|
||||||
|
{Name: "Power Supply Bay 8_InputVoltage", Type: "voltage", Value: 228, Unit: "V", Status: "OK"},
|
||||||
|
},
|
||||||
|
Hardware: &models.HardwareConfig{
|
||||||
|
BoardInfo: models.BoardInfo{SerialNumber: "BOARD-001"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := ConvertToReanimator(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ConvertToReanimator() failed: %v", err)
|
||||||
|
}
|
||||||
|
if out.Hardware.Sensors == nil || len(out.Hardware.Sensors.Power) != 1 {
|
||||||
|
t.Fatalf("expected one merged power sensor, got %#v", out.Hardware.Sensors)
|
||||||
|
}
|
||||||
|
got := out.Hardware.Sensors.Power[0]
|
||||||
|
if got.Name != "Power Supply Bay 8" {
|
||||||
|
t.Fatalf("expected merged sensor name, got %q", got.Name)
|
||||||
|
}
|
||||||
|
if got.PowerW != 231 || got.VoltageV != 228 {
|
||||||
|
t.Fatalf("expected merged power/voltage readings, got %#v", got)
|
||||||
|
}
|
||||||
|
if got.Location != "" {
|
||||||
|
t.Fatalf("expected sensor location to be omitted, got %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestConvertToReanimator_PreservesCanonicalDedupWithoutDeviceVitals(t *testing.T) {
|
func TestConvertToReanimator_PreservesCanonicalDedupWithoutDeviceVitals(t *testing.T) {
|
||||||
input := &models.AnalysisResult{
|
input := &models.AnalysisResult{
|
||||||
Filename: "dedup-vitals.json",
|
Filename: "dedup-vitals.json",
|
||||||
@@ -1336,6 +1770,7 @@ func TestIsDeviceBoundFirmwareName(t *testing.T) {
|
|||||||
{"NVMe Drive", true},
|
{"NVMe Drive", true},
|
||||||
// HGX FW ID patterns (in case Id is used as name)
|
// HGX FW ID patterns (in case Id is used as name)
|
||||||
{"HGX_FW_GPU_SXM_1", true},
|
{"HGX_FW_GPU_SXM_1", true},
|
||||||
|
{"HGX_FW_ERoT_NVSwitch_0", true},
|
||||||
{"HGX_InfoROM_GPU_SXM_2", true},
|
{"HGX_InfoROM_GPU_SXM_2", true},
|
||||||
// System-level firmware — must NOT be excluded
|
// System-level firmware — must NOT be excluded
|
||||||
{"BIOS", false},
|
{"BIOS", false},
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ type ReanimatorHardware struct {
|
|||||||
PCIeDevices []ReanimatorPCIe `json:"pcie_devices,omitempty"`
|
PCIeDevices []ReanimatorPCIe `json:"pcie_devices,omitempty"`
|
||||||
PowerSupplies []ReanimatorPSU `json:"power_supplies,omitempty"`
|
PowerSupplies []ReanimatorPSU `json:"power_supplies,omitempty"`
|
||||||
Sensors *ReanimatorSensors `json:"sensors,omitempty"`
|
Sensors *ReanimatorSensors `json:"sensors,omitempty"`
|
||||||
|
EventLogs []ReanimatorEventLog `json:"event_logs,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReanimatorBoard represents motherboard/server information
|
// ReanimatorBoard represents motherboard/server information
|
||||||
@@ -65,6 +66,7 @@ type ReanimatorCPU struct {
|
|||||||
Status string `json:"status,omitempty"`
|
Status string `json:"status,omitempty"`
|
||||||
StatusCheckedAt string `json:"status_checked_at,omitempty"`
|
StatusCheckedAt string `json:"status_checked_at,omitempty"`
|
||||||
StatusChangedAt string `json:"status_changed_at,omitempty"`
|
StatusChangedAt string `json:"status_changed_at,omitempty"`
|
||||||
|
ManufacturedYearWeek string `json:"manufactured_year_week,omitempty"`
|
||||||
StatusHistory []ReanimatorStatusHistoryEntry `json:"status_history,omitempty"`
|
StatusHistory []ReanimatorStatusHistoryEntry `json:"status_history,omitempty"`
|
||||||
ErrorDescription string `json:"error_description,omitempty"`
|
ErrorDescription string `json:"error_description,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -92,6 +94,7 @@ type ReanimatorMemory struct {
|
|||||||
Status string `json:"status,omitempty"`
|
Status string `json:"status,omitempty"`
|
||||||
StatusCheckedAt string `json:"status_checked_at,omitempty"`
|
StatusCheckedAt string `json:"status_checked_at,omitempty"`
|
||||||
StatusChangedAt string `json:"status_changed_at,omitempty"`
|
StatusChangedAt string `json:"status_changed_at,omitempty"`
|
||||||
|
ManufacturedYearWeek string `json:"manufactured_year_week,omitempty"`
|
||||||
StatusHistory []ReanimatorStatusHistoryEntry `json:"status_history,omitempty"`
|
StatusHistory []ReanimatorStatusHistoryEntry `json:"status_history,omitempty"`
|
||||||
ErrorDescription string `json:"error_description,omitempty"`
|
ErrorDescription string `json:"error_description,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -125,6 +128,7 @@ type ReanimatorStorage struct {
|
|||||||
Status string `json:"status,omitempty"`
|
Status string `json:"status,omitempty"`
|
||||||
StatusCheckedAt string `json:"status_checked_at,omitempty"`
|
StatusCheckedAt string `json:"status_checked_at,omitempty"`
|
||||||
StatusChangedAt string `json:"status_changed_at,omitempty"`
|
StatusChangedAt string `json:"status_changed_at,omitempty"`
|
||||||
|
ManufacturedYearWeek string `json:"manufactured_year_week,omitempty"`
|
||||||
StatusHistory []ReanimatorStatusHistoryEntry `json:"status_history,omitempty"`
|
StatusHistory []ReanimatorStatusHistoryEntry `json:"status_history,omitempty"`
|
||||||
ErrorDescription string `json:"error_description,omitempty"`
|
ErrorDescription string `json:"error_description,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -152,7 +156,7 @@ type ReanimatorPCIe struct {
|
|||||||
SFPRXPowerDBm float64 `json:"sfp_rx_power_dbm,omitempty"`
|
SFPRXPowerDBm float64 `json:"sfp_rx_power_dbm,omitempty"`
|
||||||
SFPVoltageV float64 `json:"sfp_voltage_v,omitempty"`
|
SFPVoltageV float64 `json:"sfp_voltage_v,omitempty"`
|
||||||
SFPBiasMA float64 `json:"sfp_bias_ma,omitempty"`
|
SFPBiasMA float64 `json:"sfp_bias_ma,omitempty"`
|
||||||
BDF string `json:"bdf,omitempty"`
|
BDF string `json:"-"`
|
||||||
DeviceClass string `json:"device_class,omitempty"`
|
DeviceClass string `json:"device_class,omitempty"`
|
||||||
Manufacturer string `json:"manufacturer,omitempty"`
|
Manufacturer string `json:"manufacturer,omitempty"`
|
||||||
Model string `json:"model,omitempty"`
|
Model string `json:"model,omitempty"`
|
||||||
@@ -167,6 +171,7 @@ type ReanimatorPCIe struct {
|
|||||||
Status string `json:"status,omitempty"`
|
Status string `json:"status,omitempty"`
|
||||||
StatusCheckedAt string `json:"status_checked_at,omitempty"`
|
StatusCheckedAt string `json:"status_checked_at,omitempty"`
|
||||||
StatusChangedAt string `json:"status_changed_at,omitempty"`
|
StatusChangedAt string `json:"status_changed_at,omitempty"`
|
||||||
|
ManufacturedYearWeek string `json:"manufactured_year_week,omitempty"`
|
||||||
StatusHistory []ReanimatorStatusHistoryEntry `json:"status_history,omitempty"`
|
StatusHistory []ReanimatorStatusHistoryEntry `json:"status_history,omitempty"`
|
||||||
ErrorDescription string `json:"error_description,omitempty"`
|
ErrorDescription string `json:"error_description,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -191,10 +196,23 @@ type ReanimatorPSU struct {
|
|||||||
LifeUsedPct float64 `json:"life_used_pct,omitempty"`
|
LifeUsedPct float64 `json:"life_used_pct,omitempty"`
|
||||||
StatusCheckedAt string `json:"status_checked_at,omitempty"`
|
StatusCheckedAt string `json:"status_checked_at,omitempty"`
|
||||||
StatusChangedAt string `json:"status_changed_at,omitempty"`
|
StatusChangedAt string `json:"status_changed_at,omitempty"`
|
||||||
|
ManufacturedYearWeek string `json:"manufactured_year_week,omitempty"`
|
||||||
StatusHistory []ReanimatorStatusHistoryEntry `json:"status_history,omitempty"`
|
StatusHistory []ReanimatorStatusHistoryEntry `json:"status_history,omitempty"`
|
||||||
ErrorDescription string `json:"error_description,omitempty"`
|
ErrorDescription string `json:"error_description,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ReanimatorEventLog struct {
|
||||||
|
Source string `json:"source"`
|
||||||
|
EventTime string `json:"event_time,omitempty"`
|
||||||
|
Severity string `json:"severity,omitempty"`
|
||||||
|
MessageID string `json:"message_id,omitempty"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
ComponentRef string `json:"component_ref,omitempty"`
|
||||||
|
Fingerprint string `json:"fingerprint,omitempty"`
|
||||||
|
IsActive *bool `json:"is_active,omitempty"`
|
||||||
|
RawPayload map[string]any `json:"raw_payload,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type ReanimatorSensors struct {
|
type ReanimatorSensors struct {
|
||||||
Fans []ReanimatorFanSensor `json:"fans,omitempty"`
|
Fans []ReanimatorFanSensor `json:"fans,omitempty"`
|
||||||
Power []ReanimatorPowerSensor `json:"power,omitempty"`
|
Power []ReanimatorPowerSensor `json:"power,omitempty"`
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package parser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/logpile/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
var manufacturedYearWeekPattern = regexp.MustCompile(`^\d{4}-W\d{2}$`)
|
||||||
|
|
||||||
|
// NormalizeManufacturedYearWeek converts common FRU manufacturing date formats
|
||||||
|
// into contract-compatible YYYY-Www values. Unknown or ambiguous inputs return "".
|
||||||
|
func NormalizeManufacturedYearWeek(raw string) string {
|
||||||
|
value := strings.TrimSpace(raw)
|
||||||
|
if value == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
upper := strings.ToUpper(value)
|
||||||
|
if manufacturedYearWeekPattern.MatchString(upper) {
|
||||||
|
return upper
|
||||||
|
}
|
||||||
|
|
||||||
|
layouts := []string{
|
||||||
|
time.RFC3339,
|
||||||
|
"2006-01-02T15:04:05",
|
||||||
|
"2006-01-02 15:04:05",
|
||||||
|
"2006-01-02",
|
||||||
|
"2006/01/02",
|
||||||
|
"01/02/2006 15:04:05",
|
||||||
|
"01/02/2006",
|
||||||
|
"01-02-2006",
|
||||||
|
"Mon Jan 2 15:04:05 2006",
|
||||||
|
"Mon Jan _2 15:04:05 2006",
|
||||||
|
"Jan 2 2006",
|
||||||
|
"Jan _2 2006",
|
||||||
|
}
|
||||||
|
for _, layout := range layouts {
|
||||||
|
if ts, err := time.Parse(layout, value); err == nil {
|
||||||
|
year, week := ts.ISOWeek()
|
||||||
|
return formatYearWeek(year, week)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatYearWeek(year, week int) string {
|
||||||
|
if year <= 0 || week <= 0 || week > 53 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%04d-W%02d", year, week)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyManufacturedYearWeekFromFRU attaches normalized manufactured_year_week to
|
||||||
|
// component details by exact serial-number match. Board-level FRU entries are not
|
||||||
|
// expanded to components.
|
||||||
|
func ApplyManufacturedYearWeekFromFRU(frus []models.FRUInfo, hw *models.HardwareConfig) {
|
||||||
|
if hw == nil || len(frus) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bySerial := make(map[string]string, len(frus))
|
||||||
|
for _, fru := range frus {
|
||||||
|
serial := normalizeFRUSerial(fru.SerialNumber)
|
||||||
|
yearWeek := NormalizeManufacturedYearWeek(fru.MfgDate)
|
||||||
|
if serial == "" || yearWeek == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, exists := bySerial[serial]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
bySerial[serial] = yearWeek
|
||||||
|
}
|
||||||
|
if len(bySerial) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range hw.CPUs {
|
||||||
|
attachYearWeek(&hw.CPUs[i].Details, bySerial[normalizeFRUSerial(hw.CPUs[i].SerialNumber)])
|
||||||
|
}
|
||||||
|
for i := range hw.Memory {
|
||||||
|
attachYearWeek(&hw.Memory[i].Details, bySerial[normalizeFRUSerial(hw.Memory[i].SerialNumber)])
|
||||||
|
}
|
||||||
|
for i := range hw.Storage {
|
||||||
|
attachYearWeek(&hw.Storage[i].Details, bySerial[normalizeFRUSerial(hw.Storage[i].SerialNumber)])
|
||||||
|
}
|
||||||
|
for i := range hw.PCIeDevices {
|
||||||
|
attachYearWeek(&hw.PCIeDevices[i].Details, bySerial[normalizeFRUSerial(hw.PCIeDevices[i].SerialNumber)])
|
||||||
|
}
|
||||||
|
for i := range hw.GPUs {
|
||||||
|
attachYearWeek(&hw.GPUs[i].Details, bySerial[normalizeFRUSerial(hw.GPUs[i].SerialNumber)])
|
||||||
|
}
|
||||||
|
for i := range hw.NetworkAdapters {
|
||||||
|
attachYearWeek(&hw.NetworkAdapters[i].Details, bySerial[normalizeFRUSerial(hw.NetworkAdapters[i].SerialNumber)])
|
||||||
|
}
|
||||||
|
for i := range hw.PowerSupply {
|
||||||
|
attachYearWeek(&hw.PowerSupply[i].Details, bySerial[normalizeFRUSerial(hw.PowerSupply[i].SerialNumber)])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func attachYearWeek(details *map[string]any, yearWeek string) {
|
||||||
|
if yearWeek == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if *details == nil {
|
||||||
|
*details = map[string]any{}
|
||||||
|
}
|
||||||
|
if existing, ok := (*details)["manufactured_year_week"]; ok && strings.TrimSpace(toString(existing)) != "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
(*details)["manufactured_year_week"] = yearWeek
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeFRUSerial(v string) string {
|
||||||
|
s := strings.TrimSpace(v)
|
||||||
|
if s == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
switch strings.ToUpper(s) {
|
||||||
|
case "N/A", "NA", "NULL", "UNKNOWN", "-", "0":
|
||||||
|
return ""
|
||||||
|
default:
|
||||||
|
return strings.ToUpper(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toString(v any) string {
|
||||||
|
switch x := v.(type) {
|
||||||
|
case string:
|
||||||
|
return x
|
||||||
|
default:
|
||||||
|
return strings.TrimSpace(fmt.Sprint(v))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package parser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/logpile/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNormalizeManufacturedYearWeek(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"2024-W07", "2024-W07"},
|
||||||
|
{"2024-02-13", "2024-W07"},
|
||||||
|
{"02/13/2024", "2024-W07"},
|
||||||
|
{"Tue Feb 13 12:00:00 2024", "2024-W07"},
|
||||||
|
{"", ""},
|
||||||
|
{"not-a-date", ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
if got := NormalizeManufacturedYearWeek(tt.in); got != tt.want {
|
||||||
|
t.Fatalf("NormalizeManufacturedYearWeek(%q) = %q, want %q", tt.in, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyManufacturedYearWeekFromFRU_AttachesByExactSerial(t *testing.T) {
|
||||||
|
hw := &models.HardwareConfig{
|
||||||
|
PowerSupply: []models.PSU{
|
||||||
|
{
|
||||||
|
Slot: "PSU0",
|
||||||
|
SerialNumber: "PSU-SN-001",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Storage: []models.Storage{
|
||||||
|
{
|
||||||
|
Slot: "OB01",
|
||||||
|
SerialNumber: "DISK-SN-001",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
fru := []models.FRUInfo{
|
||||||
|
{
|
||||||
|
Description: "PSU0_FRU (ID 30)",
|
||||||
|
SerialNumber: "PSU-SN-001",
|
||||||
|
MfgDate: "2024-02-13",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Description: "Builtin FRU Device (ID 0)",
|
||||||
|
SerialNumber: "BOARD-SN-001",
|
||||||
|
MfgDate: "2024-02-01",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
ApplyManufacturedYearWeekFromFRU(fru, hw)
|
||||||
|
|
||||||
|
if got := hw.PowerSupply[0].Details["manufactured_year_week"]; got != "2024-W07" {
|
||||||
|
t.Fatalf("expected PSU year week 2024-W07, got %#v", hw.PowerSupply[0].Details)
|
||||||
|
}
|
||||||
|
if hw.Storage[0].Details != nil {
|
||||||
|
t.Fatalf("expected unmatched storage serial to stay untouched, got %#v", hw.Storage[0].Details)
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
@@ -216,6 +216,7 @@ func parseH3CG5(files []parser.ExtractedFile) *models.AnalysisResult {
|
|||||||
}
|
}
|
||||||
result.Hardware.Storage = dedupeStorage(result.Hardware.Storage)
|
result.Hardware.Storage = dedupeStorage(result.Hardware.Storage)
|
||||||
result.Hardware.Volumes = dedupeVolumes(result.Hardware.Volumes)
|
result.Hardware.Volumes = dedupeVolumes(result.Hardware.Volumes)
|
||||||
|
parser.ApplyManufacturedYearWeekFromFRU(result.FRU, result.Hardware)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
@@ -286,6 +287,7 @@ func parseH3CG6(files []parser.ExtractedFile) *models.AnalysisResult {
|
|||||||
}
|
}
|
||||||
result.Hardware.Storage = dedupeStorage(result.Hardware.Storage)
|
result.Hardware.Storage = dedupeStorage(result.Hardware.Storage)
|
||||||
result.Hardware.Volumes = dedupeVolumes(result.Hardware.Volumes)
|
result.Hardware.Volumes = dedupeVolumes(result.Hardware.Volumes)
|
||||||
|
parser.ApplyManufacturedYearWeekFromFRU(result.FRU, result.Hardware)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package inspur
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestParseIDLLog_UsesBMCSourceForEventLogs(t *testing.T) {
|
||||||
|
content := []byte(`|2025-12-02T17:54:27+08:00|MEMORY|Assert|Warning|0C180401|CPU1_C4D0 Memory Device Disabled - Assert|`)
|
||||||
|
|
||||||
|
events := ParseIDLLog(content)
|
||||||
|
if len(events) != 1 {
|
||||||
|
t.Fatalf("expected 1 event, got %d", len(events))
|
||||||
|
}
|
||||||
|
if events[0].Source != "BMC" {
|
||||||
|
t.Fatalf("expected IDL events to use BMC source, got %#v", events[0])
|
||||||
|
}
|
||||||
|
if events[0].SensorName != "CPU1_C4D0" {
|
||||||
|
t.Fatalf("expected extracted DIMM component ref, got %#v", events[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseSyslog_UsesHostSourceAndProcessAsSensorName(t *testing.T) {
|
||||||
|
content := []byte(`<13>2026-03-15T14:03:11+00:00 host123 systemd[1]: Started Example Service`)
|
||||||
|
|
||||||
|
events := ParseSyslog(content, "syslog/info")
|
||||||
|
if len(events) != 1 {
|
||||||
|
t.Fatalf("expected 1 event, got %d", len(events))
|
||||||
|
}
|
||||||
|
if events[0].Source != "syslog" {
|
||||||
|
t.Fatalf("expected syslog source, got %#v", events[0])
|
||||||
|
}
|
||||||
|
if events[0].SensorName != "systemd[1]" {
|
||||||
|
t.Fatalf("expected process name in sensor/component slot, got %#v", events[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-2
@@ -165,7 +165,10 @@ func TestParseIDLLog_ParsesStructuredJSONLine(t *testing.T) {
|
|||||||
if events[0].ID != "17FFB002" {
|
if events[0].ID != "17FFB002" {
|
||||||
t.Fatalf("expected event ID 17FFB002, got %q", events[0].ID)
|
t.Fatalf("expected event ID 17FFB002, got %q", events[0].ID)
|
||||||
}
|
}
|
||||||
if events[0].Source != "PCIE" {
|
if events[0].Source != "BMC" {
|
||||||
t.Fatalf("expected source PCIE, got %q", events[0].Source)
|
t.Fatalf("expected BMC source for IDL event, got %q", events[0].Source)
|
||||||
|
}
|
||||||
|
if events[0].SensorType != "pcie" {
|
||||||
|
t.Fatalf("expected component type pcie, got %#v", events[0])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -60,7 +60,7 @@ func ParseIDLLog(content []byte) []models.Event {
|
|||||||
events = append(events, models.Event{
|
events = append(events, models.Event{
|
||||||
ID: eventID,
|
ID: eventID,
|
||||||
Timestamp: ts,
|
Timestamp: ts,
|
||||||
Source: component,
|
Source: "BMC",
|
||||||
SensorType: strings.ToLower(component),
|
SensorType: strings.ToLower(component),
|
||||||
SensorName: sensorName,
|
SensorName: sensorName,
|
||||||
EventType: eventType,
|
EventType: eventType,
|
||||||
|
|||||||
+1
@@ -217,6 +217,7 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er
|
|||||||
// Apply RAID disk serials from audit.log (authoritative: last non-NULL SN change).
|
// Apply RAID disk serials from audit.log (authoritative: last non-NULL SN change).
|
||||||
// These override redis/component.log serials which may be stale after disk replacement.
|
// These override redis/component.log serials which may be stale after disk replacement.
|
||||||
applyRAIDSlotSerials(result.Hardware, raidSlotSerials)
|
applyRAIDSlotSerials(result.Hardware, raidSlotSerials)
|
||||||
|
parser.ApplyManufacturedYearWeekFromFRU(result.FRU, result.Hardware)
|
||||||
}
|
}
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
|
|||||||
+2
-2
@@ -48,9 +48,9 @@ func ParseSyslog(content []byte, sourcePath string) []models.Event {
|
|||||||
event := models.Event{
|
event := models.Event{
|
||||||
ID: generateEventID(sourcePath, lineNum),
|
ID: generateEventID(sourcePath, lineNum),
|
||||||
Timestamp: timestamp,
|
Timestamp: timestamp,
|
||||||
Source: matches[4],
|
Source: "syslog",
|
||||||
SensorType: "syslog",
|
SensorType: "syslog",
|
||||||
SensorName: matches[3],
|
SensorName: matches[4],
|
||||||
Description: matches[5],
|
Description: matches[5],
|
||||||
Severity: severity,
|
Severity: severity,
|
||||||
RawData: line,
|
RawData: line,
|
||||||
|
|||||||
@@ -18,12 +18,46 @@ func newCollectTestServer() (*Server, *httptest.Server) {
|
|||||||
collectors: testCollectorRegistry(),
|
collectors: testCollectorRegistry(),
|
||||||
}
|
}
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("POST /api/collect/probe", s.handleCollectProbe)
|
||||||
mux.HandleFunc("POST /api/collect", s.handleCollectStart)
|
mux.HandleFunc("POST /api/collect", s.handleCollectStart)
|
||||||
mux.HandleFunc("GET /api/collect/{id}", s.handleCollectStatus)
|
mux.HandleFunc("GET /api/collect/{id}", s.handleCollectStatus)
|
||||||
mux.HandleFunc("POST /api/collect/{id}/cancel", s.handleCollectCancel)
|
mux.HandleFunc("POST /api/collect/{id}/cancel", s.handleCollectCancel)
|
||||||
return s, httptest.NewServer(mux)
|
return s, httptest.NewServer(mux)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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"}`
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload CollectProbeResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||||
|
t.Fatalf("decode probe response: %v", err)
|
||||||
|
}
|
||||||
|
if !payload.Reachable {
|
||||||
|
t.Fatalf("expected reachable=true, got false")
|
||||||
|
}
|
||||||
|
if payload.HostPoweredOn {
|
||||||
|
t.Fatalf("expected host powered off in probe response")
|
||||||
|
}
|
||||||
|
if payload.HostPowerState != "Off" {
|
||||||
|
t.Fatalf("expected host power state Off, got %q", payload.HostPowerState)
|
||||||
|
}
|
||||||
|
if !payload.PowerControlAvailable {
|
||||||
|
t.Fatalf("expected power control to be available")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCollectLifecycleToTerminal(t *testing.T) {
|
func TestCollectLifecycleToTerminal(t *testing.T) {
|
||||||
_, ts := newCollectTestServer()
|
_, ts := newCollectTestServer()
|
||||||
defer ts.Close()
|
defer ts.Close()
|
||||||
|
|||||||
@@ -17,6 +17,20 @@ func (c *mockConnector) Protocol() string {
|
|||||||
return c.protocol
|
return c.protocol
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *mockConnector) Probe(ctx context.Context, req collector.Request) (*collector.ProbeResult, error) {
|
||||||
|
if strings.Contains(strings.ToLower(req.Host), "fail") {
|
||||||
|
return nil, context.DeadlineExceeded
|
||||||
|
}
|
||||||
|
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"),
|
||||||
|
PowerControlAvailable: true,
|
||||||
|
SystemPath: "/redfish/v1/Systems/1",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *mockConnector) Collect(ctx context.Context, req collector.Request, emit collector.ProgressFn) (*models.AnalysisResult, error) {
|
func (c *mockConnector) Collect(ctx context.Context, req collector.Request, emit collector.ProgressFn) (*models.AnalysisResult, error) {
|
||||||
steps := []collector.Progress{
|
steps := []collector.Progress{
|
||||||
{Status: CollectStatusRunning, Progress: 20, Message: "Подключение..."},
|
{Status: CollectStatusRunning, Progress: 20, Message: "Подключение..."},
|
||||||
|
|||||||
@@ -19,6 +19,16 @@ type CollectRequest struct {
|
|||||||
Password string `json:"password,omitempty"`
|
Password string `json:"password,omitempty"`
|
||||||
Token string `json:"token,omitempty"`
|
Token string `json:"token,omitempty"`
|
||||||
TLSMode string `json:"tls_mode"`
|
TLSMode string `json:"tls_mode"`
|
||||||
|
PowerOnIfHostOff bool `json:"power_on_if_host_off,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CollectProbeResponse struct {
|
||||||
|
Reachable bool `json:"reachable"`
|
||||||
|
Protocol string `json:"protocol,omitempty"`
|
||||||
|
HostPowerState string `json:"host_power_state,omitempty"`
|
||||||
|
HostPoweredOn bool `json:"host_powered_on"`
|
||||||
|
PowerControlAvailable bool `json:"power_control_available"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CollectJobResponse struct {
|
type CollectJobResponse struct {
|
||||||
|
|||||||
@@ -1510,6 +1510,69 @@ func (s *Server) handleCollectStart(w http.ResponseWriter, r *http.Request) {
|
|||||||
_ = json.NewEncoder(w).Encode(job.toJobResponse("Collection job accepted"))
|
_ = json.NewEncoder(w).Encode(job.toJobResponse("Collection job accepted"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleCollectProbe(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req CollectRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
jsonError(w, "Invalid JSON body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := validateCollectRequest(req); err != nil {
|
||||||
|
jsonError(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
connector, ok := s.getCollector(req.Protocol)
|
||||||
|
if !ok {
|
||||||
|
jsonError(w, "Коннектор для протокола не зарегистрирован", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
prober, ok := connector.(collector.Prober)
|
||||||
|
if !ok {
|
||||||
|
jsonError(w, "Проверка подключения для протокола не поддерживается", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
result, err := prober.Probe(ctx, toCollectorRequest(req))
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, "Проверка подключения не удалась: "+err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
message := "Связь с BMC установлена"
|
||||||
|
if result != nil {
|
||||||
|
switch {
|
||||||
|
case !result.HostPoweredOn && result.PowerControlAvailable:
|
||||||
|
message = "Связь с BMC установлена, host выключен. Можно включить перед сбором."
|
||||||
|
case !result.HostPoweredOn:
|
||||||
|
message = "Связь с BMC установлена, host выключен."
|
||||||
|
default:
|
||||||
|
message = "Связь с BMC установлена, host включен."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hostPowerState := ""
|
||||||
|
hostPoweredOn := false
|
||||||
|
powerControlAvailable := false
|
||||||
|
reachable := false
|
||||||
|
if result != nil {
|
||||||
|
reachable = result.Reachable
|
||||||
|
hostPowerState = strings.TrimSpace(result.HostPowerState)
|
||||||
|
hostPoweredOn = result.HostPoweredOn
|
||||||
|
powerControlAvailable = result.PowerControlAvailable
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonResponse(w, CollectProbeResponse{
|
||||||
|
Reachable: reachable,
|
||||||
|
Protocol: req.Protocol,
|
||||||
|
HostPowerState: hostPowerState,
|
||||||
|
HostPoweredOn: hostPoweredOn,
|
||||||
|
PowerControlAvailable: powerControlAvailable,
|
||||||
|
Message: message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleCollectStatus(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleCollectStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
jobID := strings.TrimSpace(r.PathValue("id"))
|
jobID := strings.TrimSpace(r.PathValue("id"))
|
||||||
if !isValidCollectJobID(jobID) {
|
if !isValidCollectJobID(jobID) {
|
||||||
@@ -1795,6 +1858,7 @@ func toCollectorRequest(req CollectRequest) collector.Request {
|
|||||||
Password: req.Password,
|
Password: req.Password,
|
||||||
Token: req.Token,
|
Token: req.Token,
|
||||||
TLSMode: req.TLSMode,
|
TLSMode: req.TLSMode,
|
||||||
|
PowerOnIfHostOff: req.PowerOnIfHostOff,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -386,6 +386,10 @@ func buildParserFieldSummary(result *models.AnalysisResult) map[string]any {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
hw := result.Hardware
|
hw := result.Hardware
|
||||||
|
out["vendor"] = hw.BoardInfo.Manufacturer
|
||||||
|
out["model"] = hw.BoardInfo.ProductName
|
||||||
|
out["serial"] = hw.BoardInfo.SerialNumber
|
||||||
|
out["part_number"] = hw.BoardInfo.PartNumber
|
||||||
out["hardware"] = map[string]any{
|
out["hardware"] = map[string]any{
|
||||||
"board": hw.BoardInfo,
|
"board": hw.BoardInfo,
|
||||||
"counts": map[string]int{
|
"counts": map[string]int{
|
||||||
|
|||||||
@@ -1,101 +1,35 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"archive/zip"
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
"git.mchus.pro/mchus/logpile/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCollectLogTimeBounds(t *testing.T) {
|
func TestBuildParserFieldSummary_MirrorsBoardInfoToTopLevel(t *testing.T) {
|
||||||
lines := []string{
|
result := &models.AnalysisResult{
|
||||||
"2026-02-28T13:10:13.7442032Z Задача поставлена в очередь",
|
Hardware: &models.HardwareConfig{
|
||||||
"not-a-timestamp line",
|
BoardInfo: models.BoardInfo{
|
||||||
"2026-02-28T13:31:00.5077486Z Сбор завершен",
|
Manufacturer: "Supermicro",
|
||||||
}
|
ProductName: "SYS-821GE-TNHR",
|
||||||
|
SerialNumber: "A514359X5C08846",
|
||||||
startedAt, finishedAt, ok := collectLogTimeBounds(lines)
|
PartNumber: "SYS-821GE-TNHR",
|
||||||
if !ok {
|
|
||||||
t.Fatalf("expected bounds to be parsed")
|
|
||||||
}
|
|
||||||
if got := formatRawExportDuration(finishedAt.Sub(startedAt)); got != "20m47s" {
|
|
||||||
t.Fatalf("unexpected duration: %s", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildHumanReadableCollectionLog_IncludesDurationHeader(t *testing.T) {
|
|
||||||
pkg := &RawExportPackage{
|
|
||||||
Format: rawExportFormatV1,
|
|
||||||
Source: RawExportSource{
|
|
||||||
Kind: "live_redfish",
|
|
||||||
CollectLogs: []string{
|
|
||||||
"2026-02-28T13:10:13.7442032Z Redfish: подключение к BMC...",
|
|
||||||
"2026-02-28T13:31:00.5077486Z Сбор завершен",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
logText := buildHumanReadableCollectionLog(pkg, nil, "LOGPile test")
|
got := buildParserFieldSummary(result)
|
||||||
for _, token := range []string{
|
|
||||||
"Collection Started:",
|
|
||||||
"Collection Finished:",
|
|
||||||
"Collection Duration:",
|
|
||||||
} {
|
|
||||||
if !strings.Contains(logText, token) {
|
|
||||||
t.Fatalf("expected %q in log header", token)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseRawExportBundle_ExtractsCollectedAtHintFromParserFields(t *testing.T) {
|
if got["vendor"] != "Supermicro" {
|
||||||
pkg := &RawExportPackage{
|
t.Fatalf("expected vendor mirror, got %v", got["vendor"])
|
||||||
Format: rawExportFormatV1,
|
|
||||||
ExportedAt: time.Date(2026, 2, 25, 9, 59, 41, 479023400, time.UTC),
|
|
||||||
Source: RawExportSource{
|
|
||||||
Kind: "live_redfish",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
pkgJSON, err := json.Marshal(pkg)
|
if got["model"] != "SYS-821GE-TNHR" {
|
||||||
if err != nil {
|
t.Fatalf("expected model mirror, got %v", got["model"])
|
||||||
t.Fatalf("marshal pkg: %v", err)
|
|
||||||
}
|
}
|
||||||
|
if got["serial"] != "A514359X5C08846" {
|
||||||
parserFields := []byte(`{"collected_at":"2026-02-25T09:58:05.9129753Z"}`)
|
t.Fatalf("expected serial mirror, got %v", got["serial"])
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
zw := zip.NewWriter(&buf)
|
|
||||||
|
|
||||||
jf, err := zw.Create(rawExportBundlePackageFile)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create package file: %v", err)
|
|
||||||
}
|
}
|
||||||
if _, err := jf.Write(pkgJSON); err != nil {
|
if got["part_number"] != "SYS-821GE-TNHR" {
|
||||||
t.Fatalf("write package file: %v", err)
|
t.Fatalf("expected part_number mirror, got %v", got["part_number"])
|
||||||
}
|
|
||||||
|
|
||||||
ff, err := zw.Create(rawExportBundleFieldsFile)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create parser fields file: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := ff.Write(parserFields); err != nil {
|
|
||||||
t.Fatalf("write parser fields file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := zw.Close(); err != nil {
|
|
||||||
t.Fatalf("close zip writer: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
gotPkg, ok, err := parseRawExportBundle(buf.Bytes())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("parse bundle: %v", err)
|
|
||||||
}
|
|
||||||
if !ok || gotPkg == nil {
|
|
||||||
t.Fatalf("expected valid raw export bundle")
|
|
||||||
}
|
|
||||||
want := time.Date(2026, 2, 25, 9, 58, 5, 912975300, time.UTC)
|
|
||||||
if !gotPkg.CollectedAtHint.Equal(want) {
|
|
||||||
t.Fatalf("expected collected_at hint %s, got %s", want, gotPkg.CollectedAtHint)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/logpile/internal/exporter"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestReanimatorExport_RedfishExampleDoesNotDuplicateCPUs(t *testing.T) {
|
||||||
|
payload, err := os.ReadFile(filepath.Join("..", "..", "example", "2026-03-11 (SYS-821GE-TNHR) - A514359X5C08846.zip"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read example bundle: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rawPkg, ok, err := parseRawExportBundle(payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse raw export bundle: %v", err)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("example bundle was not recognized as raw export bundle")
|
||||||
|
}
|
||||||
|
|
||||||
|
s := &Server{}
|
||||||
|
result, vendor, err := s.reanalyzeRawExportPackage(rawPkg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reanalyze raw export bundle: %v", err)
|
||||||
|
}
|
||||||
|
if vendor != "redfish" {
|
||||||
|
t.Fatalf("expected redfish vendor, got %q", vendor)
|
||||||
|
}
|
||||||
|
if result == nil || result.Hardware == nil {
|
||||||
|
t.Fatalf("expected parsed hardware result")
|
||||||
|
}
|
||||||
|
if got := len(result.Hardware.CPUs); got != 2 {
|
||||||
|
t.Fatalf("expected 2 CPUs after replay, got %d", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
reanimatorData, err := exporter.ConvertToReanimator(result)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ConvertToReanimator: %v", err)
|
||||||
|
}
|
||||||
|
if got := len(reanimatorData.Hardware.CPUs); got != 2 {
|
||||||
|
t.Fatalf("expected 2 CPUs in reanimator export, got %d", got)
|
||||||
|
}
|
||||||
|
for i, cpu := range reanimatorData.Hardware.CPUs {
|
||||||
|
if cpu.SerialNumber != "" {
|
||||||
|
t.Fatalf("expected CPU %d serial to stay empty, got %q", i, cpu.SerialNumber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, dev := range reanimatorData.Hardware.PCIeDevices {
|
||||||
|
joined := strings.ToLower(strings.Join([]string{dev.Slot, dev.Model, dev.DeviceClass}, " "))
|
||||||
|
if strings.Contains(joined, "nvme") || strings.Contains(joined, "ssd") || strings.Contains(joined, "disk") {
|
||||||
|
t.Fatalf("expected storage endpoint to stay out of pcie export, got %+v", dev)
|
||||||
|
}
|
||||||
|
if strings.EqualFold(dev.Model, "Network Device View") {
|
||||||
|
t.Fatalf("expected placeholder network model to be replaced, got %+v", dev)
|
||||||
|
}
|
||||||
|
if strings.Contains(dev.SerialNumber, "-PCIE-") {
|
||||||
|
t.Fatalf("expected no synthetic pcie serials, got %+v", dev)
|
||||||
|
}
|
||||||
|
if isNumericOnly(dev.Slot) && dev.DeviceClass == "NetworkController" && dev.Model == "" && dev.SerialNumber == "" {
|
||||||
|
t.Fatalf("expected placeholder numeric-slot network export to be skipped, got %+v", dev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isNumericOnly(v string) bool {
|
||||||
|
v = strings.TrimSpace(v)
|
||||||
|
if v == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range v {
|
||||||
|
if r < '0' || r > '9' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -88,6 +88,7 @@ func (s *Server) setupRoutes() {
|
|||||||
s.mux.HandleFunc("DELETE /api/clear", s.handleClear)
|
s.mux.HandleFunc("DELETE /api/clear", s.handleClear)
|
||||||
s.mux.HandleFunc("POST /api/shutdown", s.handleShutdown)
|
s.mux.HandleFunc("POST /api/shutdown", s.handleShutdown)
|
||||||
s.mux.HandleFunc("POST /api/collect", s.handleCollectStart)
|
s.mux.HandleFunc("POST /api/collect", s.handleCollectStart)
|
||||||
|
s.mux.HandleFunc("POST /api/collect/probe", s.handleCollectProbe)
|
||||||
s.mux.HandleFunc("GET /api/collect/{id}", s.handleCollectStatus)
|
s.mux.HandleFunc("GET /api/collect/{id}", s.handleCollectStatus)
|
||||||
s.mux.HandleFunc("POST /api/collect/{id}/cancel", s.handleCollectCancel)
|
s.mux.HandleFunc("POST /api/collect/{id}/cancel", s.handleCollectCancel)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -169,7 +169,10 @@ main {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#api-check-btn,
|
||||||
#api-connect-btn,
|
#api-connect-btn,
|
||||||
|
#api-power-on-collect-btn,
|
||||||
|
#api-collect-off-btn,
|
||||||
#convert-folder-btn,
|
#convert-folder-btn,
|
||||||
#convert-run-btn,
|
#convert-run-btn,
|
||||||
#cancel-job-btn,
|
#cancel-job-btn,
|
||||||
@@ -185,7 +188,10 @@ main {
|
|||||||
transition: background-color 0.2s ease, opacity 0.2s ease;
|
transition: background-color 0.2s ease, opacity 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#api-check-btn:hover,
|
||||||
#api-connect-btn:hover,
|
#api-connect-btn:hover,
|
||||||
|
#api-power-on-collect-btn:hover,
|
||||||
|
#api-collect-off-btn:hover,
|
||||||
#convert-folder-btn:hover,
|
#convert-folder-btn:hover,
|
||||||
#convert-run-btn:hover,
|
#convert-run-btn:hover,
|
||||||
#cancel-job-btn:hover,
|
#cancel-job-btn:hover,
|
||||||
@@ -195,7 +201,10 @@ main {
|
|||||||
|
|
||||||
#convert-run-btn:disabled,
|
#convert-run-btn:disabled,
|
||||||
#convert-folder-btn:disabled,
|
#convert-folder-btn:disabled,
|
||||||
|
#api-check-btn:disabled,
|
||||||
#api-connect-btn:disabled,
|
#api-connect-btn:disabled,
|
||||||
|
#api-power-on-collect-btn:disabled,
|
||||||
|
#api-collect-off-btn:disabled,
|
||||||
#cancel-job-btn:disabled,
|
#cancel-job-btn:disabled,
|
||||||
.upload-area button:disabled {
|
.upload-area button:disabled {
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
@@ -219,6 +228,10 @@ main {
|
|||||||
color: #0f4dba;
|
color: #0f4dba;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.api-connect-status.warning {
|
||||||
|
color: #b06a00;
|
||||||
|
}
|
||||||
|
|
||||||
.convert-progress {
|
.convert-progress {
|
||||||
margin-top: 0.9rem;
|
margin-top: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|||||||
+163
-12
@@ -24,6 +24,8 @@ let collectionJobPollTimer = null;
|
|||||||
let collectionJobLogCounter = 0;
|
let collectionJobLogCounter = 0;
|
||||||
let apiPortTouchedByUser = false;
|
let apiPortTouchedByUser = false;
|
||||||
let isAutoUpdatingApiPort = false;
|
let isAutoUpdatingApiPort = false;
|
||||||
|
let apiProbeResult = null;
|
||||||
|
let apiPowerDecisionTimer = null;
|
||||||
|
|
||||||
function initSourceType() {
|
function initSourceType() {
|
||||||
const sourceButtons = document.querySelectorAll('.source-switch-btn');
|
const sourceButtons = document.querySelectorAll('.source-switch-btn');
|
||||||
@@ -66,22 +68,14 @@ function initApiSource() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cancelJobButton = document.getElementById('cancel-job-btn');
|
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 fieldNames = ['host', 'port', 'username', 'password'];
|
const fieldNames = ['host', 'port', 'username', 'password'];
|
||||||
|
|
||||||
apiForm.addEventListener('submit', (event) => {
|
apiForm.addEventListener('submit', (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const { isValid, payload, errors } = validateCollectForm();
|
startCollectionFromCurrentProbe(false);
|
||||||
renderFormErrors(errors);
|
|
||||||
|
|
||||||
if (!isValid) {
|
|
||||||
renderApiConnectStatus(false, null);
|
|
||||||
apiConnectPayload = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
apiConnectPayload = payload;
|
|
||||||
renderApiConnectStatus(true, payload);
|
|
||||||
startCollectionJob(payload);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (cancelJobButton) {
|
if (cancelJobButton) {
|
||||||
@@ -89,6 +83,23 @@ function initApiSource() {
|
|||||||
cancelCollectionJob();
|
cancelCollectionJob();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (checkButton) {
|
||||||
|
checkButton.addEventListener('click', () => {
|
||||||
|
startApiProbe();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (collectOffButton) {
|
||||||
|
collectOffButton.addEventListener('click', () => {
|
||||||
|
clearApiPowerDecisionTimer();
|
||||||
|
startCollectionFromCurrentProbe(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (powerOnCollectButton) {
|
||||||
|
powerOnCollectButton.addEventListener('click', () => {
|
||||||
|
clearApiPowerDecisionTimer();
|
||||||
|
startCollectionFromCurrentProbe(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
fieldNames.forEach((fieldName) => {
|
fieldNames.forEach((fieldName) => {
|
||||||
const field = apiForm.elements.namedItem(fieldName);
|
const field = apiForm.elements.namedItem(fieldName);
|
||||||
@@ -105,6 +116,7 @@ function initApiSource() {
|
|||||||
const { errors } = validateCollectForm();
|
const { errors } = validateCollectForm();
|
||||||
renderFormErrors(errors);
|
renderFormErrors(errors);
|
||||||
clearApiConnectStatus();
|
clearApiConnectStatus();
|
||||||
|
resetApiProbeState();
|
||||||
|
|
||||||
if (collectionJob && isCollectionJobTerminal(collectionJob.status)) {
|
if (collectionJob && isCollectionJobTerminal(collectionJob.status)) {
|
||||||
resetCollectionJobState();
|
resetCollectionJobState();
|
||||||
@@ -116,6 +128,144 @@ function initApiSource() {
|
|||||||
renderCollectionJob();
|
renderCollectionJob();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function startApiProbe() {
|
||||||
|
const { isValid, payload, errors } = validateCollectForm();
|
||||||
|
renderFormErrors(errors);
|
||||||
|
if (!isValid) {
|
||||||
|
renderApiConnectStatus(false, null);
|
||||||
|
resetApiProbeState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
apiConnectPayload = payload;
|
||||||
|
resetApiProbeState();
|
||||||
|
setApiFormBlocked(true);
|
||||||
|
renderApiConnectStatus(true, { ...payload, password: '***' });
|
||||||
|
|
||||||
|
fetch('/api/collect/probe', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
})
|
||||||
|
.then(async (response) => {
|
||||||
|
const body = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(body.error || 'Проверка подключения не удалась');
|
||||||
|
}
|
||||||
|
|
||||||
|
apiProbeResult = body;
|
||||||
|
renderApiProbeState();
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
resetApiProbeState();
|
||||||
|
renderApiConnectStatus(false, null);
|
||||||
|
const status = document.getElementById('api-connect-status');
|
||||||
|
if (status) {
|
||||||
|
status.textContent = err.message || 'Проверка подключения не удалась';
|
||||||
|
status.className = 'api-connect-status error';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!collectionJob || isCollectionJobTerminal(collectionJob.status)) {
|
||||||
|
setApiFormBlocked(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function startCollectionFromCurrentProbe(powerOnIfHostOff) {
|
||||||
|
const { isValid, payload, errors } = validateCollectForm();
|
||||||
|
renderFormErrors(errors);
|
||||||
|
if (!isValid) {
|
||||||
|
renderApiConnectStatus(false, null);
|
||||||
|
resetApiProbeState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!apiProbeResult || !apiProbeResult.reachable) {
|
||||||
|
const status = document.getElementById('api-connect-status');
|
||||||
|
if (status) {
|
||||||
|
status.textContent = 'Сначала выполните проверку подключения.';
|
||||||
|
status.className = 'api-connect-status error';
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearApiPowerDecisionTimer();
|
||||||
|
payload.power_on_if_host_off = Boolean(powerOnIfHostOff);
|
||||||
|
startCollectionJob(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderApiProbeState() {
|
||||||
|
const collectButton = document.getElementById('api-connect-btn');
|
||||||
|
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) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
decision.classList.add('hidden');
|
||||||
|
clearApiPowerDecisionTimer();
|
||||||
|
collectButton.disabled = !apiProbeResult || !apiProbeResult.reachable;
|
||||||
|
|
||||||
|
if (!apiProbeResult || !apiProbeResult.reachable) {
|
||||||
|
status.textContent = 'Проверка подключения не пройдена.';
|
||||||
|
status.className = 'api-connect-status error';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (apiProbeResult.host_powered_on) {
|
||||||
|
status.textContent = apiProbeResult.message || 'Связь с BMC есть, host включен.';
|
||||||
|
status.className = 'api-connect-status success';
|
||||||
|
collectButton.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
status.textContent = apiProbeResult.message || 'Связь с BMC есть, host выключен.';
|
||||||
|
status.className = 'api-connect-status warning';
|
||||||
|
if (!apiProbeResult.power_control_available) {
|
||||||
|
collectButton.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
updateDecisionText();
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetApiProbeState() {
|
||||||
|
apiProbeResult = null;
|
||||||
|
clearApiPowerDecisionTimer();
|
||||||
|
const collectButton = document.getElementById('api-connect-btn');
|
||||||
|
const decision = document.getElementById('api-power-decision');
|
||||||
|
if (collectButton) {
|
||||||
|
collectButton.disabled = true;
|
||||||
|
}
|
||||||
|
if (decision) {
|
||||||
|
decision.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearApiPowerDecisionTimer() {
|
||||||
|
if (!apiPowerDecisionTimer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.clearInterval(apiPowerDecisionTimer);
|
||||||
|
apiPowerDecisionTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
function validateCollectForm() {
|
function validateCollectForm() {
|
||||||
const host = getApiValue('host');
|
const host = getApiValue('host');
|
||||||
const portRaw = getApiValue('port');
|
const portRaw = getApiValue('port');
|
||||||
@@ -230,6 +380,7 @@ function clearApiConnectStatus() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function startCollectionJob(payload) {
|
function startCollectionJob(payload) {
|
||||||
|
clearApiPowerDecisionTimer();
|
||||||
resetCollectionJobState();
|
resetCollectionJobState();
|
||||||
setApiFormBlocked(true);
|
setApiFormBlocked(true);
|
||||||
|
|
||||||
|
|||||||
@@ -63,9 +63,18 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="api-form-actions">
|
<div class="api-form-actions">
|
||||||
<button id="api-connect-btn" type="submit">Подключиться</button>
|
<button id="api-check-btn" type="button">Проверить</button>
|
||||||
|
<button id="api-connect-btn" type="submit" disabled>Собрать</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="api-connect-status" class="api-connect-status"></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 class="api-form-actions">
|
||||||
|
<button id="api-power-on-collect-btn" type="button">Включить и собрать</button>
|
||||||
|
<button id="api-collect-off-btn" type="button">Собирать выключенный</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<section id="api-job-status" class="job-status hidden" aria-live="polite">
|
<section id="api-job-status" class="job-status hidden" aria-live="polite">
|
||||||
|
|||||||
Reference in New Issue
Block a user