12 Commits

Author SHA1 Message Date
Mikhail Chusavitin
c47c34fd11 feat(hpe): improve inventory extraction and export fidelity 2026-03-30 15:04:17 +03:00
Mikhail Chusavitin
d8c3256e41 chore(hpe_ilo_ahs): normalize module version format — v1.0 2026-03-30 13:43:10 +03:00
Mikhail Chusavitin
1b2d978d29 test: add real fixture coverage for HPE AHS parser 2026-03-30 13:41:02 +03:00
Mikhail Chusavitin
0f310d57c4 feat: HPE iLO support — profile, post-probe hang fix, replay parser fixes, AHS parser
- Add HPE iLO Redfish profile (priority 20): matches on manufacturer/OEM/iLO signals,
  adds SmartStorage/SmartStorageConfig to critical paths, sets realistic ETA baseline
  and rate policy for iLO's known slowness
- Fix post-probe hang on HPE iLO: skip numeric probing of collections where
  Members@odata.count == len(Members); add 4s postProbeClient timeout as safety net
- Exclude /WorkloadPerformanceAdvisor from crawl paths
- Fix replay parser: skip absent CPU sockets, absent DIMM slots, absent drive bays
- Filter N/A version entries from firmware inventory
- Remove drive firmware from general firmware list (already in Storage[].Firmware)
- Add HPE AHS (.ahs) archive parser with hybrid SMBIOS/Redfish extraction

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 13:39:14 +03:00
Mikhail Chusavitin
3547ef9083 Skip placeholder firmware versions in API output 2026-03-26 18:42:54 +03:00
Mikhail Chusavitin
99f0d6217c Improve Multillect Redfish replay and power detection 2026-03-26 18:41:02 +03:00
Mikhail Chusavitin
8acbba3cc9 feat: add reanimator easy bee parser 2026-03-25 19:36:13 +03:00
Mikhail Chusavitin
8942991f0c Add Inspur Group OEM Redfish profile 2026-03-25 15:08:40 +03:00
Mikhail Chusavitin
9b71c4a95f chore: update bible submodule
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 11:22:33 +03:00
Mikhail Chusavitin
125f77ef69 feat: adaptive BMC readiness check + ghost NIC dedup fix + empty collection plan-B retry
BMC readiness after power-on (waitForStablePoweredOnHost):
- After initial 1m stabilization, poll BMC inventory readiness before collecting
- Ready if MemorySummary.TotalSystemMemoryGiB > 0 OR PCIeDevices.Members non-empty
- On failure: wait +60s, retry; on second failure: wait +120s, retry; then warn and proceed
- Configurable via LOGPILE_REDFISH_BMC_READY_WAITS (default: 60s,120s)

Empty critical collection plan-B retry (EnableEmptyCriticalCollectionRetry):
- Hardware inventory collections that returned Members=[] are now re-probed in plan-B
- Covers PCIeDevices, NetworkAdapters, Processors, Drives, Storage, EthernetInterfaces
- Enabled by default in generic profile (applies to all vendors)

Ghost NIC dedup fix (enrichNICsFromNetworkInterfaces):
- NetworkInterface entries (e.g. Id=2) that don't match existing NIC slots are now
  resolved via Links.NetworkAdapter cross-reference to the real Chassis NIC
- Prevents duplicate ghost entries (slot=2 "Network Device View") from appearing
  alongside real NICs (slot="RISER 5 slot 1 (7)") with the same MAC addresses

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 11:19:36 +03:00
Mikhail Chusavitin
063b08d5fb feat: redesign collection UI + add StopHostAfterCollect + TCP ping pre-probe
- Single "Подключиться" button flow: probe first, then show collect options
- Power management checkboxes: power on before / stop after collect
- Modal confirmation when enabling shutdown on already-powered-on host
- StopHostAfterCollect flag: host shuts down only when explicitly requested
- TCP ping (10 attempts, min 3 successes) before Redfish probe
- Debug payloads checkbox (Oem/Ami/Inventory/Crc, off by default)
- Remove platform_config BIOS settings collection (unreliable on AMI)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 18:50:01 +03:00
Mikhail Chusavitin
e3ff1745fc feat: clean up collection job log UI
- Filter out debug noise (plan-B per-path lines, heartbeats, timing stats, telemetry)
- Strip server-side nanosecond timestamp prefix from displayed messages
- Transform snapshot progress lines to show current path instead of doc count + ETA
- Humanize recurring message patterns (plan-B summary, prefetch, snapshot total)
- Raw collect.log and raw_export.json are unaffected

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 00:50:13 +03:00
36 changed files with 5703 additions and 188 deletions

2
bible

Submodule bible updated: 0c829182a1...52444350c1

View File

@@ -27,8 +27,10 @@ All modes converge on the same normalized hardware model and exporter pipeline.
## Current vendor coverage
- Dell TSR
- Reanimator Easy Bee support bundles
- H3C SDS G5/G6
- Inspur / Kaytus
- HPE iLO AHS
- NVIDIA HGX Field Diagnostics
- NVIDIA Bug Report
- Unraid

View File

@@ -100,6 +100,13 @@ Live Redfish collection must expose profile-match diagnostics:
- the collect page should render active modules as chips from structured status data, not by
parsing log lines
Profile matching may use stable platform grammar signals in addition to vendor strings:
- discovered member/resource naming from lightweight discovery collections
- firmware inventory member IDs
- OEM action names and linked target paths embedded in discovery documents
- replay-only snapshot hints such as OEM assembly/type markers when they are present in
`raw_payloads.redfish_tree`
On replay, profile-derived analysis directives may enable vendor-specific inventory linking
helpers such as processor-GPU fallback, chassis-ID alias resolution, and bounded storage recovery.
Replay should now resolve a structured analysis plan inside `redfishprofile/`, analogous to the

View File

@@ -50,8 +50,10 @@ When `vendor_id` and `device_id` are known but the model name is missing or gene
| Vendor ID | Input family | Notes |
|-----------|--------------|-------|
| `dell` | TSR ZIP archives | Broad hardware, firmware, sensors, lifecycle events |
| `easy_bee` | `bee-support-*.tar.gz` | Imports embedded `export/bee-audit.json` snapshot from reanimator-easy-bee bundles |
| `h3c_g5` | H3C SDS G5 bundles | INI/XML/CSV-driven hardware and event parsing |
| `h3c_g6` | H3C SDS G6 bundles | Similar flow with G6-specific files |
| `hpe_ilo_ahs` | HPE iLO Active Health System (`.ahs`) | Proprietary `ABJR` container with gzip-compressed `zbb` members; parser combines SMBIOS-style inventory strings and embedded Redfish storage JSON |
| `inspur` | onekeylog archives | FRU/SDR plus optional Redis enrichment |
| `nvidia` | HGX Field Diagnostics | GPU- and fabric-heavy diagnostic input |
| `nvidia_bug_report` | `nvidia-bug-report-*.log.gz` | dmidecode, lspci, NVIDIA driver sections |
@@ -120,6 +122,32 @@ with content markers (e.g. `Unraid kernel build`, parity data markers).
---
### HPE iLO AHS (`hpe_ilo_ahs`)
**Status:** Ready (v1.0.0). Tested on HPE ProLiant Gen11 `.ahs` export from iLO 6.
**Archive format:** `.ahs` single-file Active Health System export.
**Detection:** Single-file input with `ABJR` container header and HPE AHS member names
such as `CUST_INFO.DAT`, `*.zbb`, `ilo_boot_support.zbb`.
**Extracted data (current):**
- System board identity (manufacturer, model, serial, part number)
- iLO / System ROM / SPS top-level firmware
- CPU inventory (model-level)
- Memory DIMM inventory for populated slots
- PSU inventory
- PCIe / OCP NIC inventory from SMBIOS-style slot records
- Storage controller and physical drives from embedded Redfish JSON inside `zbb` members
- Basic iLO event log entries with timestamps when present
**Implementation note:** The format is proprietary. Parser support is intentionally hybrid:
container parsing (`ABJR` + gzip) plus structured extraction from embedded Redfish objects and
printable SMBIOS/FRU payloads. This is sufficient for inventory-grade parsing without decoding the
entire internal `zbb` schema.
---
### Generic text fallback (`generic`)
**Status:** Ready (v1.0.0).
@@ -139,6 +167,8 @@ with content markers (e.g. `Unraid kernel build`, parity data markers).
| Vendor | ID | Status | Tested on |
|--------|----|--------|-----------|
| Dell TSR | `dell` | Ready | TSR nested zip archives |
| Reanimator Easy Bee | `easy_bee` | Ready | `bee-support-*.tar.gz` support bundles |
| HPE iLO AHS | `hpe_ilo_ahs` | Ready | iLO 6 `.ahs` exports |
| Inspur / Kaytus | `inspur` | Ready | KR4268X2 onekeylog |
| NVIDIA HGX Field Diag | `nvidia` | Ready | Various HGX servers |
| NVIDIA Bug Report | `nvidia_bug_report` | Ready | H100 systems |

View File

@@ -258,6 +258,9 @@ at parse time before storing in any model struct. Use the regex
**Date:** 2026-03-12
**Context:** `shouldAdaptiveNVMeProbe` was introduced in `2fa4a12` to recover NVMe drives on
Supermicro BMCs that expose empty `Drives` collections but serve disks at direct `Disk.Bay.N`
---
paths. The function returns `true` for any chassis with an empty `Members` array. On
Supermicro HGX systems (SYS-A21GE-NBRT and similar) ~35 sub-chassis (GPU, NVSwitch,
PCIeRetimer, ERoT, IRoT, BMC, FPGA) all carry `ChassisType=Module/Component/Zone` and
@@ -918,3 +921,127 @@ hardware change.
- Hardware event history (last 7 days) visible in Reanimator `EventLogs` section.
- No impact on existing inventory pipeline or offline archive replay (archives without `redfish_log_entries` key silently skip parsing).
- Adds extra HTTP requests during live collection (sequential, after tree-walk completes).
---
## ADL-036 — Redfish profile matching may use platform grammar hints beyond vendor strings
**Date:** 2026-03-25
**Context:**
Some BMCs expose unusable `Manufacturer` / `Model` values (`NULL`, placeholders, or generic SoC
names) while still exposing a stable platform-specific Redfish grammar: repeated member names,
firmware inventory IDs, OEM action names, and target-path quirks. Matching only on vendor
strings forced such systems into fallback mode even when the platform shape was consistent.
**Decision:**
- Extend `redfishprofile.MatchSignals` with doc-derived hint tokens collected from discovery docs
and replay snapshots.
- Allow profile matchers to score on stable platform grammar such as:
- collection member naming (`outboardPCIeCard*`, drive slot grammars)
- firmware inventory member IDs
- OEM action/type markers and linked target paths
- During live collection, gather only lightweight extra hint collections needed for matching
(`NetworkInterfaces`, `NetworkAdapters`, `Drives`, `UpdateService/FirmwareInventory`), not slow
deep inventory branches.
- Keep such profiles out of fallback aggregation unless they are proven safe as broad additive
hints.
**Consequences:**
- Platform-family profiles can activate even when vendor strings are absent or set to `NULL`.
- Matching logic becomes more robust for OEM BMC implementations that differ mainly by Redfish
grammar rather than by explicit vendor strings.
- Live collection gains a small amount of extra discovery I/O to harvest stable member IDs, but
avoids slow deep probes such as `Assembly` just for profile selection.
---
## ADL-037 — easy-bee archives are parsed from the embedded bee-audit snapshot
**Date:** 2026-03-25
**Context:**
`reanimator-easy-bee` support bundles already contain a normalized hardware snapshot in
`export/bee-audit.json` plus supporting logs and techdump files. Rebuilding the same inventory
from raw `techdump/` files inside LOGPile would duplicate parser logic and create drift between
the producer utility and archive importer.
**Decision:**
- Add a dedicated `easy_bee` vendor parser for `bee-support-*.tar.gz` bundles.
- Detect the bundle by `manifest.txt` (`bee_version=...`) plus `export/bee-audit.json`.
- Parse the archive from the embedded snapshot first; treat `techdump/` and runtime files as
secondary context only.
- Normalize snapshot-only fields needed by LOGPile, notably:
- flatten `hardware.sensors` groups into `[]SensorReading`
- turn runtime issues/status into `[]Event`
- synthesize a board FRU entry when the snapshot does not include FRU data
**Consequences:**
- LOGPile stays aligned with the schema emitted by `reanimator-easy-bee`.
- Adding support required only a thin archive adapter instead of a full hardware parser.
- If the upstream utility changes the embedded snapshot schema, the `easy_bee` adapter is the
only place that must be updated.
---
## ADL-038 — HPE AHS parser uses hybrid extraction instead of full `zbb` schema decoding
**Date:** 2026-03-30
**Context:** HPE iLO Active Health System exports (`.ahs`) are proprietary `ABJR` containers
with gzip-compressed `zbb` payloads. The sample inventory data contains two practical signal
families: printable SMBIOS/FRU-style strings and embedded Redfish JSON subtrees, especially for
storage controllers and drives. Full `zbb` binary schema decoding is not documented and would add
significant complexity before proving user value.
**Decision:** Support HPE AHS with a hybrid parser:
- decode the outer `ABJR` container
- gunzip embedded members when applicable
- extract inventory from printable SMBIOS/FRU payloads
- extract storage/controller/backplane details from embedded Redfish JSON objects
- enrich firmware and PSU inventory from auxiliary package payloads such as `bcert.pkg`
- do not attempt complete semantic decoding of the internal `zbb` record format
**Consequences:**
- Parser reaches inventory-grade usefulness quickly for HPE `.ahs` uploads.
- Storage inventory is stronger than text-only parsing because it reuses structured Redfish data when present.
- Auxiliary package payloads can supply missing firmware/PSU fields even when the main SMBIOS-like blob is incomplete.
- Future deeper `zbb` decoding can be added incrementally without replacing the current parser contract.
---
## ADL-039 — Canonical inventory keeps DIMMs with unknown capacity when identity is known
**Date:** 2026-03-30
**Context:** Some sources, notably HPE iLO AHS SMBIOS-like blobs, expose installed DIMM identity
(slot, serial, part number, manufacturer) but do not include capacity. The parser already extracts
those modules into `Hardware.Memory`, but canonical device building and export previously dropped
them because `size_mb == 0`.
**Decision:** Treat a DIMM as installed inventory when `present=true` and it has identifying
memory fields such as serial number or part number, even if `size_mb` is unknown.
**Consequences:**
- HPE AHS uploads now show real installed memory modules instead of hiding them.
- Empty slots still stay filtered because they lack inventory identity or are marked absent.
- Specification/export can include "size unknown" memory entries without inventing capacity data.
---
## ADL-040 — HPE Redfish normalization prefers chassis `Devices/*` over generic PCIe topology labels
**Date:** 2026-03-30
**Context:** HPE ProLiant Gen11 Redfish snapshots expose parallel inventory trees. `Chassis/*/PCIeDevices/*`
is good for topology presence, but often reports only generic `DeviceType` values such as
`SingleFunction`. `Chassis/*/Devices/*` carries the concrete slot label, richer device type, and
product-vs-spare part identifiers for the same physical NIC/controller. Replay fallback over empty
storage volume collections can also discover `Volumes/Capabilities` children, which are not real
logical volumes.
**Decision:**
- Treat Redfish `SKU` as a valid fallback for `hardware.board.part_number` when `PartNumber` is empty.
- Ignore `Volumes/Capabilities` documents during logical-volume parsing.
- Enrich `Chassis/*/PCIeDevices/*` entries with matching `Chassis/*/Devices/*` documents by
serial/name/part identity.
- Keep `pcie.device_class` semantic; do not replace it with model or part-number strings when
Redfish exposes only generic topology labels.
**Consequences:**
- HPE Redfish imports now keep the server SKU in `hardware.board.part_number`.
- Empty volume collections no longer produce fake `Capabilities` volume records.
- HPE PCIe inventory gets better slot labels like `OCP 3.0 Slot 15` plus concrete classes such as
`LOM/NIC` or `SAS/SATA Storage Controller`.
- `part_number` remains available separately for model identity, without polluting the class field.

View File

