export: align reanimator contract v2.7

This commit is contained in:
Mikhail Chusavitin
2026-03-15 23:27:32 +03:00
parent 9007f1b360
commit 476630190d
31 changed files with 3502 additions and 689 deletions
+17
View File
@@ -55,6 +55,23 @@ Responses:
- `400` on malformed JSON
- `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}`
Returns async collection job status, progress, timestamps, and accumulated logs.
+16
View File
@@ -20,12 +20,28 @@ Request fields passed from the server:
- `auth_type`
- credential field (`password` or token)
- `tls_mode`
- optional `power_on_if_host_off`
### Core rule
Live collection and replay must stay behaviorally aligned.
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
The collector does not rely on one fixed vendor tree.
+14 -5
View File
@@ -35,25 +35,34 @@ Implementation files:
Conversion rules:
- 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`
- local mirror currently tracks upstream contract `v2.7`
- timestamps are RFC3339
- status is normalized to Reanimator-friendly values
- missing PCIe serials may be generated from board serial + slot
- missing CPU serials may be generated from board serial + socket
- missing component serial numbers must stay absent; LOGPile must not synthesize fake serials for Reanimator export
- CPU `firmware` field means CPU microcode, not generic processor firmware inventory
- `NULL`-style board manufacturer/product values are treated as absent
- optional component telemetry/health fields are exported when LOGPile already has the data
- partial `hardware.devices` must not suppress components still present only in legacy parser/collector fields
- `present` is not serialized for exported components; presence is expressed by the existence of the component record itself
- Reanimator ingest may apply its own server-side fallback serial rules for CPU and PCIe when LOGPile leaves serials absent
## Inclusion rules
Included:
- PCIe-class devices even when serial must be synthesized
- contract `v2.4` component telemetry and health fields when source data exists
- hardware sensors grouped into `fans`, `power`, `temperatures`, `other`
- PCIe-class devices when the component itself is present, even if serial number is missing
- contract `v2.7` component telemetry and health fields when source data exists
- 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`
- `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:
- 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 `present=false` or `status=Empty`
- CPUs with `present=false`
+126
View File
@@ -455,3 +455,129 @@ live collection and raw snapshot replay still underreported component health fie
component record.
<!-- 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
View File
@@ -69,6 +69,32 @@ func (c *RedfishConnector) Protocol() string {
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{}) {
if !c.debug {
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")
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")
managerPaths := c.discoverMemberPaths(ctx, snapshotClient, req, baseURL, "/redfish/v1/Managers", "/redfish/v1/Managers/1")
criticalPaths := redfishCriticalEndpoints(systemPaths, chassisPaths, managerPaths)
@@ -186,6 +227,257 @@ func (c *RedfishConnector) Collect(ctx context.Context, req Request, emit Progre
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(
ctx context.Context,
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)
if err == nil {
for _, driveDoc := range driveDocs {
if isVirtualStorageDrive(driveDoc) {
continue
}
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
}
if len(driveDocs) == 0 {
for _, driveDoc := range c.probeDirectDiskBayChildren(ctx, client, req, baseURL, driveCollectionPath) {
if isVirtualStorageDrive(driveDoc) {
continue
}
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
}
@@ -444,6 +742,9 @@ func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Clie
if err != nil {
continue
}
if isVirtualStorageDrive(driveDoc) {
continue
}
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
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.
if looksLikeDrive(member) {
if isVirtualStorageDrive(member) {
continue
}
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, member, "DriveMetrics", "EnvironmentMetrics", "Metrics")
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"))
if err == nil {
for _, driveDoc := range driveDocs {
if looksLikeDrive(driveDoc) {
if looksLikeDrive(driveDoc) && !isVirtualStorageDrive(driveDoc) {
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
}
}
if len(driveDocs) == 0 {
for _, driveDoc := range c.probeDirectDiskBayChildren(ctx, client, req, baseURL, joinPath(enclosurePath, "/Drives")) {
if isVirtualStorageDrive(driveDoc) {
continue
}
out = append(out, parseDrive(driveDoc))
}
}
@@ -481,7 +788,7 @@ func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Clie
"/Storage/IntelVROC/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")
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
}
@@ -496,7 +803,7 @@ func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Clie
}
for _, devAny := range devices {
devDoc, ok := devAny.(map[string]interface{})
if !ok || !looksLikeDrive(devDoc) {
if !ok || !looksLikeDrive(devDoc) || isVirtualStorageDrive(devDoc) {
continue
}
out = append(out, parseDriveWithSupplementalDocs(devDoc))
@@ -511,7 +818,7 @@ func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Clie
continue
}
for _, driveDoc := range driveDocs {
if !looksLikeDrive(driveDoc) {
if !looksLikeDrive(driveDoc) || isVirtualStorageDrive(driveDoc) {
continue
}
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
}
for _, driveDoc := range c.probeSupermicroNVMeDiskBays(ctx, client, req, baseURL, chassisPath) {
if !looksLikeDrive(driveDoc) {
if !looksLikeDrive(driveDoc) || isVirtualStorageDrive(driveDoc) {
continue
}
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
}
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, doc, "EnvironmentMetrics", "Metrics")
supplementalDocs = append(supplementalDocs, c.getChassisScopedPCIeSupplementalDocs(ctx, client, req, baseURL, doc)...)
for _, fn := range functionDocs {
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)
}
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 {
collection, err := c.getJSON(ctx, client, req, baseURL, collectionPath)
if err == nil {
@@ -1810,6 +2151,15 @@ func redfishCriticalSlowGap() time.Duration {
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 {
if v := strings.TrimSpace(os.Getenv("LOGPILE_REDFISH_MEMORY_TIMEOUT")); v != "" {
if d, err := time.ParseDuration(v); err == nil && d > 0 {
@@ -1984,11 +2334,22 @@ func looksLikeCanonicalBDF(bdf string) bool {
return false
}
func sanitizeRedfishBDF(bdf string) string {
bdf = strings.TrimSpace(bdf)
if looksLikeCanonicalBDF(bdf) {
return bdf
}
return ""
}
func shouldCrawlPath(path string) bool {
if path == "" {
return false
}
normalized := normalizeRedfishPath(path)
if isAllowedNVSwitchFabricPath(normalized) {
return true
}
if strings.Contains(normalized, "/Chassis/") &&
strings.Contains(normalized, "/PCIeDevices/") &&
strings.Contains(normalized, "/PCIeFunctions/") {
@@ -2079,6 +2440,22 @@ func shouldCrawlPath(path string) bool {
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 {
normalized := normalizeRedfishPath(path)
if !strings.Contains(normalized, "/Systems/") {
@@ -2880,23 +3257,38 @@ func parseNIC(doc map[string]interface{}) models.NetworkAdapter {
vendor = pciids.VendorName(vendorID)
}
location := redfishLocationLabel(doc["Location"])
slot := firstNonEmpty(redfishLocationLabel(doc["Location"]), asString(doc["Id"]), asString(doc["Name"]))
var firmware string
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 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"])
if caps, ok := ctrl["ControllerCapabilities"].(map[string]interface{}); ok {
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{
Slot: firstNonEmpty(asString(doc["Id"]), asString(doc["Name"])),
Slot: slot,
Location: location,
Present: !strings.EqualFold(mapStatus(doc["Status"]), "Absent"),
BDF: asString(doc["BDF"]),
BDF: sanitizeRedfishBDF(asString(doc["BDF"])),
Model: strings.TrimSpace(model),
Vendor: strings.TrimSpace(vendor),
VendorID: vendorID,
@@ -2905,11 +3297,29 @@ func parseNIC(doc map[string]interface{}) models.NetworkAdapter {
PartNumber: asString(doc["PartNumber"]),
Firmware: firmware,
PortCount: portCount,
LinkWidth: linkWidth,
LinkSpeed: linkSpeed,
MaxLinkWidth: maxLinkWidth,
MaxLinkSpeed: maxLinkSpeed,
Status: mapStatus(doc["Status"]),
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 {
var out []string
if controllers, ok := doc["Controllers"].([]interface{}); ok {
@@ -2967,7 +3377,7 @@ func enrichNICFromPCIe(nic *models.NetworkAdapter, pcieDoc map[string]interface{
}
for _, fn := range functionDocs {
if strings.TrimSpace(nic.BDF) == "" {
nic.BDF = asString(fn["FunctionId"])
nic.BDF = sanitizeRedfishBDF(asString(fn["FunctionId"]))
}
if nic.VendorID == 0 {
nic.VendorID = asHexOrInt(fn["VendorId"])
@@ -3132,9 +3542,13 @@ func redfishPCIeDetailsWithSupplementalDocs(doc map[string]interface{}, function
lookupDocs = append(lookupDocs, functionDocs...)
lookupDocs = append(lookupDocs, supplementalDocs...)
details := make(map[string]any)
addFloatDetail(details, "temperature_c", redfishFirstNumericAcrossDocs(lookupDocs,
temperatureC := redfishFirstNumericAcrossDocs(lookupDocs,
"TemperatureCelsius", "TemperatureC", "Temperature",
))
)
if temperatureC == 0 {
temperatureC = redfishSupplementalThermalMetricForPCIe(doc, supplementalDocs)
}
addFloatDetail(details, "temperature_c", temperatureC)
addFloatDetail(details, "power_w", redfishFirstNumericAcrossDocs(lookupDocs,
"PowerConsumedWatts", "PowerWatts", "PowerConsumptionWatts",
))
@@ -3189,6 +3603,89 @@ func redfishPCIeDetailsWithSupplementalDocs(doc map[string]interface{}, function
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 {
for _, key := range keys {
if v, ok := redfishLookupValue(doc, key); ok {
@@ -3392,7 +3889,7 @@ func parseGPUWithSupplementalDocs(doc map[string]interface{}, functionDocs []map
Details: redfishPCIeDetailsWithSupplementalDocs(doc, functionDocs, supplementalDocs),
}
if bdf := asString(doc["BDF"]); bdf != "" {
if bdf := sanitizeRedfishBDF(asString(doc["BDF"])); bdf != "" {
gpu.BDF = bdf
}
if gpu.BDF == "" {
@@ -3407,7 +3904,7 @@ func parseGPUWithSupplementalDocs(doc map[string]interface{}, functionDocs []map
for _, fn := range functionDocs {
if gpu.BDF == "" {
gpu.BDF = asString(fn["FunctionId"])
gpu.BDF = sanitizeRedfishBDF(asString(fn["FunctionId"]))
}
if gpu.VendorID == 0 {
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 {
dev := models.PCIeDevice{
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"]),
Manufacturer: asString(doc["Manufacturer"]),
PartNumber: asString(doc["PartNumber"]),
@@ -3463,7 +3960,7 @@ func parsePCIeDeviceWithSupplementalDocs(doc map[string]interface{}, functionDoc
for _, fn := range functionDocs {
if dev.BDF == "" {
dev.BDF = asString(fn["FunctionId"])
dev.BDF = sanitizeRedfishBDF(asString(fn["FunctionId"]))
}
if dev.DeviceClass == "" || isGenericPCIeClassLabel(dev.DeviceClass) {
dev.DeviceClass = firstNonEmpty(asString(fn["DeviceClass"]), asString(fn["ClassCode"]))
@@ -3522,7 +4019,7 @@ func parsePCIeFunctionWithSupplementalDocs(doc map[string]interface{}, supplemen
dev := models.PCIeDevice{
Slot: slot,
BDF: asString(doc["FunctionId"]),
BDF: sanitizeRedfishBDF(asString(doc["BDF"])),
VendorID: asHexOrInt(doc["VendorId"]),
DeviceID: asHexOrInt(doc["DeviceId"]),
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"])),
Details: redfishPCIeDetailsWithSupplementalDocs(doc, nil, supplementalDocs),
}
if dev.BDF == "" {
dev.BDF = firstNonEmpty(buildBDFfromOemPublic(doc), sanitizeRedfishBDF(asString(doc["FunctionId"])))
}
if isGenericPCIeClassLabel(dev.DeviceClass) {
if resolved := pciids.DeviceName(dev.VendorID, dev.DeviceID); resolved != "" {
dev.DeviceClass = resolved
@@ -3951,7 +4451,24 @@ func isVirtualStorageDrive(doc map[string]interface{}) bool {
}
mfr := strings.ToUpper(strings.TrimSpace(asString(doc["Manufacturer"])))
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 false
+60 -15
View File
@@ -93,12 +93,12 @@ func ReplayRedfishFromRawPayloads(rawPayloads map[string]any, emit ProgressFn) (
collectedAt, sourceTimezone := inferRedfishCollectionTime(managerDoc, rawPayloads)
result := &models.AnalysisResult{
CollectedAt: collectedAt,
CollectedAt: collectedAt,
SourceTimezone: sourceTimezone,
Events: append(append(append(make([]models.Event, 0, len(discreteEvents)+len(healthEvents)+len(driveFetchWarningEvents)+1), healthEvents...), discreteEvents...), driveFetchWarningEvents...),
FRU: assemblyFRU,
Sensors: dedupeSensorReadings(append(append(thresholdSensors, thermalSensors...), powerSensors...)),
RawPayloads: cloneRawPayloads(rawPayloads),
Events: append(append(append(make([]models.Event, 0, len(discreteEvents)+len(healthEvents)+len(driveFetchWarningEvents)+1), healthEvents...), discreteEvents...), driveFetchWarningEvents...),
FRU: assemblyFRU,
Sensors: dedupeSensorReadings(append(append(thresholdSensors, thermalSensors...), powerSensors...)),
RawPayloads: cloneRawPayloads(rawPayloads),
Hardware: &models.HardwareConfig{
BoardInfo: boardInfo,
CPUs: processors,
@@ -273,11 +273,7 @@ func (r redfishSnapshotReader) collectFirmwareInventory() []models.FirmwareInfo
if strings.TrimSpace(version) == "" {
continue
}
name := firstNonEmpty(
asString(doc["DeviceName"]),
asString(doc["Name"]),
asString(doc["Id"]),
)
name := firmwareInventoryDeviceName(doc)
name = strings.TrimSpace(name)
if name == "" {
continue
@@ -291,6 +287,25 @@ func (r redfishSnapshotReader) collectFirmwareInventory() []models.FirmwareInfo
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 {
seen := make(map[string]struct{}, len(items))
out := make([]models.FirmwareInfo, 0, len(items))
@@ -1094,6 +1109,9 @@ func (r redfishSnapshotReader) collectStorage(systemPath string) []models.Storag
continue
}
if looksLikeDrive(member) {
if isVirtualStorageDrive(member) {
continue
}
supplementalDocs := r.getLinkedSupplementalDocs(member, "DriveMetrics", "EnvironmentMetrics", "Metrics")
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"))
if err == nil {
for _, driveDoc := range driveDocs {
if looksLikeDrive(driveDoc) {
if looksLikeDrive(driveDoc) && !isVirtualStorageDrive(driveDoc) {
supplementalDocs := r.getLinkedSupplementalDocs(driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
}
}
if len(driveDocs) == 0 {
for _, driveDoc := range r.probeDirectDiskBayChildren(joinPath(enclosurePath, "/Drives")) {
if isVirtualStorageDrive(driveDoc) {
continue
}
out = append(out, parseDrive(driveDoc))
}
}
@@ -1120,7 +1141,7 @@ func (r redfishSnapshotReader) collectStorage(systemPath string) []models.Storag
"/Storage/IntelVROC/Drives",
"/Storage/IntelVROC/Controllers/1/Drives",
}) {
if looksLikeDrive(driveDoc) {
if looksLikeDrive(driveDoc) && !isVirtualStorageDrive(driveDoc) {
supplementalDocs := r.getLinkedSupplementalDocs(driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
}
@@ -1134,7 +1155,7 @@ func (r redfishSnapshotReader) collectStorage(systemPath string) []models.Storag
}
for _, devAny := range devices {
devDoc, ok := devAny.(map[string]interface{})
if !ok || !looksLikeDrive(devDoc) {
if !ok || !looksLikeDrive(devDoc) || isVirtualStorageDrive(devDoc) {
continue
}
out = append(out, parseDrive(devDoc))
@@ -1148,7 +1169,7 @@ func (r redfishSnapshotReader) collectStorage(systemPath string) []models.Storag
continue
}
for _, driveDoc := range driveDocs {
if !looksLikeDrive(driveDoc) {
if !looksLikeDrive(driveDoc) || isVirtualStorageDrive(driveDoc) {
continue
}
out = append(out, parseDrive(driveDoc))
@@ -1159,7 +1180,7 @@ func (r redfishSnapshotReader) collectStorage(systemPath string) []models.Storag
continue
}
for _, driveDoc := range r.probeSupermicroNVMeDiskBays(chassisPath) {
if !looksLikeDrive(driveDoc) {
if !looksLikeDrive(driveDoc) || isVirtualStorageDrive(driveDoc) {
continue
}
out = append(out, parseDrive(driveDoc))
@@ -1353,6 +1374,7 @@ func (r redfishSnapshotReader) collectPCIeDevices(systemPaths, chassisPaths []st
continue
}
supplementalDocs := r.getLinkedSupplementalDocs(doc, "EnvironmentMetrics", "Metrics")
supplementalDocs = append(supplementalDocs, r.getChassisScopedPCIeSupplementalDocs(doc)...)
for _, fn := range functionDocs {
supplementalDocs = append(supplementalDocs, r.getLinkedSupplementalDocs(fn, "EnvironmentMetrics", "Metrics")...)
}
@@ -1377,6 +1399,29 @@ func (r redfishSnapshotReader) collectPCIeDevices(systemPaths, chassisPaths []st
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 {
for len(s) > 1 && s[len(s)-1] == '/' {
s = s[:len(s)-1]
+336 -22
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"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) {
doc := map[string]interface{}{
"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) {
nic := parseNIC(map[string]interface{}{
"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) {
doc := map[string]interface{}{
"Name": "dev0",
@@ -727,9 +977,9 @@ func TestParseCPUAndMemory_CollectOemDetails(t *testing.T) {
"TemperatureCelsius": 63,
"Oem": map[string]interface{}{
"VendorX": map[string]interface{}{
"MicrocodeVersion": "0x2b000643",
"MicrocodeVersion": "0x2b000643",
"UncorrectableErrors": 1,
"ThermalThrottled": true,
"ThermalThrottled": true,
},
},
},
@@ -749,18 +999,18 @@ func TestParseCPUAndMemory_CollectOemDetails(t *testing.T) {
dimms := parseMemory([]map[string]interface{}{
{
"Id": "DIMM0",
"Id": "DIMM0",
"DeviceLocator": "CPU0_C0D0",
"CapacityMiB": 32768,
"SerialNumber": "DIMM-001",
"CapacityMiB": 32768,
"SerialNumber": "DIMM-001",
"Oem": map[string]interface{}{
"VendorX": map[string]interface{}{
"CorrectableECCErrorCount": 12,
"UncorrectableECCErrorCount": 2,
"TemperatureC": 41.5,
"CorrectableECCErrorCount": 12,
"UncorrectableECCErrorCount": 2,
"TemperatureC": 41.5,
"SpareBlocksRemainingPercent": 88,
"PerformanceDegraded": true,
"DataLossDetected": false,
"PerformanceDegraded": true,
"DataLossDetected": false,
},
},
},
@@ -831,9 +1081,9 @@ func TestReplayRedfishFromRawPayloads_UsesProcessorAndMemoryMetrics(t *testing.T
},
},
"/redfish/v1/Systems/1/Memory/DIMM0/MemoryMetrics": map[string]interface{}{
"CorrectableECCErrorCount": 14,
"TemperatureCelsius": 42,
"PerformanceDegraded": true,
"CorrectableECCErrorCount": 14,
"TemperatureCelsius": 42,
"PerformanceDegraded": true,
"SpareBlocksRemainingPercent": 91,
},
},
@@ -897,10 +1147,10 @@ func TestReplayRedfishFromRawPayloads_UsesDriveMetrics(t *testing.T) {
},
},
"/redfish/v1/Systems/1/Storage/RAID1/Drives/Drive0/DriveMetrics": map[string]interface{}{
"PowerOnHours": 1001,
"MediaErrors": 3,
"PowerOnHours": 1001,
"MediaErrors": 3,
"AvailableSparePercent": 92,
"TemperatureCelsius": 37,
"TemperatureCelsius": 37,
},
},
}
@@ -978,9 +1228,9 @@ func TestParseDriveAndPSU_CollectComponentMetricsIntoDetails(t *testing.T) {
"SerialNumber": "SSD-002",
"Oem": map[string]interface{}{
"Public": map[string]interface{}{
"temperature": 19,
"temperature": 19,
"PercentAvailableSpare": 93,
"PercentageUsed": 7,
"PercentageUsed": 7,
},
},
})
@@ -1076,9 +1326,9 @@ func TestParseComponentDetails_UseLinkedSupplementalMetrics(t *testing.T) {
"SerialNumber": "SSD-001",
},
map[string]interface{}{
"PowerOnHours": 5001,
"MediaErrors": 2,
"TemperatureC": 39.5,
"PowerOnHours": 5001,
"MediaErrors": 2,
"TemperatureC": 39.5,
"LifeUsedPercent": 9,
},
)
@@ -1093,7 +1343,7 @@ func TestParseComponentDetails_UseLinkedSupplementalMetrics(t *testing.T) {
},
1,
map[string]interface{}{
"Temperature": 44,
"Temperature": 44,
"LifeRemainingPercent": 97,
},
)
@@ -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) {
if !shouldCrawlPath("/redfish/v1/Systems/1/Memory/CPU0_C0D0") {
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") {
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) {
@@ -2531,3 +2829,19 @@ func TestNVMePostProbeSkipsNonStorageChassis(t *testing.T) {
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")
}
}
+22 -8
View File
@@ -7,14 +7,15 @@ import (
)
type Request struct {
Host string
Protocol string
Port int
Username string
AuthType string
Password string
Token string
TLSMode string
Host string
Protocol string
Port int
Username string
AuthType string
Password string
Token string
TLSMode string
PowerOnIfHostOff bool
}
type Progress struct {
@@ -25,7 +26,20 @@ type Progress struct {
type ProgressFn func(Progress)
type ProbeResult struct {
Reachable bool
Protocol string
HostPowerState string
HostPoweredOn bool
PowerControlAvailable bool
SystemPath string
}
type Connector interface {
Protocol() string
Collect(ctx context.Context, req Request, emit ProgressFn) (*models.AnalysisResult, error)
}
type Prober interface {
Probe(ctx context.Context, req Request) (*ProbeResult, error)
}
+419 -134
View File
@@ -45,12 +45,13 @@ func ConvertToReanimator(result *models.AnalysisResult) (*ReanimatorExport, erro
Hardware: ReanimatorHardware{
Board: convertBoard(result.Hardware.BoardInfo),
Firmware: dedupeFirmware(convertFirmware(result.Hardware.Firmware)),
CPUs: convertCPUsFromDevices(devices, collectedAt, result.Hardware.BoardInfo.SerialNumber, buildCPUMicrocodeBySocket(result.Hardware.Firmware)),
Memory: convertMemoryFromDevices(devices, collectedAt),
Storage: convertStorageFromDevices(devices, collectedAt),
PCIeDevices: convertPCIeFromDevices(devices, collectedAt, result.Hardware.BoardInfo.SerialNumber),
PowerSupplies: convertPSUsFromDevices(devices, collectedAt),
CPUs: dedupeCPUs(convertCPUsFromDevices(devices, collectedAt, result.Hardware.BoardInfo.SerialNumber, buildCPUMicrocodeBySocket(result.Hardware.Firmware))),
Memory: dedupeMemory(convertMemoryFromDevices(devices, collectedAt)),
Storage: dedupeStorage(convertStorageFromDevices(devices, collectedAt)),
PCIeDevices: dedupePCIe(convertPCIeFromDevices(devices, collectedAt)),
PowerSupplies: dedupePSUs(convertPSUsFromDevices(devices, collectedAt)),
Sensors: convertSensors(result.Sensors),
EventLogs: convertEventLogs(result.Events, collectedAt),
},
}
@@ -91,6 +92,7 @@ func buildDevicesFromLegacy(hw *models.HardwareConfig) []models.HardwareDevice {
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))
nvswitchFirmwareBySlot := buildNVSwitchFirmwareBySlot(hw.Firmware)
appendDevice := func(d models.HardwareDevice) {
all = append(all, d)
}
@@ -175,6 +177,16 @@ func buildDevicesFromLegacy(hw *models.HardwareConfig) []models.HardwareDevice {
if pcieModel == "" {
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{
Kind: models.DeviceKindPCIe,
Slot: pcie.Slot,
@@ -197,7 +209,7 @@ func buildDevicesFromLegacy(hw *models.HardwareConfig) []models.HardwareDevice {
StatusAtCollect: pcie.StatusAtCollect,
StatusHistory: pcie.StatusHistory,
ErrorDescription: pcie.ErrorDescription,
Details: mergeDetailMaps(nil, pcie.Details),
Details: details,
})
}
for _, gpu := range hw.GPUs {
@@ -353,6 +365,7 @@ func dedupeCanonicalDevices(items []models.HardwareDevice) []models.HardwareDevi
var unmatched []models.HardwareDevice
for _, item := range noKey {
mergeKind := canonicalMergeKind(item.Kind)
identity := deviceIdentity(item)
mfr := strings.ToLower(strings.TrimSpace(item.Manufacturer))
if identity == "" {
@@ -363,7 +376,8 @@ func dedupeCanonicalDevices(items []models.HardwareDevice) []models.HardwareDevi
matchCount := 0
for _, k := range order {
existing := byKey[k].item
if deviceIdentity(existing) == identity &&
if canonicalMergeKind(existing.Kind) == mergeKind &&
deviceIdentity(existing) == identity &&
strings.ToLower(strings.TrimSpace(existing.Manufacturer)) == mfr {
matchKey = k
matchCount++
@@ -507,32 +521,43 @@ func mergeDetailMaps(primary, secondary map[string]any) map[string]any {
}
func canonicalKey(item models.HardwareDevice) string {
kind := canonicalMergeKind(item.Kind)
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 != "" {
return "bdf:" + bdf
return kind + "|bdf:" + bdf
}
return ""
}
func canonicalLooseKey(item models.HardwareDevice) string {
kind := canonicalMergeKind(item.Kind)
slot := strings.ToLower(strings.TrimSpace(item.Slot))
model := strings.ToLower(strings.TrimSpace(item.Model))
part := strings.ToLower(strings.TrimSpace(item.PartNumber))
mfr := strings.ToLower(strings.TrimSpace(item.Manufacturer))
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 != "" {
return "slotmodel:" + slot + "|" + model + "|" + mfr
return kind + "|slotmodel:" + slot + "|" + model + "|" + mfr
}
if slot != "" && part != "" && mfr != "" {
return "slotpart:" + slot + "|" + part + "|" + mfr
return kind + "|slotpart:" + slot + "|" + part + "|" + mfr
}
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 {
score := 0
if normalizedSerial(item.SerialNumber) != "" {
@@ -607,18 +632,19 @@ func convertCPUsFromDevices(devices []models.HardwareDevice, collectedAt, boardS
UncorrectableErrorCount: int64FromDetailMap(d.Details, "uncorrectable_error_count"),
LifeRemainingPct: floatFromDetailMap(d.Details, "life_remaining_pct"),
LifeUsedPct: floatFromDetailMap(d.Details, "life_used_pct"),
SerialNumber: generatedCPUSerial(d.SerialNumber, boardSerial, socket),
Firmware: firstNonEmptyString(
SerialNumber: strings.TrimSpace(d.SerialNumber),
Firmware: firstNonEmptyString(
stringFromDetailMap(d.Details, "microcode"),
microcodeBySocket[socket],
stringFromDetailMap(d.Details, "firmware"),
),
Manufacturer: inferCPUManufacturer(d.Model),
Status: cpuStatus,
StatusCheckedAt: meta.StatusCheckedAt,
StatusChangedAt: meta.StatusChangedAt,
StatusHistory: meta.StatusHistory,
ErrorDescription: meta.ErrorDescription,
Manufacturer: inferCPUManufacturer(d.Model),
Status: cpuStatus,
StatusCheckedAt: meta.StatusCheckedAt,
StatusChangedAt: meta.StatusChangedAt,
ManufacturedYearWeek: manufacturedYearWeekFromDetails(d.Details),
StatusHistory: meta.StatusHistory,
ErrorDescription: meta.ErrorDescription,
})
}
return result
@@ -657,6 +683,7 @@ func convertMemoryFromDevices(devices []models.HardwareDevice, collectedAt strin
Status: status,
StatusCheckedAt: meta.StatusCheckedAt,
StatusChangedAt: meta.StatusChangedAt,
ManufacturedYearWeek: manufacturedYearWeekFromDetails(d.Details),
StatusHistory: meta.StatusHistory,
ErrorDescription: meta.ErrorDescription,
})
@@ -670,6 +697,9 @@ func convertStorageFromDevices(devices []models.HardwareDevice, collectedAt stri
if d.Kind != models.DeviceKindStorage {
continue
}
if isVirtualExportStorageDevice(d) {
continue
}
if strings.TrimSpace(d.SerialNumber) == "" {
continue
}
@@ -709,6 +739,7 @@ func convertStorageFromDevices(devices []models.HardwareDevice, collectedAt stri
Status: status,
StatusCheckedAt: meta.StatusCheckedAt,
StatusChangedAt: meta.StatusChangedAt,
ManufacturedYearWeek: manufacturedYearWeekFromDetails(d.Details),
StatusHistory: meta.StatusHistory,
ErrorDescription: meta.ErrorDescription,
})
@@ -716,19 +747,25 @@ func convertStorageFromDevices(devices []models.HardwareDevice, collectedAt stri
return result
}
func convertPCIeFromDevices(devices []models.HardwareDevice, collectedAt, boardSerial string) []ReanimatorPCIe {
func convertPCIeFromDevices(devices []models.HardwareDevice, collectedAt string) []ReanimatorPCIe {
result := make([]ReanimatorPCIe, 0)
for _, d := range devices {
if d.Kind != models.DeviceKindPCIe && d.Kind != models.DeviceKindGPU && d.Kind != models.DeviceKindNetwork {
continue
}
if isStorageEndpointPCIeDevice(d) {
continue
}
if isPlaceholderPCIeExportDevice(d) {
continue
}
if d.Present != nil && !*d.Present {
continue
}
deviceClass := normalizePCIeDeviceClass(d)
model := d.Model
model := normalizePlaceholderDeviceModel(d.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.
if model == "" && d.VendorID != 0 && d.DeviceID != 0 {
@@ -749,8 +786,9 @@ func convertPCIeFromDevices(devices []models.HardwareDevice, collectedAt, boardS
)
status := normalizeStatus(d.Status, false)
meta := buildStatusMeta(status, d.StatusCheckedAt, d.StatusChangedAt, d.StatusHistory, d.ErrorDescription, collectedAt)
slot := firstNonEmptyString(d.Slot, d.BDF)
result = append(result, ReanimatorPCIe{
Slot: d.Slot,
Slot: slot,
VendorID: d.VendorID,
DeviceID: d.DeviceID,
NUMANode: d.NUMANode,
@@ -780,11 +818,12 @@ func convertPCIeFromDevices(devices []models.HardwareDevice, collectedAt, boardS
MaxLinkWidth: d.MaxLinkWidth,
MaxLinkSpeed: d.MaxLinkSpeed,
MACAddresses: append([]string(nil), d.MACAddresses...),
SerialNumber: generatedPCIeSerial(d.SerialNumber, boardSerial, d.Slot),
Firmware: d.Firmware,
SerialNumber: strings.TrimSpace(d.SerialNumber),
Firmware: firstNonEmptyString(d.Firmware, stringFromDetailMap(d.Details, "firmware")),
Status: status,
StatusCheckedAt: meta.StatusCheckedAt,
StatusChangedAt: meta.StatusChangedAt,
ManufacturedYearWeek: manufacturedYearWeekFromDetails(d.Details),
StatusHistory: meta.StatusHistory,
ErrorDescription: meta.ErrorDescription,
})
@@ -792,6 +831,96 @@ func convertPCIeFromDevices(devices []models.HardwareDevice, collectedAt, boardS
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 {
result := make([]ReanimatorPSU, 0)
for _, d := range devices {
@@ -805,30 +934,66 @@ func convertPSUsFromDevices(devices []models.HardwareDevice, collectedAt string)
status := normalizeStatus(d.Status, false)
meta := buildStatusMeta(status, d.StatusCheckedAt, d.StatusChangedAt, d.StatusHistory, d.ErrorDescription, collectedAt)
result = append(result, ReanimatorPSU{
Slot: d.Slot,
Model: d.Model,
Vendor: d.Manufacturer,
WattageW: d.WattageW,
SerialNumber: d.SerialNumber,
PartNumber: d.PartNumber,
Firmware: d.Firmware,
Status: status,
InputType: d.InputType,
InputPowerW: float64(d.InputPowerW),
OutputPowerW: float64(d.OutputPowerW),
InputVoltage: d.InputVoltage,
TemperatureC: firstNonZeroFloat(float64(d.TemperatureC), floatFromDetailMap(d.Details, "temperature_c")),
LifeRemainingPct: floatFromDetailMap(d.Details, "life_remaining_pct"),
LifeUsedPct: floatFromDetailMap(d.Details, "life_used_pct"),
StatusCheckedAt: meta.StatusCheckedAt,
StatusChangedAt: meta.StatusChangedAt,
StatusHistory: meta.StatusHistory,
ErrorDescription: meta.ErrorDescription,
Slot: d.Slot,
Model: d.Model,
Vendor: d.Manufacturer,
WattageW: d.WattageW,
SerialNumber: d.SerialNumber,
PartNumber: d.PartNumber,
Firmware: d.Firmware,
Status: status,
InputType: d.InputType,
InputPowerW: float64(d.InputPowerW),
OutputPowerW: float64(d.OutputPowerW),
InputVoltage: d.InputVoltage,
TemperatureC: firstNonZeroFloat(float64(d.TemperatureC), floatFromDetailMap(d.Details, "temperature_c")),
LifeRemainingPct: floatFromDetailMap(d.Details, "life_remaining_pct"),
LifeUsedPct: floatFromDetailMap(d.Details, "life_used_pct"),
StatusCheckedAt: meta.StatusCheckedAt,
StatusChangedAt: meta.StatusChangedAt,
ManufacturedYearWeek: manufacturedYearWeekFromDetails(d.Details),
StatusHistory: meta.StatusHistory,
ErrorDescription: meta.ErrorDescription,
})
}
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 {
if len(sensors) == 0 {
return nil
@@ -836,7 +1001,7 @@ func convertSensors(sensors []models.SensorReading) *ReanimatorSensors {
out := &ReanimatorSensors{}
seenFans := map[string]struct{}{}
seenPower := map[string]struct{}{}
powerIndex := map[string]int{}
seenTemps := map[string]struct{}{}
seenOther := map[string]struct{}{}
@@ -845,10 +1010,12 @@ func convertSensors(sensors []models.SensorReading) *ReanimatorSensors {
if name == "" {
continue
}
if !sensorHasNumericReading(s) {
continue
}
status := normalizeSensorStatus(s.Status)
sType := strings.ToLower(strings.TrimSpace(s.Type))
unit := strings.TrimSpace(s.Unit)
location := inferSensorLocation(name)
switch {
case sType == "fan" || strings.EqualFold(unit, "RPM"):
@@ -856,49 +1023,41 @@ func convertSensors(sensors []models.SensorReading) *ReanimatorSensors {
continue
}
out.Fans = append(out.Fans, ReanimatorFanSensor{
Name: name,
Location: location,
RPM: int(s.Value),
Status: status,
Name: name,
RPM: int(s.Value),
Status: status,
})
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
}
item := ReanimatorPowerSensor{
Name: name,
Location: location,
Status: status,
}
switch {
case sType == "current" || strings.EqualFold(unit, "A"):
item.CurrentA = s.Value
case sType == "power" || strings.EqualFold(unit, "W"):
item.PowerW = s.Value
default:
item.VoltageV = s.Value
Name: baseName,
Status: status,
}
mergePowerSensorReading(&item, sType, unit, s.Value, status)
powerIndex[baseName] = len(out.Power)
out.Power = append(out.Power, item)
case sType == "temperature" || strings.EqualFold(unit, "C") || strings.EqualFold(unit, "°C"):
if seenFirst(seenTemps, name) {
continue
}
out.Temperatures = append(out.Temperatures, ReanimatorTemperatureSensor{
Name: name,
Location: location,
Celsius: s.Value,
Status: status,
Name: name,
Celsius: s.Value,
Status: status,
})
default:
if seenFirst(seenOther, name) {
continue
}
out.Other = append(out.Other, ReanimatorOtherSensor{
Name: name,
Location: location,
Value: s.Value,
Unit: unit,
Status: status,
Name: name,
Value: s.Value,
Unit: unit,
Status: status,
})
}
}
@@ -909,6 +1068,69 @@ func convertSensors(sensors []models.SensorReading) *ReanimatorSensors {
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 {
n := strings.TrimSpace(strings.ToLower(name))
if n == "" {
@@ -937,6 +1159,7 @@ func isDeviceBoundFirmwareName(name string) bool {
// HGX baseboard firmware inventory IDs for device-bound components
strings.Contains(n, "_fw_gpu_") ||
strings.Contains(n, "_fw_nvswitch_") ||
strings.Contains(n, "_fw_erot_") ||
strings.Contains(n, "_inforom_gpu_") {
return true
}
@@ -944,6 +1167,60 @@ func isDeviceBoundFirmwareName(name string) bool {
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
// (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
@@ -989,7 +1266,7 @@ func buildCPUMicrocodeBySocket(firmware []models.FirmwareInfo) map[int]string {
}
// 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 {
return nil
}
@@ -1018,7 +1295,7 @@ func convertCPUs(cpus []models.CPU, collectedAt, boardSerial string) []Reanimato
Threads: cpu.Threads,
FrequencyMHz: cpu.FrequencyMHz,
MaxFrequencyMHz: cpu.MaxFreqMHz,
SerialNumber: generatedCPUSerial(cpu.SerialNumber, boardSerial, cpu.Socket),
SerialNumber: strings.TrimSpace(cpu.SerialNumber),
Firmware: "",
Manufacturer: manufacturer,
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
func convertPCIeDevices(hw *models.HardwareConfig, collectedAt, boardSerial string) []ReanimatorPCIe {
func convertPCIeDevices(hw *models.HardwareConfig, collectedAt string) []ReanimatorPCIe {
result := make([]ReanimatorPCIe, 0)
gpuSlots := make(map[string]struct{}, len(hw.GPUs))
nvswitchFirmwareBySlot := buildNVSwitchFirmwareBySlot(hw.Firmware)
@@ -1140,12 +1417,23 @@ func convertPCIeDevices(hw *models.HardwareConfig, collectedAt, boardSerial stri
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)
model := pcie.PartNumber
model := normalizePlaceholderDeviceModel(pcie.PartNumber)
if model == "" {
model = pcie.Description
model = normalizePlaceholderDeviceModel(pcie.Description)
}
if model == "" {
model = pcie.DeviceClass
@@ -1191,7 +1479,7 @@ func convertPCIeDevices(hw *models.HardwareConfig, collectedAt, boardSerial stri
// Convert GPUs as PCIe devices
for _, gpu := range hw.GPUs {
serialNumber := generatedPCIeSerial(gpu.SerialNumber, boardSerial, gpu.Slot)
serialNumber := strings.TrimSpace(gpu.SerialNumber)
status := normalizeStatus(gpu.Status, false)
meta := buildStatusMeta(
@@ -1233,7 +1521,7 @@ func convertPCIeDevices(hw *models.HardwareConfig, collectedAt, boardSerial stri
continue
}
serialNumber := generatedPCIeSerial(nic.SerialNumber, boardSerial, nic.Slot)
serialNumber := strings.TrimSpace(nic.SerialNumber)
status := normalizeStatus(nic.Status, false)
meta := buildStatusMeta(
@@ -1285,19 +1573,25 @@ func buildNVSwitchFirmwareBySlot(firmware []models.FirmwareInfo) map[string]stri
result := make(map[string]string)
for _, fw := range firmware {
name := strings.TrimSpace(fw.DeviceName)
if !strings.HasPrefix(strings.ToUpper(name), "NVSWITCH ") {
if strings.HasPrefix(strings.ToUpper(name), "HGX_FW_EROT_") {
continue
}
rest := strings.TrimSpace(name[len("NVSwitch "):])
if rest == "" {
slot := ""
switch {
case strings.HasPrefix(strings.ToUpper(name), "NVSWITCH "):
rest := strings.TrimSpace(name[len("NVSwitch "):])
if rest == "" {
continue
}
slot = rest
if idx := strings.Index(rest, " ("); idx > 0 {
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 := rest
if idx := strings.Index(rest, " ("); idx > 0 {
slot = strings.TrimSpace(rest[:idx])
}
slot = normalizeNVSwitchSlotForLookup(slot)
if slot == "" {
continue
@@ -1388,28 +1682,6 @@ func seenFirst(seen map[string]struct{}, key string) bool {
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 {
return normalizeStatus(status, false)
}
@@ -1829,29 +2101,6 @@ func boolFromPresentPtr(v *bool, defaultValue bool) bool {
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 {
if details == nil {
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
func inferStorageStatus(stor models.Storage) string {
if !stor.Present {
+455 -20
View File
@@ -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 {
t.Fatalf("expected 2 CPUs, got %d", len(result))
@@ -227,8 +227,8 @@ func TestConvertCPUs(t *testing.T) {
if result[0].Status != "Unknown" {
t.Errorf("expected Unknown status, got %q", result[0].Status)
}
if result[0].SerialNumber != "BOARD-001-CPU-0" {
t.Errorf("expected generated CPU serial, got %q", result[0].SerialNumber)
if 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) {
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) {
pct100 := 100
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
if len(result) != 4 {
t.Fatalf("expected 4 PCIe devices total, got %d", len(result))
}
// Check that serial is generated for second PCIe device
if result[1].SerialNumber != "BOARD-001-PCIE-PCIeCard2" {
t.Errorf("expected generated serial for missing device serial, got %q", result[1].SerialNumber)
// Missing serials must remain absent.
if result[1].SerialNumber != "" {
t.Errorf("expected empty serial for missing device serial, got %q", result[1].SerialNumber)
}
// 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 {
t.Fatalf("expected 1 PCIe device, got %d", len(result))
}
if result[0].SerialNumber != "BOARD-001-PCIE-NVSWITCH1" {
t.Fatalf("expected generated NVSwitch serial, got %q", result[0].SerialNumber)
if result[0].SerialNumber != "" {
t.Fatalf("expected empty NVSwitch serial, got %q", result[0].SerialNumber)
}
if result[0].Firmware != "96.10.6D.00.01" {
t.Fatalf("expected NVSwitch firmware 96.10.6D.00.01, got %q", result[0].Firmware)
}
}
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) {
hw := &models.HardwareConfig{
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 {
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 {
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 {
t.Fatalf("expected deduped firmware len=1, got %d", len(out.Hardware.Firmware))
}
if len(out.Hardware.CPUs) != 2 {
t.Fatalf("expected cpus len=2 (no serial/bdf dedupe), got %d", len(out.Hardware.CPUs))
if len(out.Hardware.CPUs) != 1 {
t.Fatalf("expected cpus len=1 after socket dedupe, got %d", len(out.Hardware.CPUs))
}
if len(out.Hardware.Memory) != 2 {
t.Fatalf("expected memory len=2 (different serials), got %d", len(out.Hardware.Memory))
if len(out.Hardware.Memory) != 1 {
t.Fatalf("expected memory len=1 after slot dedupe, got %d", len(out.Hardware.Memory))
}
if len(out.Hardware.Storage) != 1 {
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 {
t.Fatalf("expected deduped psu len=1, got %d", len(out.Hardware.PowerSupplies))
}
if len(out.Hardware.PCIeDevices) != 4 {
t.Fatalf("expected pcie len=4 with serial->bdf dedupe, got %d", len(out.Hardware.PCIeDevices))
if len(out.Hardware.PCIeDevices) != 2 {
t.Fatalf("expected pcie len=2 after final pcie-class dedupe, got %d", len(out.Hardware.PCIeDevices))
}
gpuCount := 0
@@ -689,8 +932,8 @@ func TestConvertToReanimator_DeduplicatesAllSections(t *testing.T) {
gpuCount++
}
}
if gpuCount != 2 {
t.Fatalf("expected two #GPU0 records (pcie+gpu kinds), got %d", gpuCount)
if gpuCount != 1 {
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) {
input := &models.AnalysisResult{
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) {
input := &models.AnalysisResult{
Filename: "dedup-vitals.json",
@@ -1336,6 +1770,7 @@ func TestIsDeviceBoundFirmwareName(t *testing.T) {
{"NVMe Drive", true},
// HGX FW ID patterns (in case Id is used as name)
{"HGX_FW_GPU_SXM_1", true},
{"HGX_FW_ERoT_NVSwitch_0", true},
{"HGX_InfoROM_GPU_SXM_2", true},
// System-level firmware — must NOT be excluded
{"BIOS", false},
+39 -21
View File
@@ -20,6 +20,7 @@ type ReanimatorHardware struct {
PCIeDevices []ReanimatorPCIe `json:"pcie_devices,omitempty"`
PowerSupplies []ReanimatorPSU `json:"power_supplies,omitempty"`
Sensors *ReanimatorSensors `json:"sensors,omitempty"`
EventLogs []ReanimatorEventLog `json:"event_logs,omitempty"`
}
// ReanimatorBoard represents motherboard/server information
@@ -65,6 +66,7 @@ type ReanimatorCPU struct {
Status string `json:"status,omitempty"`
StatusCheckedAt string `json:"status_checked_at,omitempty"`
StatusChangedAt string `json:"status_changed_at,omitempty"`
ManufacturedYearWeek string `json:"manufactured_year_week,omitempty"`
StatusHistory []ReanimatorStatusHistoryEntry `json:"status_history,omitempty"`
ErrorDescription string `json:"error_description,omitempty"`
}
@@ -92,6 +94,7 @@ type ReanimatorMemory struct {
Status string `json:"status,omitempty"`
StatusCheckedAt string `json:"status_checked_at,omitempty"`
StatusChangedAt string `json:"status_changed_at,omitempty"`
ManufacturedYearWeek string `json:"manufactured_year_week,omitempty"`
StatusHistory []ReanimatorStatusHistoryEntry `json:"status_history,omitempty"`
ErrorDescription string `json:"error_description,omitempty"`
}
@@ -125,6 +128,7 @@ type ReanimatorStorage struct {
Status string `json:"status,omitempty"`
StatusCheckedAt string `json:"status_checked_at,omitempty"`
StatusChangedAt string `json:"status_changed_at,omitempty"`
ManufacturedYearWeek string `json:"manufactured_year_week,omitempty"`
StatusHistory []ReanimatorStatusHistoryEntry `json:"status_history,omitempty"`
ErrorDescription string `json:"error_description,omitempty"`
}
@@ -152,7 +156,7 @@ type ReanimatorPCIe struct {
SFPRXPowerDBm float64 `json:"sfp_rx_power_dbm,omitempty"`
SFPVoltageV float64 `json:"sfp_voltage_v,omitempty"`
SFPBiasMA float64 `json:"sfp_bias_ma,omitempty"`
BDF string `json:"bdf,omitempty"`
BDF string `json:"-"`
DeviceClass string `json:"device_class,omitempty"`
Manufacturer string `json:"manufacturer,omitempty"`
Model string `json:"model,omitempty"`
@@ -167,32 +171,46 @@ type ReanimatorPCIe struct {
Status string `json:"status,omitempty"`
StatusCheckedAt string `json:"status_checked_at,omitempty"`
StatusChangedAt string `json:"status_changed_at,omitempty"`
ManufacturedYearWeek string `json:"manufactured_year_week,omitempty"`
StatusHistory []ReanimatorStatusHistoryEntry `json:"status_history,omitempty"`
ErrorDescription string `json:"error_description,omitempty"`
}
// ReanimatorPSU represents a power supply unit
type ReanimatorPSU struct {
Slot string `json:"slot"`
Present *bool `json:"present,omitempty"`
Model string `json:"model,omitempty"`
Vendor string `json:"vendor,omitempty"`
WattageW int `json:"wattage_w,omitempty"`
SerialNumber string `json:"serial_number,omitempty"`
PartNumber string `json:"part_number,omitempty"`
Firmware string `json:"firmware,omitempty"`
Status string `json:"status,omitempty"`
InputType string `json:"input_type,omitempty"`
InputPowerW float64 `json:"input_power_w,omitempty"`
OutputPowerW float64 `json:"output_power_w,omitempty"`
InputVoltage float64 `json:"input_voltage,omitempty"`
TemperatureC float64 `json:"temperature_c,omitempty"`
LifeRemainingPct float64 `json:"life_remaining_pct,omitempty"`
LifeUsedPct float64 `json:"life_used_pct,omitempty"`
StatusCheckedAt string `json:"status_checked_at,omitempty"`
StatusChangedAt string `json:"status_changed_at,omitempty"`
StatusHistory []ReanimatorStatusHistoryEntry `json:"status_history,omitempty"`
ErrorDescription string `json:"error_description,omitempty"`
Slot string `json:"slot"`
Present *bool `json:"present,omitempty"`
Model string `json:"model,omitempty"`
Vendor string `json:"vendor,omitempty"`
WattageW int `json:"wattage_w,omitempty"`
SerialNumber string `json:"serial_number,omitempty"`
PartNumber string `json:"part_number,omitempty"`
Firmware string `json:"firmware,omitempty"`
Status string `json:"status,omitempty"`
InputType string `json:"input_type,omitempty"`
InputPowerW float64 `json:"input_power_w,omitempty"`
OutputPowerW float64 `json:"output_power_w,omitempty"`
InputVoltage float64 `json:"input_voltage,omitempty"`
TemperatureC float64 `json:"temperature_c,omitempty"`
LifeRemainingPct float64 `json:"life_remaining_pct,omitempty"`
LifeUsedPct float64 `json:"life_used_pct,omitempty"`
StatusCheckedAt string `json:"status_checked_at,omitempty"`
StatusChangedAt string `json:"status_changed_at,omitempty"`
ManufacturedYearWeek string `json:"manufactured_year_week,omitempty"`
StatusHistory []ReanimatorStatusHistoryEntry `json:"status_history,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 {
+135
View File
@@ -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))
}
}
+65
View File
@@ -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
View File
@@ -216,6 +216,7 @@ func parseH3CG5(files []parser.ExtractedFile) *models.AnalysisResult {
}
result.Hardware.Storage = dedupeStorage(result.Hardware.Storage)
result.Hardware.Volumes = dedupeVolumes(result.Hardware.Volumes)
parser.ApplyManufacturedYearWeekFromFRU(result.FRU, result.Hardware)
return result
}
@@ -286,6 +287,7 @@ func parseH3CG6(files []parser.ExtractedFile) *models.AnalysisResult {
}
result.Hardware.Storage = dedupeStorage(result.Hardware.Storage)
result.Hardware.Volumes = dedupeVolumes(result.Hardware.Volumes)
parser.ApplyManufacturedYearWeekFromFRU(result.FRU, result.Hardware)
return result
}
+33
View File
@@ -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
View File
@@ -165,7 +165,10 @@ func TestParseIDLLog_ParsesStructuredJSONLine(t *testing.T) {
if events[0].ID != "17FFB002" {
t.Fatalf("expected event ID 17FFB002, got %q", events[0].ID)
}
if events[0].Source != "PCIE" {
t.Fatalf("expected source PCIE, got %q", events[0].Source)
if events[0].Source != "BMC" {
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
View File
@@ -60,7 +60,7 @@ func ParseIDLLog(content []byte) []models.Event {
events = append(events, models.Event{
ID: eventID,
Timestamp: ts,
Source: component,
Source: "BMC",
SensorType: strings.ToLower(component),
SensorName: sensorName,
EventType: eventType,
+1
View File
@@ -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).
// These override redis/component.log serials which may be stale after disk replacement.
applyRAIDSlotSerials(result.Hardware, raidSlotSerials)
parser.ApplyManufacturedYearWeekFromFRU(result.FRU, result.Hardware)
}
return result, nil
+2 -2
View File
@@ -48,9 +48,9 @@ func ParseSyslog(content []byte, sourcePath string) []models.Event {
event := models.Event{
ID: generateEventID(sourcePath, lineNum),
Timestamp: timestamp,
Source: matches[4],
Source: "syslog",
SensorType: "syslog",
SensorName: matches[3],
SensorName: matches[4],
Description: matches[5],
Severity: severity,
RawData: line,
+35 -1
View File
@@ -14,16 +14,50 @@ import (
func newCollectTestServer() (*Server, *httptest.Server) {
s := &Server{
jobManager: NewJobManager(),
jobManager: NewJobManager(),
collectors: testCollectorRegistry(),
}
mux := http.NewServeMux()
mux.HandleFunc("POST /api/collect/probe", s.handleCollectProbe)
mux.HandleFunc("POST /api/collect", s.handleCollectStart)
mux.HandleFunc("GET /api/collect/{id}", s.handleCollectStatus)
mux.HandleFunc("POST /api/collect/{id}/cancel", s.handleCollectCancel)
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) {
_, ts := newCollectTestServer()
defer ts.Close()
@@ -17,6 +17,20 @@ func (c *mockConnector) Protocol() string {
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) {
steps := []collector.Progress{
{Status: CollectStatusRunning, Progress: 20, Message: "Подключение..."},
+18 -8
View File
@@ -11,14 +11,24 @@ const (
)
type CollectRequest struct {
Host string `json:"host"`
Protocol string `json:"protocol"`
Port int `json:"port"`
Username string `json:"username"`
AuthType string `json:"auth_type"`
Password string `json:"password,omitempty"`
Token string `json:"token,omitempty"`
TLSMode string `json:"tls_mode"`
Host string `json:"host"`
Protocol string `json:"protocol"`
Port int `json:"port"`
Username string `json:"username"`
AuthType string `json:"auth_type"`
Password string `json:"password,omitempty"`
Token string `json:"token,omitempty"`
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 {
+72 -8
View File
@@ -1510,6 +1510,69 @@ func (s *Server) handleCollectStart(w http.ResponseWriter, r *http.Request) {
_ = 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) {
jobID := strings.TrimSpace(r.PathValue("id"))
if !isValidCollectJobID(jobID) {
@@ -1787,14 +1850,15 @@ func applyCollectSourceMetadata(result *models.AnalysisResult, req CollectReques
func toCollectorRequest(req CollectRequest) collector.Request {
return collector.Request{
Host: req.Host,
Protocol: req.Protocol,
Port: req.Port,
Username: req.Username,
AuthType: req.AuthType,
Password: req.Password,
Token: req.Token,
TLSMode: req.TLSMode,
Host: req.Host,
Protocol: req.Protocol,
Port: req.Port,
Username: req.Username,
AuthType: req.AuthType,
Password: req.Password,
Token: req.Token,
TLSMode: req.TLSMode,
PowerOnIfHostOff: req.PowerOnIfHostOff,
}
}
+28 -24
View File
@@ -32,17 +32,17 @@ type RawExportPackage struct {
}
type RawExportSource struct {
Kind string `json:"kind"` // file_bytes | live_redfish | snapshot_json
Filename string `json:"filename,omitempty"`
MIMEType string `json:"mime_type,omitempty"`
Encoding string `json:"encoding,omitempty"` // base64
Data string `json:"data,omitempty"`
Protocol string `json:"protocol,omitempty"`
TargetHost string `json:"target_host,omitempty"`
SourceTimezone string `json:"source_timezone,omitempty"`
RawPayloads map[string]any `json:"raw_payloads,omitempty"`
CollectLogs []string `json:"collect_logs,omitempty"`
CollectMeta *CollectRequestMeta `json:"collect_meta,omitempty"`
Kind string `json:"kind"` // file_bytes | live_redfish | snapshot_json
Filename string `json:"filename,omitempty"`
MIMEType string `json:"mime_type,omitempty"`
Encoding string `json:"encoding,omitempty"` // base64
Data string `json:"data,omitempty"`
Protocol string `json:"protocol,omitempty"`
TargetHost string `json:"target_host,omitempty"`
SourceTimezone string `json:"source_timezone,omitempty"`
RawPayloads map[string]any `json:"raw_payloads,omitempty"`
CollectLogs []string `json:"collect_logs,omitempty"`
CollectMeta *CollectRequestMeta `json:"collect_meta,omitempty"`
}
func newRawExportFromUploadedFile(filename, mimeType string, payload []byte, result *models.AnalysisResult) *RawExportPackage {
@@ -50,13 +50,13 @@ func newRawExportFromUploadedFile(filename, mimeType string, payload []byte, res
Format: rawExportFormatV1,
ExportedAt: time.Now().UTC(),
Source: RawExportSource{
Kind: "file_bytes",
Filename: filename,
MIMEType: mimeType,
Encoding: "base64",
Data: base64.StdEncoding.EncodeToString(payload),
Protocol: resultProtocol(result),
TargetHost: resultTargetHost(result),
Kind: "file_bytes",
Filename: filename,
MIMEType: mimeType,
Encoding: "base64",
Data: base64.StdEncoding.EncodeToString(payload),
Protocol: resultProtocol(result),
TargetHost: resultTargetHost(result),
SourceTimezone: resultSourceTimezone(result),
},
}
@@ -81,13 +81,13 @@ func newRawExportFromLiveCollect(result *models.AnalysisResult, req CollectReque
Format: rawExportFormatV1,
ExportedAt: time.Now().UTC(),
Source: RawExportSource{
Kind: "live_redfish",
Protocol: req.Protocol,
TargetHost: req.Host,
Kind: "live_redfish",
Protocol: req.Protocol,
TargetHost: req.Host,
SourceTimezone: resultSourceTimezone(result),
RawPayloads: rawPayloads,
CollectLogs: append([]string(nil), logs...),
CollectMeta: &meta,
RawPayloads: rawPayloads,
CollectLogs: append([]string(nil), logs...),
CollectMeta: &meta,
},
}
}
@@ -386,6 +386,10 @@ func buildParserFieldSummary(result *models.AnalysisResult) map[string]any {
return out
}
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{
"board": hw.BoardInfo,
"counts": map[string]int{
+23 -89
View File
@@ -1,101 +1,35 @@
package server
import (
"archive/zip"
"bytes"
"encoding/json"
"strings"
"testing"
"time"
"git.mchus.pro/mchus/logpile/internal/models"
)
func TestCollectLogTimeBounds(t *testing.T) {
lines := []string{
"2026-02-28T13:10:13.7442032Z Задача поставлена в очередь",
"not-a-timestamp line",
"2026-02-28T13:31:00.5077486Z Сбор завершен",
}
startedAt, finishedAt, ok := collectLogTimeBounds(lines)
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 Сбор завершен",
func TestBuildParserFieldSummary_MirrorsBoardInfoToTopLevel(t *testing.T) {
result := &models.AnalysisResult{
Hardware: &models.HardwareConfig{
BoardInfo: models.BoardInfo{
Manufacturer: "Supermicro",
ProductName: "SYS-821GE-TNHR",
SerialNumber: "A514359X5C08846",
PartNumber: "SYS-821GE-TNHR",
},
},
}
logText := buildHumanReadableCollectionLog(pkg, nil, "LOGPile test")
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) {
pkg := &RawExportPackage{
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 err != nil {
t.Fatalf("marshal pkg: %v", err)
}
parserFields := []byte(`{"collected_at":"2026-02-25T09:58:05.9129753Z"}`)
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 {
t.Fatalf("write package file: %v", err)
}
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)
got := buildParserFieldSummary(result)
if got["vendor"] != "Supermicro" {
t.Fatalf("expected vendor mirror, got %v", got["vendor"])
}
if got["model"] != "SYS-821GE-TNHR" {
t.Fatalf("expected model mirror, got %v", got["model"])
}
if got["serial"] != "A514359X5C08846" {
t.Fatalf("expected serial mirror, got %v", got["serial"])
}
if got["part_number"] != "SYS-821GE-TNHR" {
t.Fatalf("expected part_number mirror, got %v", got["part_number"])
}
}
@@ -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
}
+1
View File
@@ -88,6 +88,7 @@ func (s *Server) setupRoutes() {
s.mux.HandleFunc("DELETE /api/clear", s.handleClear)
s.mux.HandleFunc("POST /api/shutdown", s.handleShutdown)
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("POST /api/collect/{id}/cancel", s.handleCollectCancel)
}
+13
View File
@@ -169,7 +169,10 @@ main {
pointer-events: none;
}
#api-check-btn,
#api-connect-btn,
#api-power-on-collect-btn,
#api-collect-off-btn,
#convert-folder-btn,
#convert-run-btn,
#cancel-job-btn,
@@ -185,7 +188,10 @@ main {
transition: background-color 0.2s ease, opacity 0.2s ease;
}
#api-check-btn:hover,
#api-connect-btn:hover,
#api-power-on-collect-btn:hover,
#api-collect-off-btn:hover,
#convert-folder-btn:hover,
#convert-run-btn:hover,
#cancel-job-btn:hover,
@@ -195,7 +201,10 @@ main {
#convert-run-btn:disabled,
#convert-folder-btn:disabled,
#api-check-btn:disabled,
#api-connect-btn:disabled,
#api-power-on-collect-btn:disabled,
#api-collect-off-btn:disabled,
#cancel-job-btn:disabled,
.upload-area button:disabled {
opacity: 0.6;
@@ -219,6 +228,10 @@ main {
color: #0f4dba;
}
.api-connect-status.warning {
color: #b06a00;
}
.convert-progress {
margin-top: 0.9rem;
}
+163 -12
View File
@@ -24,6 +24,8 @@ let collectionJobPollTimer = null;
let collectionJobLogCounter = 0;
let apiPortTouchedByUser = false;
let isAutoUpdatingApiPort = false;
let apiProbeResult = null;
let apiPowerDecisionTimer = null;
function initSourceType() {
const sourceButtons = document.querySelectorAll('.source-switch-btn');
@@ -66,22 +68,14 @@ function initApiSource() {
}
const cancelJobButton = document.getElementById('cancel-job-btn');
const checkButton = document.getElementById('api-check-btn');
const collectOffButton = document.getElementById('api-collect-off-btn');
const powerOnCollectButton = document.getElementById('api-power-on-collect-btn');
const fieldNames = ['host', 'port', 'username', 'password'];
apiForm.addEventListener('submit', (event) => {
event.preventDefault();
const { isValid, payload, errors } = validateCollectForm();
renderFormErrors(errors);
if (!isValid) {
renderApiConnectStatus(false, null);
apiConnectPayload = null;
return;
}
apiConnectPayload = payload;
renderApiConnectStatus(true, payload);
startCollectionJob(payload);
startCollectionFromCurrentProbe(false);
});
if (cancelJobButton) {
@@ -89,6 +83,23 @@ function initApiSource() {
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) => {
const field = apiForm.elements.namedItem(fieldName);
@@ -105,6 +116,7 @@ function initApiSource() {
const { errors } = validateCollectForm();
renderFormErrors(errors);
clearApiConnectStatus();
resetApiProbeState();
if (collectionJob && isCollectionJobTerminal(collectionJob.status)) {
resetCollectionJobState();
@@ -116,6 +128,144 @@ function initApiSource() {
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() {
const host = getApiValue('host');
const portRaw = getApiValue('port');
@@ -230,6 +380,7 @@ function clearApiConnectStatus() {
}
function startCollectionJob(payload) {
clearApiPowerDecisionTimer();
resetCollectionJobState();
setApiFormBlocked(true);
+10 -1
View File
@@ -63,9 +63,18 @@
</div>
<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 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>
<section id="api-job-status" class="job-status hidden" aria-live="polite">