@@ -110,7 +110,7 @@ func (c *RedfishConnector) Probe(ctx context.Context, req Request) (*ProbeResult
if err != nil {
return nil, fmt.Errorf("redfish system: %w", err)
}
powerState := strings.TrimSpace(asString(systemDoc["PowerState"]))
powerState := redfishSystemPowerState(systemDoc)
return &ProbeResult{
Reachable: true,
Protocol: "redfish",
@@ -147,6 +147,7 @@ func (c *RedfishConnector) Collect(ctx context.Context, req Request, emit Progre
snapshotClient := c.httpClientWithTimeout(req, redfishSnapshotRequestTimeout())
prefetchClient := c.httpClientWithTimeout(req, redfishPrefetchRequestTimeout())
criticalClient := c.httpClientWithTimeout(req, redfishCriticalRequestTimeout())
hintClient := c.httpClientWithTimeout(req, 4*time.Second)
if emit != nil {
emit(Progress{Status: "running", Progress: 10, Message: "Redfish: подключение к BMC..."})
@@ -159,14 +160,11 @@ func (c *RedfishConnector) Collect(ctx context.Context, req Request, emit Progre
systemPaths := c.discoverMemberPaths(discoveryCtx, snapshotClient, req, baseURL, "/redfish/v1/Systems", "/redfish/v1/Systems/1")
primarySystem := firstNonEmptyPath(systemPaths, "/redfish/v1/Systems/1")
poweredOnByCollector := false
if primarySystem != "" {
if on, changed := c.ensureHostPowerForCollection(ctx, snapshotClient, req, baseURL, primarySystem, emit); on {
poweredOnByCollector = changed
}
c.ensureHostPowerForCollection(ctx, snapshotClient, req, baseURL, primarySystem, emit)
}
defer func() {
if !poweredOnByCollector || primarySystem == "" {
if primarySystem == "" || !req.StopHostAfterCollect {
return
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
@@ -181,7 +179,8 @@ func (c *RedfishConnector) Collect(ctx context.Context, req Request, emit Progre
chassisDoc, _ := c.getJSON(discoveryCtx, snapshotClient, req, baseURL, primaryChassis)
managerDoc, _ := c.getJSON(discoveryCtx, snapshotClient, req, baseURL, primaryManager)
resourceHints := append(append([]string{}, systemPaths...), append(chassisPaths, managerPaths...)...)
signals := redfishprofile.CollectSignals(serviceRootDoc, systemDoc, chassisDoc, managerDoc, resourceHints)
hintDocs := c.collectProfileHintDocs(discoveryCtx, hintClient, req, baseURL, primarySystem, primaryChassis)
signals := redfishprofile.CollectSignals(serviceRootDoc, systemDoc, chassisDoc, managerDoc, resourceHints, hintDocs...)
matchResult := redfishprofile.MatchProfiles(signals)
acquisitionPlan := redfishprofile.BuildAcquisitionPlan(signals)
telemetrySummary := telemetry.Snapshot()
@@ -313,6 +312,10 @@ func (c *RedfishConnector) Collect(ctx context.Context, req Request, emit Progre
}
// Collect hardware event logs separately (not part of tree-walk to avoid bloat).
rawLogEntries := c.collectRedfishLogEntries(withRedfishTelemetryPhase(ctx, "log_entries"), snapshotClient, req, baseURL, systemPaths, managerPaths)
var debugPayloads map[string]any
if req.DebugPayloads {
debugPayloads = c.collectDebugPayloads(ctx, snapshotClient, req, baseURL, systemPaths)
}
rawPayloads := map[string]any{
"redfish_tree": rawTree,
"redfish_profiles": map[string]any{
@@ -418,6 +421,9 @@ func (c *RedfishConnector) Collect(ctx context.Context, req Request, emit Progre
if len(rawLogEntries) > 0 {
rawPayloads["redfish_log_entries"] = rawLogEntries
}
if len(debugPayloads) > 0 {
rawPayloads["redfish_debug_payloads"] = debugPayloads
}
// Unified tunnel: live collection and raw import go through the same analyzer over redfish_tree.
result, err := ReplayRedfishFromRawPayloads(rawPayloads, nil)
if err != nil {
@@ -488,7 +494,7 @@ func (c *RedfishConnector) ensureHostPowerForCollection(ctx context.Context, cli
return false, false
}
powerState := strings.TrimSpace(asString(systemDoc["PowerState"]))
powerState := redfishSystemPowerState(systemDoc)
if isRedfishHostPoweredOn(powerState) {
if emit != nil {
emit(Progress{Status: "running", Progress: 18, Message: fmt.Sprintf("Redfish: host включен (%s)", firstNonEmpty(powerState, "On"))})
@@ -565,11 +571,12 @@ func (c *RedfishConnector) waitForStablePoweredOnHost(ctx context.Context, clien
})
}
timer := time.NewTimer(stabilizationDelay)
defer timer.Stop()
select {
case <-ctx.Done():
timer.Stop()
return false
case <-timer.C:
timer.Stop()
}
}
if emit != nil {
@@ -579,7 +586,92 @@ func (c *RedfishConnector) waitForStablePoweredOnHost(ctx context.Context, clien
Message: "Redfish: повторная проверка PowerState после стабилизации host",
})
}
return c.waitForHostPowerState(ctx, client, req, baseURL, systemPath, true, 5*time.Second)
if !c.waitForHostPowerState(ctx, client, req, baseURL, systemPath, true, 5*time.Second) {
return false
}
// After the initial stabilization wait, the BMC may still be populating its
// hardware inventory (PCIeDevices, memory summary). Poll readiness with
// increasing back-off (default: +60s, +120s), then warn and proceed.
readinessWaits := redfishBMCReadinessWaits()
for attempt, extraWait := range readinessWaits {
ready, reason := c.isBMCInventoryReady(ctx, client, req, baseURL, systemPath)
if ready {
if emit != nil {
emit(Progress{
Status: "running",
Progress: 20,
Message: fmt.Sprintf("Redfish: BMC готов (%s)", reason),
})
}
return true
}
if emit != nil {
emit(Progress{
Status: "running",
Progress: 20,
Message: fmt.Sprintf("Redfish: BMC не готов (%s), ожидание %s (попытка %d/%d)", reason, extraWait, attempt+1, len(readinessWaits)),
})
}
timer := time.NewTimer(extraWait)
select {
case <-ctx.Done():
timer.Stop()
return false
case <-timer.C:
timer.Stop()
}
if emit != nil {
emit(Progress{
Status: "running",
Progress: 20,
Message: fmt.Sprintf("Redfish: повторная проверка готовности BMC (%d/%d)...", attempt+1, len(readinessWaits)),
})
}
}
ready, reason := c.isBMCInventoryReady(ctx, client, req, baseURL, systemPath)
if !ready {
if emit != nil {
emit(Progress{
Status: "running",
Progress: 20,
Message: fmt.Sprintf("Redfish: WARNING — BMC не подтвердил готовность (%s), сбор может быть неполным", reason),
})
}
} else if emit != nil {
emit(Progress{
Status: "running",
Progress: 20,
Message: fmt.Sprintf("Redfish: BMC готов (%s)", reason),
})
}
return true
}
// isBMCInventoryReady checks whether the BMC has finished populating its
// hardware inventory after a power-on. Returns (ready, reason).
// It considers the BMC ready if either the system memory summary reports
// a non-zero total or the PCIeDevices collection is non-empty.
func (c *RedfishConnector) isBMCInventoryReady(ctx context.Context, client *http.Client, req Request, baseURL, systemPath string) (bool, string) {
systemDoc, err := c.getJSON(ctx, client, req, baseURL, systemPath)
if err != nil {
return false, "не удалось прочитать System"
}
if summary, ok := systemDoc["MemorySummary"].(map[string]interface{}); ok {
if asFloat(summary["TotalSystemMemoryGiB"]) > 0 {
return true, "MemorySummary заполнен"
}
}
pcieDoc, err := c.getJSON(ctx, client, req, baseURL, joinPath(systemPath, "/PCIeDevices"))
if err == nil {
if asInt(pcieDoc["Members@odata.count"]) > 0 {
return true, "PCIeDevices не пуст"
}
if members, ok := pcieDoc["Members"].([]interface{}); ok && len(members) > 0 {
return true, "PCIeDevices не пуст"
}
}
return false, "MemorySummary=0, PCIeDevices пуст"
}
func (c *RedfishConnector) restoreHostPowerAfterCollection(ctx context.Context, client *http.Client, req Request, baseURL, systemPath string, emit ProgressFn) {
@@ -618,6 +710,20 @@ func (c *RedfishConnector) restoreHostPowerAfterCollection(ctx context.Context,
}
}
// collectDebugPayloads fetches vendor-specific diagnostic endpoints on a best-effort basis.
// Results are stored in rawPayloads["redfish_debug_payloads"] and exported with the bundle.
// Enabled only when Request.DebugPayloads is true.
func (c *RedfishConnector) collectDebugPayloads(ctx context.Context, client *http.Client, req Request, baseURL string, systemPaths []string) map[string]any {
out := map[string]any{}
for _, systemPath := range systemPaths {
// AMI/MSI: inventory CRC groups — reveals which groups are supported by this BMC.
if doc, err := c.getJSON(ctx, client, req, baseURL, joinPath(systemPath, "/Oem/Ami/Inventory/Crc")); err == nil {
out[joinPath(systemPath, "/Oem/Ami/Inventory/Crc")] = doc
}
}
return out
}
// invalidateRedfishInventory POSTs to the AMI/MSI InventoryCrc endpoint to zero out
// all known CRC groups before a host power-on. This causes the BMC to accept fresh
// inventory from the host after boot, preventing stale inventory (ghost GPUs, wrong
@@ -630,8 +736,6 @@ func (c *RedfishConnector) invalidateRedfishInventory(ctx context.Context, clien
{"CPU": 0},
{"DIMM": 0},
{"PCIE": 0},
{"CERTIFICATES": 0},
{"SECUREBOOT": 0},
},
}
if err := c.postJSON(ctx, client, req, baseURL, crcPath, body); err != nil {
@@ -649,7 +753,7 @@ func (c *RedfishConnector) waitForHostPowerState(ctx context.Context, client *ht
for {
systemDoc, err := c.getJSON(ctx, client, req, baseURL, systemPath)
if err == nil {
if isRedfishHostPoweredOn(strings.TrimSpace(asString(systemDoc["PowerState"]))) == wantOn {
if isRedfishHostPoweredOn(redfishSystemPowerState(systemDoc)) == wantOn {
return true
}
}
@@ -682,6 +786,19 @@ func isRedfishHostPoweredOn(state string) bool {
}
}
func redfishSystemPowerState(systemDoc map[string]interface{}) string {
if len(systemDoc) == 0 {
return ""
}
if state := strings.TrimSpace(asString(systemDoc["PowerState"])); state != "" {
return state
}
if summary, ok := systemDoc["PowerSummary"].(map[string]interface{}); ok {
return strings.TrimSpace(asString(summary["PowerState"]))
}
return ""
}
func redfishResetActionTarget(systemDoc map[string]interface{}) string {
if systemDoc == nil {
return ""
@@ -1396,16 +1513,13 @@ func (c *RedfishConnector) collectPCIeDevices(ctx context.Context, client *http.
}
func (c *RedfishConnector) getChassisScopedPCIeSupplementalDocs(ctx context.Context, client *http.Client, req Request, baseURL string, doc map[string]interface{}) []map[string]interface{} {
if !looksLikeNVSwitchPCIeDoc(doc) {
return nil
}
docPath := normalizeRedfishPath(asString(doc["@odata.id"]))
chassisPath := chassisPathForPCIeDoc(docPath)
if chassisPath == "" {
return nil
}
out := make([]map[string]interface{}, 0, 4)
out := make([]map[string]interface{}, 0, 6)
seen := make(map[string]struct{})
add := func(path string) {
path = normalizeRedfishPath(path)
@@ -1423,8 +1537,19 @@ func (c *RedfishConnector) getChassisScopedPCIeSupplementalDocs(ctx context.Cont
out = append(out, supplementalDoc)
}
add(joinPath(chassisPath, "/EnvironmentMetrics"))
add(joinPath(chassisPath, "/ThermalSubsystem/ThermalMetrics"))
if looksLikeNVSwitchPCIeDoc(doc) {
add(joinPath(chassisPath, "/EnvironmentMetrics"))
add(joinPath(chassisPath, "/ThermalSubsystem/ThermalMetrics"))
}
deviceDocs, err := c.getCollectionMembers(ctx, client, req, baseURL, joinPath(chassisPath, "/Devices"))
if err == nil {
for _, deviceDoc := range deviceDocs {
if !redfishPCIeMatchesChassisDeviceDoc(doc, deviceDoc) {
continue
}
add(asString(deviceDoc["@odata.id"]))
}
}
return out
}
@@ -1455,6 +1580,33 @@ func (c *RedfishConnector) discoverMemberPaths(ctx context.Context, client *http
return nil
}
func (c *RedfishConnector) collectProfileHintDocs(ctx context.Context, client *http.Client, req Request, baseURL, systemPath, chassisPath string) []map[string]interface{} {
paths := []string{
"/redfish/v1/UpdateService/FirmwareInventory",
joinPath(systemPath, "/NetworkInterfaces"),
joinPath(chassisPath, "/Drives"),
joinPath(chassisPath, "/NetworkAdapters"),
}
seen := make(map[string]struct{}, len(paths))
docs := make([]map[string]interface{}, 0, len(paths))
for _, path := range paths {
path = normalizeRedfishPath(path)
if path == "" {
continue
}
if _, ok := seen[path]; ok {
continue
}
seen[path] = struct{}{}
doc, err := c.getJSON(ctx, client, req, baseURL, path)
if err != nil {
continue
}
docs = append(docs, doc)
}
return docs
}
func (c *RedfishConnector) collectRawRedfishTree(ctx context.Context, client *http.Client, req Request, baseURL string, seedPaths []string, tuning redfishprofile.AcquisitionTuning, emit ProgressFn) (map[string]interface{}, []map[string]interface{}, redfishPostProbeMetrics, string) {
maxDocuments := redfishSnapshotMaxDocuments(tuning)
workers := redfishSnapshotWorkers(tuning)
@@ -1462,6 +1614,7 @@ func (c *RedfishConnector) collectRawRedfishTree(ctx context.Context, client *ht
crawlStart := time.Now()
memoryClient := c.httpClientWithTimeout(req, redfishSnapshotMemoryRequestTimeout())
memoryGate := make(chan struct{}, redfishSnapshotMemoryConcurrency())
postProbeClient := c.httpClientWithTimeout(req, redfishSnapshotPostProbeRequestTimeout())
branchLimiter := newRedfishSnapshotBranchLimiter(redfishSnapshotBranchConcurrency())
branchRetryPause := redfishSnapshotBranchRequeueBackoff()
timings := newRedfishPathTimingCollector(4)
@@ -1764,7 +1917,7 @@ func (c *RedfishConnector) collectRawRedfishTree(ctx context.Context, client *ht
ETASeconds: int(estimateProgressETA(postProbeStart, i, len(postProbeCollections), 3*time.Second).Seconds()),
})
}
for childPath, doc := range c.probeDirectRedfishCollectionChildren(ctx, client, req, baseURL, path) {
for childPath, doc := range c.probeDirectRedfishCollectionChildren(ctx, postProbeClient, req, baseURL, path) {
if _, exists := out[childPath]; exists {
continue
}
@@ -2014,6 +2167,12 @@ func shouldAdaptivePostProbeCollectionPath(path string, collectionDoc map[string
if len(memberRefs) == 0 {
return true
}
// If the collection reports an explicit non-zero member count that already
// matches the number of discovered member refs, every member is accounted
// for and numeric probing cannot find anything new.
if odataCount := asInt(collectionDoc["Members@odata.count"]); odataCount > 0 && odataCount == len(memberRefs) {
return false
}
return redfishCollectionHasNumericMemberRefs(memberRefs)
}
@@ -2091,6 +2250,25 @@ func looksLikeRedfishResource(doc map[string]interface{}) bool {
return false
}
// isHardwareInventoryCollectionPath reports whether the path is a hardware
// inventory collection that is expected to have members when the machine is
// powered on and the BMC has finished initializing.
func isHardwareInventoryCollectionPath(p string) bool {
for _, suffix := range []string{
"/PCIeDevices",
"/NetworkAdapters",
"/Processors",
"/Drives",
"/Storage",
"/EthernetInterfaces",
} {
if strings.HasSuffix(p, suffix) {
return true
}
}
return false
}
func shouldSlowProbeCriticalCollection(p string, tuning redfishprofile.AcquisitionTuning) bool {
p = normalizeRedfishPath(p)
if !tuning.RecoveryPolicy.EnableCriticalSlowProbe {
@@ -2187,6 +2365,18 @@ func redfishSnapshotRequestTimeout() time.Duration {
return 12 * time.Second
}
func redfishSnapshotPostProbeRequestTimeout() time.Duration {
if v := strings.TrimSpace(os.Getenv("LOGPILE_REDFISH_POSTPROBE_TIMEOUT")); v != "" {
if d, err := time.ParseDuration(v); err == nil && d > 0 {
return d
}
}
// Post-probe probes non-existent numeric paths expecting fast 404s.
// A short timeout prevents BMCs that hang on unknown paths from stalling
// the entire collection for minutes (e.g. HPE iLO on NetworkAdapters Ports).
return 4 * time.Second
}
func redfishSnapshotWorkers(tuning redfishprofile.AcquisitionTuning) int {
if tuning.SnapshotWorkers >= 1 && tuning.SnapshotWorkers <= 16 {
return tuning.SnapshotWorkers
@@ -2411,6 +2601,25 @@ func redfishPowerOnStabilizationDelay() time.Duration {
return 60 * time.Second
}
// redfishBMCReadinessWaits returns the extra wait durations used when polling
// BMC inventory readiness after power-on. Defaults: [60s, 120s].
// Override with LOGPILE_REDFISH_BMC_READY_WAITS (comma-separated durations,
// e.g. "60s,120s").
func redfishBMCReadinessWaits() []time.Duration {
if v := strings.TrimSpace(os.Getenv("LOGPILE_REDFISH_BMC_READY_WAITS")); v != "" {
var out []time.Duration
for _, part := range strings.Split(v, ",") {
if d, err := time.ParseDuration(strings.TrimSpace(part)); err == nil && d >= 0 {
out = append(out, d)
}
}
if len(out) > 0 {
return out
}
}
return []time.Duration{60 * time.Second, 120 * time.Second}
}
func redfishSnapshotMemoryRequestTimeout() time.Duration {
if v := strings.TrimSpace(os.Getenv("LOGPILE_REDFISH_MEMORY_TIMEOUT")); v != "" {
if d, err := time.ParseDuration(v); err == nil && d > 0 {
@@ -2670,6 +2879,8 @@ func shouldCrawlPath(path string) bool {
"/GetServerAllUSBStatus",
"/Oem/Public/KVM",
"/SecureBoot/SecureBootDatabases",
// HPE iLO WorkloadPerformanceAdvisor — operational/advisory data, not inventory.
"/WorkloadPerformanceAdvisor",
} {
if strings.Contains(normalized, part) {
return false
@@ -3015,6 +3226,38 @@ func (c *RedfishConnector) recoverCriticalRedfishDocsPlanB(ctx context.Context,
addTarget(memberPath)
}
}
// Re-probe critical hardware collections that were successfully fetched but
// returned no members. This happens when the BMC hasn't finished enumerating
// hardware at collection time (e.g. PCIeDevices or NetworkAdapters empty right
// after power-on). Only hardware inventory collection suffixes are retried.
if tuning.RecoveryPolicy.EnableEmptyCriticalCollectionRetry {
for _, p := range criticalPaths {
p = normalizeRedfishPath(p)
if p == "" {
continue
}
if _, queued := seenTargets[p]; queued {
continue
}
docAny, ok := rawTree[p]
if !ok {
continue
}
doc, ok := docAny.(map[string]interface{})
if !ok {
continue
}
if redfishCollectionHasExplicitMembers(doc) {
continue
}
if !isHardwareInventoryCollectionPath(p) {
continue
}
addTarget(p)
}
}
if len(targets) == 0 {
return 0
}
@@ -3199,8 +3442,11 @@ func parseBoardInfo(system map[string]interface{}) models.BoardInfo {
asString(system["Name"]),
)),
SerialNumber: normalizeRedfishIdentityField(asString(system["SerialNumber"])),
PartNumber: normalizeRedfishIdentityField(asString(system["PartNumber"])),
UUID: normalizeRedfishIdentityField(asString(system["UUID"])),
PartNumber: normalizeRedfishIdentityField(firstNonEmpty(
asString(system["PartNumber"]),
asString(system["SKU"]),
)),
UUID: normalizeRedfishIdentityField(asString(system["UUID"])),
}
}
@@ -3304,14 +3550,20 @@ func parseCPUs(docs []map[string]interface{}) []models.CPU {
}
}
l1, l2, l3 := parseCPUCachesFromProcessorMemory(doc)
publicSerial := redfishCPUPublicSerial(doc)
serial := normalizeRedfishIdentityField(asString(doc["SerialNumber"]))
if serial == "" && publicSerial == "" {
serial = findFirstNormalizedStringByKeys(doc, "SerialNumber")
}
cpus = append(cpus, models.CPU{
Socket: socket,
Model: firstNonEmpty(asString(doc["Model"]), asString(doc["Name"])),
Cores: asInt(doc["TotalCores"]),
Threads: asInt(doc["TotalThreads"]),
FrequencyMHz: asInt(doc["OperatingSpeedMHz"]),
MaxFreqMHz: asInt(doc["MaxSpeedMHz"]),
SerialNumber: findFirstNormalizedStringByKeys(doc, "SerialNumber"),
FrequencyMHz: int(redfishFirstNumeric(doc, "OperatingSpeedMHz", "CurrentSpeedMHz", "FrequencyMHz")),
MaxFreqMHz: int(redfishFirstNumeric(doc, "MaxSpeedMHz", "TurboEnableMaxSpeedMHz", "TurboDisableMaxSpeedMHz")),
PPIN: firstNonEmpty(findFirstNormalizedStringByKeys(doc, "PPIN", "ProtectedIdentificationNumber"), publicSerial),
SerialNumber: serial,
L1CacheKB: l1,
L2CacheKB: l2,
L3CacheKB: l3,
@@ -3322,6 +3574,12 @@ func parseCPUs(docs []map[string]interface{}) []models.CPU {
return cpus
}
func redfishCPUPublicSerial(doc map[string]interface{}) string {
oem, _ := doc["Oem"].(map[string]interface{})
public, _ := oem["Public"].(map[string]interface{})
return normalizeRedfishIdentityField(asString(public["SerialNumber"]))
}
// parseCPUCachesFromProcessorMemory reads L1/L2/L3 cache sizes from the
// Redfish ProcessorMemory array (Processor.v1_x spec).
func parseCPUCachesFromProcessorMemory(doc map[string]interface{}) (l1, l2, l3 int) {
@@ -3571,6 +3829,22 @@ func parseStorageVolume(doc map[string]interface{}, controller string) models.St
}
}
func redfishVolumeCapabilitiesDoc(doc map[string]interface{}) bool {
if len(doc) == 0 {
return false
}
if strings.Contains(strings.ToLower(strings.TrimSpace(asString(doc["@odata.type"]))), "collectioncapabilities") {
return true
}
path := strings.ToLower(normalizeRedfishPath(asString(doc["@odata.id"])))
if strings.HasSuffix(path, "/volumes/capabilities") {
return true
}
id := strings.TrimSpace(asString(doc["Id"]))
name := strings.ToLower(strings.TrimSpace(asString(doc["Name"])))
return strings.EqualFold(id, "Capabilities") || strings.Contains(name, "capabilities for volumecollection")
}
func parseNIC(doc map[string]interface{}) models.NetworkAdapter {
vendorID := asHexOrInt(doc["VendorId"])
deviceID := asHexOrInt(doc["DeviceId"])
@@ -3770,7 +4044,7 @@ func parsePSUWithSupplementalDocs(doc map[string]interface{}, idx int, supplemen
Present: present,
Model: firstNonEmpty(asString(doc["Model"]), asString(doc["Name"])),
Vendor: asString(doc["Manufacturer"]),
WattageW: asInt(doc["PowerCapacityWatts"]),
WattageW: redfishPSUNominalWattage(doc),
SerialNumber: findFirstNormalizedStringByKeys(doc, "SerialNumber"),
PartNumber: asString(doc["PartNumber"]),
Firmware: asString(doc["FirmwareVersion"]),
@@ -3783,6 +4057,25 @@ func parsePSUWithSupplementalDocs(doc map[string]interface{}, idx int, supplemen
}
}
func redfishPSUNominalWattage(doc map[string]interface{}) int {
if ranges, ok := doc["InputRanges"].([]interface{}); ok {
best := 0
for _, rawRange := range ranges {
rangeDoc, ok := rawRange.(map[string]interface{})
if !ok {
continue
}
if wattage := asInt(rangeDoc["OutputWattage"]); wattage > best {
best = wattage
}
}
if best > 0 {
return best
}
}
return asInt(doc["PowerCapacityWatts"])
}
func redfishDriveDetails(doc map[string]interface{}) map[string]any {
return redfishDriveDetailsWithSupplementalDocs(doc)
}
@@ -4090,6 +4383,39 @@ func redfishFirstBoolAcrossDocs(docs []map[string]interface{}, keys ...string) *
return nil
}
func redfishFirstString(doc map[string]interface{}, keys ...string) string {
for _, key := range keys {
if v, ok := redfishLookupValue(doc, key); ok {
if s := strings.TrimSpace(asString(v)); s != "" {
return s
}
}
}
return ""
}
func redfishFirstStringAcrossDocs(docs []map[string]interface{}, keys ...string) string {
for _, doc := range docs {
if v := redfishFirstString(doc, keys...); v != "" {
return v
}
}
return ""
}
func redfishFirstLocationAcrossDocs(docs []map[string]interface{}, keys ...string) string {
for _, doc := range docs {
for _, key := range keys {
if v, ok := redfishLookupValue(doc, key); ok {
if loc := redfishLocationLabel(v); loc != "" {
return loc
}
}
}
}
return ""
}
func redfishLookupValue(doc map[string]interface{}, key string) (any, bool) {
if doc == nil || strings.TrimSpace(key) == "" {
return nil, false
@@ -4271,8 +4597,9 @@ func parsePCIeDevice(doc map[string]interface{}, functionDocs []map[string]inter
}
func parsePCIeDeviceWithSupplementalDocs(doc map[string]interface{}, functionDocs []map[string]interface{}, supplementalDocs []map[string]interface{}) models.PCIeDevice {
supplementalSlot := redfishFirstLocationAcrossDocs(supplementalDocs, "Slot", "Location", "PhysicalLocation")
dev := models.PCIeDevice{
Slot: firstNonEmpty(redfishLocationLabel(doc["Slot"]), redfishLocationLabel(doc["Location"]), asString(doc["Name"]), asString(doc["Id"])),
Slot: firstNonEmpty(redfishLocationLabel(doc["Slot"]), redfishLocationLabel(doc["Location"]), supplementalSlot, asString(doc["Name"]), asString(doc["Id"])),
BDF: sanitizeRedfishBDF(asString(doc["BDF"])),
DeviceClass: asString(doc["DeviceType"]),
Manufacturer: asString(doc["Manufacturer"]),
@@ -4312,6 +4639,9 @@ func parsePCIeDeviceWithSupplementalDocs(doc map[string]interface{}, functionDoc
dev.MaxLinkSpeed = firstNonEmpty(asString(fn["MaxLinkSpeedGTs"]), asString(fn["MaxLinkSpeed"]))
}
}
if dev.DeviceClass == "" || isGenericPCIeClassLabel(dev.DeviceClass) {
dev.DeviceClass = firstNonEmpty(redfishFirstStringAcrossDocs(supplementalDocs, "DeviceType"), dev.DeviceClass)
}
if dev.DeviceClass == "" {
dev.DeviceClass = "PCIe device"
@@ -4322,15 +4652,22 @@ func parsePCIeDeviceWithSupplementalDocs(doc map[string]interface{}, functionDoc
}
}
if isGenericPCIeClassLabel(dev.DeviceClass) {
// Redfish DeviceType (e.g. MultiFunction/Simulated) is a topology attribute,
// not a user-facing device name. Prefer model/part labels when class cannot be resolved.
dev.DeviceClass = firstNonEmpty(asString(doc["Model"]), dev.PartNumber, dev.DeviceClass)
dev.DeviceClass = "PCIe device"
}
if strings.TrimSpace(dev.Manufacturer) == "" {
dev.Manufacturer = pciids.VendorName(dev.VendorID)
dev.Manufacturer = firstNonEmpty(
redfishFirstStringAcrossDocs(supplementalDocs, "Manufacturer"),
pciids.VendorName(dev.VendorID),
)
}
if strings.TrimSpace(dev.PartNumber) == "" {
dev.PartNumber = pciids.DeviceName(dev.VendorID, dev.DeviceID)
dev.PartNumber = firstNonEmpty(
redfishFirstStringAcrossDocs(supplementalDocs, "ProductPartNumber", "PartNumber"),
pciids.DeviceName(dev.VendorID, dev.DeviceID),
)
}
if normalizeRedfishIdentityField(dev.SerialNumber) == "" {
dev.SerialNumber = redfishFirstStringAcrossDocs(supplementalDocs, "SerialNumber")
}
return dev
}
@@ -4433,6 +4770,70 @@ func isGenericPCIeClassLabel(v string) bool {
}
}
func redfishPCIeMatchesChassisDeviceDoc(doc, deviceDoc map[string]interface{}) bool {
if len(doc) == 0 || len(deviceDoc) == 0 || redfishChassisDeviceDocLooksEmpty(deviceDoc) {
return false
}
docSerial := normalizeRedfishIdentityField(findFirstNormalizedStringByKeys(doc, "SerialNumber"))
deviceSerial := normalizeRedfishIdentityField(findFirstNormalizedStringByKeys(deviceDoc, "SerialNumber"))
if docSerial != "" && deviceSerial != "" && strings.EqualFold(docSerial, deviceSerial) {
return true
}
docTokens := redfishPCIeMatchTokens(doc)
deviceTokens := redfishPCIeMatchTokens(deviceDoc)
if len(docTokens) == 0 || len(deviceTokens) == 0 {
return false
}
for _, token := range docTokens {
for _, candidate := range deviceTokens {
if strings.EqualFold(token, candidate) {
return true
}
}
}
return false
}
func redfishPCIeMatchTokens(doc map[string]interface{}) []string {
if len(doc) == 0 {
return nil
}
rawValues := []string{
asString(doc["Name"]),
asString(doc["Model"]),
asString(doc["PartNumber"]),
asString(doc["ProductPartNumber"]),
}
out := make([]string, 0, len(rawValues))
seen := make(map[string]struct{}, len(rawValues))
for _, raw := range rawValues {
value := normalizeRedfishIdentityField(raw)
if value == "" {
continue
}
key := strings.ToLower(value)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, value)
}
return out
}
func redfishChassisDeviceDocLooksEmpty(doc map[string]interface{}) bool {
name := strings.ToLower(strings.TrimSpace(asString(doc["Name"])))
if strings.HasPrefix(name, "empty slot") {
return true
}
if strings.ToLower(strings.TrimSpace(asString(doc["DeviceType"]))) != "unknown" {
return false
}
return normalizeRedfishIdentityField(asString(doc["PartNumber"])) == "" &&
normalizeRedfishIdentityField(asString(doc["ProductPartNumber"])) == "" &&
normalizeRedfishIdentityField(findFirstNormalizedStringByKeys(doc, "SerialNumber")) == ""
}
func buildBDFfromOemPublic(doc map[string]interface{}) string {
if len(doc) == 0 {
return ""
@@ -4802,6 +5203,16 @@ func isVirtualStorageDrive(doc map[string]interface{}) bool {
return false
}
// isAbsentDriveDoc returns true when the drive document represents an empty bay
// with no installed media (Status.State == "Absent"). These should be excluded
// from the storage inventory.
func isAbsentDriveDoc(doc map[string]interface{}) bool {
if status, ok := doc["Status"].(map[string]interface{}); ok {
return strings.EqualFold(asString(status["State"]), "Absent")
}
return strings.EqualFold(asString(doc["Status"]), "Absent")
}
func looksLikeDrive(doc map[string]interface{}) bool {
if asString(doc["MediaType"]) != "" {
return true
@@ -4850,6 +5261,9 @@ func classifyStorageType(doc map[string]interface{}) string {
}
func looksLikeVolume(doc map[string]interface{}) bool {
if redfishVolumeCapabilitiesDoc(doc) {
return false
}
if asString(doc["RAIDType"]) != "" || asString(doc["VolumeType"]) != "" {
return true
}

View File

@@ -31,8 +31,7 @@ func ReplayRedfishFromRawPayloads(rawPayloads map[string]any, emit ProgressFn) (
if emit != nil {
emit(Progress{Status: "running", Progress: 10, Message: "Redfish snapshot: replay service root..."})
}
serviceRootDoc, err := r.getJSON("/redfish/v1")
if err != nil {
if _, err := r.getJSON("/redfish/v1"); err != nil {
log.Printf("redfish replay: service root /redfish/v1 missing from snapshot, continuing with defaults: %v", err)
}
@@ -61,8 +60,7 @@ func ReplayRedfishFromRawPayloads(rawPayloads map[string]any, emit ProgressFn) (
fruDoc = chassisFRUDoc
}
boardFallbackDocs := r.collectBoardFallbackDocs(systemPaths, chassisPaths)
resourceHints := append(append([]string{}, systemPaths...), append(chassisPaths, managerPaths...)...)
profileSignals := redfishprofile.CollectSignals(serviceRootDoc, systemDoc, chassisDoc, managerDoc, resourceHints)
profileSignals := redfishprofile.CollectSignalsFromTree(tree)
profileMatch := redfishprofile.MatchProfiles(profileSignals)
analysisPlan := redfishprofile.ResolveAnalysisPlan(profileMatch, tree, redfishprofile.DiscoveredResources{
SystemPaths: systemPaths,
@@ -98,20 +96,27 @@ func ReplayRedfishFromRawPayloads(rawPayloads map[string]any, emit ProgressFn) (
networkProtocolDoc, _ := r.getJSON(joinPath(primaryManager, "/NetworkProtocol"))
firmware := parseFirmware(systemDoc, biosDoc, managerDoc, networkProtocolDoc)
firmware = dedupeFirmwareInfo(append(firmware, r.collectFirmwareInventory()...))
boardInfo.BMCMACAddress = r.collectBMCMAC(managerPaths)
firmware = filterStorageDriveFirmware(firmware, storageDevices)
bmcManagementSummary := r.collectBMCManagementSummary(managerPaths)
boardInfo.BMCMACAddress = strings.TrimSpace(firstNonEmpty(
asString(bmcManagementSummary["mac_address"]),
r.collectBMCMAC(managerPaths),
))
assemblyFRU := r.collectAssemblyFRU(chassisPaths)
collectedAt, sourceTimezone := inferRedfishCollectionTime(managerDoc, rawPayloads)
inventoryLastModifiedAt := inferInventoryLastModifiedTime(r.tree)
logEntryEvents := parseRedfishLogEntries(rawPayloads, collectedAt)
sensorHintSummary, sensorHintEvents := r.collectSensorsListHints(chassisPaths, collectedAt)
bmcManagementEvent := buildBMCManagementSummaryEvent(bmcManagementSummary, collectedAt)
result := &models.AnalysisResult{
CollectedAt: collectedAt,
InventoryLastModifiedAt: inventoryLastModifiedAt,
SourceTimezone: sourceTimezone,
Events: append(append(append(append(make([]models.Event, 0, len(discreteEvents)+len(healthEvents)+len(driveFetchWarningEvents)+len(logEntryEvents)+1), healthEvents...), discreteEvents...), driveFetchWarningEvents...), logEntryEvents...),
FRU: assemblyFRU,
Sensors: dedupeSensorReadings(append(append(thresholdSensors, thermalSensors...), powerSensors...)),
RawPayloads: cloneRawPayloads(rawPayloads),
SourceTimezone: sourceTimezone,
Events: append(append(append(append(append(append(make([]models.Event, 0, len(discreteEvents)+len(healthEvents)+len(driveFetchWarningEvents)+len(logEntryEvents)+len(sensorHintEvents)+2), healthEvents...), discreteEvents...), driveFetchWarningEvents...), logEntryEvents...), sensorHintEvents...), bmcManagementEvent...),
FRU: assemblyFRU,
Sensors: dedupeSensorReadings(append(append(thresholdSensors, thermalSensors...), powerSensors...)),
RawPayloads: cloneRawPayloads(rawPayloads),
Hardware: &models.HardwareConfig{
BoardInfo: boardInfo,
CPUs: processors,
@@ -157,6 +162,12 @@ func ReplayRedfishFromRawPayloads(rawPayloads map[string]any, emit ProgressFn) (
if strings.TrimSpace(sourceTimezone) != "" {
result.RawPayloads["source_timezone"] = sourceTimezone
}
if len(sensorHintSummary) > 0 {
result.RawPayloads["redfish_sensor_hints"] = sensorHintSummary
}
if len(bmcManagementSummary) > 0 {
result.RawPayloads["redfish_bmc_network_summary"] = bmcManagementSummary
}
appendMissingServerModelWarning(result, systemDoc, joinPath(primarySystem, "/Oem/Public/FRU"), joinPath(primaryChassis, "/Oem/Public/FRU"))
return result, nil
}
@@ -326,6 +337,153 @@ func buildDriveFetchWarningEvents(rawPayloads map[string]any) []models.Event {
}
}
func (r redfishSnapshotReader) collectSensorsListHints(chassisPaths []string, collectedAt time.Time) (map[string]any, []models.Event) {
summary := make(map[string]any)
var events []models.Event
var presentDIMMs []string
dimmTotal := 0
dimmPresent := 0
physicalDriveSlots := 0
activePhysicalDriveSlots := 0
logicalDriveStatus := ""
for _, chassisPath := range chassisPaths {
doc, err := r.getJSON(joinPath(chassisPath, "/SensorsList"))
if err != nil || len(doc) == 0 {
continue
}
sensors, ok := doc["SensorsList"].([]interface{})
if !ok {
continue
}
for _, item := range sensors {
sensor, ok := item.(map[string]interface{})
if !ok {
continue
}
name := strings.TrimSpace(asString(sensor["SensorName"]))
sensorType := strings.TrimSpace(asString(sensor["SensorType"]))
status := strings.TrimSpace(asString(sensor["Status"]))
switch {
case strings.HasPrefix(name, "DIMM") && strings.HasSuffix(name, "_Status") && strings.EqualFold(sensorType, "Memory"):
dimmTotal++
if redfishSlotStatusLooksPresent(status) {
dimmPresent++
presentDIMMs = append(presentDIMMs, strings.TrimSuffix(name, "_Status"))
}
case strings.EqualFold(sensorType, "Drive Slot"):
if strings.EqualFold(name, "Logical_Drive") {
logicalDriveStatus = firstNonEmpty(logicalDriveStatus, status)
continue
}
physicalDriveSlots++
if redfishSlotStatusLooksPresent(status) {
activePhysicalDriveSlots++
}
}
}
}
if dimmTotal > 0 {
sort.Strings(presentDIMMs)
summary["memory_slots"] = map[string]any{
"total": dimmTotal,
"present_count": dimmPresent,
"present_slots": presentDIMMs,
"source": "SensorsList",
}
events = append(events, models.Event{
Timestamp: replayEventTimestamp(collectedAt),
Source: "Redfish",
EventType: "Collection Info",
Severity: models.SeverityInfo,
Description: fmt.Sprintf("Memory slot sensors report %d populated positions out of %d", dimmPresent, dimmTotal),
RawData: firstNonEmpty(strings.Join(presentDIMMs, ", "), "no populated DIMM slots reported"),
})
}
if physicalDriveSlots > 0 || logicalDriveStatus != "" {
summary["drive_slots"] = map[string]any{
"physical_total": physicalDriveSlots,
"physical_active_count": activePhysicalDriveSlots,
"logical_drive_status": logicalDriveStatus,
"source": "SensorsList",
}
rawParts := []string{
fmt.Sprintf("physical_active=%d/%d", activePhysicalDriveSlots, physicalDriveSlots),
}
if logicalDriveStatus != "" {
rawParts = append(rawParts, "logical_drive="+logicalDriveStatus)
}
events = append(events, models.Event{
Timestamp: replayEventTimestamp(collectedAt),
Source: "Redfish",
EventType: "Collection Info",
Severity: models.SeverityInfo,
Description: fmt.Sprintf("Drive slot sensors report %d active physical slots out of %d", activePhysicalDriveSlots, physicalDriveSlots),
RawData: strings.Join(rawParts, "; "),
})
}
return summary, events
}
func buildBMCManagementSummaryEvent(summary map[string]any, collectedAt time.Time) []models.Event {
if len(summary) == 0 {
return nil
}
desc := fmt.Sprintf(
"BMC management interface %s link=%s ip=%s",
firstNonEmpty(asString(summary["interface_id"]), "unknown"),
firstNonEmpty(asString(summary["link_status"]), "unknown"),
firstNonEmpty(asString(summary["ipv4_address"]), "n/a"),
)
rawParts := make([]string, 0, 8)
for _, part := range []string{
"mac_address=" + strings.TrimSpace(asString(summary["mac_address"])),
"speed_mbps=" + strings.TrimSpace(asString(summary["speed_mbps"])),
"lldp_chassis_name=" + strings.TrimSpace(asString(summary["lldp_chassis_name"])),
"lldp_port_desc=" + strings.TrimSpace(asString(summary["lldp_port_desc"])),
"lldp_port_id=" + strings.TrimSpace(asString(summary["lldp_port_id"])),
"ipv4_gateway=" + strings.TrimSpace(asString(summary["ipv4_gateway"])),
} {
if !strings.HasSuffix(part, "=") {
rawParts = append(rawParts, part)
}
}
if vlan := asInt(summary["lldp_vlan_id"]); vlan > 0 {
rawParts = append(rawParts, fmt.Sprintf("lldp_vlan_id=%d", vlan))
}
if asBool(summary["ncsi_enabled"]) {
rawParts = append(rawParts, "ncsi_enabled=true")
}
return []models.Event{
{
Timestamp: replayEventTimestamp(collectedAt),
Source: "Redfish",
EventType: "Collection Info",
Severity: models.SeverityInfo,
Description: desc,
RawData: strings.Join(rawParts, "; "),
},
}
}
func redfishSlotStatusLooksPresent(status string) bool {
switch strings.ToLower(strings.TrimSpace(status)) {
case "ok", "enabled", "present", "warning", "critical":
return true
default:
return false
}
}
func replayEventTimestamp(collectedAt time.Time) time.Time {
if !collectedAt.IsZero() {
return collectedAt
}
return time.Now()
}
func (r redfishSnapshotReader) collectFirmwareInventory() []models.FirmwareInfo {
docs, err := r.getCollectionMembers("/redfish/v1/UpdateService/FirmwareInventory")
if err != nil || len(docs) == 0 {
@@ -341,6 +499,10 @@ func (r redfishSnapshotReader) collectFirmwareInventory() []models.FirmwareInfo
if strings.TrimSpace(version) == "" {
continue
}
// Skip placeholder version strings that carry no useful information.
if strings.EqualFold(strings.TrimSpace(version), "N/A") {
continue
}
name := firmwareInventoryDeviceName(doc)
name = strings.TrimSpace(name)
if name == "" {
@@ -393,6 +555,32 @@ func dedupeFirmwareInfo(items []models.FirmwareInfo) []models.FirmwareInfo {
return out
}
// filterStorageDriveFirmware removes from fw any entries whose DeviceName+Version
// already appear as a storage drive's Model+Firmware. Drive firmware is already
// represented in the Storage section and should not be duplicated in the general
// firmware list.
func filterStorageDriveFirmware(fw []models.FirmwareInfo, storage []models.Storage) []models.FirmwareInfo {
if len(storage) == 0 {
return fw
}
driveFW := make(map[string]struct{}, len(storage))
for _, d := range storage {
model := strings.ToLower(strings.TrimSpace(d.Model))
rev := strings.ToLower(strings.TrimSpace(d.Firmware))
if model != "" && rev != "" {
driveFW[model+"|"+rev] = struct{}{}
}
}
out := fw[:0:0]
for _, f := range fw {
key := strings.ToLower(strings.TrimSpace(f.DeviceName)) + "|" + strings.ToLower(strings.TrimSpace(f.Version))
if _, skip := driveFW[key]; !skip {
out = append(out, f)
}
}
return out
}
func (r redfishSnapshotReader) collectThresholdSensors(chassisPaths []string) []models.SensorReading {
out := make([]models.SensorReading, 0)
seen := make(map[string]struct{})
@@ -858,6 +1046,9 @@ func (r redfishSnapshotReader) fallbackCollectionMembers(collectionPath string,
if err != nil {
continue
}
if redfishFallbackMemberLooksLikePlaceholder(collectionPath, doc) {
continue
}
if strings.TrimSpace(asString(doc["@odata.id"])) == "" {
doc["@odata.id"] = normalizeRedfishPath(p)
}
@@ -866,6 +1057,135 @@ func (r redfishSnapshotReader) fallbackCollectionMembers(collectionPath string,
return out, nil
}
func redfishFallbackMemberLooksLikePlaceholder(collectionPath string, doc map[string]interface{}) bool {
if len(doc) == 0 {
return true
}
path := normalizeRedfishPath(collectionPath)
switch {
case strings.HasSuffix(path, "/NetworkAdapters"):
return redfishNetworkAdapterPlaceholderDoc(doc)
case strings.HasSuffix(path, "/PCIeDevices"):
return redfishPCIePlaceholderDoc(doc)
case strings.Contains(path, "/Storage"):
return redfishStoragePlaceholderDoc(doc)
default:
return false
}
}
func redfishNetworkAdapterPlaceholderDoc(doc map[string]interface{}) bool {
if normalizeRedfishIdentityField(asString(doc["Model"])) != "" ||
normalizeRedfishIdentityField(asString(doc["Manufacturer"])) != "" ||
normalizeRedfishIdentityField(asString(doc["SerialNumber"])) != "" ||
normalizeRedfishIdentityField(asString(doc["PartNumber"])) != "" ||
normalizeRedfishIdentityField(asString(doc["BDF"])) != "" ||
asHexOrInt(doc["VendorId"]) != 0 ||
asHexOrInt(doc["DeviceId"]) != 0 {
return false
}
return redfishDocHasOnlyAllowedKeys(doc,
"@odata.context",
"@odata.id",
"@odata.type",
"Id",
"Name",
)
}
func redfishPCIePlaceholderDoc(doc map[string]interface{}) bool {
if normalizeRedfishIdentityField(asString(doc["Model"])) != "" ||
normalizeRedfishIdentityField(asString(doc["Manufacturer"])) != "" ||
normalizeRedfishIdentityField(asString(doc["SerialNumber"])) != "" ||
normalizeRedfishIdentityField(asString(doc["PartNumber"])) != "" ||
normalizeRedfishIdentityField(asString(doc["BDF"])) != "" ||
asHexOrInt(doc["VendorId"]) != 0 ||
asHexOrInt(doc["DeviceId"]) != 0 {
return false
}
return redfishDocHasOnlyAllowedKeys(doc,
"@odata.context",
"@odata.id",
"@odata.type",
"Id",
"Name",
)
}
func redfishStoragePlaceholderDoc(doc map[string]interface{}) bool {
if normalizeRedfishIdentityField(asString(doc["Model"])) != "" ||
normalizeRedfishIdentityField(asString(doc["Manufacturer"])) != "" ||
normalizeRedfishIdentityField(asString(doc["SerialNumber"])) != "" ||
normalizeRedfishIdentityField(asString(doc["PartNumber"])) != "" ||
normalizeRedfishIdentityField(asString(doc["BDF"])) != "" ||
asHexOrInt(doc["VendorId"]) != 0 ||
asHexOrInt(doc["DeviceId"]) != 0 {
return false
}
if !redfishDocHasOnlyAllowedKeys(doc,
"@odata.id",
"@odata.type",
"Drives",
"Drives@odata.count",
"LogicalDisk",
"PhysicalDisk",
"Name",
) {
return false
}
return redfishFieldIsEmptyCollection(doc["Drives"]) &&
redfishFieldIsZeroLike(doc["Drives@odata.count"]) &&
redfishFieldIsEmptyCollection(doc["LogicalDisk"]) &&
redfishFieldIsEmptyCollection(doc["PhysicalDisk"])
}
func redfishDocHasOnlyAllowedKeys(doc map[string]interface{}, allowed ...string) bool {
if len(doc) == 0 {
return false
}
allowedSet := make(map[string]struct{}, len(allowed))
for _, key := range allowed {
allowedSet[key] = struct{}{}
}
for key := range doc {
if _, ok := allowedSet[key]; !ok {
return false
}
}
return true
}
func redfishFieldIsEmptyCollection(v any) bool {
switch x := v.(type) {
case nil:
return true
case []interface{}:
return len(x) == 0
default:
return false
}
}
func redfishFieldIsZeroLike(v any) bool {
switch x := v.(type) {
case nil:
return true
case int:
return x == 0
case int32:
return x == 0
case int64:
return x == 0
case float64:
return x == 0
case string:
x = strings.TrimSpace(x)
return x == "" || x == "0"
default:
return false
}
}
func cloneRawPayloads(src map[string]any) map[string]any {
if len(src) == 0 {
return nil
@@ -972,6 +1292,12 @@ func (r redfishSnapshotReader) collectProcessors(systemPath string) []models.CPU
!strings.EqualFold(pt, "CPU") && !strings.EqualFold(pt, "General") {
continue
}
// Skip absent processor sockets — empty slots with no CPU installed.
if status, ok := doc["Status"].(map[string]interface{}); ok {
if strings.EqualFold(asString(status["State"]), "Absent") {
continue
}
}
cpu := parseCPUs([]map[string]interface{}{doc})[0]
if cpu.Socket == 0 && socketIdx > 0 && strings.TrimSpace(asString(doc["Socket"])) == "" {
cpu.Socket = socketIdx
@@ -998,6 +1324,10 @@ func (r redfishSnapshotReader) collectMemory(systemPath string) []models.MemoryD
out := make([]models.MemoryDIMM, 0, len(memberDocs))
for _, doc := range memberDocs {
dimm := parseMemory([]map[string]interface{}{doc})[0]
// Skip empty DIMM slots — no installed memory.
if !dimm.Present {
continue
}
supplementalDocs := r.getLinkedSupplementalDocs(doc, "MemoryMetrics", "EnvironmentMetrics", "Metrics")
if len(supplementalDocs) > 0 {
dimm.Details = mergeGenericDetails(dimm.Details, redfishMemoryDetailsAcrossDocs(doc, supplementalDocs...))

View File

@@ -26,6 +26,16 @@ func (r redfishSnapshotReader) enrichNICsFromNetworkInterfaces(nics *[]models.Ne
continue
}
idx, ok := bySlot[strings.ToLower(strings.TrimSpace(slot))]
if !ok {
// The NetworkInterface Id (e.g. "2") may not match the display slot of
// the real NIC that came from Chassis/NetworkAdapters (e.g. "RISER 5
// slot 1 (7)"). Try to find the real NIC via the Links.NetworkAdapter
// cross-reference before creating a ghost entry.
if linkedIdx := r.findNICIndexByLinkedNetworkAdapter(iface, bySlot); linkedIdx >= 0 {
idx = linkedIdx
ok = true
}
}
if !ok {
*nics = append(*nics, models.NetworkAdapter{
Slot: slot,
@@ -133,34 +143,56 @@ func (r redfishSnapshotReader) collectPCIeDevices(systemPaths, chassisPaths []st
}
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 := make([]map[string]interface{}, 0, 6)
if looksLikeNVSwitchPCIeDoc(doc) {
for _, path := range []string{
joinPath(chassisPath, "/EnvironmentMetrics"),
joinPath(chassisPath, "/ThermalSubsystem/ThermalMetrics"),
} {
supplementalDoc, err := r.getJSON(path)
if err != nil || len(supplementalDoc) == 0 {
continue
}
out = append(out, supplementalDoc)
}
}
deviceDocs, err := r.getCollectionMembers(joinPath(chassisPath, "/Devices"))
if err == nil {
for _, deviceDoc := range deviceDocs {
if !redfishPCIeMatchesChassisDeviceDoc(doc, deviceDoc) {
continue
}
out = append(out, deviceDoc)
}
out = append(out, supplementalDoc)
}
return out
}
// collectBMCMAC returns the MAC address of the first active BMC management
// interface found in Managers/*/EthernetInterfaces. Returns empty string if
// no MAC is available.
// collectBMCMAC returns the MAC address of the best BMC management interface
// found in Managers/*/EthernetInterfaces. Prefer an active link with an IP
// address over a passive sideband interface.
func (r redfishSnapshotReader) collectBMCMAC(managerPaths []string) string {
summary := r.collectBMCManagementSummary(managerPaths)
if len(summary) == 0 {
return ""
}
return strings.ToUpper(strings.TrimSpace(asString(summary["mac_address"])))
}
func (r redfishSnapshotReader) collectBMCManagementSummary(managerPaths []string) map[string]any {
bestScore := -1
var best map[string]any
for _, managerPath := range managerPaths {
members, err := r.getCollectionMembers(joinPath(managerPath, "/EthernetInterfaces"))
collectionPath := joinPath(managerPath, "/EthernetInterfaces")
collectionDoc, _ := r.getJSON(collectionPath)
ncsiEnabled, lldpMode, lldpByEth := redfishManagerEthernetCollectionHints(collectionDoc)
members, err := r.getCollectionMembers(collectionPath)
if err != nil || len(members) == 0 {
continue
}
@@ -172,12 +204,170 @@ func (r redfishSnapshotReader) collectBMCMAC(managerPaths []string) string {
if mac == "" || strings.EqualFold(mac, "00:00:00:00:00:00") {
continue
}
return strings.ToUpper(mac)
ifaceID := strings.TrimSpace(firstNonEmpty(asString(doc["Id"]), asString(doc["Name"])))
summary := map[string]any{
"manager_path": managerPath,
"interface_id": ifaceID,
"hostname": strings.TrimSpace(asString(doc["HostName"])),
"fqdn": strings.TrimSpace(asString(doc["FQDN"])),
"mac_address": strings.ToUpper(mac),
"link_status": strings.TrimSpace(asString(doc["LinkStatus"])),
"speed_mbps": asInt(doc["SpeedMbps"]),
"interface_name": strings.TrimSpace(asString(doc["Name"])),
"interface_desc": strings.TrimSpace(asString(doc["Description"])),
"ncsi_enabled": ncsiEnabled,
"lldp_mode": lldpMode,
"ipv4_address": redfishManagerIPv4Field(doc, "Address"),
"ipv4_gateway": redfishManagerIPv4Field(doc, "Gateway"),
"ipv4_subnet": redfishManagerIPv4Field(doc, "SubnetMask"),
"ipv6_address": redfishManagerIPv6Field(doc, "Address"),
"link_is_active": strings.EqualFold(strings.TrimSpace(asString(doc["LinkStatus"])), "LinkActive"),
"interface_score": 0,
}
if lldp, ok := lldpByEth[strings.ToLower(ifaceID)]; ok {
summary["lldp_chassis_name"] = lldp["ChassisName"]
summary["lldp_port_desc"] = lldp["PortDesc"]
summary["lldp_port_id"] = lldp["PortId"]
if vlan := asInt(lldp["VlanId"]); vlan > 0 {
summary["lldp_vlan_id"] = vlan
}
}
score := redfishManagerInterfaceScore(summary)
summary["interface_score"] = score
if score > bestScore {
bestScore = score
best = summary
}
}
}
return best
}
func redfishManagerEthernetCollectionHints(collectionDoc map[string]interface{}) (bool, string, map[string]map[string]interface{}) {
lldpByEth := make(map[string]map[string]interface{})
if len(collectionDoc) == 0 {
return false, "", lldpByEth
}
oem, _ := collectionDoc["Oem"].(map[string]interface{})
public, _ := oem["Public"].(map[string]interface{})
ncsiEnabled := asBool(public["NcsiEnabled"])
lldp, _ := public["LLDP"].(map[string]interface{})
lldpMode := strings.TrimSpace(asString(lldp["LLDPMode"]))
if members, ok := lldp["Members"].([]interface{}); ok {
for _, item := range members {
member, ok := item.(map[string]interface{})
if !ok {
continue
}
ethIndex := strings.ToLower(strings.TrimSpace(asString(member["EthIndex"])))
if ethIndex == "" {
continue
}
lldpByEth[ethIndex] = member
}
}
return ncsiEnabled, lldpMode, lldpByEth
}
func redfishManagerIPv4Field(doc map[string]interface{}, key string) string {
if len(doc) == 0 {
return ""
}
for _, field := range []string{"IPv4Addresses", "IPv4StaticAddresses"} {
list, ok := doc[field].([]interface{})
if !ok {
continue
}
for _, item := range list {
entry, ok := item.(map[string]interface{})
if !ok {
continue
}
value := strings.TrimSpace(asString(entry[key]))
if value != "" {
return value
}
}
}
return ""
}
func redfishManagerIPv6Field(doc map[string]interface{}, key string) string {
if len(doc) == 0 {
return ""
}
list, ok := doc["IPv6Addresses"].([]interface{})
if !ok {
return ""
}
for _, item := range list {
entry, ok := item.(map[string]interface{})
if !ok {
continue
}
value := strings.TrimSpace(asString(entry[key]))
if value != "" {
return value
}
}
return ""
}
func redfishManagerInterfaceScore(summary map[string]any) int {
score := 0
if strings.EqualFold(strings.TrimSpace(asString(summary["link_status"])), "LinkActive") {
score += 100
}
if strings.TrimSpace(asString(summary["ipv4_address"])) != "" {
score += 40
}
if strings.TrimSpace(asString(summary["ipv6_address"])) != "" {
score += 10
}
if strings.TrimSpace(asString(summary["mac_address"])) != "" {
score += 10
}
if asInt(summary["speed_mbps"]) > 0 {
score += 5
}
if ifaceID := strings.ToLower(strings.TrimSpace(asString(summary["interface_id"]))); ifaceID != "" && !strings.HasPrefix(ifaceID, "usb") {
score += 3
}
if asBool(summary["ncsi_enabled"]) {
score += 1
}
return score
}
// findNICIndexByLinkedNetworkAdapter resolves a NetworkInterface document to an
// existing NIC in bySlot by following Links.NetworkAdapter → the Chassis
// NetworkAdapter doc → its slot label. Returns -1 if no match is found.
func (r redfishSnapshotReader) findNICIndexByLinkedNetworkAdapter(iface map[string]interface{}, bySlot map[string]int) int {
links, ok := iface["Links"].(map[string]interface{})
if !ok {
return -1
}
adapterRef, ok := links["NetworkAdapter"].(map[string]interface{})
if !ok {
return -1
}
adapterPath := normalizeRedfishPath(asString(adapterRef["@odata.id"]))
if adapterPath == "" {
return -1
}
adapterDoc, err := r.getJSON(adapterPath)
if err != nil || len(adapterDoc) == 0 {
return -1
}
adapterNIC := parseNIC(adapterDoc)
if slot := strings.ToLower(strings.TrimSpace(adapterNIC.Slot)); slot != "" {
if idx, ok := bySlot[slot]; ok {
return idx
}
}
return -1
}
// enrichNICMACsFromNetworkDeviceFunctions reads the NetworkDeviceFunctions
// collection linked from a NetworkAdapter document and populates the NIC's
// MACAddresses from each function's Ethernet.PermanentMACAddress / MACAddress.

View File

@@ -14,13 +14,16 @@ func (r redfishSnapshotReader) collectStorage(systemPath string, plan redfishpro
driveDocs, err := r.getCollectionMembers(driveCollectionPath)
if err == nil {
for _, driveDoc := range driveDocs {
if !isVirtualStorageDrive(driveDoc) {
if !isAbsentDriveDoc(driveDoc) && !isVirtualStorageDrive(driveDoc) {
supplementalDocs := r.getLinkedSupplementalDocs(driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
}
}
if len(driveDocs) == 0 {
for _, driveDoc := range r.probeDirectDiskBayChildren(driveCollectionPath) {
if isAbsentDriveDoc(driveDoc) {
continue
}
supplementalDocs := r.getLinkedSupplementalDocs(driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
}
@@ -43,7 +46,7 @@ func (r redfishSnapshotReader) collectStorage(systemPath string, plan redfishpro
if err != nil {
continue
}
if !isVirtualStorageDrive(driveDoc) {
if !isAbsentDriveDoc(driveDoc) && !isVirtualStorageDrive(driveDoc) {
supplementalDocs := r.getLinkedSupplementalDocs(driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
}
@@ -51,7 +54,7 @@ func (r redfishSnapshotReader) collectStorage(systemPath string, plan redfishpro
continue
}
if looksLikeDrive(member) {
if isVirtualStorageDrive(member) {
if isAbsentDriveDoc(member) || isVirtualStorageDrive(member) {
continue
}
supplementalDocs := r.getLinkedSupplementalDocs(member, "DriveMetrics", "EnvironmentMetrics", "Metrics")
@@ -63,14 +66,14 @@ func (r redfishSnapshotReader) collectStorage(systemPath string, plan redfishpro
driveDocs, err := r.getCollectionMembers(joinPath(enclosurePath, "/Drives"))
if err == nil {
for _, driveDoc := range driveDocs {
if looksLikeDrive(driveDoc) && !isVirtualStorageDrive(driveDoc) {
if looksLikeDrive(driveDoc) && !isAbsentDriveDoc(driveDoc) && !isVirtualStorageDrive(driveDoc) {
supplementalDocs := r.getLinkedSupplementalDocs(driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
}
}
if len(driveDocs) == 0 {
for _, driveDoc := range r.probeDirectDiskBayChildren(joinPath(enclosurePath, "/Drives")) {
if isVirtualStorageDrive(driveDoc) {
if isAbsentDriveDoc(driveDoc) || isVirtualStorageDrive(driveDoc) {
continue
}
out = append(out, parseDrive(driveDoc))
@@ -83,7 +86,7 @@ func (r redfishSnapshotReader) collectStorage(systemPath string, plan redfishpro
if len(plan.KnownStorageDriveCollections) > 0 {
for _, driveDoc := range r.collectKnownStorageMembers(systemPath, plan.KnownStorageDriveCollections) {
if looksLikeDrive(driveDoc) && !isVirtualStorageDrive(driveDoc) {
if looksLikeDrive(driveDoc) && !isAbsentDriveDoc(driveDoc) && !isVirtualStorageDrive(driveDoc) {
supplementalDocs := r.getLinkedSupplementalDocs(driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
}
@@ -98,7 +101,7 @@ func (r redfishSnapshotReader) collectStorage(systemPath string, plan redfishpro
}
for _, devAny := range devices {
devDoc, ok := devAny.(map[string]interface{})
if !ok || !looksLikeDrive(devDoc) || isVirtualStorageDrive(devDoc) {
if !ok || !looksLikeDrive(devDoc) || isAbsentDriveDoc(devDoc) || isVirtualStorageDrive(devDoc) {
continue
}
out = append(out, parseDrive(devDoc))
@@ -112,7 +115,7 @@ func (r redfishSnapshotReader) collectStorage(systemPath string, plan redfishpro
continue
}
for _, driveDoc := range driveDocs {
if !looksLikeDrive(driveDoc) || isVirtualStorageDrive(driveDoc) {
if !looksLikeDrive(driveDoc) || isAbsentDriveDoc(driveDoc) || isVirtualStorageDrive(driveDoc) {
continue
}
out = append(out, parseDrive(driveDoc))
@@ -124,7 +127,7 @@ func (r redfishSnapshotReader) collectStorage(systemPath string, plan redfishpro
continue
}
for _, driveDoc := range r.probeSupermicroNVMeDiskBays(chassisPath) {
if !looksLikeDrive(driveDoc) || isVirtualStorageDrive(driveDoc) {
if !looksLikeDrive(driveDoc) || isAbsentDriveDoc(driveDoc) || isVirtualStorageDrive(driveDoc) {
continue
}
out = append(out, parseDrive(driveDoc))

View File

@@ -270,8 +270,74 @@ func TestRedfishConnectorProbe(t *testing.T) {
}
}
func TestRedfishConnectorProbe_FallsBackToPowerSummary(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", map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Systems/1"},
},
})
register("/redfish/v1/Systems/1", map[string]interface{}{
"@odata.id": "/redfish/v1/Systems/1",
"PowerSummary": map[string]interface{}{
"PowerState": "On",
},
"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 on host from PowerSummary")
}
if got.HostPowerState != "On" {
t.Fatalf("expected power state On, 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")
t.Setenv("LOGPILE_REDFISH_BMC_READY_WAITS", "1ms,1ms")
powerState := "Off"
resetCalls := 0
@@ -282,6 +348,9 @@ func TestEnsureHostPowerForCollection_WaitsForStablePowerOn(t *testing.T) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"@odata.id": "/redfish/v1/Systems/1",
"PowerState": powerState,
"MemorySummary": map[string]interface{}{
"TotalSystemMemoryGiB": 128,
},
"Actions": map[string]interface{}{
"#ComputerSystem.Reset": map[string]interface{}{
"target": "/redfish/v1/Systems/1/Actions/ComputerSystem.Reset",
@@ -329,6 +398,7 @@ func TestEnsureHostPowerForCollection_WaitsForStablePowerOn(t *testing.T) {
func TestEnsureHostPowerForCollection_FailsIfHostDoesNotStayOnAfterStabilization(t *testing.T) {
t.Setenv("LOGPILE_REDFISH_POWERON_STABILIZATION", "1ms")
t.Setenv("LOGPILE_REDFISH_BMC_READY_WAITS", "1ms,1ms")
powerState := "Off"
@@ -383,6 +453,104 @@ func TestEnsureHostPowerForCollection_FailsIfHostDoesNotStayOnAfterStabilization
}
}
func TestEnsureHostPowerForCollection_UsesPowerSummaryState(t *testing.T) {
t.Setenv("LOGPILE_REDFISH_POWERON_STABILIZATION", "1ms")
t.Setenv("LOGPILE_REDFISH_BMC_READY_WAITS", "1ms,1ms")
powerState := "On"
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",
"PowerSummary": map[string]interface{}{
"PowerState": powerState,
},
"MemorySummary": map[string]interface{}{
"TotalSystemMemoryGiB": 128,
},
"Actions": map[string]interface{}{
"#ComputerSystem.Reset": map[string]interface{}{
"target": "/redfish/v1/Systems/1/Actions/ComputerSystem.Reset",
"ResetType@Redfish.AllowableValues": []interface{}{"On"},
},
},
})
})
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 already-on host from PowerSummary, got hostOn=%v changed=%v", hostOn, changed)
}
}
func TestWaitForHostPowerState_UsesPowerSummaryState(t *testing.T) {
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 == "Off" {
powerState = "On"
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"@odata.id": "/redfish/v1/Systems/1",
"PowerSummary": map[string]interface{}{
"PowerState": current,
},
})
})
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()
ok := c.waitForHostPowerState(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",
}, ts.URL, "/redfish/v1/Systems/1", true, 3*time.Second)
if !ok {
t.Fatalf("expected waitForHostPowerState to use PowerSummary")
}
}
func TestParsePCIeDeviceSlot_FromNestedRedfishSlotLocation(t *testing.T) {
doc := map[string]interface{}{
"Id": "NIC1",
@@ -483,6 +651,271 @@ func TestReplayRedfishFromRawPayloads_FallbackCollectionMembersByPrefix(t *testi
}
}
func TestReplayRedfishFromRawPayloads_FallbackCollectionMembersSkipsPlaceholderNumericDocs(t *testing.T) {
raw := map[string]any{
"redfish_tree": map[string]interface{}{
"/redfish/v1": map[string]interface{}{
"Systems": map[string]interface{}{"@odata.id": "/redfish/v1/Systems"},
"Chassis": map[string]interface{}{"@odata.id": "/redfish/v1/Chassis"},
"Managers": map[string]interface{}{"@odata.id": "/redfish/v1/Managers"},
},
"/redfish/v1/Systems": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Systems/1"},
},
},
"/redfish/v1/Systems/1": map[string]interface{}{
"Manufacturer": "Multillect",
"Model": "MLT-S06",
"SerialNumber": "430044262001626",
"Storage": map[string]interface{}{"@odata.id": "/redfish/v1/Systems/1/Storage"},
},
"/redfish/v1/Systems/1/Storage": map[string]interface{}{
"@odata.id": "/redfish/v1/Systems/1/Storage",
"Members": []interface{}{},
"Members@odata.count": 0,
},
"/redfish/v1/Systems/1/Storage/1": map[string]interface{}{
"@odata.id": "/redfish/v1/Systems/1/Storage/1",
"@odata.type": "#Storage.v1_7_1.Storage",
"Drives": []interface{}{},
"Drives@odata.count": "0",
"LogicalDisk": []interface{}{},
"PhysicalDisk": []interface{}{},
},
"/redfish/v1/Chassis": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1"},
},
},
"/redfish/v1/Chassis/1": map[string]interface{}{
"Id": "1",
"NetworkAdapters": map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/NetworkAdapters"},
"PCIeDevices": map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices"},
},
"/redfish/v1/Chassis/1/NetworkAdapters": map[string]interface{}{
"@odata.id": "/redfish/v1/Chassis/1/NetworkAdapters",
"Members": []interface{}{},
"Members@odata.count": 0,
},
"/redfish/v1/Chassis/1/NetworkAdapters/1": map[string]interface{}{
"@odata.context": "/redfish/v1/$metadata#Chassis/Members/1/NetworkAdapters/Members/$entity",
"@odata.id": "/redfish/v1/Chassis/1/NetworkAdapters/1",
"@odata.type": "#NetworkAdapter.v1_0_0.Networkadapter",
"Id": "1",
"Name": "1",
},
"/redfish/v1/Chassis/1/PCIeDevices": map[string]interface{}{
"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices",
"Members": []interface{}{},
"Members@odata.count": 0,
},
"/redfish/v1/Chassis/1/PCIeDevices/1": map[string]interface{}{
"@odata.context": "/redfish/v1/$metadata#PCIeDevice.PCIeDevice",
"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/1",
"@odata.type": "#PCIeDevice.v1_4_0.PCIeDevice",
"Id": "1",
"Name": "PCIe Device",
},
"/redfish/v1/Managers": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Managers/1"},
},
},
"/redfish/v1/Managers/1": map[string]interface{}{
"Id": "1",
},
},
}
got, err := ReplayRedfishFromRawPayloads(raw, nil)
if err != nil {
t.Fatalf("replay failed: %v", err)
}
if got.Hardware == nil {
t.Fatalf("expected hardware")
}
if len(got.Hardware.NetworkAdapters) != 0 {
t.Fatalf("expected placeholder network adapters to be skipped, got %d", len(got.Hardware.NetworkAdapters))
}
if len(got.Hardware.PCIeDevices) != 0 {
t.Fatalf("expected placeholder PCIe devices to be skipped, got %d", len(got.Hardware.PCIeDevices))
}
if len(got.Hardware.Storage) != 0 {
t.Fatalf("expected placeholder storage members to be skipped, got %d", len(got.Hardware.Storage))
}
}
func TestReplayRedfishFromRawPayloads_PrefersActiveBMCInterfaceForBoardMAC(t *testing.T) {
raw := map[string]any{
"redfish_tree": map[string]interface{}{
"/redfish/v1": map[string]interface{}{
"Systems": map[string]interface{}{"@odata.id": "/redfish/v1/Systems"},
"Chassis": map[string]interface{}{"@odata.id": "/redfish/v1/Chassis"},
"Managers": map[string]interface{}{"@odata.id": "/redfish/v1/Managers"},
},
"/redfish/v1/Systems": map[string]interface{}{
"Members": []interface{}{map[string]interface{}{"@odata.id": "/redfish/v1/Systems/1"}},
},
"/redfish/v1/Systems/1": map[string]interface{}{
"Manufacturer": "Multillect",
"Model": "MLT-S06",
"SerialNumber": "430044262001626",
},
"/redfish/v1/Chassis": map[string]interface{}{
"Members": []interface{}{map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1"}},
},
"/redfish/v1/Chassis/1": map[string]interface{}{"Id": "1"},
"/redfish/v1/Managers": map[string]interface{}{
"Members": []interface{}{map[string]interface{}{"@odata.id": "/redfish/v1/Managers/1"}},
},
"/redfish/v1/Managers/1": map[string]interface{}{
"Id": "1",
},
"/redfish/v1/Managers/1/EthernetInterfaces": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Managers/1/EthernetInterfaces/eth0"},
map[string]interface{}{"@odata.id": "/redfish/v1/Managers/1/EthernetInterfaces/eth1"},
},
"Oem": map[string]interface{}{
"Public": map[string]interface{}{
"NcsiEnabled": true,
"LLDP": map[string]interface{}{
"LLDPMode": "Rx",
"Members": []interface{}{
map[string]interface{}{
"EthIndex": "eth1",
"ChassisName": "castor.netwell.local",
"PortDesc": "ge-0/0/17",
"PortId": "531",
"VlanId": 20,
},
},
},
},
},
},
"/redfish/v1/Managers/1/EthernetInterfaces/eth0": map[string]interface{}{
"Id": "eth0",
"MACAddress": "00:25:6c:70:00:13",
"LinkStatus": "NoLink",
"SpeedMbps": 65535,
},
"/redfish/v1/Managers/1/EthernetInterfaces/eth1": map[string]interface{}{
"Id": "eth1",
"MACAddress": "00:25:6c:70:00:12",
"LinkStatus": "LinkActive",
"SpeedMbps": 1000,
"IPv4Addresses": []interface{}{
map[string]interface{}{
"Address": "172.16.41.42",
"Gateway": "172.16.41.1",
"SubnetMask": "255.255.255.0",
},
},
},
},
}
got, err := ReplayRedfishFromRawPayloads(raw, nil)
if err != nil {
t.Fatalf("replay failed: %v", err)
}
if got.Hardware == nil {
t.Fatalf("expected hardware")
}
if got.Hardware.BoardInfo.BMCMACAddress != "00:25:6C:70:00:12" {
t.Fatalf("expected active BMC MAC from eth1, got %q", got.Hardware.BoardInfo.BMCMACAddress)
}
summary, ok := got.RawPayloads["redfish_bmc_network_summary"].(map[string]any)
if !ok {
t.Fatalf("expected redfish_bmc_network_summary")
}
if summary["interface_id"] != "eth1" {
t.Fatalf("expected eth1 summary, got %#v", summary["interface_id"])
}
}
func TestReplayRedfishFromRawPayloads_AddsSensorsListHintSummary(t *testing.T) {
raw := map[string]any{
"redfish_tree": map[string]interface{}{
"/redfish/v1": map[string]interface{}{
"Systems": map[string]interface{}{"@odata.id": "/redfish/v1/Systems"},
"Chassis": map[string]interface{}{"@odata.id": "/redfish/v1/Chassis"},
"Managers": map[string]interface{}{"@odata.id": "/redfish/v1/Managers"},
},
"/redfish/v1/Systems": map[string]interface{}{
"Members": []interface{}{map[string]interface{}{"@odata.id": "/redfish/v1/Systems/1"}},
},
"/redfish/v1/Systems/1": map[string]interface{}{
"Manufacturer": "Multillect",
"Model": "MLT-S06",
"SerialNumber": "430044262001626",
},
"/redfish/v1/Chassis": map[string]interface{}{
"Members": []interface{}{map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1"}},
},
"/redfish/v1/Chassis/1": map[string]interface{}{"Id": "1"},
"/redfish/v1/Chassis/1/SensorsList": map[string]interface{}{
"SensorsList": []interface{}{
map[string]interface{}{"SensorName": "DIMM000_Status", "SensorType": "Memory", "Status": "OK"},
map[string]interface{}{"SensorName": "DIMM001_Status", "SensorType": "Memory", "Status": "nop"},
map[string]interface{}{"SensorName": "DIMM100_Status", "SensorType": "Memory", "Status": "OK"},
map[string]interface{}{"SensorName": "HDD0_F_Status", "SensorType": "Drive Slot", "Status": "nop"},
map[string]interface{}{"SensorName": "NVME0_F_Status", "SensorType": "Drive Slot", "Status": "nop"},
map[string]interface{}{"SensorName": "Logical_Drive", "SensorType": "Drive Slot", "Status": "OK"},
},
},
"/redfish/v1/Managers": map[string]interface{}{
"Members": []interface{}{map[string]interface{}{"@odata.id": "/redfish/v1/Managers/1"}},
},
"/redfish/v1/Managers/1": map[string]interface{}{"Id": "1"},
},
}
got, err := ReplayRedfishFromRawPayloads(raw, nil)
if err != nil {
t.Fatalf("replay failed: %v", err)
}
hints, ok := got.RawPayloads["redfish_sensor_hints"].(map[string]any)
if !ok {
t.Fatalf("expected redfish_sensor_hints")
}
memHints, ok := hints["memory_slots"].(map[string]any)
if !ok {
t.Fatalf("expected memory_slots hint")
}
if asInt(memHints["present_count"]) != 2 {
t.Fatalf("expected 2 present memory slot hints, got %#v", memHints["present_count"])
}
driveHints, ok := hints["drive_slots"].(map[string]any)
if !ok {
t.Fatalf("expected drive_slots hint")
}
if asInt(driveHints["physical_total"]) != 2 {
t.Fatalf("expected 2 physical drive slots, got %#v", driveHints["physical_total"])
}
if driveHints["logical_drive_status"] != "OK" {
t.Fatalf("expected logical drive status OK, got %#v", driveHints["logical_drive_status"])
}
foundMemoryEvent := false
foundDriveEvent := false
for _, ev := range got.Events {
if strings.Contains(ev.Description, "Memory slot sensors report 2 populated positions out of 3") {
foundMemoryEvent = true
}
if strings.Contains(ev.Description, "Drive slot sensors report 0 active physical slots out of 2") {
foundDriveEvent = true
}
}
if !foundMemoryEvent {
t.Fatalf("expected memory slot hint event")
}
if !foundDriveEvent {
t.Fatalf("expected drive slot hint event")
}
}
func TestReplayRedfishFromRawPayloads_PreservesSourceTimezoneAndUTCCollectedAt(t *testing.T) {
raw := map[string]any{
"redfish_tree": map[string]interface{}{
@@ -883,6 +1316,23 @@ func TestParsePCIeDevice_PrefersFunctionClassOverDeviceType(t *testing.T) {
}
}
func TestParsePCIeDevice_DoesNotPromotePartNumberToDeviceClass(t *testing.T) {
doc := map[string]interface{}{
"Id": "NIC1",
"DeviceType": "SingleFunction",
"Model": "MCX75310AAS-NEAT",
"PartNumber": "MCX75310AAS-NEAT",
}
got := parsePCIeDevice(doc, nil)
if got.DeviceClass != "PCIe device" {
t.Fatalf("expected generic PCIe class fallback, got %q", got.DeviceClass)
}
if got.PartNumber != "MCX75310AAS-NEAT" {
t.Fatalf("expected part number to stay intact, got %q", got.PartNumber)
}
}
func TestParsePCIeComponents_DoNotTreatNumericFunctionIDAsBDF(t *testing.T) {
pcieFn := parsePCIeFunction(map[string]interface{}{
"Id": "1",
@@ -933,7 +1383,9 @@ func TestParseComponents_UseNestedSerialNumberFallback(t *testing.T) {
"Manufacturer": "vendor0",
"SerialNumber": "N/A",
"Oem": map[string]interface{}{
"SerialNumber": "SN-OK-001",
"VendorX": map[string]interface{}{
"SerialNumber": "SN-OK-001",
},
},
}
@@ -973,6 +1425,38 @@ func TestParseComponents_UseNestedSerialNumberFallback(t *testing.T) {
}
}
func TestParseCPU_UsesPublicSerialAsPPINAndCurrentSpeedMHz(t *testing.T) {
cpus := parseCPUs([]map[string]interface{}{
{
"Id": "CPU0",
"Model": "Intel Xeon",
"TotalCores": 48,
"TotalThreads": 96,
"MaxSpeedMHz": 4000,
"OperatingSpeedMHz": 0,
"Oem": map[string]interface{}{
"Public": map[string]interface{}{
"SerialNumber": "6FB5241E81CECDFD",
"CurrentSpeedMHz": 2700,
},
},
},
})
if len(cpus) != 1 {
t.Fatalf("expected one CPU, got %d", len(cpus))
}
if cpus[0].PPIN != "6FB5241E81CECDFD" {
t.Fatalf("expected PPIN from Oem.Public.SerialNumber, got %+v", cpus[0])
}
if cpus[0].SerialNumber != "" {
t.Fatalf("expected empty CPU serial number when only Public serial exists, got %+v", cpus[0])
}
if cpus[0].FrequencyMHz != 2700 {
t.Fatalf("expected CPU frequency from Oem.Public.CurrentSpeedMHz, got %+v", cpus[0])
}
}
func TestParseCPUAndMemory_CollectOemDetails(t *testing.T) {
cpus := parseCPUs([]map[string]interface{}{
{
@@ -1682,7 +2166,7 @@ func TestReplayCollectStorage_UsesKnownControllerRecoveryWhenEnabled(t *testing.
}}
got := r.collectStorage("/redfish/v1/Systems/1", redfishprofile.ResolvedAnalysisPlan{
Directives: redfishprofile.AnalysisDirectives{EnableKnownStorageControllerRecovery: true},
Directives: redfishprofile.AnalysisDirectives{EnableKnownStorageControllerRecovery: true},
KnownStorageDriveCollections: []string{"/Storage/IntelVROC/Drives"},
})
if len(got) != 1 {
@@ -1693,6 +2177,94 @@ func TestReplayCollectStorage_UsesKnownControllerRecoveryWhenEnabled(t *testing.
}
}
func TestReplayCollectStorageVolumes_SkipsVolumeCapabilitiesFallbackMember(t *testing.T) {
r := redfishSnapshotReader{tree: map[string]interface{}{
"/redfish/v1/Systems/1/Storage": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Systems/1/Storage/DE00A000"},
},
},
"/redfish/v1/Systems/1/Storage/DE00A000": map[string]interface{}{
"Id": "DE00A000",
"Volumes": map[string]interface{}{"@odata.id": "/redfish/v1/Systems/1/Storage/DE00A000/Volumes"},
},
"/redfish/v1/Systems/1/Storage/DE00A000/Volumes": map[string]interface{}{
"@odata.id": "/redfish/v1/Systems/1/Storage/DE00A000/Volumes",
"@odata.type": "#VolumeCollection.VolumeCollection",
"Members": []interface{}{},
"Members@odata.count": 0,
"Name": "MR Volume Collection",
},
"/redfish/v1/Systems/1/Storage/DE00A000/Volumes/Capabilities": map[string]interface{}{
"@odata.id": "/redfish/v1/Systems/1/Storage/DE00A000/Volumes/Capabilities",
"@odata.type": "#Volume.v1_9_0.Volume",
"Id": "Capabilities",
"Name": "Capabilities for VolumeCollection",
},
}}
got := r.collectStorageVolumes("/redfish/v1/Systems/1", testAnalysisPlan(redfishprofile.AnalysisDirectives{}))
if len(got) != 0 {
t.Fatalf("expected capabilities-only volume collection to stay empty, got %+v", got)
}
}
func TestReplayCollectPCIeDevices_UsesChassisDeviceSupplementalDocs(t *testing.T) {
r := redfishSnapshotReader{tree: map[string]interface{}{
"/redfish/v1/Chassis/1/PCIeDevices": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/2"},
},
},
"/redfish/v1/Chassis/1/PCIeDevices/2": map[string]interface{}{
"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/2",
"Name": "BCM 5719 1Gb 4p BASE-T OCP Adptr",
"Model": "P51183-001",
"PartNumber": "P51183-001",
"Manufacturer": "Broadcom",
"SerialNumber": "1CH0150001",
"DeviceType": "SingleFunction",
},
"/redfish/v1/Chassis/1/Devices": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/Devices/2"},
map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/Devices/4"},
},
},
"/redfish/v1/Chassis/1/Devices/2": map[string]interface{}{
"@odata.id": "/redfish/v1/Chassis/1/Devices/2",
"Name": "BCM 5719 1Gb 4p BASE-T OCP Adptr",
"DeviceType": "LOM/NIC",
"Manufacturer": "Broadcom",
"PartNumber": "BCM95719N1905HC",
"ProductPartNumber": "P51183-001",
"SerialNumber": "1CH0150001",
"Location": "OCP 3.0 Slot 15",
},
"/redfish/v1/Chassis/1/Devices/4": map[string]interface{}{
"@odata.id": "/redfish/v1/Chassis/1/Devices/4",
"Name": "Empty slot 2",
"DeviceType": "Unknown",
"Location": "PCI-E Slot 2",
"SerialNumber": "",
},
}}
got := r.collectPCIeDevices(nil, []string{"/redfish/v1/Chassis/1"})
if len(got) != 1 {
t.Fatalf("expected one PCIe device, got %d", len(got))
}
if got[0].Slot != "OCP 3.0 Slot 15" {
t.Fatalf("expected chassis device location to override weak slot label, got %+v", got[0])
}
if got[0].DeviceClass != "LOM/NIC" {
t.Fatalf("expected chassis device type to enrich class, got %+v", got[0])
}
if got[0].DeviceClass == "P51183-001" {
t.Fatalf("device class should not degrade into part number: %+v", got[0])
}
}
func TestReplayCollectGPUs_DoesNotCollapseOnPlaceholderSerialAndSkipsNIC(t *testing.T) {
r := redfishSnapshotReader{tree: map[string]interface{}{
"/redfish/v1/Chassis/1/PCIeDevices": map[string]interface{}{
@@ -1773,6 +2345,18 @@ func TestParseBoardInfo_NormalizesNullPlaceholders(t *testing.T) {
}
}
func TestParseBoardInfo_UsesSKUAsPartNumberFallback(t *testing.T) {
got := parseBoardInfo(map[string]interface{}{
"Manufacturer": "HPE",
"Model": "ProLiant DL380 Gen11",
"SerialNumber": "CZ2D1X0GS4",
"SKU": "P52560-421",
})
if got.PartNumber != "P52560-421" {
t.Fatalf("expected SKU to populate part number, got %q", got.PartNumber)
}
}
func TestShouldCrawlPath_SkipsJsonSchemas(t *testing.T) {
if shouldCrawlPath("/redfish/v1/JsonSchemas") {
t.Fatalf("expected /JsonSchemas to be skipped")
@@ -2352,6 +2936,20 @@ func TestAppendPSU_MergesRicherDuplicate(t *testing.T) {
}
}
func TestRedfishPSUNominalWattage_PrefersInputRangeOutputWattage(t *testing.T) {
doc := map[string]interface{}{
"PowerCapacityWatts": 22600,
"InputRanges": []interface{}{
map[string]interface{}{"OutputWattage": 2700},
map[string]interface{}{"OutputWattage": 3200},
},
}
if got := redfishPSUNominalWattage(doc); got != 3200 {
t.Fatalf("redfishPSUNominalWattage() = %d, want 3200", got)
}
}
func TestReplayCollectGPUs_DropsModelOnlyPlaceholderWhenConcreteDiscoveredLater(t *testing.T) {
r := redfishSnapshotReader{tree: map[string]interface{}{
"/redfish/v1/Systems/1/GraphicsControllers": map[string]interface{}{
@@ -2672,7 +3270,7 @@ func TestCollectGPUsFromProcessors_SupermicroHGXUsesChassisAliasSerial(t *testin
gpus := r.collectGPUs(systemPaths, chassisPaths, testAnalysisPlan(redfishprofile.AnalysisDirectives{EnableGenericGraphicsControllerDedup: true}))
gpus = r.collectGPUsFromProcessors(systemPaths, chassisPaths, gpus, redfishprofile.ResolvedAnalysisPlan{
Directives: redfishprofile.AnalysisDirectives{EnableProcessorGPUFallback: true, EnableProcessorGPUChassisAlias: true},
Directives: redfishprofile.AnalysisDirectives{EnableProcessorGPUFallback: true, EnableProcessorGPUChassisAlias: true},
ProcessorGPUChassisLookupModes: []string{"hgx-alias"},
})
@@ -2710,7 +3308,7 @@ func TestCollectGPUsFromProcessors_MSIUsesIndexedChassisLookup(t *testing.T) {
[]string{"/redfish/v1/Chassis/GPU1"},
nil,
redfishprofile.ResolvedAnalysisPlan{
Directives: redfishprofile.AnalysisDirectives{EnableProcessorGPUFallback: true, EnableMSIProcessorGPUChassisLookup: true},
Directives: redfishprofile.AnalysisDirectives{EnableProcessorGPUFallback: true, EnableMSIProcessorGPUChassisLookup: true},
ProcessorGPUChassisLookupModes: []string{"msi-index"},
},
)

View File

@@ -102,6 +102,7 @@ func genericProfile() Profile {
ensureRecoveryPolicy(plan, AcquisitionRecoveryPolicy{
EnableCriticalCollectionMemberRetry: true,
EnableCriticalSlowProbe: true,
EnableEmptyCriticalCollectionRetry: true,
})
ensureRatePolicy(plan, AcquisitionRatePolicy{
TargetP95LatencyMS: 900,

View File

@@ -0,0 +1,67 @@
package redfishprofile
func hpeProfile() Profile {
return staticProfile{
name: "hpe",
priority: 20,
safeForFallback: true,
matchFn: func(s MatchSignals) int {
score := 0
if containsFold(s.SystemManufacturer, "hpe") ||
containsFold(s.SystemManufacturer, "hewlett packard") ||
containsFold(s.ChassisManufacturer, "hpe") ||
containsFold(s.ChassisManufacturer, "hewlett packard") {
score += 80
}
for _, ns := range s.OEMNamespaces {
if containsFold(ns, "hpe") {
score += 30
break
}
}
if containsFold(s.ServiceRootProduct, "ilo") {
score += 30
}
if containsFold(s.ManagerManufacturer, "hpe") || containsFold(s.ManagerManufacturer, "ilo") {
score += 20
}
return min(score, 100)
},
extendAcquisition: func(plan *AcquisitionPlan, _ MatchSignals) {
// HPE ProLiant SmartStorage RAID controller inventory is not reachable
// via standard Redfish Storage paths — it requires the HPE OEM SmartStorage tree.
ensureScopedPathPolicy(plan, AcquisitionScopedPathPolicy{
SystemCriticalSuffixes: []string{
"/SmartStorage",
"/SmartStorageConfig",
},
ManagerCriticalSuffixes: []string{
"/LicenseService",
},
})
// HPE iLO responds more slowly than average BMCs under load; give the
// ETA estimator a realistic baseline so progress reports are accurate.
ensureETABaseline(plan, AcquisitionETABaseline{
DiscoverySeconds: 12,
SnapshotSeconds: 180,
PrefetchSeconds: 30,
CriticalPlanBSeconds: 40,
ProfilePlanBSeconds: 25,
})
ensureRecoveryPolicy(plan, AcquisitionRecoveryPolicy{
EnableProfilePlanB: true,
})
// HPE iLO starts throttling under high request rates. Setting a higher
// latency tolerance prevents the adaptive throttler from treating normal
// iLO slowness as a reason to stall the collection.
ensureRatePolicy(plan, AcquisitionRatePolicy{
TargetP95LatencyMS: 1200,
ThrottleP95LatencyMS: 2500,
MinSnapshotWorkers: 2,
MinPrefetchWorkers: 1,
DisablePrefetchOnErrors: true,
})
addPlanNote(plan, "hpe ilo acquisition extensions enabled")
},
}
}

View File

@@ -0,0 +1,149 @@
package redfishprofile
import (
"regexp"
"strings"
)
var (
outboardCardHintRe = regexp.MustCompile(`/outboardPCIeCard\d+(?:/|$)`)
obDriveHintRe = regexp.MustCompile(`/Drives/OB\d+$`)
fpDriveHintRe = regexp.MustCompile(`/Drives/FP00HDD\d+$`)
vrFirmwareHintRe = regexp.MustCompile(`^CPU\d+_PVCC.*_VR$`)
)
var inspurGroupOEMFirmwareHints = map[string]struct{}{
"Front_HDD_CPLD0": {},
"MainBoard0CPLD": {},
"MainBoardCPLD": {},
"PDBBoardCPLD": {},
"SCMCPLD": {},
"SWBoardCPLD": {},
}
func inspurGroupOEMPlatformsProfile() Profile {
return staticProfile{
name: "inspur-group-oem-platforms",
priority: 25,
safeForFallback: false,
matchFn: func(s MatchSignals) int {
topologyScore := 0
boardScore := 0
chassisOutboard := matchedPathTokens(s.ResourceHints, "/redfish/v1/Chassis/", outboardCardHintRe)
systemOutboard := matchedPathTokens(s.ResourceHints, "/redfish/v1/Systems/", outboardCardHintRe)
obDrives := matchedPathTokens(s.ResourceHints, "", obDriveHintRe)
fpDrives := matchedPathTokens(s.ResourceHints, "", fpDriveHintRe)
firmwareNames, vrFirmwareNames := inspurGroupOEMFirmwareMatches(s.ResourceHints)
if len(chassisOutboard) > 0 {
topologyScore += 20
}
if len(systemOutboard) > 0 {
topologyScore += 10
}
switch {
case len(obDrives) > 0 && len(fpDrives) > 0:
topologyScore += 15
}
switch {
case len(firmwareNames) >= 2:
boardScore += 15
}
switch {
case len(vrFirmwareNames) >= 2:
boardScore += 10
}
if anySignalContains(s, "COMMONbAssembly") {
boardScore += 12
}
if anySignalContains(s, "EnvironmentMetrcs") {
boardScore += 8
}
if anySignalContains(s, "GetServerAllUSBStatus") {
boardScore += 8
}
if topologyScore == 0 || boardScore == 0 {
return 0
}
return min(topologyScore+boardScore, 100)
},
extendAcquisition: func(plan *AcquisitionPlan, _ MatchSignals) {
addPlanNote(plan, "Inspur Group OEM platform fingerprint matched")
},
applyAnalysisDirectives: func(d *AnalysisDirectives, _ MatchSignals) {
d.EnableGenericGraphicsControllerDedup = true
},
}
}
func matchedPathTokens(paths []string, requiredPrefix string, re *regexp.Regexp) []string {
seen := make(map[string]struct{})
for _, rawPath := range paths {
path := normalizePath(rawPath)
if path == "" || (requiredPrefix != "" && !strings.HasPrefix(path, requiredPrefix)) {
continue
}
token := re.FindString(path)
if token == "" {
continue
}
token = strings.Trim(token, "/")
if token == "" {
continue
}
seen[token] = struct{}{}
}
out := make([]string, 0, len(seen))
for token := range seen {
out = append(out, token)
}
return dedupeSorted(out)
}
func inspurGroupOEMFirmwareMatches(paths []string) ([]string, []string) {
firmwareNames := make(map[string]struct{})
vrNames := make(map[string]struct{})
for _, rawPath := range paths {
path := normalizePath(rawPath)
if !strings.HasPrefix(path, "/redfish/v1/UpdateService/FirmwareInventory/") {
continue
}
name := strings.TrimSpace(path[strings.LastIndex(path, "/")+1:])
if name == "" {
continue
}
if _, ok := inspurGroupOEMFirmwareHints[name]; ok {
firmwareNames[name] = struct{}{}
}
if vrFirmwareHintRe.MatchString(name) {
vrNames[name] = struct{}{}
}
}
return mapKeysSorted(firmwareNames), mapKeysSorted(vrNames)
}
func anySignalContains(signals MatchSignals, needle string) bool {
needle = strings.TrimSpace(needle)
if needle == "" {
return false
}
for _, signal := range signals.ResourceHints {
if strings.Contains(signal, needle) {
return true
}
}
for _, signal := range signals.DocHints {
if strings.Contains(signal, needle) {
return true
}
}
return false
}
func mapKeysSorted(items map[string]struct{}) []string {
out := make([]string, 0, len(items))
for item := range items {
out = append(out, item)
}
return dedupeSorted(out)
}

View File

@@ -0,0 +1,182 @@
package redfishprofile
import (
"archive/zip"
"encoding/json"
"os"
"path/filepath"
"testing"
)
func TestCollectSignalsFromTree_InspurGroupOEMPlatformsSelectsMatchedMode(t *testing.T) {
tree := map[string]interface{}{
"/redfish/v1": map[string]interface{}{
"@odata.id": "/redfish/v1",
},
"/redfish/v1/Systems": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Systems/1"},
},
},
"/redfish/v1/Systems/1": map[string]interface{}{
"@odata.id": "/redfish/v1/Systems/1",
"Oem": map[string]interface{}{
"Public": map[string]interface{}{
"USB": map[string]interface{}{
"@odata.id": "/redfish/v1/Systems/1/Oem/Public/GetServerAllUSBStatus",
},
},
},
"NetworkInterfaces": map[string]interface{}{
"@odata.id": "/redfish/v1/Systems/1/NetworkInterfaces",
},
},
"/redfish/v1/Systems/1/NetworkInterfaces": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Systems/1/NetworkInterfaces/outboardPCIeCard0"},
map[string]interface{}{"@odata.id": "/redfish/v1/Systems/1/NetworkInterfaces/outboardPCIeCard1"},
},
},
"/redfish/v1/Chassis": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1"},
},
},
"/redfish/v1/Chassis/1": map[string]interface{}{
"@odata.id": "/redfish/v1/Chassis/1",
"Actions": map[string]interface{}{
"Oem": map[string]interface{}{
"Public": map[string]interface{}{
"NvGpuPowerLimitWatts": map[string]interface{}{
"target": "/redfish/v1/Chassis/1/GPU/EnvironmentMetrcs",
},
},
},
},
"Drives": map[string]interface{}{
"@odata.id": "/redfish/v1/Chassis/1/Drives",
},
"NetworkAdapters": map[string]interface{}{
"@odata.id": "/redfish/v1/Chassis/1/NetworkAdapters",
},
},
"/redfish/v1/Chassis/1/Drives": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/Drives/OB01"},
map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/Drives/FP00HDD00"},
},
},
"/redfish/v1/Chassis/1/NetworkAdapters": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/NetworkAdapters/outboardPCIeCard0"},
map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/NetworkAdapters/outboardPCIeCard1"},
},
},
"/redfish/v1/Chassis/1/Assembly": map[string]interface{}{
"Assemblies": []interface{}{
map[string]interface{}{
"Oem": map[string]interface{}{
"COMMONb": map[string]interface{}{
"COMMONbAssembly": map[string]interface{}{
"@odata.type": "#COMMONbAssembly.v1_0_0.COMMONbAssembly",
},
},
},
},
},
},
"/redfish/v1/Managers": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Managers/1"},
},
},
"/redfish/v1/Managers/1": map[string]interface{}{
"Actions": map[string]interface{}{
"Oem": map[string]interface{}{
"#PublicManager.ExportConfFile": map[string]interface{}{
"target": "/redfish/v1/Managers/1/Actions/Oem/Public/ExportConfFile",
},
},
},
},
"/redfish/v1/UpdateService/FirmwareInventory": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/UpdateService/FirmwareInventory/Front_HDD_CPLD0"},
map[string]interface{}{"@odata.id": "/redfish/v1/UpdateService/FirmwareInventory/SCMCPLD"},
map[string]interface{}{"@odata.id": "/redfish/v1/UpdateService/FirmwareInventory/CPU0_PVCCD_HV_VR"},
map[string]interface{}{"@odata.id": "/redfish/v1/UpdateService/FirmwareInventory/CPU1_PVCCIN_VR"},
},
},
}
signals := CollectSignalsFromTree(tree)
match := MatchProfiles(signals)
if match.Mode != ModeMatched {
t.Fatalf("expected matched mode, got %q", match.Mode)
}
assertProfileSelected(t, match, "inspur-group-oem-platforms")
}
func TestCollectSignalsFromTree_InspurGroupOEMPlatformsDoesNotFalsePositiveOnExampleRawExports(t *testing.T) {
examples := []string{
"2026-03-18 (G5500 V7) - 210619KUGGXGS2000015.zip",
"2026-03-11 (SYS-821GE-TNHR) - A514359X5C08846.zip",
"2026-03-15 (CG480-S5063) - P5T0006091.zip",
"2026-03-18 (CG290-S3063) - PAT0011258.zip",
"2024-04-25 (AS -4124GQ-TNMI) - S490387X4418273.zip",
}
for _, name := range examples {
t.Run(name, func(t *testing.T) {
tree := loadRawExportTreeFromExampleZip(t, name)
match := MatchProfiles(CollectSignalsFromTree(tree))
assertProfileNotSelected(t, match, "inspur-group-oem-platforms")
})
}
}
func loadRawExportTreeFromExampleZip(t *testing.T, name string) map[string]interface{} {
t.Helper()
path := filepath.Join("..", "..", "..", "example", name)
f, err := os.Open(path)
if err != nil {
t.Fatalf("open example zip %s: %v", path, err)
}
defer f.Close()
info, err := f.Stat()
if err != nil {
t.Fatalf("stat example zip %s: %v", path, err)
}
zr, err := zip.NewReader(f, info.Size())
if err != nil {
t.Fatalf("read example zip %s: %v", path, err)
}
for _, file := range zr.File {
if file.Name != "raw_export.json" {
continue
}
rc, err := file.Open()
if err != nil {
t.Fatalf("open %s in %s: %v", file.Name, path, err)
}
defer rc.Close()
var payload struct {
Source struct {
RawPayloads struct {
RedfishTree map[string]interface{} `json:"redfish_tree"`
} `json:"raw_payloads"`
} `json:"source"`
}
if err := json.NewDecoder(rc).Decode(&payload); err != nil {
t.Fatalf("decode raw_export.json from %s: %v", path, err)
}
if len(payload.Source.RawPayloads.RedfishTree) == 0 {
t.Fatalf("example %s has empty redfish_tree", path)
}
return payload.Source.RawPayloads.RedfishTree
}
t.Fatalf("raw_export.json not found in %s", path)
return nil
}

View File

@@ -55,6 +55,8 @@ func BuiltinProfiles() []Profile {
msiProfile(),
supermicroProfile(),
dellProfile(),
hpeProfile(),
inspurGroupOEMPlatformsProfile(),
hgxProfile(),
xfusionProfile(),
}
@@ -205,6 +207,9 @@ func ensureRecoveryPolicy(plan *AcquisitionPlan, policy AcquisitionRecoveryPolic
if policy.EnableProfilePlanB {
plan.Tuning.RecoveryPolicy.EnableProfilePlanB = true
}
if policy.EnableEmptyCriticalCollectionRetry {
plan.Tuning.RecoveryPolicy.EnableEmptyCriticalCollectionRetry = true
}
}
func ensureScopedPathPolicy(plan *AcquisitionPlan, policy AcquisitionScopedPathPolicy) {

View File

@@ -2,7 +2,14 @@ package redfishprofile
import "strings"
func CollectSignals(serviceRootDoc, systemDoc, chassisDoc, managerDoc map[string]interface{}, resourceHints []string) MatchSignals {
func CollectSignals(serviceRootDoc, systemDoc, chassisDoc, managerDoc map[string]interface{}, resourceHints []string, hintDocs ...map[string]interface{}) MatchSignals {
resourceHints = append([]string{}, resourceHints...)
docHints := make([]string, 0)
for _, doc := range append([]map[string]interface{}{serviceRootDoc, systemDoc, chassisDoc, managerDoc}, hintDocs...) {
embeddedPaths, embeddedHints := collectDocSignalHints(doc)
resourceHints = append(resourceHints, embeddedPaths...)
docHints = append(docHints, embeddedHints...)
}
signals := MatchSignals{
ServiceRootVendor: lookupString(serviceRootDoc, "Vendor"),
ServiceRootProduct: lookupString(serviceRootDoc, "Product"),
@@ -13,6 +20,7 @@ func CollectSignals(serviceRootDoc, systemDoc, chassisDoc, managerDoc map[string
ChassisModel: lookupString(chassisDoc, "Model"),
ManagerManufacturer: lookupString(managerDoc, "Manufacturer"),
ResourceHints: resourceHints,
DocHints: docHints,
}
signals.OEMNamespaces = dedupeSorted(append(
oemNamespaces(serviceRootDoc),
@@ -50,6 +58,7 @@ func CollectSignalsFromTree(tree map[string]interface{}) MatchSignals {
managerPath := memberPath("/redfish/v1/Managers", "/redfish/v1/Managers/1")
resourceHints := make([]string, 0, len(tree))
hintDocs := make([]map[string]interface{}, 0, len(tree))
for path := range tree {
path = strings.TrimSpace(path)
if path == "" {
@@ -57,6 +66,13 @@ func CollectSignalsFromTree(tree map[string]interface{}) MatchSignals {
}
resourceHints = append(resourceHints, path)
}
for _, v := range tree {
doc, ok := v.(map[string]interface{})
if !ok {
continue
}
hintDocs = append(hintDocs, doc)
}
return CollectSignals(
getDoc("/redfish/v1"),
@@ -64,9 +80,72 @@ func CollectSignalsFromTree(tree map[string]interface{}) MatchSignals {
getDoc(chassisPath),
getDoc(managerPath),
resourceHints,
hintDocs...,
)
}
func collectDocSignalHints(doc map[string]interface{}) ([]string, []string) {
if len(doc) == 0 {
return nil, nil
}
paths := make([]string, 0)
hints := make([]string, 0)
var walk func(any)
walk = func(v any) {
switch x := v.(type) {
case map[string]interface{}:
for rawKey, child := range x {
key := strings.TrimSpace(rawKey)
if key != "" {
hints = append(hints, key)
}
if s, ok := child.(string); ok {
s = strings.TrimSpace(s)
if s != "" {
switch key {
case "@odata.id", "target":
paths = append(paths, s)
case "@odata.type":
hints = append(hints, s)
default:
if isInterestingSignalString(s) {
hints = append(hints, s)
if strings.HasPrefix(s, "/") {
paths = append(paths, s)
}
}
}
}
}
walk(child)
}
case []interface{}:
for _, child := range x {
walk(child)
}
}
}
walk(doc)
return paths, hints
}
func isInterestingSignalString(s string) bool {
switch {
case strings.HasPrefix(s, "/"):
return true
case strings.HasPrefix(s, "#"):
return true
case strings.Contains(s, "COMMONb"):
return true
case strings.Contains(s, "EnvironmentMetrcs"):
return true
case strings.Contains(s, "GetServerAllUSBStatus"):
return true
default:
return false
}
}
func lookupString(doc map[string]interface{}, key string) string {
if len(doc) == 0 {
return ""

View File

@@ -17,6 +17,7 @@ type MatchSignals struct {
ManagerManufacturer string
OEMNamespaces []string
ResourceHints []string
DocHints []string
}
type AcquisitionPlan struct {
@@ -90,6 +91,7 @@ type AcquisitionRecoveryPolicy struct {
EnableCriticalCollectionMemberRetry bool
EnableCriticalSlowProbe bool
EnableProfilePlanB bool
EnableEmptyCriticalCollectionRetry bool
}
type AcquisitionPrefetchPolicy struct {
@@ -109,12 +111,12 @@ type AnalysisDirectives struct {
}
type ResolvedAnalysisPlan struct {
Match MatchResult
Directives AnalysisDirectives
Notes []string
ProcessorGPUChassisLookupModes []string
KnownStorageDriveCollections []string
KnownStorageVolumeCollections []string
Match MatchResult
Directives AnalysisDirectives
Notes []string
ProcessorGPUChassisLookupModes []string
KnownStorageDriveCollections []string
KnownStorageVolumeCollections []string
}
type Profile interface {
@@ -145,6 +147,7 @@ type ProfileScore struct {
func normalizeSignals(signals MatchSignals) MatchSignals {
signals.OEMNamespaces = dedupeSorted(signals.OEMNamespaces)
signals.ResourceHints = dedupeSorted(signals.ResourceHints)
signals.DocHints = dedupeSorted(signals.DocHints)
return signals
}

View File

@@ -15,7 +15,9 @@ type Request struct {
Password string
Token string
TLSMode string
PowerOnIfHostOff bool
PowerOnIfHostOff bool
StopHostAfterCollect bool
DebugPayloads bool
}
type Progress struct {

View File

@@ -669,7 +669,17 @@ func convertMemoryFromDevices(devices []models.HardwareDevice, collectedAt strin
}
present := boolFromPresentPtr(d.Present, true)
status := normalizeStatus(d.Status, true)
if !present || d.SizeMB == 0 || status == "Empty" || strings.TrimSpace(d.SerialNumber) == "" {
mem := models.MemoryDIMM{
Present: present,
SizeMB: d.SizeMB,
Type: d.Type,
Description: stringFromDetailMap(d.Details, "description"),
Manufacturer: d.Manufacturer,
SerialNumber: d.SerialNumber,
PartNumber: d.PartNumber,
Status: d.Status,
}
if !mem.IsInstalledInventory() || status == "Empty" || strings.TrimSpace(d.SerialNumber) == "" {
continue
}
meta := buildStatusMeta(status, d.StatusCheckedAt, d.StatusChangedAt, d.StatusHistory, d.ErrorDescription, collectedAt)
@@ -1334,7 +1344,7 @@ func convertMemory(memory []models.MemoryDIMM, collectedAt string) []ReanimatorM
result := make([]ReanimatorMemory, 0, len(memory))
for _, mem := range memory {
if !mem.Present || mem.SizeMB == 0 || normalizeStatus(mem.Status, true) == "Empty" || strings.TrimSpace(mem.SerialNumber) == "" {
if !mem.IsInstalledInventory() || normalizeStatus(mem.Status, true) == "Empty" || strings.TrimSpace(mem.SerialNumber) == "" {
continue
}
status := normalizeStatus(mem.Status, true)

View File

@@ -259,6 +259,29 @@ func TestConvertMemory(t *testing.T) {
}
}
func TestConvertMemory_KeepsInstalledDIMMWithUnknownSize(t *testing.T) {
memory := []models.MemoryDIMM{
{
Slot: "PROC 1 DIMM 3",
Present: true,
SizeMB: 0,
Manufacturer: "Hynix",
PartNumber: "HMCG88AEBRA115N",
SerialNumber: "2B5F92C6",
Status: "OK",
},
}
result := convertMemory(memory, "2026-03-30T10:00:00Z")
if len(result) != 1 {
t.Fatalf("expected 1 inventory-only DIMM, got %d", len(result))
}
if result[0].PartNumber != "HMCG88AEBRA115N" || result[0].SerialNumber != "2B5F92C6" || result[0].SizeMB != 0 {
t.Fatalf("unexpected converted memory: %+v", result[0])
}
}
func TestConvertToReanimator_CPUSerialIsNotSynthesizedAndSocketIsDeduped(t *testing.T) {
input := &models.AnalysisResult{
Filename: "cpu-dedupe.json",

29
internal/models/memory.go Normal file
View File

@@ -0,0 +1,29 @@
package models
import "strings"
// HasInventoryIdentity reports whether the DIMM has enough identifying
// inventory data to treat it as a populated module even when size is unknown.
func (m MemoryDIMM) HasInventoryIdentity() bool {
return strings.TrimSpace(m.SerialNumber) != "" ||
strings.TrimSpace(m.PartNumber) != "" ||
strings.TrimSpace(m.Type) != "" ||
strings.TrimSpace(m.Technology) != "" ||
strings.TrimSpace(m.Description) != ""
}
// IsInstalledInventory reports whether the DIMM represents an installed module
// that should be kept in canonical inventory and exports.
func (m MemoryDIMM) IsInstalledInventory() bool {
if !m.Present {
return false
}
status := strings.ToLower(strings.TrimSpace(m.Status))
switch status {
case "empty", "absent", "not installed":
return false
}
return m.SizeMB > 0 || m.HasInventoryIdentity()
}

View File

@@ -19,6 +19,7 @@ const maxZipArchiveSize = 50 * 1024 * 1024
const maxGzipDecompressedSize = 50 * 1024 * 1024
var supportedArchiveExt = map[string]struct{}{
".ahs": {},
".gz": {},
".tgz": {},
".tar": {},
@@ -45,6 +46,8 @@ func ExtractArchive(archivePath string) ([]ExtractedFile, error) {
ext := strings.ToLower(filepath.Ext(archivePath))
switch ext {
case ".ahs":
return extractSingleFile(archivePath)
case ".gz", ".tgz":
return extractTarGz(archivePath)
case ".tar", ".sds":
@@ -66,6 +69,8 @@ func ExtractArchiveFromReader(r io.Reader, filename string) ([]ExtractedFile, er
ext := strings.ToLower(filepath.Ext(filename))
switch ext {
case ".ahs":
return extractSingleFileFromReader(r, filename)
case ".gz", ".tgz":
return extractTarGzFromReader(r, filename)
case ".tar", ".sds":

View File

@@ -76,6 +76,7 @@ func TestIsSupportedArchiveFilename(t *testing.T) {
name string
want bool
}{
{name: "HPE_CZ2D1X0GS3_20260330.ahs", want: true},
{name: "dump.tar.gz", want: true},
{name: "nvidia-bug-report-1651124000923.log.gz", want: true},
{name: "snapshot.zip", want: true},
@@ -124,3 +125,20 @@ func TestExtractArchiveFromReaderSDS(t *testing.T) {
t.Fatalf("expected bmc/pack.info, got %q", files[0].Path)
}
}
func TestExtractArchiveFromReaderAHS(t *testing.T) {
payload := []byte("ABJRtest")
files, err := ExtractArchiveFromReader(bytes.NewReader(payload), "sample.ahs")
if err != nil {
t.Fatalf("extract ahs from reader: %v", err)
}
if len(files) != 1 {
t.Fatalf("expected 1 extracted file, got %d", len(files))
}
if files[0].Path != "sample.ahs" {
t.Fatalf("expected sample.ahs, got %q", files[0].Path)
}
if string(files[0].Content) != string(payload) {
t.Fatalf("content mismatch")
}
}

View File

@@ -0,0 +1,601 @@
package easy_bee
import (
"encoding/json"
"fmt"
"strings"
"time"
"git.mchus.pro/mchus/logpile/internal/models"
"git.mchus.pro/mchus/logpile/internal/parser"
)
const parserVersion = "1.0"
func init() {
parser.Register(&Parser{})
}
// Parser imports support bundles produced by reanimator-easy-bee.
// These archives embed a ready-to-use hardware snapshot in export/bee-audit.json.
type Parser struct{}
func (p *Parser) Name() string {
return "Reanimator Easy Bee Parser"
}
func (p *Parser) Vendor() string {
return "easy_bee"
}
func (p *Parser) Version() string {
return parserVersion
}
func (p *Parser) Detect(files []parser.ExtractedFile) int {
confidence := 0
hasManifest := false
hasBeeAudit := false
hasRuntimeHealth := false
hasTechdump := false
hasBundlePrefix := false
for _, f := range files {
path := strings.ToLower(strings.TrimSpace(f.Path))
content := strings.ToLower(string(f.Content))
if !hasBundlePrefix && strings.Contains(path, "bee-support-") {
hasBundlePrefix = true
confidence += 5
}
if (strings.HasSuffix(path, "/manifest.txt") || path == "manifest.txt") &&
strings.Contains(content, "bee_version=") {
hasManifest = true
confidence += 35
if strings.Contains(content, "export_dir=") {
confidence += 10
}
}
if strings.HasSuffix(path, "/export/bee-audit.json") || path == "bee-audit.json" {
hasBeeAudit = true
confidence += 55
}
if hasBundlePrefix && (strings.HasSuffix(path, "/export/runtime-health.json") || path == "runtime-health.json") {
hasRuntimeHealth = true
confidence += 10
}
if hasBundlePrefix && !hasTechdump && strings.Contains(path, "/export/techdump/") {
hasTechdump = true
confidence += 10
}
}
if hasManifest && hasBeeAudit {
return 100
}
if hasBeeAudit && (hasRuntimeHealth || hasTechdump) {
confidence += 10
}
if confidence > 100 {
return 100
}
return confidence
}
func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, error) {
snapshotFile := findSnapshotFile(files)
if snapshotFile == nil {
return nil, fmt.Errorf("easy-bee snapshot not found")
}
var snapshot beeSnapshot
if err := json.Unmarshal(snapshotFile.Content, &snapshot); err != nil {
return nil, fmt.Errorf("decode %s: %w", snapshotFile.Path, err)
}
manifest := parseManifest(files)
result := &models.AnalysisResult{
SourceType: strings.TrimSpace(snapshot.SourceType),
Protocol: strings.TrimSpace(snapshot.Protocol),
TargetHost: firstNonEmpty(snapshot.TargetHost, manifest.Host),
SourceTimezone: strings.TrimSpace(snapshot.SourceTimezone),
CollectedAt: chooseCollectedAt(snapshot, manifest),
InventoryLastModifiedAt: snapshot.InventoryLastModifiedAt,
RawPayloads: snapshot.RawPayloads,
Events: make([]models.Event, 0),
FRU: append([]models.FRUInfo(nil), snapshot.FRU...),
Sensors: make([]models.SensorReading, 0),
Hardware: &models.HardwareConfig{
Firmware: append([]models.FirmwareInfo(nil), snapshot.Hardware.Firmware...),
BoardInfo: snapshot.Hardware.Board,
Devices: append([]models.HardwareDevice(nil), snapshot.Hardware.Devices...),
CPUs: append([]models.CPU(nil), snapshot.Hardware.CPUs...),
Memory: append([]models.MemoryDIMM(nil), snapshot.Hardware.Memory...),
Storage: append([]models.Storage(nil), snapshot.Hardware.Storage...),
Volumes: append([]models.StorageVolume(nil), snapshot.Hardware.Volumes...),
PCIeDevices: normalizePCIeDevices(snapshot.Hardware.PCIeDevices),
GPUs: append([]models.GPU(nil), snapshot.Hardware.GPUs...),
NetworkCards: append([]models.NIC(nil), snapshot.Hardware.NetworkCards...),
NetworkAdapters: normalizeNetworkAdapters(snapshot.Hardware.NetworkAdapters),
PowerSupply: append([]models.PSU(nil), snapshot.Hardware.PowerSupply...),
},
}
result.Events = append(result.Events, snapshot.Events...)
result.Events = append(result.Events, convertRuntimeToEvents(snapshot.Runtime, result.CollectedAt)...)
result.Events = append(result.Events, convertEventLogs(snapshot.Hardware.EventLogs)...)
result.Sensors = append(result.Sensors, snapshot.Sensors...)
result.Sensors = append(result.Sensors, flattenSensorGroups(snapshot.Hardware.Sensors)...)
if len(result.FRU) == 0 {
if boardFRU, ok := buildBoardFRU(snapshot.Hardware.Board); ok {
result.FRU = append(result.FRU, boardFRU)
}
}
if result.Hardware == nil || (result.Hardware.BoardInfo.SerialNumber == "" &&
len(result.Hardware.CPUs) == 0 &&
len(result.Hardware.Memory) == 0 &&
len(result.Hardware.Storage) == 0 &&
len(result.Hardware.PCIeDevices) == 0 &&
len(result.Hardware.Devices) == 0) {
return nil, fmt.Errorf("unsupported easy-bee snapshot format")
}
return result, nil
}
type beeSnapshot struct {
SourceType string `json:"source_type,omitempty"`
Protocol string `json:"protocol,omitempty"`
TargetHost string `json:"target_host,omitempty"`
SourceTimezone string `json:"source_timezone,omitempty"`
CollectedAt time.Time `json:"collected_at,omitempty"`
InventoryLastModifiedAt time.Time `json:"inventory_last_modified_at,omitempty"`
RawPayloads map[string]any `json:"raw_payloads,omitempty"`
Events []models.Event `json:"events,omitempty"`
FRU []models.FRUInfo `json:"fru,omitempty"`
Sensors []models.SensorReading `json:"sensors,omitempty"`
Hardware beeHardware `json:"hardware"`
Runtime beeRuntime `json:"runtime,omitempty"`
}
type beeHardware struct {
Board models.BoardInfo `json:"board"`
Firmware []models.FirmwareInfo `json:"firmware,omitempty"`
Devices []models.HardwareDevice `json:"devices,omitempty"`
CPUs []models.CPU `json:"cpus,omitempty"`
Memory []models.MemoryDIMM `json:"memory,omitempty"`
Storage []models.Storage `json:"storage,omitempty"`
Volumes []models.StorageVolume `json:"volumes,omitempty"`
PCIeDevices []models.PCIeDevice `json:"pcie_devices,omitempty"`
GPUs []models.GPU `json:"gpus,omitempty"`
NetworkCards []models.NIC `json:"network_cards,omitempty"`
NetworkAdapters []models.NetworkAdapter `json:"network_adapters,omitempty"`
PowerSupply []models.PSU `json:"power_supplies,omitempty"`
Sensors beeSensorGroups `json:"sensors,omitempty"`
EventLogs []beeEventLog `json:"event_logs,omitempty"`
}
type beeSensorGroups struct {
Fans []beeFanSensor `json:"fans,omitempty"`
Power []beePowerSensor `json:"power,omitempty"`
Temperatures []beeTemperatureSensor `json:"temperatures,omitempty"`
Other []beeOtherSensor `json:"other,omitempty"`
}
type beeFanSensor struct {
Name string `json:"name"`
Location string `json:"location,omitempty"`
RPM int `json:"rpm,omitempty"`
Status string `json:"status,omitempty"`
}
type beePowerSensor struct {
Name string `json:"name"`
Location string `json:"location,omitempty"`
VoltageV float64 `json:"voltage_v,omitempty"`
CurrentA float64 `json:"current_a,omitempty"`
PowerW float64 `json:"power_w,omitempty"`
Status string `json:"status,omitempty"`
}
type beeTemperatureSensor struct {
Name string `json:"name"`
Location string `json:"location,omitempty"`
Celsius float64 `json:"celsius,omitempty"`
ThresholdWarningCelsius float64 `json:"threshold_warning_celsius,omitempty"`
ThresholdCriticalCelsius float64 `json:"threshold_critical_celsius,omitempty"`
Status string `json:"status,omitempty"`
}
type beeOtherSensor struct {
Name string `json:"name"`
Location string `json:"location,omitempty"`
Value float64 `json:"value,omitempty"`
Unit string `json:"unit,omitempty"`
Status string `json:"status,omitempty"`
}
type beeRuntime struct {
Status string `json:"status,omitempty"`
CheckedAt time.Time `json:"checked_at,omitempty"`
NetworkStatus string `json:"network_status,omitempty"`
Issues []beeRuntimeIssue `json:"issues,omitempty"`
Services []beeRuntimeStatus `json:"services,omitempty"`
Interfaces []beeInterface `json:"interfaces,omitempty"`
}
type beeRuntimeIssue struct {
Code string `json:"code,omitempty"`
Severity string `json:"severity,omitempty"`
Description string `json:"description,omitempty"`
}
type beeRuntimeStatus struct {
Name string `json:"name,omitempty"`
Status string `json:"status,omitempty"`
}
type beeInterface struct {
Name string `json:"name,omitempty"`
State string `json:"state,omitempty"`
IPv4 []string `json:"ipv4,omitempty"`
Outcome string `json:"outcome,omitempty"`
}
type beeEventLog struct {
Source string `json:"source,omitempty"`
EventTime string `json:"event_time,omitempty"`
Severity string `json:"severity,omitempty"`
MessageID string `json:"message_id,omitempty"`
Message string `json:"message,omitempty"`
RawPayload map[string]any `json:"raw_payload,omitempty"`
}
type manifestMetadata struct {
Host string
GeneratedAtUTC time.Time
}
func findSnapshotFile(files []parser.ExtractedFile) *parser.ExtractedFile {
for i := range files {
path := strings.ToLower(strings.TrimSpace(files[i].Path))
if strings.HasSuffix(path, "/export/bee-audit.json") || path == "bee-audit.json" {
return &files[i]
}
}
for i := range files {
path := strings.ToLower(strings.TrimSpace(files[i].Path))
if strings.HasSuffix(path, ".json") && strings.Contains(path, "reanimator") {
return &files[i]
}
}
return nil
}
func parseManifest(files []parser.ExtractedFile) manifestMetadata {
var meta manifestMetadata
for _, f := range files {
path := strings.ToLower(strings.TrimSpace(f.Path))
if !(strings.HasSuffix(path, "/manifest.txt") || path == "manifest.txt") {
continue
}
lines := strings.Split(string(f.Content), "\n")
for _, line := range lines {
key, value, ok := strings.Cut(strings.TrimSpace(line), "=")
if !ok {
continue
}
switch strings.TrimSpace(key) {
case "host":
meta.Host = strings.TrimSpace(value)
case "generated_at_utc":
if ts, err := time.Parse(time.RFC3339, strings.TrimSpace(value)); err == nil {
meta.GeneratedAtUTC = ts.UTC()
}
}
}
break
}
return meta
}
func chooseCollectedAt(snapshot beeSnapshot, manifest manifestMetadata) time.Time {
switch {
case !snapshot.CollectedAt.IsZero():
return snapshot.CollectedAt.UTC()
case !snapshot.Runtime.CheckedAt.IsZero():
return snapshot.Runtime.CheckedAt.UTC()
case !manifest.GeneratedAtUTC.IsZero():
return manifest.GeneratedAtUTC.UTC()
default:
return time.Time{}
}
}
func convertRuntimeToEvents(runtime beeRuntime, fallback time.Time) []models.Event {
events := make([]models.Event, 0)
ts := runtime.CheckedAt
if ts.IsZero() {
ts = fallback
}
if status := strings.TrimSpace(runtime.Status); status != "" {
desc := "Bee runtime status: " + status
if networkStatus := strings.TrimSpace(runtime.NetworkStatus); networkStatus != "" {
desc += " (network: " + networkStatus + ")"
}
events = append(events, models.Event{
Timestamp: ts,
Source: "Bee Runtime",
EventType: "Runtime Status",
Severity: mapSeverity(status),
Description: desc,
})
}
for _, issue := range runtime.Issues {
desc := strings.TrimSpace(issue.Description)
if desc == "" {
desc = "Bee runtime issue"
}
events = append(events, models.Event{
Timestamp: ts,
Source: "Bee Runtime",
EventType: "Runtime Issue",
Severity: mapSeverity(issue.Severity),
Description: desc,
RawData: strings.TrimSpace(issue.Code),
})
}
for _, svc := range runtime.Services {
status := strings.TrimSpace(svc.Status)
if status == "" || strings.EqualFold(status, "active") {
continue
}
events = append(events, models.Event{
Timestamp: ts,
Source: "systemd",
EventType: "Service Status",
Severity: mapSeverity(status),
Description: fmt.Sprintf("%s is %s", strings.TrimSpace(svc.Name), status),
})
}
for _, iface := range runtime.Interfaces {
state := strings.TrimSpace(iface.State)
outcome := strings.TrimSpace(iface.Outcome)
if state == "" && outcome == "" {
continue
}
if strings.EqualFold(state, "up") && strings.EqualFold(outcome, "lease_acquired") {
continue
}
desc := fmt.Sprintf("interface %s state=%s outcome=%s", strings.TrimSpace(iface.Name), state, outcome)
events = append(events, models.Event{
Timestamp: ts,
Source: "network",
EventType: "Interface Status",
Severity: models.SeverityWarning,
Description: strings.TrimSpace(desc),
})
}
return events
}
func convertEventLogs(items []beeEventLog) []models.Event {
events := make([]models.Event, 0, len(items))
for _, item := range items {
message := strings.TrimSpace(item.Message)
if message == "" {
continue
}
ts := parseEventTime(item.EventTime)
rawData := strings.TrimSpace(item.MessageID)
events = append(events, models.Event{
Timestamp: ts,
Source: firstNonEmpty(strings.TrimSpace(item.Source), "Reanimator"),
EventType: "Event Log",
Severity: mapSeverity(item.Severity),
Description: message,
RawData: rawData,
})
}
return events
}
func parseEventTime(raw string) time.Time {
raw = strings.TrimSpace(raw)
if raw == "" {
return time.Time{}
}
layouts := []string{time.RFC3339Nano, time.RFC3339}
for _, layout := range layouts {
if ts, err := time.Parse(layout, raw); err == nil {
return ts.UTC()
}
}
return time.Time{}
}
func flattenSensorGroups(groups beeSensorGroups) []models.SensorReading {
result := make([]models.SensorReading, 0, len(groups.Fans)+len(groups.Power)+len(groups.Temperatures)+len(groups.Other))
for _, fan := range groups.Fans {
result = append(result, models.SensorReading{
Name: sensorName(fan.Name, fan.Location),
Type: "fan",
Value: float64(fan.RPM),
Unit: "RPM",
Status: strings.TrimSpace(fan.Status),
})
}
for _, power := range groups.Power {
name := sensorName(power.Name, power.Location)
status := strings.TrimSpace(power.Status)
if power.PowerW != 0 {
result = append(result, models.SensorReading{
Name: name,
Type: "power",
Value: power.PowerW,
Unit: "W",
Status: status,
})
}
if power.VoltageV != 0 {
result = append(result, models.SensorReading{
Name: name + " Voltage",
Type: "voltage",
Value: power.VoltageV,
Unit: "V",
Status: status,
})
}
if power.CurrentA != 0 {
result = append(result, models.SensorReading{
Name: name + " Current",
Type: "current",
Value: power.CurrentA,
Unit: "A",
Status: status,
})
}
}
for _, temp := range groups.Temperatures {
result = append(result, models.SensorReading{
Name: sensorName(temp.Name, temp.Location),
Type: "temperature",
Value: temp.Celsius,
Unit: "C",
Status: strings.TrimSpace(temp.Status),
})
}
for _, other := range groups.Other {
result = append(result, models.SensorReading{
Name: sensorName(other.Name, other.Location),
Type: "other",
Value: other.Value,
Unit: strings.TrimSpace(other.Unit),
Status: strings.TrimSpace(other.Status),
})
}
return result
}
func sensorName(name, location string) string {
name = strings.TrimSpace(name)
location = strings.TrimSpace(location)
if name == "" {
return location
}
if location == "" {
return name
}
return name + " [" + location + "]"
}
func normalizePCIeDevices(items []models.PCIeDevice) []models.PCIeDevice {
out := append([]models.PCIeDevice(nil), items...)
for i := range out {
slot := strings.TrimSpace(out[i].Slot)
if out[i].BDF == "" && looksLikeBDF(slot) {
out[i].BDF = slot
}
if out[i].Slot == "" && out[i].BDF != "" {
out[i].Slot = out[i].BDF
}
}
return out
}
func normalizeNetworkAdapters(items []models.NetworkAdapter) []models.NetworkAdapter {
out := append([]models.NetworkAdapter(nil), items...)
for i := range out {
slot := strings.TrimSpace(out[i].Slot)
if out[i].BDF == "" && looksLikeBDF(slot) {
out[i].BDF = slot
}
if out[i].Slot == "" && out[i].BDF != "" {
out[i].Slot = out[i].BDF
}
}
return out
}
func looksLikeBDF(value string) bool {
value = strings.TrimSpace(value)
if len(value) != len("0000:00:00.0") {
return false
}
for i, r := range value {
switch i {
case 4, 7:
if r != ':' {
return false
}
case 10:
if r != '.' {
return false
}
default:
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) {
return false
}
}
}
return true
}
func buildBoardFRU(board models.BoardInfo) (models.FRUInfo, bool) {
if strings.TrimSpace(board.SerialNumber) == "" &&
strings.TrimSpace(board.Manufacturer) == "" &&
strings.TrimSpace(board.ProductName) == "" &&
strings.TrimSpace(board.PartNumber) == "" {
return models.FRUInfo{}, false
}
return models.FRUInfo{
Description: "System Board",
Manufacturer: strings.TrimSpace(board.Manufacturer),
ProductName: strings.TrimSpace(board.ProductName),
SerialNumber: strings.TrimSpace(board.SerialNumber),
PartNumber: strings.TrimSpace(board.PartNumber),
}, true
}
func mapSeverity(raw string) models.Severity {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "critical", "crit", "error", "failed", "failure":
return models.SeverityCritical
case "warning", "warn", "partial", "degraded", "inactive", "activating", "deactivating":
return models.SeverityWarning
default:
return models.SeverityInfo
}
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
value = strings.TrimSpace(value)
if value != "" {
return value
}
}
return ""
}

View File

@@ -0,0 +1,219 @@
package easy_bee
import (
"testing"
"time"
"git.mchus.pro/mchus/logpile/internal/parser"
)
func TestDetectBeeSupportArchive(t *testing.T) {
p := &Parser{}
files := []parser.ExtractedFile{
{
Path: "bee-support-debian-20260325-162030/manifest.txt",
Content: []byte("bee_version=1.0.0\nhost=debian\ngenerated_at_utc=2026-03-25T16:20:30Z\nexport_dir=/appdata/bee/export\n"),
},
{
Path: "bee-support-debian-20260325-162030/export/bee-audit.json",
Content: []byte(`{"hardware":{"board":{"serial_number":"SN-BEE-001"}}}`),
},
{
Path: "bee-support-debian-20260325-162030/export/runtime-health.json",
Content: []byte(`{"status":"PARTIAL"}`),
},
}
if got := p.Detect(files); got < 90 {
t.Fatalf("expected high confidence detect score, got %d", got)
}
}
func TestDetectRejectsNonBeeArchive(t *testing.T) {
p := &Parser{}
files := []parser.ExtractedFile{
{
Path: "random/manifest.txt",
Content: []byte("host=test\n"),
},
{
Path: "random/export/runtime-health.json",
Content: []byte(`{"status":"OK"}`),
},
}
if got := p.Detect(files); got != 0 {
t.Fatalf("expected detect score 0, got %d", got)
}
}
func TestParseBeeAuditSnapshot(t *testing.T) {
p := &Parser{}
files := []parser.ExtractedFile{
{
Path: "bee-support-debian-20260325-162030/manifest.txt",
Content: []byte("bee_version=1.0.0\nhost=debian\ngenerated_at_utc=2026-03-25T16:20:30Z\nexport_dir=/appdata/bee/export\n"),
},
{
Path: "bee-support-debian-20260325-162030/export/bee-audit.json",
Content: []byte(`{
"source_type": "manual",
"target_host": "debian",
"collected_at": "2026-03-25T16:08:09Z",
"runtime": {
"status": "PARTIAL",
"checked_at": "2026-03-25T16:07:56Z",
"network_status": "OK",
"issues": [
{
"code": "nvidia_kernel_module_missing",
"severity": "warning",
"description": "NVIDIA kernel module is not loaded."
}
],
"services": [
{
"name": "bee-web",
"status": "inactive"
}
]
},
"hardware": {
"board": {
"manufacturer": "Supermicro",
"product_name": "AS-4124GQ-TNMI",
"serial_number": "S490387X4418273",
"part_number": "H12DGQ-NT6",
"uuid": "d868ae00-a61f-11ee-8000-7cc255e10309"
},
"firmware": [
{
"device_name": "BIOS",
"version": "2.8"
}
],
"cpus": [
{
"status": "OK",
"status_checked_at": "2026-03-25T16:08:09Z",
"socket": 1,
"model": "AMD EPYC 7763 64-Core Processor",
"cores": 64,
"threads": 128,
"frequency_mhz": 2450,
"max_frequency_mhz": 3525
}
],
"memory": [
{
"status": "OK",
"status_checked_at": "2026-03-25T16:08:09Z",
"slot": "P1-DIMMA1",
"location": "P0_Node0_Channel0_Dimm0",
"present": true,
"size_mb": 32768,
"type": "DDR4",
"max_speed_mhz": 3200,
"current_speed_mhz": 2933,
"manufacturer": "SK Hynix",
"serial_number": "80AD01224887286666",
"part_number": "HMA84GR7DJR4N-XN"
}
],
"storage": [
{
"status": "Unknown",
"status_checked_at": "2026-03-25T16:08:09Z",
"slot": "nvme0n1",
"type": "NVMe",
"model": "KCD6XLUL960G",
"serial_number": "2470A00XT5M8",
"interface": "NVMe",
"present": true
}
],
"pcie_devices": [
{
"status": "OK",
"status_checked_at": "2026-03-25T16:08:09Z",
"slot": "0000:05:00.0",
"vendor_id": 5555,
"device_id": 4123,
"device_class": "EthernetController",
"manufacturer": "Mellanox Technologies",
"model": "MT28908 Family [ConnectX-6]",
"link_width": 16,
"link_speed": "Gen4",
"max_link_width": 16,
"max_link_speed": "Gen4",
"mac_addresses": ["94:6d:ae:9a:75:4a"],
"present": true
}
],
"sensors": {
"power": [
{
"name": "PPT",
"location": "amdgpu-pci-1100",
"power_w": 95
}
],
"temperatures": [
{
"name": "Composite",
"location": "nvme-pci-0600",
"celsius": 28.85,
"threshold_warning_celsius": 72.85,
"threshold_critical_celsius": 81.85,
"status": "OK"
}
]
}
}
}`),
},
}
result, err := p.Parse(files)
if err != nil {
t.Fatalf("parse failed: %v", err)
}
if result.Hardware == nil {
t.Fatal("expected hardware to be populated")
}
if result.TargetHost != "debian" {
t.Fatalf("expected target host debian, got %q", result.TargetHost)
}
wantCollectedAt := time.Date(2026, 3, 25, 16, 8, 9, 0, time.UTC)
if !result.CollectedAt.Equal(wantCollectedAt) {
t.Fatalf("expected collected_at %s, got %s", wantCollectedAt, result.CollectedAt)
}
if result.Hardware.BoardInfo.SerialNumber != "S490387X4418273" {
t.Fatalf("unexpected board serial %q", result.Hardware.BoardInfo.SerialNumber)
}
if len(result.Hardware.CPUs) != 1 {
t.Fatalf("expected 1 cpu, got %d", len(result.Hardware.CPUs))
}
if len(result.Hardware.Memory) != 1 {
t.Fatalf("expected 1 dimm, got %d", len(result.Hardware.Memory))
}
if len(result.Hardware.Storage) != 1 {
t.Fatalf("expected 1 storage device, got %d", len(result.Hardware.Storage))
}
if len(result.Hardware.PCIeDevices) != 1 {
t.Fatalf("expected 1 pcie device, got %d", len(result.Hardware.PCIeDevices))
}
if result.Hardware.PCIeDevices[0].BDF != "0000:05:00.0" {
t.Fatalf("expected BDF to be normalized from slot, got %q", result.Hardware.PCIeDevices[0].BDF)
}
if len(result.Sensors) != 2 {
t.Fatalf("expected 2 flattened sensors, got %d", len(result.Sensors))
}
if len(result.Events) < 3 {
t.Fatalf("expected runtime events to be created, got %d", len(result.Events))
}
if len(result.FRU) == 0 {
t.Fatal("expected board FRU fallback to be populated")
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,316 @@
package hpe_ilo_ahs
import (
"bytes"
"compress/gzip"
"encoding/binary"
"os"
"path/filepath"
"testing"
"git.mchus.pro/mchus/logpile/internal/parser"
)
func TestDetectAHS(t *testing.T) {
p := &Parser{}
score := p.Detect([]parser.ExtractedFile{{
Path: "HPE_CZ2D1X0GS3_20260330.ahs",
Content: makeAHSArchive(t, []ahsTestEntry{{Name: "CUST_INFO.DAT", Payload: []byte("x")}}),
}})
if score < 80 {
t.Fatalf("expected high confidence detect, got %d", score)
}
}
func TestParseAHSInventory(t *testing.T) {
p := &Parser{}
content := makeAHSArchive(t, []ahsTestEntry{
{Name: "CUST_INFO.DAT", Payload: make([]byte, 16)},
{Name: "0000088-2026-03-30.zbb", Payload: gzipBytes(t, []byte(sampleInventoryBlob()))},
{Name: "bcert.pkg", Payload: []byte(sampleBCertBlob())},
})
result, err := p.Parse([]parser.ExtractedFile{{
Path: "HPE_CZ2D1X0GS3_20260330.ahs",
Content: content,
}})
if err != nil {
t.Fatalf("parse failed: %v", err)
}
if result.Hardware == nil {
t.Fatalf("expected hardware section")
}
board := result.Hardware.BoardInfo
if board.Manufacturer != "HPE" {
t.Fatalf("unexpected board manufacturer: %q", board.Manufacturer)
}
if board.ProductName != "ProLiant DL380 Gen11" {
t.Fatalf("unexpected board product: %q", board.ProductName)
}
if board.SerialNumber != "CZ2D1X0GS3" {
t.Fatalf("unexpected board serial: %q", board.SerialNumber)
}
if board.PartNumber != "P52560-421" {
t.Fatalf("unexpected board part number: %q", board.PartNumber)
}
if len(result.Hardware.CPUs) != 1 || result.Hardware.CPUs[0].Model != "Intel(R) Xeon(R) Gold 6444Y" {
t.Fatalf("unexpected CPUs: %+v", result.Hardware.CPUs)
}
if len(result.Hardware.Memory) != 1 {
t.Fatalf("expected one DIMM, got %d", len(result.Hardware.Memory))
}
if result.Hardware.Memory[0].PartNumber != "HMCG88AEBRA115N" {
t.Fatalf("unexpected DIMM part number: %q", result.Hardware.Memory[0].PartNumber)
}
if len(result.Hardware.NetworkAdapters) != 2 {
t.Fatalf("expected two network adapters, got %d", len(result.Hardware.NetworkAdapters))
}
if len(result.Hardware.PowerSupply) != 1 {
t.Fatalf("expected one PSU, got %d", len(result.Hardware.PowerSupply))
}
if result.Hardware.PowerSupply[0].SerialNumber != "5XUWB0C4DJG4BV" {
t.Fatalf("unexpected PSU serial: %q", result.Hardware.PowerSupply[0].SerialNumber)
}
if result.Hardware.PowerSupply[0].Firmware != "2.00" {
t.Fatalf("unexpected PSU firmware: %q", result.Hardware.PowerSupply[0].Firmware)
}
if len(result.Hardware.Storage) != 1 {
t.Fatalf("expected one physical drive, got %d", len(result.Hardware.Storage))
}
drive := result.Hardware.Storage[0]
if drive.Model != "SAMSUNGMZ7L3480HCHQ-00A07" {
t.Fatalf("unexpected drive model: %q", drive.Model)
}
if drive.SerialNumber != "S664NC0Y502720" {
t.Fatalf("unexpected drive serial: %q", drive.SerialNumber)
}
if drive.SizeGB != 480 {
t.Fatalf("unexpected drive size: %d", drive.SizeGB)
}
if len(result.Hardware.Firmware) == 0 {
t.Fatalf("expected firmware inventory")
}
foundILO := false
foundControllerFW := false
foundNICFW := false
foundBackplaneFW := false
for _, item := range result.Hardware.Firmware {
if item.DeviceName == "iLO 6" && item.Version == "v1.63p20" {
foundILO = true
}
if item.DeviceName == "HPE MR408i-o Gen11" && item.Version == "52.26.3-5379" {
foundControllerFW = true
}
if item.DeviceName == "BCM 5719 1Gb 4p BASE-T OCP Adptr" && item.Version == "20.28.41" {
foundNICFW = true
}
if item.DeviceName == "8 SFF 24G x1NVMe/SAS UBM3 BC BP" && item.Version == "1.24" {
foundBackplaneFW = true
}
}
if !foundILO {
t.Fatalf("expected iLO firmware entry")
}
if !foundControllerFW {
t.Fatalf("expected controller firmware entry")
}
if !foundNICFW {
t.Fatalf("expected broadcom firmware entry")
}
if !foundBackplaneFW {
t.Fatalf("expected backplane firmware entry")
}
broadcomFound := false
backplaneFound := false
for _, nic := range result.Hardware.NetworkAdapters {
if nic.SerialNumber == "1CH0150001" && nic.Firmware == "20.28.41" {
broadcomFound = true
}
}
for _, dev := range result.Hardware.Devices {
if dev.DeviceClass == "storage_backplane" && dev.Firmware == "1.24" {
backplaneFound = true
}
}
if !broadcomFound {
t.Fatalf("expected broadcom adapter firmware to be enriched")
}
if !backplaneFound {
t.Fatalf("expected backplane canonical device")
}
if len(result.Hardware.Devices) < 6 {
t.Fatalf("expected canonical devices, got %d", len(result.Hardware.Devices))
}
if len(result.Events) == 0 {
t.Fatalf("expected parsed events")
}
}
func TestParseExampleAHS(t *testing.T) {
path := filepath.Join("..", "..", "..", "..", "example", "HPE_CZ2D1X0GS3_20260330.ahs")
content, err := os.ReadFile(path)
if err != nil {
t.Skipf("example fixture unavailable: %v", err)
}
p := &Parser{}
result, err := p.Parse([]parser.ExtractedFile{{
Path: filepath.Base(path),
Content: content,
}})
if err != nil {
t.Fatalf("parse example failed: %v", err)
}
if result.Hardware == nil {
t.Fatalf("expected hardware section")
}
board := result.Hardware.BoardInfo
if board.ProductName != "ProLiant DL380 Gen11" {
t.Fatalf("unexpected board product: %q", board.ProductName)
}
if board.SerialNumber != "CZ2D1X0GS3" {
t.Fatalf("unexpected board serial: %q", board.SerialNumber)
}
if len(result.Hardware.Storage) < 2 {
t.Fatalf("expected at least two drives, got %d", len(result.Hardware.Storage))
}
if len(result.Hardware.PowerSupply) != 2 {
t.Fatalf("expected exactly two PSUs, got %d: %+v", len(result.Hardware.PowerSupply), result.Hardware.PowerSupply)
}
foundController := false
foundBackplaneFW := false
foundNICFW := false
for _, device := range result.Hardware.Devices {
if device.Model == "HPE MR408i-o Gen11" && device.SerialNumber == "PXSFQ0BBIJY3B3" {
foundController = true
}
if device.DeviceClass == "storage_backplane" && device.Firmware == "1.24" {
foundBackplaneFW = true
}
}
if !foundController {
t.Fatalf("expected MR408i-o controller in canonical devices")
}
for _, fw := range result.Hardware.Firmware {
if fw.DeviceName == "BCM 5719 1Gb 4p BASE-T OCP Adptr" && fw.Version == "20.28.41" {
foundNICFW = true
}
}
if !foundBackplaneFW {
t.Fatalf("expected backplane device in canonical devices")
}
if !foundNICFW {
t.Fatalf("expected broadcom firmware from bcert/pkg lockdown")
}
}
type ahsTestEntry struct {
Name string
Payload []byte
Flag uint32
}
func makeAHSArchive(t *testing.T, entries []ahsTestEntry) []byte {
t.Helper()
var buf bytes.Buffer
for _, entry := range entries {
header := make([]byte, ahsHeaderSize)
copy(header[:4], []byte("ABJR"))
binary.LittleEndian.PutUint16(header[4:6], 0x0300)
binary.LittleEndian.PutUint16(header[6:8], 0x0002)
binary.LittleEndian.PutUint32(header[8:12], uint32(len(entry.Payload)))
flag := entry.Flag
if flag == 0 {
flag = 0x80000002
if len(entry.Payload) >= 2 && entry.Payload[0] == 0x1f && entry.Payload[1] == 0x8b {
flag = 0x80000001
}
}
binary.LittleEndian.PutUint32(header[16:20], flag)
copy(header[20:52], []byte(entry.Name))
buf.Write(header)
buf.Write(entry.Payload)
}
return buf.Bytes()
}
func gzipBytes(t *testing.T, payload []byte) []byte {
t.Helper()
var buf bytes.Buffer
zw := gzip.NewWriter(&buf)
if _, err := zw.Write(payload); err != nil {
t.Fatalf("gzip payload: %v", err)
}
if err := zw.Close(); err != nil {
t.Fatalf("close gzip writer: %v", err)
}
return buf.Bytes()
}
func sampleInventoryBlob() string {
return stringsJoin(
"iLO 6 v1.63p20 built on Sep 13 2024",
"HPE",
"ProLiant DL380 Gen11",
"CZ2D1X0GS3",
"P52560-421",
"Proc 1",
"Intel(R) Corporation",
"Intel(R) Xeon(R) Gold 6444Y",
"PROC 1 DIMM 3",
"Hynix",
"HMCG88AEBRA115N",
"2B5F92C6",
"Power Supply 1",
"5XUWB0C4DJG4BV",
"P03178-B21",
"PciRoot(0x1)/Pci(0x5,0x0)/Pci(0x0,0x0)",
"NIC.Slot.1.1",
"Network Controller",
"Slot 1",
"MCX512A-ACAT",
"MT2230478382",
"PciRoot(0x3)/Pci(0x1,0x0)/Pci(0x0,0x0)",
"OCP.Slot.15.1",
"Broadcom NetXtreme Gigabit Ethernet - NIC",
"OCP Slot 15",
"P51183-001",
"1CH0150001",
"20.28.41",
"System ROM",
"v2.22 (06/19/2024)",
"03/30/2026 09:47:33",
"iLO network link down.",
`{"@odata.id":"/redfish/v1/Systems/1/Storage/DE00A000/Controllers/0","@odata.type":"#StorageController.v1_7_0.StorageController","Id":"0","Name":"HPE MR408i-o Gen11","FirmwareVersion":"52.26.3-5379","Manufacturer":"HPE","Model":"HPE MR408i-o Gen11","PartNumber":"P58543-001","SKU":"P58335-B21","SerialNumber":"PXSFQ0BBIJY3B3","Status":{"State":"Enabled","Health":"OK"},"Location":{"PartLocation":{"ServiceLabel":"Slot=14","LocationType":"Slot","LocationOrdinalValue":14}},"PCIeInterface":{"PCIeType":"Gen4","LanesInUse":8}}`,
`{"@odata.id":"/redfish/v1/Fabrics/DE00A000","@odata.type":"#Fabric.v1_3_0.Fabric","Id":"DE00A000","Name":"8 SFF 24G x1NVMe/SAS UBM3 BC BP","FabricType":"MultiProtocol"}`,
`{"@odata.id":"/redfish/v1/Fabrics/DE00A000/Switches/1","@odata.type":"#Switch.v1_9_1.Switch","Id":"1","Name":"Direct Attached","Model":"UBM3","FirmwareVersion":"1.24","SupportedProtocols":["SAS","SATA","NVMe"],"SwitchType":"MultiProtocol","Status":{"State":"Enabled","Health":"OK"}}`,
`{"@odata.id":"/redfish/v1/Chassis/DE00A000/Drives/0","@odata.type":"#Drive.v1_17_0.Drive","Id":"0","Name":"480GB 6G SATA SSD","Status":{"State":"StandbyOffline","Health":"OK"},"PhysicalLocation":{"PartLocation":{"ServiceLabel":"Slot=14:Port=1:Box=3:Bay=1","LocationType":"Bay","LocationOrdinalValue":1}},"CapacityBytes":480103981056,"MediaType":"SSD","Model":"SAMSUNGMZ7L3480HCHQ-00A07","Protocol":"SATA","Revision":"JXTC604Q","SerialNumber":"S664NC0Y502720","PredictedMediaLifeLeftPercent":100}`,
`{"@odata.id":"/redfish/v1/Chassis/DE00A000/Drives/64515","@odata.type":"#Drive.v1_17_0.Drive","Id":"64515","Name":"Empty Bay","Status":{"State":"Absent","Health":"OK"}}`,
)
}
func sampleBCertBlob() string {
return `<BC><MfgRecord><PowerSupplySlot id="0"><Present>Yes</Present><SerialNumber>5XUWB0C4DJG4BV</SerialNumber><FirmwareVersion>2.00</FirmwareVersion><SparePartNumber>P44412-001</SparePartNumber></PowerSupplySlot><FirmwareLockdown><SystemProgrammableLogicDevice>0x12</SystemProgrammableLogicDevice><ServerPlatformServicesSPSFirmware>6.1.4.47</ServerPlatformServicesSPSFirmware><STMicroGen11TPM>1.512</STMicroGen11TPM><HPEMR408i-oGen11>52.26.3-5379</HPEMR408i-oGen11><UBM3>UBM3/1.24</UBM3><BCM57191Gb4pBASE-TOCP3>20.28.41</BCM57191Gb4pBASE-TOCP3></FirmwareLockdown></MfgRecord></BC>`
}
func stringsJoin(parts ...string) string {
return string(bytes.Join(func() [][]byte {
out := make([][]byte, 0, len(parts))
for _, part := range parts {
out = append(out, []byte(part))
}
return out
}(), []byte{0}))
}

View File

@@ -5,7 +5,9 @@ package vendors
import (
// Import vendor modules to trigger their init() registration
_ "git.mchus.pro/mchus/logpile/internal/parser/vendors/dell"
_ "git.mchus.pro/mchus/logpile/internal/parser/vendors/easy_bee"
_ "git.mchus.pro/mchus/logpile/internal/parser/vendors/h3c"
_ "git.mchus.pro/mchus/logpile/internal/parser/vendors/hpe_ilo_ahs"
_ "git.mchus.pro/mchus/logpile/internal/parser/vendors/inspur"
_ "git.mchus.pro/mchus/logpile/internal/parser/vendors/nvidia"
_ "git.mchus.pro/mchus/logpile/internal/parser/vendors/nvidia_bug_report"

View File

@@ -19,7 +19,9 @@ type CollectRequest struct {
Password string `json:"password,omitempty"`
Token string `json:"token,omitempty"`
TLSMode string `json:"tls_mode"`
PowerOnIfHostOff bool `json:"power_on_if_host_off,omitempty"`
PowerOnIfHostOff bool `json:"power_on_if_host_off,omitempty"`
StopHostAfterCollect bool `json:"stop_host_after_collect,omitempty"`
DebugPayloads bool `json:"debug_payloads,omitempty"`
}
type CollectProbeResponse struct {

View File

@@ -81,7 +81,7 @@ func BuildHardwareDevices(hw *models.HardwareConfig) []models.HardwareDevice {
}
for _, mem := range hw.Memory {
if !mem.Present || mem.SizeMB == 0 {
if !mem.IsInstalledInventory() {
continue
}
present := mem.Present

View File

@@ -90,6 +90,63 @@ func TestBuildHardwareDevices_MemorySameSerialDifferentSlots_NotDeduped(t *testi
}
}
func TestBuildHardwareDevices_ZeroSizeMemoryWithInventoryIsIncluded(t *testing.T) {
hw := &models.HardwareConfig{
Memory: []models.MemoryDIMM{
{
Slot: "PROC 1 DIMM 3",
Location: "PROC 1 DIMM 3",
Present: true,
SizeMB: 0,
Manufacturer: "Hynix",
SerialNumber: "2B5F92C6",
PartNumber: "HMCG88AEBRA115N",
Status: "ok",
},
},
}
devices := BuildHardwareDevices(hw)
memoryCount := 0
for _, d := range devices {
if d.Kind != models.DeviceKindMemory {
continue
}
memoryCount++
if d.Slot != "PROC 1 DIMM 3" || d.PartNumber != "HMCG88AEBRA115N" || d.SerialNumber != "2B5F92C6" {
t.Fatalf("unexpected memory device: %+v", d)
}
}
if memoryCount != 1 {
t.Fatalf("expected 1 installed zero-size memory record, got %d", memoryCount)
}
}
func TestBuildSpecification_ZeroSizeMemoryWithInventoryIsShown(t *testing.T) {
hw := &models.HardwareConfig{
Memory: []models.MemoryDIMM{
{
Slot: "PROC 1 DIMM 3",
Present: true,
SizeMB: 0,
Manufacturer: "Hynix",
PartNumber: "HMCG88AEBRA115N",
SerialNumber: "2B5F92C6",
Status: "ok",
},
},
}
spec := buildSpecification(hw)
for _, line := range spec {
if line.Category == "Память" && line.Name == "Hynix HMCG88AEBRA115N (size unknown)" && line.Quantity == 1 {
return
}
}
t.Fatalf("expected memory spec line for zero-size identified DIMM, got %+v", spec)
}
func TestBuildHardwareDevices_DuplicateSerials_AreAnnotated(t *testing.T) {
hw := &models.HardwareConfig{
Memory: []models.MemoryDIMM{

View File

@@ -10,6 +10,7 @@ import (
"fmt"
"html/template"
"io"
"net"
"net/http"
"os"
"path/filepath"
@@ -17,6 +18,7 @@ import (
"sort"
"strconv"
"strings"
"sync/atomic"
"time"
"git.mchus.pro/mchus/logpile/internal/collector"
@@ -528,11 +530,21 @@ func buildSpecification(hw *models.HardwareConfig) []SpecLine {
continue
}
present := mem.Present != nil && *mem.Present
// Skip empty slots (not present or 0 size)
if !present || mem.SizeMB == 0 {
if !present {
continue
}
// Include frequency if available
if mem.SizeMB == 0 {
name := strings.TrimSpace(strings.Join(nonEmptyStrings(mem.Manufacturer, mem.PartNumber, mem.Type), " "))
if name == "" {
name = "Installed DIMM (size unknown)"
} else {
name += " (size unknown)"
}
memGroups[name]++
continue
}
key := ""
currentSpeed := intFromDetails(mem.Details, "current_speed_mhz")
if currentSpeed > 0 {
@@ -624,6 +636,18 @@ func buildSpecification(hw *models.HardwareConfig) []SpecLine {
return spec
}
func nonEmptyStrings(values ...string) []string {
out := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
out = append(out, value)
}
return out
}
func (s *Server) handleGetSerials(w http.ResponseWriter, r *http.Request) {
result := s.GetResult()
if result == nil {
@@ -715,6 +739,19 @@ func hasUsableSerial(serial string) bool {
}
}
func hasUsableFirmwareVersion(version string) bool {
v := strings.TrimSpace(version)
if v == "" {
return false
}
switch strings.ToUpper(v) {
case "N/A", "NA", "NONE", "NULL", "UNKNOWN", "-":
return false
default:
return true
}
}
func (s *Server) handleGetFirmware(w http.ResponseWriter, r *http.Request) {
result := s.GetResult()
if result == nil || result.Hardware == nil {
@@ -942,7 +979,7 @@ func buildFirmwareEntries(hw *models.HardwareConfig) []firmwareEntry {
component = strings.TrimSpace(component)
model = strings.TrimSpace(model)
version = strings.TrimSpace(version)
if component == "" || version == "" {
if component == "" || !hasUsableFirmwareVersion(version) {
return
}
if model == "" {
@@ -1574,6 +1611,32 @@ func (s *Server) handleCollectStart(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(job.toJobResponse("Collection job accepted"))
}
// pingHost dials host:port up to total times with 2s timeout each, returns true if
// at least need attempts succeeded.
func pingHost(host string, port int, total, need int) (bool, string) {
addr := fmt.Sprintf("%s:%d", host, port)
var successes atomic.Int32
done := make(chan struct{}, total)
for i := 0; i < total; i++ {
go func() {
defer func() { done <- struct{}{} }()
conn, err := net.DialTimeout("tcp", addr, 2*time.Second)
if err == nil {
conn.Close()
successes.Add(1)
}
}()
}
for i := 0; i < total; i++ {
<-done
}
n := int(successes.Load())
if n < need {
return false, fmt.Sprintf("Хост недоступен: только %d из %d попыток подключения к %s прошли успешно (требуется минимум %d)", n, total, addr, need)
}
return true, ""
}
func (s *Server) handleCollectProbe(w http.ResponseWriter, r *http.Request) {
var req CollectRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -1595,6 +1658,11 @@ func (s *Server) handleCollectProbe(w http.ResponseWriter, r *http.Request) {
return
}
if ok, msg := pingHost(req.Host, req.Port, 10, 3); !ok {
jsonError(w, msg, http.StatusBadRequest)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
@@ -1959,15 +2027,17 @@ func applyCollectSourceMetadata(result *models.AnalysisResult, req CollectReques
func toCollectorRequest(req CollectRequest) collector.Request {
return collector.Request{
Host: req.Host,
Protocol: req.Protocol,
Port: req.Port,
Username: req.Username,
AuthType: req.AuthType,
Password: req.Password,
Token: req.Token,
TLSMode: req.TLSMode,
PowerOnIfHostOff: req.PowerOnIfHostOff,
Host: req.Host,
Protocol: req.Protocol,
Port: req.Port,
Username: req.Username,
AuthType: req.AuthType,
Password: req.Password,
Token: req.Token,
TLSMode: req.TLSMode,
PowerOnIfHostOff: req.PowerOnIfHostOff,
StopHostAfterCollect: req.StopHostAfterCollect,
DebugPayloads: req.DebugPayloads,
}
}

View File

@@ -62,3 +62,22 @@ func TestBuildFirmwareEntries_IncludesGPUFirmwareFallback(t *testing.T) {
t.Fatalf("expected GPU firmware entry from hardware.gpus fallback")
}
}
func TestBuildFirmwareEntries_SkipsPlaceholderVersions(t *testing.T) {
hw := &models.HardwareConfig{
Firmware: []models.FirmwareInfo{
{DeviceName: "BMC", Version: "3.13.42P13"},
{DeviceName: "Front_BP_1", Version: "NA"},
{DeviceName: "Rear_BP_0", Version: "N/A"},
{DeviceName: "HDD_BP", Version: "-"},
},
}
entries := buildFirmwareEntries(hw)
if len(entries) != 1 {
t.Fatalf("expected only usable firmware entries, got %#v", entries)
}
if entries[0].Component != "BMC" || entries[0].Version != "3.13.42P13" {
t.Fatalf("unexpected remaining firmware entry: %#v", entries[0])
}
}

View File

@@ -210,7 +210,6 @@ main {
pointer-events: none;
}
#api-check-btn,
#api-connect-btn,
#api-power-on-collect-btn,
#api-collect-off-btn,
@@ -229,7 +228,6 @@ main {
transition: background-color 0.2s ease, opacity 0.2s ease;
}
#api-check-btn:hover,
#api-connect-btn:hover,
#api-power-on-collect-btn:hover,
#api-collect-off-btn:hover,
@@ -242,7 +240,6 @@ main {
#convert-run-btn:disabled,
#convert-folder-btn:disabled,
#api-check-btn:disabled,
#api-connect-btn:disabled,
#api-power-on-collect-btn:disabled,
#api-collect-off-btn:disabled,
@@ -252,6 +249,127 @@ main {
cursor: not-allowed;
}
#api-collect-btn {
background: #1f8f4c;
color: #fff;
border: none;
border-radius: 6px;
padding: 0.6rem 1.25rem;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s ease, opacity 0.2s ease;
}
#api-collect-btn:hover {
background: #176e3a;
}
#api-collect-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.api-probe-options {
margin-top: 0.9rem;
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.api-form-checkbox {
display: flex;
align-items: center;
gap: 0.45rem;
font-size: 0.9rem;
cursor: pointer;
user-select: none;
}
.api-form-checkbox input[type="checkbox"] {
width: 1rem;
height: 1rem;
cursor: pointer;
}
.api-form-checkbox input[type="checkbox"]:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.api-form-checkbox span {
color: #444;
}
.api-form-checkbox-sub {
padding-left: 0.25rem;
opacity: 0.8;
}
.api-probe-options-separator {
margin: 0.5rem 0;
border-top: 1px solid #e2e8f0;
}
.api-confirm-modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.api-confirm-modal {
background: #fff;
border-radius: 10px;
padding: 1.5rem 1.75rem;
max-width: 380px;
width: 90%;
box-shadow: 0 8px 32px rgba(0,0,0,0.18);
}
.api-confirm-modal p {
margin-bottom: 1.1rem;
font-size: 0.95rem;
color: #333;
line-height: 1.5;
}
.api-confirm-modal-actions {
display: flex;
gap: 0.6rem;
justify-content: flex-end;
}
.api-confirm-modal-actions button {
border: none;
border-radius: 6px;
padding: 0.5rem 1rem;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
}
.api-confirm-modal-actions .btn-cancel {
background: #e2e8f0;
color: #333;
}
.api-confirm-modal-actions .btn-cancel:hover {
background: #cbd5e1;
}
.api-confirm-modal-actions .btn-confirm {
background: #dc3545;
color: #fff;
}
.api-confirm-modal-actions .btn-confirm:hover {
background: #b02a37;
}
.api-connect-status {
margin-top: 0.75rem;
font-size: 0.85rem;

View File

@@ -91,14 +91,18 @@ function initApiSource() {
}
const cancelJobButton = document.getElementById('cancel-job-btn');
const checkButton = document.getElementById('api-check-btn');
const collectOffButton = document.getElementById('api-collect-off-btn');
const powerOnCollectButton = document.getElementById('api-power-on-collect-btn');
const connectButton = document.getElementById('api-connect-btn');
const collectButton = document.getElementById('api-collect-btn');
const powerOffCheckbox = document.getElementById('api-power-off');
const fieldNames = ['host', 'port', 'username', 'password'];
apiForm.addEventListener('submit', (event) => {
event.preventDefault();
startCollectionFromCurrentProbe(false);
if (apiProbeResult && apiProbeResult.reachable) {
startCollectionWithOptions();
} else {
startApiProbe();
}
});
if (cancelJobButton) {
@@ -106,21 +110,29 @@ function initApiSource() {
cancelCollectionJob();
});
}
if (checkButton) {
checkButton.addEventListener('click', () => {
if (connectButton) {
connectButton.addEventListener('click', () => {
startApiProbe();
});
}
if (collectOffButton) {
collectOffButton.addEventListener('click', () => {
clearApiPowerDecisionTimer();
startCollectionFromCurrentProbe(false);
if (collectButton) {
collectButton.addEventListener('click', () => {
startCollectionWithOptions();
});
}
if (powerOnCollectButton) {
powerOnCollectButton.addEventListener('click', () => {
clearApiPowerDecisionTimer();
startCollectionFromCurrentProbe(true);
if (powerOffCheckbox) {
powerOffCheckbox.addEventListener('change', () => {
if (!powerOffCheckbox.checked) {
return;
}
// If host was already on when probed, warn before enabling shutdown
if (apiProbeResult && apiProbeResult.host_powered_on) {
showConfirmModal(
'Хост был включён до начала сбора. Вы уверены, что хотите выключить его после завершения сбора?',
() => { /* confirmed — leave checked */ },
() => { powerOffCheckbox.checked = false; }
);
}
});
}
@@ -151,11 +163,42 @@ function initApiSource() {
renderCollectionJob();
}
function showConfirmModal(message, onConfirm, onCancel) {
const backdrop = document.createElement('div');
backdrop.className = 'api-confirm-modal-backdrop';
backdrop.innerHTML = `
<div class="api-confirm-modal" role="dialog" aria-modal="true">
<p>${escapeHtml(message)}</p>
<div class="api-confirm-modal-actions">
<button class="btn-cancel">Отмена</button>
<button class="btn-confirm">Да, выключить</button>
</div>
</div>
`;
document.body.appendChild(backdrop);
const close = () => document.body.removeChild(backdrop);
backdrop.querySelector('.btn-cancel').addEventListener('click', () => {
close();
if (onCancel) onCancel();
});
backdrop.querySelector('.btn-confirm').addEventListener('click', () => {
close();
if (onConfirm) onConfirm();
});
backdrop.addEventListener('click', (e) => {
if (e.target === backdrop) {
close();
if (onCancel) onCancel();
}
});
}
function startApiProbe() {
const { isValid, payload, errors } = validateCollectForm();
renderFormErrors(errors);
if (!isValid) {
renderApiConnectStatus(false, null);
renderApiConnectStatus(false);
resetApiProbeState();
return;
}
@@ -163,7 +206,7 @@ function startApiProbe() {
apiConnectPayload = payload;
resetApiProbeState();
setApiFormBlocked(true);
renderApiConnectStatus(true, { ...payload, password: '***' });
renderApiConnectStatus(true);
fetch('/api/collect/probe', {
method: 'POST',
@@ -181,7 +224,7 @@ function startApiProbe() {
})
.catch((err) => {
resetApiProbeState();
renderApiConnectStatus(false, null);
renderApiConnectStatus(false);
const status = document.getElementById('api-connect-status');
if (status) {
status.textContent = err.message || 'Проверка подключения не удалась';
@@ -195,12 +238,11 @@ function startApiProbe() {
});
}
function startCollectionFromCurrentProbe(powerOnIfHostOff) {
function startCollectionWithOptions() {
const { isValid, payload, errors } = validateCollectForm();
renderFormErrors(errors);
if (!isValid) {
renderApiConnectStatus(false, null);
resetApiProbeState();
renderApiConnectStatus(false);
return;
}
@@ -213,71 +255,78 @@ function startCollectionFromCurrentProbe(powerOnIfHostOff) {
return;
}
clearApiPowerDecisionTimer();
payload.power_on_if_host_off = Boolean(powerOnIfHostOff);
const powerOnCheckbox = document.getElementById('api-power-on');
const powerOffCheckbox = document.getElementById('api-power-off');
const debugPayloads = document.getElementById('api-debug-payloads');
payload.power_on_if_host_off = powerOnCheckbox ? powerOnCheckbox.checked : false;
payload.stop_host_after_collect = powerOffCheckbox ? powerOffCheckbox.checked : false;
payload.debug_payloads = debugPayloads ? debugPayloads.checked : false;
startCollectionJob(payload);
}
function renderApiProbeState() {
const collectButton = document.getElementById('api-connect-btn');
const connectButton = document.getElementById('api-connect-btn');
const probeOptions = document.getElementById('api-probe-options');
const status = document.getElementById('api-connect-status');
const decision = document.getElementById('api-power-decision');
const decisionText = document.getElementById('api-power-decision-text');
if (!collectButton || !status || !decision || !decisionText) {
const powerOnCheckbox = document.getElementById('api-power-on');
const powerOffCheckbox = document.getElementById('api-power-off');
if (!connectButton || !probeOptions || !status) {
return;
}
decision.classList.add('hidden');
clearApiPowerDecisionTimer();
collectButton.disabled = !apiProbeResult || !apiProbeResult.reachable;
if (!apiProbeResult || !apiProbeResult.reachable) {
status.textContent = 'Проверка подключения не пройдена.';
status.className = 'api-connect-status error';
probeOptions.classList.add('hidden');
connectButton.textContent = 'Подключиться';
return;
}
if (apiProbeResult.host_powered_on) {
status.textContent = apiProbeResult.message || 'Связь с BMC есть, host включен.';
const hostOn = apiProbeResult.host_powered_on;
const powerControlAvailable = apiProbeResult.power_control_available;
if (hostOn) {
status.textContent = apiProbeResult.message || 'Связь с BMC есть, host включён.';
status.className = 'api-connect-status success';
collectButton.disabled = false;
return;
} else {
status.textContent = apiProbeResult.message || 'Связь с BMC есть, host выключен.';
status.className = 'api-connect-status warning';
}
status.textContent = apiProbeResult.message || 'Связь с BMC есть, host выключен.';
status.className = 'api-connect-status warning';
if (!apiProbeResult.power_control_available) {
collectButton.disabled = false;
return;
}
probeOptions.classList.remove('hidden');
decision.classList.remove('hidden');
let secondsLeft = 5;
const updateDecisionText = () => {
decisionText.textContent = `Если не выбрать действие, сбор начнется без включения через ${secondsLeft} сек.`;
};
updateDecisionText();
apiPowerDecisionTimer = window.setInterval(() => {
secondsLeft -= 1;
if (secondsLeft <= 0) {
clearApiPowerDecisionTimer();
startCollectionFromCurrentProbe(false);
return;
// "Включить" checkbox
if (powerOnCheckbox) {
if (hostOn) {
// Host already on — checkbox is checked and disabled
powerOnCheckbox.checked = true;
powerOnCheckbox.disabled = true;
} else {
// Host off — default: checked (will power on), enabled
powerOnCheckbox.checked = true;
powerOnCheckbox.disabled = !powerControlAvailable;
}
updateDecisionText();
}, 1000);
}
// "Выключить" checkbox — default: unchecked
if (powerOffCheckbox) {
powerOffCheckbox.checked = false;
powerOffCheckbox.disabled = !powerControlAvailable;
}
connectButton.textContent = 'Переподключиться';
}
function resetApiProbeState() {
apiProbeResult = null;
clearApiPowerDecisionTimer();
const collectButton = document.getElementById('api-connect-btn');
const decision = document.getElementById('api-power-decision');
if (collectButton) {
collectButton.disabled = true;
const connectButton = document.getElementById('api-connect-btn');
const probeOptions = document.getElementById('api-probe-options');
if (connectButton) {
connectButton.textContent = 'Подключиться';
}
if (decision) {
decision.classList.add('hidden');
if (probeOptions) {
probeOptions.classList.add('hidden');
}
}
@@ -368,7 +417,7 @@ function renderFormErrors(errors) {
summary.innerHTML = `<strong>Исправьте ошибки в форме:</strong><ul>${messages.map(msg => `<li>${escapeHtml(msg)}</li>`).join('')}</ul>`;
}
function renderApiConnectStatus(isValid, payload) {
function renderApiConnectStatus(isValid) {
const status = document.getElementById('api-connect-status');
if (!status) {
return;
@@ -380,16 +429,8 @@ function renderApiConnectStatus(isValid, payload) {
return;
}
const payloadPreview = { ...payload };
if (payloadPreview.password) {
payloadPreview.password = '***';
}
if (payloadPreview.token) {
payloadPreview.token = '***';
}
status.textContent = `Payload сформирован: ${JSON.stringify(payloadPreview)}`;
status.className = 'api-connect-status success';
status.textContent = 'Подключение...';
status.className = 'api-connect-status info';
}
function clearApiConnectStatus() {
@@ -440,7 +481,7 @@ function startCollectionJob(payload) {
.catch((err) => {
setApiFormBlocked(false);
clearApiConnectStatus();
renderApiConnectStatus(false, null);
renderApiConnectStatus(false);
const status = document.getElementById('api-connect-status');
if (status) {
status.textContent = err.message || 'Ошибка запуска задачи';
@@ -523,14 +564,61 @@ function appendJobLog(message) {
return;
}
const time = new Date().toLocaleTimeString('ru-RU', { hour12: false });
const parsed = parseServerLogLine(message);
if (isCollectLogNoise(parsed.message)) {
// Still count toward log length so syncServerLogs offset stays correct,
// but mark as hidden so renderCollectionJob skips it.
collectionJob.logs.push({
id: ++collectionJobLogCounter,
time: parsed.time || new Date().toLocaleTimeString('ru-RU', { hour12: false }),
message: parsed.message,
hidden: true
});
return;
}
collectionJob.logs.push({
id: ++collectionJobLogCounter,
time,
message
time: parsed.time || new Date().toLocaleTimeString('ru-RU', { hour12: false }),
message: humanizeCollectLogMessage(parsed.message)
});
}
// Transform technical log messages into human-readable form for the UI.
// The original messages are preserved in collect.log / raw_export.
function humanizeCollectLogMessage(msg) {
// "Redfish snapshot: документов=520, ETA≈16s, корни=Chassis(294), Systems(114), последний=/redfish/v1/..."
// → "Snapshot: /Chassis/Self/PCIeDevices/00_34_04"
let m = msg.match(/snapshot:\s+документов=\d+[^,]*,.*последний=(\S+)/i);
if (m) {
const path = m[1].replace(/^\.\.\./, '').replace(/^\/redfish\/v1/, '') || m[1];
return `Snapshot: ${path}`;
}
// "Redfish snapshot: собрано N документов"
m = msg.match(/snapshot:\s+собрано\s+(\d+)\s+документов/i);
if (m) {
return `Snapshot: итого ${m[1]} документов`;
}
// "Redfish: plan-B завершен за 30s (targets=18, recovered=0)"
m = msg.match(/plan-B завершен за ([^(]+)\(targets=(\d+),\s*recovered=(\d+)\)/i);
if (m) {
const recovered = parseInt(m[3], 10);
const suffix = recovered > 0 ? `, восстановлено ${m[3]}` : '';
return `Plan-B: завершен за ${m[1].trim()}${suffix}`;
}
// "Redfish: prefetch критичных endpoint (адаптивно 9/72)..."
m = msg.match(/prefetch критичных endpoint[^(]*\(([^)]+)\)/i);
if (m) {
return `Prefetch критичных endpoint (${m[1]})`;
}
// Strip "Redfish: " / "Redfish snapshot: " prefix — redundant in context
return msg.replace(/^Redfish(?:\s+snapshot)?:\s+/i, '');
}
function renderCollectionJob() {
const jobStatusBlock = document.getElementById('api-job-status');
const jobIdValue = document.getElementById('job-id-value');
@@ -576,9 +664,11 @@ function renderCollectionJob() {
renderJobActiveModules(activeModulesBlock, activeModulesList);
renderJobDebugInfo(debugInfoBlock, debugSummary, phaseTelemetryNode);
logsList.innerHTML = [...collectionJob.logs].reverse().map((log) => (
`<li><span class="log-time">${escapeHtml(log.time)}</span><span class="log-message">${escapeHtml(log.message)}</span></li>`
)).join('');
logsList.innerHTML = [...collectionJob.logs].reverse()
.filter((log) => !log.hidden)
.map((log) => (
`<li><span class="log-time">${escapeHtml(log.time)}</span><span class="log-message">${escapeHtml(log.message)}</span></li>`
)).join('');
cancelButton.disabled = isTerminal;
setApiFormBlocked(!isTerminal);
@@ -668,6 +758,38 @@ function syncServerLogs(logs) {
}
}
// Patterns for log lines that are internal debug noise and should not be shown in the UI.
const _collectLogNoisePatterns = [
/plan-B \(\d+\/\d+/, // individual plan-B step lines
/plan-B топ веток/,
/snapshot: heartbeat/,
/snapshot: post-probe коллекций \(/,
/snapshot: топ веток/,
/prefetch завершен/,
/cooldown перед повторным добором/,
/Redfish telemetry:/,
/redfish-postprobe-metrics:/,
/redfish-prefetch-metrics:/,
/redfish-collect:/,
/redfish-profile-plan:/,
/redfish replay:/,
];
function isCollectLogNoise(message) {
return _collectLogNoisePatterns.some((re) => re.test(message));
}
// Strip the server-side RFC3339Nano timestamp prefix from a log line and return {time, message}.
function parseServerLogLine(raw) {
const m = String(raw).match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z)\s+(.*)/s);
if (!m) {
return { time: null, message: String(raw).trim() };
}
const d = new Date(m[1]);
const time = isNaN(d) ? null : d.toLocaleTimeString('ru-RU', { hour12: false });
return { time, message: m[2].trim() };
}
function normalizeJobStatus(status) {
return String(status || '').trim().toLowerCase();
}

View File

@@ -36,9 +36,9 @@
<div id="archive-source-content">
<div class="upload-area" id="drop-zone">
<p>Перетащите архив, TXT/LOG или JSON snapshot сюда</p>
<input type="file" id="file-input" accept="application/gzip,application/x-gzip,application/x-tar,application/zip,application/json,text/plain,.json,.tar,.tar.gz,.tgz,.sds,.zip,.txt,.log" hidden>
<input type="file" id="file-input" accept="application/gzip,application/x-gzip,application/x-tar,application/zip,application/json,text/plain,.ahs,.json,.tar,.tar.gz,.tgz,.sds,.zip,.txt,.log" hidden>
<button type="button" onclick="document.getElementById('file-input').click()">Выберите файл</button>
<p class="hint">Поддерживаемые форматы: tar.gz, tar, tgz, sds, zip, json, txt, log</p>
<p class="hint">Поддерживаемые форматы: ahs, tar.gz, tar, tgz, sds, zip, json, txt, log</p>
</div>
<div id="upload-status"></div>
<div id="parsers-info" class="parsers-info"></div>
@@ -76,16 +76,25 @@
</div>
<div class="api-form-actions">
<button id="api-check-btn" type="button">Проверить</button>
<button id="api-connect-btn" type="submit" disabled>Собрать</button>
<button id="api-connect-btn" type="button">Подключиться</button>
</div>
<div id="api-connect-status" class="api-connect-status"></div>
<div id="api-power-decision" class="api-connect-status hidden">
<strong>Host выключен.</strong>
<p id="api-power-decision-text">Если не выбрать действие, сбор начнется без включения через 5 секунд.</p>
<div id="api-probe-options" class="api-probe-options hidden">
<label class="api-form-checkbox" for="api-power-on">
<input id="api-power-on" name="power_on_if_host_off" type="checkbox">
<span>Включить перед сбором</span>
</label>
<label class="api-form-checkbox" for="api-power-off">
<input id="api-power-off" name="stop_host_after_collect" type="checkbox">
<span>Выключить после сбора</span>
</label>
<div class="api-probe-options-separator"></div>
<label class="api-form-checkbox" for="api-debug-payloads">
<input id="api-debug-payloads" name="debug_payloads" type="checkbox">
<span>Сбор расширенных метрик для отладки</span>
</label>
<div class="api-form-actions">
<button id="api-power-on-collect-btn" type="button">Включить и собрать</button>
<button id="api-collect-off-btn" type="button">Собирать выключенный</button>
<button id="api-collect-btn" type="submit">Собрать</button>
</div>
</div>
</form>