Files
logpile/internal/collector/redfish.go
Michael Chus b04877549a feat(collector): add Lenovo XCC profile to skip noisy snapshot paths
Lenovo ThinkSystem SR650 V3 (and similar XCC-based servers) caused
collection runs of 23+ minutes because the BMC exposes two large high-
error-rate subtrees in the snapshot BFS:

  - Chassis/1/Sensors: 315 individual sensor members, 282/315 failing,
    ~3.7s per request → ~19 minutes wasted. These documents are never
    read by any LOGPile parser (thermal/power data comes from aggregate
    Chassis/*/Thermal and Chassis/*/Power endpoints).

  - Chassis/1/Oem/Lenovo: 75 requests (LEDs×47, Slots×26, etc.),
    68/75 failing → 8+ minutes wasted on non-inventory data.

Add a Lenovo profile (matched on SystemManufacturer/OEMNamespace "Lenovo")
that sets SnapshotExcludeContains to block individual sensor documents and
non-inventory Lenovo OEM subtrees from the snapshot BFS queue. Also sets
rate policy thresholds appropriate for XCC BMC latency (p95 often 3-5s).

Add SnapshotExcludeContains []string to AcquisitionTuning and check it
in the snapshot enqueue closure in redfish.go.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 19:29:04 +03:00

6765 lines
205 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package collector
import (
"context"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"path"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"git.mchus.pro/mchus/logpile/internal/collector/redfishprofile"
"git.mchus.pro/mchus/logpile/internal/models"
"git.mchus.pro/mchus/logpile/internal/parser/vendors/pciids"
)
type RedfishConnector struct {
timeout time.Duration
debug bool
debugSnapshot bool
}
type redfishPrefetchMetrics struct {
Enabled bool
Candidates int
Targets int
Docs int
Added int
Duration time.Duration
SkipReason string
}
type redfishPostProbeMetrics struct {
NVMECandidates int
NVMESelected int
NVMEAdded int
CollectionCandidates int
CollectionSelected int
SkippedExplicit int
Added int
Duration time.Duration
}
type redfishRequestTelemetry struct {
mu sync.Mutex
overall redfishPhaseTelemetry
byPhase map[string]*redfishPhaseTelemetry
}
type redfishTelemetrySummary struct {
Requests int
Errors int
ErrorRate float64
Avg time.Duration
P95 time.Duration
}
type redfishTelemetryContextKey struct{}
type redfishTelemetryPhaseContextKey struct{}
type redfishPhaseTelemetry struct {
requests int
errors int
durations []time.Duration
lastAvg time.Duration
lastP95 time.Duration
}
func NewRedfishConnector() *RedfishConnector {
debug := false
if v := strings.TrimSpace(os.Getenv("LOGPILE_REDFISH_DEBUG")); v != "" && v != "0" && !strings.EqualFold(v, "false") {
debug = true
}
debugSnapshot := false
if v := strings.TrimSpace(os.Getenv("LOGPILE_REDFISH_SNAPSHOT_DEBUG")); v != "" && v != "0" && !strings.EqualFold(v, "false") {
debugSnapshot = true
}
return &RedfishConnector{
timeout: 10 * time.Second,
debug: debug,
debugSnapshot: debugSnapshot || debug,
}
}
func (c *RedfishConnector) Protocol() string {
return "redfish"
}
func (c *RedfishConnector) Probe(ctx context.Context, req Request) (*ProbeResult, error) {
baseURL, err := c.baseURL(req)
if err != nil {
return nil, err
}
client := c.httpClientWithTimeout(req, c.timeout)
if _, err := c.getJSON(ctx, client, req, baseURL, "/redfish/v1"); err != nil {
return nil, fmt.Errorf("redfish service root: %w", err)
}
systemPaths := c.discoverMemberPaths(ctx, client, req, baseURL, "/redfish/v1/Systems", "/redfish/v1/Systems/1")
primarySystem := firstNonEmptyPath(systemPaths, "/redfish/v1/Systems/1")
systemDoc, err := c.getJSON(ctx, client, req, baseURL, primarySystem)
if err != nil {
return nil, fmt.Errorf("redfish system: %w", err)
}
powerState := redfishSystemPowerState(systemDoc)
return &ProbeResult{
Reachable: true,
Protocol: "redfish",
HostPowerState: powerState,
HostPoweredOn: isRedfishHostPoweredOn(powerState),
SystemPath: primarySystem,
}, nil
}
func (c *RedfishConnector) debugf(format string, args ...interface{}) {
if !c.debug {
return
}
log.Printf("redfish-debug: "+format, args...)
}
func (c *RedfishConnector) debugSnapshotf(format string, args ...interface{}) {
if !c.debugSnapshot {
return
}
log.Printf("redfish-snapshot-debug: "+format, args...)
}
func (c *RedfishConnector) Collect(ctx context.Context, req Request, emit ProgressFn) (*models.AnalysisResult, error) {
collectStart := time.Now()
telemetry := newRedfishRequestTelemetry()
ctx = context.WithValue(ctx, redfishTelemetryContextKey{}, telemetry)
baseURL, err := c.baseURL(req)
if err != nil {
return nil, err
}
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..."})
}
discoveryCtx := withRedfishTelemetryPhase(ctx, "discovery")
serviceRootDoc, err := c.getJSON(discoveryCtx, snapshotClient, req, baseURL, "/redfish/v1")
if err != nil {
return nil, fmt.Errorf("redfish service root: %w", err)
}
systemPaths := c.discoverMemberPaths(discoveryCtx, snapshotClient, req, baseURL, "/redfish/v1/Systems", "/redfish/v1/Systems/1")
primarySystem := firstNonEmptyPath(systemPaths, "/redfish/v1/Systems/1")
chassisPaths := c.discoverMemberPaths(discoveryCtx, snapshotClient, req, baseURL, "/redfish/v1/Chassis", "/redfish/v1/Chassis/1")
managerPaths := c.discoverMemberPaths(discoveryCtx, snapshotClient, req, baseURL, "/redfish/v1/Managers", "/redfish/v1/Managers/1")
primaryChassis := firstNonEmptyPath(chassisPaths, "/redfish/v1/Chassis/1")
primaryManager := firstNonEmptyPath(managerPaths, "/redfish/v1/Managers/1")
systemDoc, _ := c.getJSON(discoveryCtx, snapshotClient, req, baseURL, primarySystem)
chassisDoc, _ := c.getJSON(discoveryCtx, snapshotClient, req, baseURL, primaryChassis)
managerDoc, _ := c.getJSON(discoveryCtx, snapshotClient, req, baseURL, primaryManager)
resourceHints := append(append([]string{}, systemPaths...), append(chassisPaths, managerPaths...)...)
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()
phaseTelemetry := telemetry.PhaseSnapshots()
adaptedTuning, throttled := adaptRedfishAcquisitionTuning(acquisitionPlan.Tuning, telemetrySummary)
acquisitionPlan.Tuning = adaptedTuning
activeModules := make([]ModuleActivation, 0, len(matchResult.Scores))
moduleScores := make([]ModuleScore, 0, len(matchResult.Scores))
for _, score := range matchResult.Scores {
moduleScores = append(moduleScores, ModuleScore{
Name: score.Name,
Score: score.Score,
Active: score.Active,
Priority: score.Priority,
})
if score.Active {
activeModules = append(activeModules, ModuleActivation{Name: score.Name, Score: score.Score})
}
}
if emit != nil {
emit(Progress{
Status: "running",
Progress: 25,
Message: fmt.Sprintf("Redfish: профили mode=%s active=%s", acquisitionPlan.Mode, formatActiveModuleLog(activeModules)),
ActiveModules: activeModules,
ModuleScores: moduleScores,
DebugInfo: &CollectDebugInfo{
AdaptiveThrottled: throttled,
SnapshotWorkers: acquisitionPlan.Tuning.SnapshotWorkers,
PrefetchWorkers: acquisitionPlan.Tuning.PrefetchWorkers,
PrefetchEnabled: acquisitionPlan.Tuning.PrefetchEnabled,
PhaseTelemetry: buildCollectPhaseTelemetry(phaseTelemetry),
},
})
if throttled {
emit(Progress{
Status: "running",
Progress: 26,
Message: fmt.Sprintf("Redfish: adaptive throttling p95=%dms err=%.0f%% workers=%d prefetch=%d", telemetrySummary.P95.Milliseconds(), telemetrySummary.ErrorRate*100, acquisitionPlan.Tuning.SnapshotWorkers, acquisitionPlan.Tuning.PrefetchWorkers),
ActiveModules: activeModules,
ModuleScores: moduleScores,
DebugInfo: &CollectDebugInfo{
AdaptiveThrottled: throttled,
SnapshotWorkers: acquisitionPlan.Tuning.SnapshotWorkers,
PrefetchWorkers: acquisitionPlan.Tuning.PrefetchWorkers,
PrefetchEnabled: acquisitionPlan.Tuning.PrefetchEnabled,
PhaseTelemetry: buildCollectPhaseTelemetry(phaseTelemetry),
},
})
}
}
resolvedPlan := redfishprofile.ResolveAcquisitionPlan(matchResult, acquisitionPlan, redfishprofile.DiscoveredResources{
SystemPaths: systemPaths,
ChassisPaths: chassisPaths,
ManagerPaths: managerPaths,
}, signals)
acquisitionPlan = resolvedPlan.Plan
seedPaths := resolvedPlan.SeedPaths
criticalPaths := resolvedPlan.CriticalPaths
if len(acquisitionPlan.Profiles) > 0 {
log.Printf(
"redfish-profile-plan: mode=%s profiles=%s notes=%s scores=%s req=%d err=%d p95=%dms avg=%dms throttled=%t",
acquisitionPlan.Mode,
strings.Join(acquisitionPlan.Profiles, ","),
strings.Join(acquisitionPlan.Notes, "; "),
formatModuleScoreLog(moduleScores),
telemetrySummary.Requests,
telemetrySummary.Errors,
telemetrySummary.P95.Milliseconds(),
telemetrySummary.Avg.Milliseconds(),
throttled,
)
}
if emit != nil {
emit(Progress{
Status: "running",
Progress: 30,
Message: "Redfish: чтение структуры Redfish...",
CurrentPhase: "snapshot",
ETASeconds: acquisitionPlan.Tuning.ETABaseline.SnapshotSeconds,
})
}
if emit != nil {
emit(Progress{Status: "running", Progress: 55, Message: "Redfish: подготовка snapshot...", CurrentPhase: "snapshot", ETASeconds: acquisitionPlan.Tuning.ETABaseline.SnapshotSeconds})
emit(Progress{Status: "running", Progress: 80, Message: "Redfish: подготовка расширенного snapshot...", CurrentPhase: "snapshot", ETASeconds: acquisitionPlan.Tuning.ETABaseline.SnapshotSeconds})
emit(Progress{Status: "running", Progress: 90, Message: "Redfish: сбор расширенного snapshot...", CurrentPhase: "snapshot", ETASeconds: acquisitionPlan.Tuning.ETABaseline.SnapshotSeconds})
}
// collectCtx covers all data-fetching phases (snapshot, prefetch, plan-B).
// Cancelling it via the skip signal aborts only the collection phases while
// leaving the replay phase intact so results from already-fetched data are preserved.
collectCtx, cancelCollect := context.WithCancel(ctx)
defer cancelCollect()
if req.SkipHungCh != nil {
go func() {
select {
case <-req.SkipHungCh:
if emit != nil {
emit(Progress{
Status: "running",
Progress: 97,
Message: "Redfish: пропуск зависших запросов, анализ уже собранных данных...",
})
}
log.Printf("redfish: skip-hung triggered, cancelling collection phases")
cancelCollect()
case <-ctx.Done():
}
}()
}
c.debugSnapshotf("snapshot crawl start host=%s port=%d", req.Host, req.Port)
rawTree, fetchErrors, postProbeMetrics, snapshotTimingSummary := c.collectRawRedfishTree(withRedfishTelemetryPhase(collectCtx, "snapshot"), snapshotClient, req, baseURL, seedPaths, acquisitionPlan.Tuning, emit)
c.debugSnapshotf("snapshot crawl done docs=%d", len(rawTree))
fetchErrMap := redfishFetchErrorListToMap(fetchErrors)
prefetchedCritical, prefetchMetrics := c.prefetchCriticalRedfishDocs(withRedfishTelemetryPhase(collectCtx, "prefetch"), prefetchClient, req, baseURL, criticalPaths, rawTree, fetchErrMap, acquisitionPlan.Tuning, emit)
for p, doc := range prefetchedCritical {
if _, exists := rawTree[p]; exists {
continue
}
rawTree[p] = doc
prefetchMetrics.Added++
}
for p := range prefetchedCritical {
delete(fetchErrMap, p)
}
log.Printf(
"redfish-prefetch-metrics: enabled=%t candidates=%d targets=%d docs=%d added=%d dur=%s skip=%s",
prefetchMetrics.Enabled,
prefetchMetrics.Candidates,
prefetchMetrics.Targets,
prefetchMetrics.Docs,
prefetchMetrics.Added,
prefetchMetrics.Duration.Round(time.Millisecond),
firstNonEmpty(prefetchMetrics.SkipReason, "-"),
)
if recoveredN := c.recoverCriticalRedfishDocsPlanB(withRedfishTelemetryPhase(collectCtx, "critical_plan_b"), criticalClient, req, baseURL, criticalPaths, rawTree, fetchErrMap, acquisitionPlan.Tuning, emit); recoveredN > 0 {
c.debugSnapshotf("critical plan-b recovered docs=%d", recoveredN)
}
if recoveredN := c.recoverProfilePlanBDocs(withRedfishTelemetryPhase(collectCtx, "profile_plan_b"), criticalClient, req, baseURL, acquisitionPlan, rawTree, emit); recoveredN > 0 {
c.debugSnapshotf("profile plan-b recovered docs=%d", recoveredN)
}
// Hide transient fetch errors for endpoints that were eventually recovered into rawTree.
for p := range fetchErrMap {
if _, ok := rawTree[p]; ok {
delete(fetchErrMap, p)
}
}
if emit != nil {
emit(Progress{Status: "running", Progress: 99, Message: "Redfish: анализ raw snapshot..."})
}
// Collect hardware event logs separately (not part of tree-walk to avoid bloat).
rawLogEntries := c.collectRedfishLogEntries(withRedfishTelemetryPhase(ctx, "log_entries"), snapshotClient, req, baseURL, systemPaths, managerPaths)
var debugPayloads map[string]any
if req.DebugPayloads {
debugPayloads = c.collectDebugPayloads(ctx, snapshotClient, req, baseURL, systemPaths)
}
rawPayloads := map[string]any{
"redfish_tree": rawTree,
"redfish_profiles": map[string]any{
"mode": acquisitionPlan.Mode,
"profiles": acquisitionPlan.Profiles,
"active_modules": activeModules,
"module_scores": moduleScores,
"notes": acquisitionPlan.Notes,
"plan_b_paths": acquisitionPlan.PlanBPaths,
"scoped_paths": map[string]any{
"system_seed_suffixes": acquisitionPlan.ScopedPaths.SystemSeedSuffixes,
"system_critical_suffixes": acquisitionPlan.ScopedPaths.SystemCriticalSuffixes,
"chassis_seed_suffixes": acquisitionPlan.ScopedPaths.ChassisSeedSuffixes,
"chassis_critical_suffixes": acquisitionPlan.ScopedPaths.ChassisCriticalSuffixes,
"manager_seed_suffixes": acquisitionPlan.ScopedPaths.ManagerSeedSuffixes,
"manager_critical_suffixes": acquisitionPlan.ScopedPaths.ManagerCriticalSuffixes,
},
"tuning": map[string]any{
"snapshot_max_documents": acquisitionPlan.Tuning.SnapshotMaxDocuments,
"snapshot_workers": acquisitionPlan.Tuning.SnapshotWorkers,
"snapshot_exclude_contains": acquisitionPlan.Tuning.SnapshotExcludeContains,
"prefetch_workers": acquisitionPlan.Tuning.PrefetchWorkers,
"prefetch_enabled": boolPointerValue(acquisitionPlan.Tuning.PrefetchEnabled),
"nvme_post_probe": boolPointerValue(acquisitionPlan.Tuning.NVMePostProbeEnabled),
"post_probe_policy": map[string]any{
"direct_nvme_disk_bay": acquisitionPlan.Tuning.PostProbePolicy.EnableDirectNVMEDiskBayProbe,
"numeric_collection": acquisitionPlan.Tuning.PostProbePolicy.EnableNumericCollectionProbe,
"sensor_collection": acquisitionPlan.Tuning.PostProbePolicy.EnableSensorCollectionProbe,
},
"prefetch_policy": map[string]any{
"include_suffixes": acquisitionPlan.Tuning.PrefetchPolicy.IncludeSuffixes,
"exclude_contains": acquisitionPlan.Tuning.PrefetchPolicy.ExcludeContains,
},
"recovery_policy": map[string]any{
"critical_collection_member_retry": acquisitionPlan.Tuning.RecoveryPolicy.EnableCriticalCollectionMemberRetry,
"critical_slow_probe": acquisitionPlan.Tuning.RecoveryPolicy.EnableCriticalSlowProbe,
"profile_plan_b": acquisitionPlan.Tuning.RecoveryPolicy.EnableProfilePlanB,
},
"rate_policy": map[string]any{
"target_p95_latency_ms": acquisitionPlan.Tuning.RatePolicy.TargetP95LatencyMS,
"throttle_p95_latency_ms": acquisitionPlan.Tuning.RatePolicy.ThrottleP95LatencyMS,
"min_snapshot_workers": acquisitionPlan.Tuning.RatePolicy.MinSnapshotWorkers,
"min_prefetch_workers": acquisitionPlan.Tuning.RatePolicy.MinPrefetchWorkers,
"disable_prefetch_error": acquisitionPlan.Tuning.RatePolicy.DisablePrefetchOnErrors,
},
"eta_baseline": map[string]any{
"discovery_seconds": acquisitionPlan.Tuning.ETABaseline.DiscoverySeconds,
"snapshot_seconds": acquisitionPlan.Tuning.ETABaseline.SnapshotSeconds,
"prefetch_seconds": acquisitionPlan.Tuning.ETABaseline.PrefetchSeconds,
"critical_plan_b_seconds": acquisitionPlan.Tuning.ETABaseline.CriticalPlanBSeconds,
"profile_plan_b_seconds": acquisitionPlan.Tuning.ETABaseline.ProfilePlanBSeconds,
},
},
"request_telemetry": map[string]any{
"requests": telemetry.Snapshot().Requests,
"errors": telemetry.Snapshot().Errors,
"error_rate": telemetry.Snapshot().ErrorRate,
"avg_ms": telemetry.Snapshot().Avg.Milliseconds(),
"p95_ms": telemetry.Snapshot().P95.Milliseconds(),
"phases": redfishPhaseTelemetryPayload(phaseTelemetry),
},
},
"redfish_telemetry": map[string]any{
"requests": telemetrySummary.Requests,
"errors": telemetrySummary.Errors,
"error_rate": telemetrySummary.ErrorRate,
"avg_ms": telemetrySummary.Avg.Milliseconds(),
"p95_ms": telemetrySummary.P95.Milliseconds(),
"adaptive_throttled": throttled,
"snapshot_timing_top": snapshotTimingSummary,
"snapshot_workers": acquisitionPlan.Tuning.SnapshotWorkers,
"prefetch_workers": acquisitionPlan.Tuning.PrefetchWorkers,
"prefetch_enabled": boolPointerValue(acquisitionPlan.Tuning.PrefetchEnabled),
"nvme_post_probe": boolPointerValue(acquisitionPlan.Tuning.NVMePostProbeEnabled),
"post_probe_policy": map[string]any{
"direct_nvme_disk_bay": acquisitionPlan.Tuning.PostProbePolicy.EnableDirectNVMEDiskBayProbe,
"numeric_collection": acquisitionPlan.Tuning.PostProbePolicy.EnableNumericCollectionProbe,
"sensor_collection": acquisitionPlan.Tuning.PostProbePolicy.EnableSensorCollectionProbe,
},
"prefetch_policy": map[string]any{
"include_suffixes": acquisitionPlan.Tuning.PrefetchPolicy.IncludeSuffixes,
"exclude_contains": acquisitionPlan.Tuning.PrefetchPolicy.ExcludeContains,
},
"recovery_policy": map[string]any{
"critical_collection_member_retry": acquisitionPlan.Tuning.RecoveryPolicy.EnableCriticalCollectionMemberRetry,
"critical_slow_probe": acquisitionPlan.Tuning.RecoveryPolicy.EnableCriticalSlowProbe,
"profile_plan_b": acquisitionPlan.Tuning.RecoveryPolicy.EnableProfilePlanB,
},
"target_p95_latency_ms": acquisitionPlan.Tuning.RatePolicy.TargetP95LatencyMS,
"throttle_p95_latency_ms": acquisitionPlan.Tuning.RatePolicy.ThrottleP95LatencyMS,
"eta_baseline": map[string]any{
"discovery_seconds": acquisitionPlan.Tuning.ETABaseline.DiscoverySeconds,
"snapshot_seconds": acquisitionPlan.Tuning.ETABaseline.SnapshotSeconds,
"prefetch_seconds": acquisitionPlan.Tuning.ETABaseline.PrefetchSeconds,
"critical_plan_b_seconds": acquisitionPlan.Tuning.ETABaseline.CriticalPlanBSeconds,
"profile_plan_b_seconds": acquisitionPlan.Tuning.ETABaseline.ProfilePlanBSeconds,
},
"phases": redfishPhaseTelemetryPayload(phaseTelemetry),
},
}
if len(fetchErrMap) > 0 {
rawPayloads["redfish_fetch_errors"] = redfishFetchErrorMapToList(fetchErrMap)
}
if len(rawLogEntries) > 0 {
rawPayloads["redfish_log_entries"] = rawLogEntries
}
if len(debugPayloads) > 0 {
rawPayloads["redfish_debug_payloads"] = debugPayloads
}
// Unified tunnel: live collection and raw import go through the same analyzer over redfish_tree.
result, err := ReplayRedfishFromRawPayloads(rawPayloads, nil)
if err != nil {
return nil, err
}
totalElapsed := time.Since(collectStart).Round(time.Second)
if !result.InventoryLastModifiedAt.IsZero() {
log.Printf("redfish-collect: inventory last modified at %s (age: %s)",
result.InventoryLastModifiedAt.Format(time.RFC3339),
time.Since(result.InventoryLastModifiedAt).Round(time.Minute),
)
}
log.Printf(
"redfish-postprobe-metrics: nvme_candidates=%d nvme_selected=%d nvme_added=%d candidates=%d selected=%d skipped_explicit=%d added=%d dur=%s",
postProbeMetrics.NVMECandidates,
postProbeMetrics.NVMESelected,
postProbeMetrics.NVMEAdded,
postProbeMetrics.CollectionCandidates,
postProbeMetrics.CollectionSelected,
postProbeMetrics.SkippedExplicit,
postProbeMetrics.Added,
postProbeMetrics.Duration.Round(time.Millisecond),
)
log.Printf(
"redfish-telemetry: req=%d err=%d err_rate=%.2f avg=%dms p95=%dms throttled=%t snapshot_workers=%d prefetch_workers=%d timing_top=%s",
telemetrySummary.Requests,
telemetrySummary.Errors,
telemetrySummary.ErrorRate,
telemetrySummary.Avg.Milliseconds(),
telemetrySummary.P95.Milliseconds(),
throttled,
acquisitionPlan.Tuning.SnapshotWorkers,
acquisitionPlan.Tuning.PrefetchWorkers,
firstNonEmpty(snapshotTimingSummary, "-"),
)
for _, line := range redfishPhaseTelemetryLogLines(phaseTelemetry) {
log.Printf("redfish-telemetry-phase: %s", line)
}
log.Printf("redfish-collect: completed in %s (docs=%d, fetch_errors=%d)", totalElapsed, len(rawTree), len(fetchErrMap))
if emit != nil {
emit(Progress{
Status: "running",
Progress: 99,
Message: fmt.Sprintf("Redfish telemetry: req=%d err=%d p95=%dms throttled=%t", telemetrySummary.Requests, telemetrySummary.Errors, telemetrySummary.P95.Milliseconds(), throttled),
DebugInfo: &CollectDebugInfo{
AdaptiveThrottled: throttled,
SnapshotWorkers: acquisitionPlan.Tuning.SnapshotWorkers,
PrefetchWorkers: acquisitionPlan.Tuning.PrefetchWorkers,
PrefetchEnabled: acquisitionPlan.Tuning.PrefetchEnabled,
PhaseTelemetry: buildCollectPhaseTelemetry(phaseTelemetry),
},
})
emit(Progress{
Status: "running",
Progress: 100,
Message: fmt.Sprintf("Redfish: сбор завершен за %s", totalElapsed),
})
}
return result, nil
}
// 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
}
func firstNonEmptyPath(paths []string, fallback string) string {
for _, p := range paths {
if strings.TrimSpace(p) != "" {
return p
}
}
return fallback
}
func isRedfishHostPoweredOn(state string) bool {
switch strings.ToLower(strings.TrimSpace(state)) {
case "on", "poweringon":
return true
default:
return false
}
}
func 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 (c *RedfishConnector) postJSON(ctx context.Context, client *http.Client, req Request, baseURL, resourcePath string, payload map[string]any) error {
body, err := json.Marshal(payload)
if err != nil {
return err
}
target := strings.TrimSpace(resourcePath)
if !strings.HasPrefix(strings.ToLower(target), "http://") && !strings.HasPrefix(strings.ToLower(target), "https://") {
target = baseURL + normalizeRedfishPath(target)
}
u, err := url.Parse(target)
if err != nil {
return err
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), strings.NewReader(string(body)))
if err != nil {
return err
}
httpReq.Header.Set("Content-Type", "application/json")
switch req.AuthType {
case "password":
httpReq.SetBasicAuth(req.Username, req.Password)
case "token":
httpReq.Header.Set("Authorization", "Bearer "+req.Token)
}
resp, err := client.Do(httpReq)
if err != nil {
return err
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("status %d from %s: %s", resp.StatusCode, resourcePath, strings.TrimSpace(string(respBody)))
}
return nil
}
func (c *RedfishConnector) prefetchCriticalRedfishDocs(
ctx context.Context,
client *http.Client,
req Request,
baseURL string,
criticalPaths []string,
rawTree map[string]interface{},
fetchErrMap map[string]string,
tuning redfishprofile.AcquisitionTuning,
emit ProgressFn,
) (map[string]interface{}, redfishPrefetchMetrics) {
metrics := redfishPrefetchMetrics{
Enabled: redfishPrefetchEnabled(tuning),
}
if !metrics.Enabled || len(criticalPaths) == 0 {
metrics.SkipReason = "disabled-or-empty"
return nil, metrics
}
candidates := redfishPrefetchTargets(criticalPaths, tuning)
metrics.Candidates = len(candidates)
if len(candidates) == 0 {
metrics.SkipReason = "no-candidates"
return nil, metrics
}
targets := redfishAdaptivePrefetchTargets(candidates, rawTree, fetchErrMap)
metrics.Targets = len(targets)
if len(targets) == 0 {
metrics.SkipReason = "not-needed"
if emit != nil {
emit(Progress{
Status: "running",
Progress: 96,
Message: fmt.Sprintf("Redfish: prefetch пропущен (адаптивно, кандидатов=%d)", metrics.Candidates),
})
}
return nil, metrics
}
if emit != nil {
emit(Progress{
Status: "running",
Progress: 96,
Message: fmt.Sprintf("Redfish: prefetch критичных endpoint (адаптивно %d/%d)...", len(targets), len(candidates)),
CurrentPhase: "prefetch",
ETASeconds: int(estimateProgressETA(time.Now(), 0, len(targets), 2*time.Second).Seconds()),
})
}
start := time.Now()
out := make(map[string]interface{}, len(targets))
seen := make(map[string]struct{}, len(targets))
var mu sync.Mutex
addDoc := func(path string, doc map[string]interface{}) {
path = normalizeRedfishPath(path)
if path == "" || len(doc) == 0 {
return
}
mu.Lock()
if _, exists := seen[path]; !exists {
seen[path] = struct{}{}
out[path] = doc
}
mu.Unlock()
}
workerN := redfishPrefetchWorkers(tuning)
jobs := make(chan string, len(targets))
var wg sync.WaitGroup
for i := 0; i < workerN; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for p := range jobs {
doc, err := c.getJSONWithRetry(ctx, client, req, baseURL, p, redfishPrefetchRetryAttempts(), redfishPrefetchRetryBackoff())
if err != nil {
continue
}
addDoc(p, doc)
memberPaths := redfishCollectionMemberRefs(doc)
if len(memberPaths) == 0 {
continue
}
if maxMembers := redfishPrefetchMemberRecoveryMax(); maxMembers > 0 && len(memberPaths) > maxMembers {
memberPaths = memberPaths[:maxMembers]
}
for _, memberPath := range memberPaths {
memberPath = normalizeRedfishPath(memberPath)
if memberPath == "" {
continue
}
mu.Lock()
_, exists := seen[memberPath]
mu.Unlock()
if exists {
continue
}
memberDoc, err := c.getJSONWithRetry(ctx, client, req, baseURL, memberPath, redfishPrefetchMemberRetryAttempts(), redfishPrefetchRetryBackoff())
if err != nil {
continue
}
addDoc(memberPath, memberDoc)
}
}
}()
}
for _, p := range targets {
select {
case jobs <- p:
case <-ctx.Done():
close(jobs)
wg.Wait()
metrics.Docs = len(out)
metrics.Duration = time.Since(start)
metrics.SkipReason = "ctx-cancelled"
return out, metrics
}
}
close(jobs)
wg.Wait()
metrics.Docs = len(out)
metrics.Duration = time.Since(start)
if emit != nil {
emit(Progress{
Status: "running",
Progress: 96,
Message: fmt.Sprintf("Redfish: prefetch завершен (адаптивно targets=%d, docs=%d)", len(targets), len(out)),
CurrentPhase: "prefetch",
})
}
return out, metrics
}
func redfishAdaptivePrefetchTargets(candidates []string, rawTree map[string]interface{}, fetchErrs map[string]string) []string {
out := make([]string, 0, len(candidates))
seen := make(map[string]struct{}, len(candidates))
for _, p := range candidates {
p = normalizeRedfishPath(p)
if p == "" {
continue
}
if _, exists := seen[p]; exists {
continue
}
needsFetch := false
docAny, inTree := rawTree[p]
if !inTree {
needsFetch = true
if msg, hasErr := fetchErrs[p]; hasErr && !isRetryableRedfishFetchError(fmt.Errorf("%s", msg)) {
needsFetch = false
}
} else if doc, ok := docAny.(map[string]interface{}); ok {
needsFetch = redfishCollectionNeedsMemberRecovery(doc, rawTree, fetchErrs)
}
if !needsFetch {
continue
}
seen[p] = struct{}{}
out = append(out, p)
}
return out
}
func redfishCollectionNeedsMemberRecovery(collectionDoc map[string]interface{}, rawTree map[string]interface{}, fetchErrs map[string]string) bool {
memberPaths := redfishCollectionMemberRefs(collectionDoc)
if len(memberPaths) == 0 {
return false
}
for _, memberPath := range memberPaths {
memberPath = normalizeRedfishPath(memberPath)
if memberPath == "" {
continue
}
if _, exists := rawTree[memberPath]; exists {
continue
}
if msg, hasErr := fetchErrs[memberPath]; hasErr && !isRetryableRedfishFetchError(fmt.Errorf("%s", msg)) {
continue
}
return true
}
return false
}
func (c *RedfishConnector) httpClient(req Request) *http.Client {
return c.httpClientWithTimeout(req, c.timeout)
}
func (c *RedfishConnector) httpClientWithTimeout(req Request, timeout time.Duration) *http.Client {
transport := &http.Transport{}
if req.TLSMode == "insecure" {
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec
}
return &http.Client{
Transport: transport,
Timeout: timeout,
}
}
func (c *RedfishConnector) baseURL(req Request) (string, error) {
host := strings.TrimSpace(req.Host)
if host == "" {
return "", fmt.Errorf("empty host")
}
if strings.HasPrefix(host, "http://") || strings.HasPrefix(host, "https://") {
u, err := url.Parse(host)
if err != nil {
return "", fmt.Errorf("invalid host URL: %w", err)
}
u.Path = ""
u.RawQuery = ""
u.Fragment = ""
return strings.TrimRight(u.String(), "/"), nil
}
scheme := "https"
if req.TLSMode == "insecure" && req.Port == 80 {
scheme = "http"
}
return fmt.Sprintf("%s://%s:%d", scheme, host, req.Port), nil
}
func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Client, req Request, baseURL, systemPath string) []models.Storage {
var out []models.Storage
storageMembers, _ := c.getCollectionMembers(ctx, client, req, baseURL, joinPath(systemPath, "/Storage"))
for _, member := range storageMembers {
// "Drives" can be embedded refs or a link to a collection.
if driveCollection, ok := member["Drives"].(map[string]interface{}); ok {
if driveCollectionPath := asString(driveCollection["@odata.id"]); driveCollectionPath != "" {
driveDocs, err := c.getCollectionMembers(ctx, client, req, baseURL, driveCollectionPath)
if err == nil {
for _, driveDoc := range driveDocs {
if isVirtualStorageDrive(driveDoc) {
continue
}
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
}
if len(driveDocs) == 0 {
for _, driveDoc := range c.probeDirectDiskBayChildren(ctx, client, req, baseURL, driveCollectionPath) {
if isVirtualStorageDrive(driveDoc) {
continue
}
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
}
}
}
continue
}
}
if drives, ok := member["Drives"].([]interface{}); ok {
for _, driveAny := range drives {
driveRef, ok := driveAny.(map[string]interface{})
if !ok {
continue
}
odata := asString(driveRef["@odata.id"])
if odata == "" {
continue
}
driveDoc, err := c.getJSON(ctx, client, req, baseURL, odata)
if err != nil {
continue
}
if isVirtualStorageDrive(driveDoc) {
continue
}
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
}
continue
}
// Some implementations return drive fields right in storage member object.
if looksLikeDrive(member) {
if isVirtualStorageDrive(member) {
continue
}
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, member, "DriveMetrics", "EnvironmentMetrics", "Metrics")
out = append(out, parseDriveWithSupplementalDocs(member, supplementalDocs...))
}
// Supermicro/RAID implementations can expose physical disks under chassis enclosures
// linked from Storage.Links.Enclosures, while Storage.Drives stays empty.
for _, enclosurePath := range redfishLinkRefs(member, "Links", "Enclosures") {
driveDocs, err := c.getCollectionMembers(ctx, client, req, baseURL, joinPath(enclosurePath, "/Drives"))
if err == nil {
for _, driveDoc := range driveDocs {
if looksLikeDrive(driveDoc) && !isVirtualStorageDrive(driveDoc) {
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
}
}
if len(driveDocs) == 0 {
for _, driveDoc := range c.probeDirectDiskBayChildren(ctx, client, req, baseURL, joinPath(enclosurePath, "/Drives")) {
if isVirtualStorageDrive(driveDoc) {
continue
}
out = append(out, parseDrive(driveDoc))
}
}
}
}
}
// IntelVROC often exposes rich drive inventory via dedicated child collections.
for _, driveDoc := range c.collectKnownStorageMembers(ctx, client, req, baseURL, systemPath, []string{
"/Storage/IntelVROC/Drives",
"/Storage/IntelVROC/Controllers/1/Drives",
}) {
if looksLikeDrive(driveDoc) && !isVirtualStorageDrive(driveDoc) {
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
}
}
// Fallback for platforms that expose disks in SimpleStorage.
simpleStorageMembers, _ := c.getCollectionMembers(ctx, client, req, baseURL, joinPath(systemPath, "/SimpleStorage"))
for _, member := range simpleStorageMembers {
devices, ok := member["Devices"].([]interface{})
if !ok {
continue
}
for _, devAny := range devices {
devDoc, ok := devAny.(map[string]interface{})
if !ok || !looksLikeDrive(devDoc) || isVirtualStorageDrive(devDoc) {
continue
}
out = append(out, parseDriveWithSupplementalDocs(devDoc))
}
}
// Fallback for platforms exposing physical drives under Chassis.
chassisPaths := c.discoverMemberPaths(ctx, client, req, baseURL, "/redfish/v1/Chassis", "/redfish/v1/Chassis/1")
for _, chassisPath := range chassisPaths {
driveDocs, err := c.getCollectionMembers(ctx, client, req, baseURL, joinPath(chassisPath, "/Drives"))
if err != nil {
continue
}
for _, driveDoc := range driveDocs {
if !looksLikeDrive(driveDoc) || isVirtualStorageDrive(driveDoc) {
continue
}
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
}
}
for _, chassisPath := range chassisPaths {
if !isSupermicroNVMeBackplanePath(chassisPath) {
continue
}
for _, driveDoc := range c.probeSupermicroNVMeDiskBays(ctx, client, req, baseURL, chassisPath) {
if !looksLikeDrive(driveDoc) || isVirtualStorageDrive(driveDoc) {
continue
}
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
}
}
out = dedupeStorage(out)
return out
}
func (c *RedfishConnector) collectStorageVolumes(ctx context.Context, client *http.Client, req Request, baseURL, systemPath string) []models.StorageVolume {
var out []models.StorageVolume
storageMembers, _ := c.getCollectionMembers(ctx, client, req, baseURL, joinPath(systemPath, "/Storage"))
for _, member := range storageMembers {
controller := firstNonEmpty(asString(member["Id"]), asString(member["Name"]))
volumeCollectionPath := redfishLinkedPath(member, "Volumes")
if volumeCollectionPath == "" {
continue
}
volumeDocs, err := c.getCollectionMembers(ctx, client, req, baseURL, volumeCollectionPath)
if err != nil {
continue
}
for _, volDoc := range volumeDocs {
if !looksLikeVolume(volDoc) {
continue
}
out = append(out, parseStorageVolume(volDoc, controller))
}
}
for _, volDoc := range c.collectKnownStorageMembers(ctx, client, req, baseURL, systemPath, []string{
"/Storage/IntelVROC/Volumes",
"/Storage/HA-RAID/Volumes",
"/Storage/MRVL.HA-RAID/Volumes",
}) {
if !looksLikeVolume(volDoc) {
continue
}
out = append(out, parseStorageVolume(volDoc, storageControllerFromPath(asString(volDoc["@odata.id"]))))
}
return dedupeStorageVolumes(out)
}
func (c *RedfishConnector) collectNICs(ctx context.Context, client *http.Client, req Request, baseURL string, chassisPaths []string) []models.NetworkAdapter {
var nics []models.NetworkAdapter
for _, chassisPath := range chassisPaths {
adapterDocs, err := c.getCollectionMembers(ctx, client, req, baseURL, joinPath(chassisPath, "/NetworkAdapters"))
if err != nil {
continue
}
for _, doc := range adapterDocs {
nic := parseNIC(doc)
adapterFunctionDocs := c.getNetworkAdapterFunctionDocs(ctx, client, req, baseURL, doc)
for _, pciePath := range networkAdapterPCIeDevicePaths(doc) {
pcieDoc, err := c.getJSON(ctx, client, req, baseURL, pciePath)
if err != nil {
continue
}
functionDocs := c.getLinkedPCIeFunctions(ctx, client, req, baseURL, pcieDoc)
for _, adapterFnDoc := range adapterFunctionDocs {
functionDocs = append(functionDocs, c.getLinkedPCIeFunctions(ctx, client, req, baseURL, adapterFnDoc)...)
}
functionDocs = dedupeJSONDocsByPath(functionDocs)
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, pcieDoc, "EnvironmentMetrics", "Metrics")
for _, fn := range functionDocs {
supplementalDocs = append(supplementalDocs, c.getLinkedSupplementalDocs(ctx, client, req, baseURL, fn, "EnvironmentMetrics", "Metrics")...)
}
enrichNICFromPCIe(&nic, pcieDoc, functionDocs, supplementalDocs)
}
nics = append(nics, nic)
}
}
return dedupeNetworkAdapters(nics)
}
func (c *RedfishConnector) collectPSUs(ctx context.Context, client *http.Client, req Request, baseURL string, chassisPaths []string) []models.PSU {
var out []models.PSU
seen := make(map[string]int)
idx := 1
for _, chassisPath := range chassisPaths {
// Redfish 2022+/X14+ commonly uses PowerSubsystem as the primary source.
if memberDocs, err := c.getCollectionMembers(ctx, client, req, baseURL, joinPath(chassisPath, "/PowerSubsystem/PowerSupplies")); err == nil && len(memberDocs) > 0 {
for _, doc := range memberDocs {
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, doc, "EnvironmentMetrics", "Metrics")
idx = appendPSU(&out, seen, parsePSUWithSupplementalDocs(doc, idx, supplementalDocs...), idx)
}
continue
}
// Legacy source: embedded array in Chassis/<id>/Power.
if powerDoc, err := c.getJSON(ctx, client, req, baseURL, joinPath(chassisPath, "/Power")); err == nil {
if members, ok := powerDoc["PowerSupplies"].([]interface{}); ok && len(members) > 0 {
for _, item := range members {
doc, ok := item.(map[string]interface{})
if !ok {
continue
}
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, doc, "EnvironmentMetrics", "Metrics")
idx = appendPSU(&out, seen, parsePSUWithSupplementalDocs(doc, idx, supplementalDocs...), idx)
}
}
}
}
return out
}
func appendPSU(out *[]models.PSU, seen map[string]int, psu models.PSU, currentIdx int) int {
nextIdx := currentIdx + 1
keys := psuIdentityKeys(psu)
if len(keys) == 0 {
return nextIdx
}
for _, key := range keys {
if idx, ok := seen[key]; ok {
(*out)[idx] = mergePSUEntries((*out)[idx], psu)
for _, mergedKey := range psuIdentityKeys((*out)[idx]) {
seen[mergedKey] = idx
}
return nextIdx
}
}
idx := len(*out)
for _, key := range keys {
seen[key] = idx
}
*out = append(*out, psu)
return nextIdx
}
func (c *RedfishConnector) collectKnownStorageMembers(ctx context.Context, client *http.Client, req Request, baseURL, systemPath string, relativeCollections []string) []map[string]interface{} {
var out []map[string]interface{}
for _, rel := range relativeCollections {
docs, err := c.getCollectionMembers(ctx, client, req, baseURL, joinPath(systemPath, rel))
if err != nil || len(docs) == 0 {
continue
}
out = append(out, docs...)
}
return out
}
func redfishLinkedPath(doc map[string]interface{}, key string) string {
if v, ok := doc[key].(map[string]interface{}); ok {
return asString(v["@odata.id"])
}
return ""
}
func (c *RedfishConnector) getLinkedSupplementalDocs(
ctx context.Context,
client *http.Client,
req Request,
baseURL string,
doc map[string]interface{},
keys ...string,
) []map[string]interface{} {
if len(doc) == 0 || len(keys) == 0 {
return nil
}
var out []map[string]interface{}
seen := make(map[string]struct{})
for _, key := range keys {
path := normalizeRedfishPath(redfishLinkedPath(doc, key))
if path == "" {
continue
}
if _, ok := seen[path]; ok {
continue
}
supplementalDoc, err := c.getJSON(ctx, client, req, baseURL, path)
if err != nil || len(supplementalDoc) == 0 {
continue
}
seen[path] = struct{}{}
out = append(out, supplementalDoc)
}
return out
}
func (c *RedfishConnector) collectGPUs(ctx context.Context, client *http.Client, req Request, baseURL string, systemPaths, chassisPaths []string) []models.GPU {
collections := make([]string, 0, len(systemPaths)*3+len(chassisPaths)*2)
for _, systemPath := range systemPaths {
collections = append(collections, joinPath(systemPath, "/PCIeDevices"))
collections = append(collections, joinPath(systemPath, "/Accelerators"))
collections = append(collections, joinPath(systemPath, "/GraphicsControllers"))
}
for _, chassisPath := range chassisPaths {
collections = append(collections, joinPath(chassisPath, "/PCIeDevices"))
collections = append(collections, joinPath(chassisPath, "/Accelerators"))
}
var out []models.GPU
seen := make(map[string]struct{})
idx := 1
for _, collectionPath := range collections {
memberDocs, err := c.getCollectionMembers(ctx, client, req, baseURL, collectionPath)
if err != nil || len(memberDocs) == 0 {
continue
}
for _, doc := range memberDocs {
functionDocs := c.getLinkedPCIeFunctions(ctx, client, req, baseURL, doc)
if !looksLikeGPU(doc, functionDocs) {
continue
}
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, doc, "EnvironmentMetrics", "Metrics")
for _, fn := range functionDocs {
supplementalDocs = append(supplementalDocs, c.getLinkedSupplementalDocs(ctx, client, req, baseURL, fn, "EnvironmentMetrics", "Metrics")...)
}
gpu := parseGPUWithSupplementalDocs(doc, functionDocs, supplementalDocs, idx)
idx++
if shouldSkipGenericGPUDuplicate(out, gpu) {
continue
}
key := gpuDocDedupKey(doc, gpu)
if key == "" {
continue
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, gpu)
}
}
return dropModelOnlyGPUPlaceholders(out)
}
func (c *RedfishConnector) collectPCIeDevices(ctx context.Context, client *http.Client, req Request, baseURL string, systemPaths, chassisPaths []string) []models.PCIeDevice {
collections := make([]string, 0, len(systemPaths)+len(chassisPaths))
for _, systemPath := range systemPaths {
collections = append(collections, joinPath(systemPath, "/PCIeDevices"))
}
for _, chassisPath := range chassisPaths {
collections = append(collections, joinPath(chassisPath, "/PCIeDevices"))
}
var out []models.PCIeDevice
for _, collectionPath := range collections {
memberDocs, err := c.getCollectionMembers(ctx, client, req, baseURL, collectionPath)
if err != nil || len(memberDocs) == 0 {
continue
}
for _, doc := range memberDocs {
functionDocs := c.getLinkedPCIeFunctions(ctx, client, req, baseURL, doc)
if looksLikeGPU(doc, functionDocs) {
continue
}
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, doc, "EnvironmentMetrics", "Metrics")
supplementalDocs = append(supplementalDocs, c.getChassisScopedPCIeSupplementalDocs(ctx, client, req, baseURL, doc)...)
for _, fn := range functionDocs {
supplementalDocs = append(supplementalDocs, c.getLinkedSupplementalDocs(ctx, client, req, baseURL, fn, "EnvironmentMetrics", "Metrics")...)
}
dev := parsePCIeDeviceWithSupplementalDocs(doc, functionDocs, supplementalDocs)
if isUnidentifiablePCIeDevice(dev) {
continue
}
out = append(out, dev)
}
}
// Fallback: some BMCs expose only PCIeFunctions collection without PCIeDevices.
for _, systemPath := range systemPaths {
functionDocs, err := c.getCollectionMembers(ctx, client, req, baseURL, joinPath(systemPath, "/PCIeFunctions"))
if err != nil || len(functionDocs) == 0 {
continue
}
for idx, fn := range functionDocs {
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, fn, "EnvironmentMetrics", "Metrics")
dev := parsePCIeFunctionWithSupplementalDocs(fn, supplementalDocs, idx+1)
out = append(out, dev)
}
}
return dedupePCIeDevices(out)
}
func (c *RedfishConnector) getChassisScopedPCIeSupplementalDocs(ctx context.Context, client *http.Client, req Request, baseURL string, doc map[string]interface{}) []map[string]interface{} {
docPath := normalizeRedfishPath(asString(doc["@odata.id"]))
chassisPath := chassisPathForPCIeDoc(docPath)
if chassisPath == "" {
return nil
}
out := make([]map[string]interface{}, 0, 6)
seen := make(map[string]struct{})
add := func(path string) {
path = normalizeRedfishPath(path)
if path == "" {
return
}
if _, ok := seen[path]; ok {
return
}
supplementalDoc, err := c.getJSON(ctx, client, req, baseURL, path)
if err != nil || len(supplementalDoc) == 0 {
return
}
seen[path] = struct{}{}
out = append(out, supplementalDoc)
}
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
}
func (c *RedfishConnector) discoverMemberPaths(ctx context.Context, client *http.Client, req Request, baseURL, collectionPath, fallbackPath string) []string {
collection, err := c.getJSON(ctx, client, req, baseURL, collectionPath)
if err == nil {
if refs, ok := collection["Members"].([]interface{}); ok && len(refs) > 0 {
paths := make([]string, 0, len(refs))
for _, refAny := range refs {
ref, ok := refAny.(map[string]interface{})
if !ok {
continue
}
memberPath := asString(ref["@odata.id"])
if memberPath != "" {
paths = append(paths, memberPath)
}
}
if len(paths) > 0 {
return paths
}
}
}
if fallbackPath != "" {
return []string{fallbackPath}
}
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)
const heartbeatInterval = 5 * time.Second
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)
postProbeMetrics := redfishPostProbeMetrics{}
out := make(map[string]interface{}, maxDocuments)
fetchErrors := make(map[string]string)
seen := make(map[string]struct{}, maxDocuments)
rootCounts := make(map[string]int)
var mu sync.Mutex
var processed int32
var lastPath atomic.Value
// Workers enqueue newly discovered links into the same queue they consume.
// The queue capacity must be at least the crawl cap to avoid producer/consumer
// deadlock when several workers discover many links at once.
jobs := make(chan string, maxDocuments)
var wg sync.WaitGroup
enqueue := func(path string) {
path = normalizeRedfishPath(path)
if !shouldCrawlPath(path) {
return
}
for _, pattern := range tuning.SnapshotExcludeContains {
if pattern != "" && strings.Contains(path, pattern) {
return
}
}
mu.Lock()
if len(seen) >= maxDocuments {
mu.Unlock()
return
}
if _, ok := seen[path]; ok {
mu.Unlock()
return
}
seen[path] = struct{}{}
wg.Add(1)
mu.Unlock()
jobs <- path
}
enqueue("/redfish/v1")
for _, seed := range seedPaths {
enqueue(seed)
}
c.debugSnapshotf("snapshot queue initialized workers=%d max_documents=%d", workers, maxDocuments)
stopHeartbeat := make(chan struct{})
if emit != nil {
go func() {
ticker := time.NewTicker(heartbeatInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
n := atomic.LoadInt32(&processed)
mu.Lock()
countsCopy := make(map[string]int, len(rootCounts))
for k, v := range rootCounts {
countsCopy[k] = v
}
seenN := len(seen)
outN := len(out)
mu.Unlock()
roots := topRoots(countsCopy, 2)
last := "/redfish/v1"
if v := lastPath.Load(); v != nil {
if s, ok := v.(string); ok && s != "" {
last = s
}
}
eta := formatETA(estimateSnapshotETA(crawlStart, int(n), seenN, len(jobs), workers, client.Timeout))
emit(Progress{
Status: "running",
Progress: 92 + int(minInt32(n/200, 6)),
Message: fmt.Sprintf("Redfish snapshot: heartbeat документов=%d (ok=%d, seen=%d), ETA≈%s, корни=%s, последний=%s", n, outN, seenN, eta, strings.Join(roots, ", "), compactProgressPath(last)),
CurrentPhase: "snapshot",
ETASeconds: int(estimateSnapshotETA(crawlStart, int(n), seenN, len(jobs), workers, client.Timeout).Seconds()),
})
case <-stopHeartbeat:
return
case <-ctx.Done():
return
}
}
}()
}
for i := 0; i < workers; i++ {
go func(workerID int) {
for current := range jobs {
if !branchLimiter.tryAcquire(current) {
select {
case jobs <- current:
c.debugSnapshotf("worker=%d requeue branch-busy path=%s branch=%s queue_len=%d", workerID, current, redfishSnapshotBranchKey(current), len(jobs))
select {
case <-time.After(branchRetryPause):
case <-ctx.Done():
}
continue
default:
}
if !branchLimiter.waitAcquire(ctx, current, branchRetryPause) {
n := atomic.AddInt32(&processed, 1)
mu.Lock()
if _, ok := fetchErrors[current]; !ok && ctx.Err() != nil {
fetchErrors[current] = ctx.Err().Error()
}
mu.Unlock()
if emit != nil && ctx.Err() != nil {
emit(Progress{
Status: "running",
Progress: 92 + int(minInt32(n/200, 6)),
Message: fmt.Sprintf("Redfish snapshot: ошибка на %s", compactProgressPath(current)),
})
}
wg.Done()
continue
}
}
lastPath.Store(current)
c.debugSnapshotf("worker=%d fetch start path=%s queue_len=%d", workerID, current, len(jobs))
fetchStart := time.Now()
doc, err := func() (map[string]interface{}, error) {
defer branchLimiter.release(current)
if !isRedfishMemoryMemberPath(current) {
return c.getJSON(ctx, client, req, baseURL, current)
}
select {
case memoryGate <- struct{}{}:
case <-ctx.Done():
return nil, ctx.Err()
}
defer func() { <-memoryGate }()
return c.getJSONWithRetry(
ctx,
memoryClient,
req,
baseURL,
current,
redfishSnapshotMemoryRetryAttempts(),
redfishSnapshotMemoryRetryBackoff(),
)
}()
timings.Observe(current, time.Since(fetchStart), err != nil)
if err == nil {
mu.Lock()
out[current] = doc
rootCounts[redfishTopRoot(current)]++
mu.Unlock()
for _, ref := range extractODataIDs(doc) {
enqueue(ref)
}
}
n := atomic.AddInt32(&processed, 1)
if err != nil {
mu.Lock()
if _, ok := fetchErrors[current]; !ok {
fetchErrors[current] = err.Error()
}
mu.Unlock()
c.debugSnapshotf("worker=%d fetch error path=%s err=%v", workerID, current, err)
if emit != nil && shouldReportSnapshotFetchError(err) {
emit(Progress{
Status: "running",
Progress: 92 + int(minInt32(n/200, 6)),
Message: fmt.Sprintf("Redfish snapshot: ошибка на %s", compactProgressPath(current)),
})
}
}
if emit != nil && n%40 == 0 {
mu.Lock()
countsCopy := make(map[string]int, len(rootCounts))
for k, v := range rootCounts {
countsCopy[k] = v
}
seenN := len(seen)
mu.Unlock()
roots := topRoots(countsCopy, 2)
last := current
if v := lastPath.Load(); v != nil {
if s, ok := v.(string); ok && s != "" {
last = s
}
}
eta := formatETA(estimateSnapshotETA(crawlStart, int(n), seenN, len(jobs), workers, client.Timeout))
emit(Progress{
Status: "running",
Progress: 92 + int(minInt32(n/200, 6)),
Message: fmt.Sprintf("Redfish snapshot: документов=%d, ETA≈%s, корни=%s, последний=%s", n, eta, strings.Join(roots, ", "), compactProgressPath(last)),
CurrentPhase: "snapshot",
ETASeconds: int(estimateSnapshotETA(crawlStart, int(n), seenN, len(jobs), workers, client.Timeout).Seconds()),
})
}
if n%20 == 0 || err != nil {
mu.Lock()
seenN := len(seen)
outN := len(out)
mu.Unlock()
c.debugSnapshotf("snapshot progress processed=%d stored=%d seen=%d queue_len=%d", n, outN, seenN, len(jobs))
}
wg.Done()
}
}(i + 1)
}
wg.Wait()
close(stopHeartbeat)
close(jobs)
// Profile-owned policy for direct NVMe Disk.Bay probing.
postProbeTotalStart := time.Now()
driveCollections := make([]string, 0)
if tuning.PostProbePolicy.EnableDirectNVMEDiskBayProbe {
for path, docAny := range out {
normalized := normalizeRedfishPath(path)
if !strings.HasSuffix(normalized, "/Drives") {
continue
}
postProbeMetrics.NVMECandidates++
doc, _ := docAny.(map[string]interface{})
if !shouldAdaptiveNVMeProbe(doc) {
continue
}
// Skip chassis types that cannot contain NVMe storage (e.g. GPU modules,
// RoT components, NVSwitch zones on HGX systems) to avoid probing hundreds
// of Disk.Bay.N URLs against chassis that will never have drives.
chassisPath := strings.TrimSuffix(normalized, "/Drives")
if chassisDocAny, ok := out[chassisPath]; ok {
if chassisDoc, ok := chassisDocAny.(map[string]interface{}); ok {
if !chassisTypeCanHaveNVMe(asString(chassisDoc["ChassisType"])) {
continue
}
}
}
driveCollections = append(driveCollections, normalized)
}
}
sort.Strings(driveCollections)
postProbeMetrics.NVMESelected = len(driveCollections)
nvmeProbeStart := time.Now()
nvmePostProbeEnabled := redfishNVMePostProbeEnabled(tuning)
for i, path := range driveCollections {
if !nvmePostProbeEnabled {
break
}
if emit != nil && len(driveCollections) > 0 && (i == 0 || i%4 == 0 || i == len(driveCollections)-1) {
emit(Progress{
Status: "running",
Progress: 97,
Message: fmt.Sprintf("Redfish snapshot: post-probe NVMe (%d/%d, ETA≈%s), коллекция=%s", i+1, len(driveCollections), formatETA(estimateProgressETA(nvmeProbeStart, i, len(driveCollections), 2*time.Second)), compactProgressPath(path)),
CurrentPhase: "snapshot_postprobe_nvme",
ETASeconds: int(estimateProgressETA(nvmeProbeStart, i, len(driveCollections), 2*time.Second).Seconds()),
})
}
for _, bayPath := range directDiskBayCandidates(path) {
doc, err := c.getJSON(ctx, client, req, baseURL, bayPath)
if err != nil {
continue
}
if !looksLikeDrive(doc) {
continue
}
normalizedBayPath := normalizeRedfishPath(bayPath)
if _, exists := out[normalizedBayPath]; exists {
continue
}
out[normalizedBayPath] = doc
postProbeMetrics.NVMEAdded++
c.debugSnapshotf("snapshot nvme bay probe hit path=%s", bayPath)
}
}
// Profile-owned policy for numeric collection post-probe.
postProbeCollections := make([]string, 0)
for path, docAny := range out {
normalized := normalizeRedfishPath(path)
if !shouldPostProbeCollectionPath(normalized, tuning) {
continue
}
postProbeMetrics.CollectionCandidates++
doc, _ := docAny.(map[string]interface{})
if shouldAdaptivePostProbeCollectionPath(normalized, doc, tuning) {
postProbeCollections = append(postProbeCollections, normalized)
continue
}
if redfishCollectionHasExplicitMembers(doc) {
postProbeMetrics.SkippedExplicit++
}
}
sort.Strings(postProbeCollections)
postProbeMetrics.CollectionSelected = len(postProbeCollections)
postProbeStart := time.Now()
addedPostProbe := 0
for i, path := range postProbeCollections {
if emit != nil && len(postProbeCollections) > 0 && (i == 0 || i%8 == 0 || i == len(postProbeCollections)-1) {
emit(Progress{
Status: "running",
Progress: 98,
Message: fmt.Sprintf("Redfish snapshot: post-probe коллекций (%d/%d, ETA≈%s), текущая=%s", i+1, len(postProbeCollections), formatETA(estimateProgressETA(postProbeStart, i, len(postProbeCollections), 3*time.Second)), compactProgressPath(path)),
CurrentPhase: "snapshot_postprobe_collections",
ETASeconds: int(estimateProgressETA(postProbeStart, i, len(postProbeCollections), 3*time.Second).Seconds()),
})
}
for childPath, doc := range c.probeDirectRedfishCollectionChildren(ctx, postProbeClient, req, baseURL, path) {
if _, exists := out[childPath]; exists {
continue
}
out[childPath] = doc
addedPostProbe++
}
}
postProbeMetrics.Added = addedPostProbe
postProbeMetrics.Duration = time.Since(postProbeTotalStart)
if emit != nil && addedPostProbe > 0 {
emit(Progress{
Status: "running",
Progress: 98,
Message: fmt.Sprintf("Redfish snapshot: post-probe добавлено %d документов", addedPostProbe),
})
}
if emit != nil {
emit(Progress{
Status: "running",
Progress: 98,
Message: fmt.Sprintf("Redfish snapshot: post-probe метрики candidates=%d selected=%d skipped_explicit=%d added=%d", postProbeMetrics.CollectionCandidates, postProbeMetrics.CollectionSelected, postProbeMetrics.SkippedExplicit, postProbeMetrics.Added),
})
}
if emit != nil {
emit(Progress{
Status: "running",
Progress: 98,
Message: fmt.Sprintf("Redfish snapshot: собрано %d документов", len(out)),
})
}
errorList := make([]map[string]interface{}, 0, len(fetchErrors))
for p, msg := range fetchErrors {
errorList = append(errorList, map[string]interface{}{
"path": p,
"error": msg,
})
}
sort.Slice(errorList, func(i, j int) bool {
return asString(errorList[i]["path"]) < asString(errorList[j]["path"])
})
if summary := timings.Summary(12); summary != "" {
log.Printf("redfish-snapshot-timing: %s", summary)
}
if emit != nil {
if summary := timings.Summary(3); summary != "" {
emit(Progress{
Status: "running",
Progress: 98,
Message: fmt.Sprintf("Redfish snapshot: топ веток по времени: %s", summary),
})
}
}
return out, errorList, postProbeMetrics, timings.Summary(12)
}
func (c *RedfishConnector) probeSupermicroNVMeDiskBays(ctx context.Context, client *http.Client, req Request, baseURL, backplanePath string) []map[string]interface{} {
return c.probeDirectDiskBayChildren(ctx, client, req, baseURL, joinPath(backplanePath, "/Drives"))
}
func isSupermicroNVMeBackplanePath(path string) bool {
path = normalizeRedfishPath(path)
return strings.Contains(path, "/Chassis/NVMeSSD.") && strings.Contains(path, ".StorageBackplane")
}
func supermicroNVMeDiskBayCandidates(backplanePath string) []string {
return directDiskBayCandidates(joinPath(backplanePath, "/Drives"))
}
func (c *RedfishConnector) probeDirectDiskBayChildren(ctx context.Context, client *http.Client, req Request, baseURL, drivesCollectionPath string) []map[string]interface{} {
var out []map[string]interface{}
for _, path := range directDiskBayCandidates(drivesCollectionPath) {
doc, err := c.getJSON(ctx, client, req, baseURL, path)
if err != nil || !looksLikeDrive(doc) {
continue
}
out = append(out, doc)
}
return out
}
func directDiskBayCandidates(drivesCollectionPath string) []string {
const maxBays = 128
prefix := normalizeRedfishPath(drivesCollectionPath)
out := make([]string, 0, maxBays*3)
for i := 0; i < maxBays; i++ {
out = append(out, fmt.Sprintf("%s/Disk.Bay.%d", prefix, i))
out = append(out, fmt.Sprintf("%s/Disk.Bay%d", prefix, i))
out = append(out, fmt.Sprintf("%s/%d", prefix, i))
}
return out
}
func (c *RedfishConnector) probeDirectRedfishCollectionChildren(ctx context.Context, client *http.Client, req Request, baseURL, collectionPath string) map[string]map[string]interface{} {
normalized := normalizeRedfishPath(collectionPath)
maxItems, startIndex, missBudget := directNumericProbePlan(normalized)
if maxItems <= 0 {
return nil
}
out := make(map[string]map[string]interface{})
consecutiveMisses := 0
for i := startIndex; i <= maxItems; i++ {
path := fmt.Sprintf("%s/%d", normalized, i)
doc, err := c.getJSON(ctx, client, req, baseURL, path)
if err != nil {
consecutiveMisses++
if consecutiveMisses >= missBudget {
break
}
continue
}
consecutiveMisses = 0
if !looksLikeRedfishResource(doc) {
continue
}
out[normalizeRedfishPath(path)] = doc
}
return out
}
func (c *RedfishConnector) probeDirectRedfishCollectionChildrenSlow(ctx context.Context, client *http.Client, req Request, baseURL, collectionPath string) map[string]map[string]interface{} {
normalized := normalizeRedfishPath(collectionPath)
maxItems, startIndex, missBudget := directNumericProbePlan(normalized)
if maxItems <= 0 {
return nil
}
out := make(map[string]map[string]interface{})
consecutiveMisses := 0
for i := startIndex; i <= maxItems; i++ {
if len(out) > 0 || i > startIndex {
select {
case <-time.After(redfishCriticalSlowGap()):
case <-ctx.Done():
return out
}
}
path := fmt.Sprintf("%s/%d", normalized, i)
doc, err := c.getJSONWithRetry(ctx, client, req, baseURL, path, redfishCriticalPlanBAttempts(), redfishCriticalRetryBackoff())
if err != nil {
consecutiveMisses++
if consecutiveMisses >= missBudget {
break
}
continue
}
consecutiveMisses = 0
if !looksLikeRedfishResource(doc) {
continue
}
out[normalizeRedfishPath(path)] = doc
}
return out
}
func directNumericProbePlan(collectionPath string) (maxItems, startIndex, missBudget int) {
switch {
case strings.HasSuffix(collectionPath, "/Systems"):
return 32, 1, 8
case strings.HasSuffix(collectionPath, "/Chassis"):
return 64, 1, 12
case strings.HasSuffix(collectionPath, "/Managers"):
return 16, 1, 6
case strings.HasSuffix(collectionPath, "/Processors"):
return 32, 1, 12
case strings.HasSuffix(collectionPath, "/Memory"):
return 512, 1, 48
case strings.HasSuffix(collectionPath, "/Storage"):
return 128, 1, 24
case strings.HasSuffix(collectionPath, "/Drives"):
return 256, 0, 24
case strings.HasSuffix(collectionPath, "/Volumes"):
return 128, 1, 16
case strings.HasSuffix(collectionPath, "/PCIeDevices"):
return 256, 1, 24
case strings.HasSuffix(collectionPath, "/PCIeFunctions"):
return 512, 1, 32
case strings.HasSuffix(collectionPath, "/NetworkAdapters"):
return 128, 1, 20
case strings.HasSuffix(collectionPath, "/NetworkPorts"):
return 256, 1, 24
case strings.HasSuffix(collectionPath, "/Ports"):
return 256, 1, 24
case strings.HasSuffix(collectionPath, "/EthernetInterfaces"):
return 256, 1, 24
case strings.HasSuffix(collectionPath, "/Certificates"):
return 256, 1, 24
case strings.HasSuffix(collectionPath, "/Accounts"):
return 128, 1, 16
case strings.HasSuffix(collectionPath, "/LogServices"):
return 32, 1, 8
case strings.HasSuffix(collectionPath, "/Sensors"):
return 512, 1, 48
case strings.HasSuffix(collectionPath, "/Temperatures"):
return 256, 1, 32
case strings.HasSuffix(collectionPath, "/Fans"):
return 256, 1, 32
case strings.HasSuffix(collectionPath, "/Voltages"):
return 256, 1, 32
case strings.HasSuffix(collectionPath, "/PowerSupplies"):
return 64, 1, 16
default:
return 0, 0, 0
}
}
func shouldPostProbeCollectionPath(path string, tuning redfishprofile.AcquisitionTuning) bool {
path = normalizeRedfishPath(path)
if !tuning.PostProbePolicy.EnableNumericCollectionProbe {
return false
}
sensorProbeEnabled := tuning.PostProbePolicy.EnableSensorCollectionProbe || redfishSnapshotSensorPostProbeEnabled()
// Restrict expensive post-probe to collections that historically recover
// missing inventory/telemetry on partially implemented BMCs.
switch {
case strings.HasSuffix(path, "/Sensors"),
strings.HasSuffix(path, "/ThresholdSensors"),
strings.HasSuffix(path, "/DiscreteSensors"),
strings.HasSuffix(path, "/Temperatures"),
strings.HasSuffix(path, "/Fans"),
strings.HasSuffix(path, "/Voltages"):
return sensorProbeEnabled
case strings.HasSuffix(path, "/PowerSupplies"),
strings.HasSuffix(path, "/EthernetInterfaces"),
strings.HasSuffix(path, "/NetworkPorts"),
strings.HasSuffix(path, "/Ports"),
strings.HasSuffix(path, "/PCIeDevices"),
strings.HasSuffix(path, "/PCIeFunctions"),
strings.HasSuffix(path, "/Drives"),
strings.HasSuffix(path, "/Volumes"):
return true
default:
return false
}
}
func shouldAdaptivePostProbeCollectionPath(path string, collectionDoc map[string]interface{}, tuning redfishprofile.AcquisitionTuning) bool {
path = normalizeRedfishPath(path)
if !shouldPostProbeCollectionPath(path, tuning) {
return false
}
if len(collectionDoc) == 0 {
return true
}
memberRefs := redfishCollectionMemberRefs(collectionDoc)
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)
}
func shouldAdaptiveNVMeProbe(collectionDoc map[string]interface{}) bool {
if len(collectionDoc) == 0 {
return true
}
return !redfishCollectionHasExplicitMembers(collectionDoc)
}
// chassisTypeCanHaveNVMe returns false for Redfish ChassisType values that
// represent compute/network/management sub-modules with no storage capability.
// Used to skip expensive Disk.Bay.N probing on HGX GPU, NVSwitch, PCIeRetimer,
// RoT and similar component chassis that expose an empty /Drives collection.
func chassisTypeCanHaveNVMe(chassisType string) bool {
switch strings.ToLower(strings.TrimSpace(chassisType)) {
case "module", // GPU SXM, NVLinkManagementNIC, PCIeRetimer
"component", // ERoT, IRoT, BMC, FPGA sub-chassis
"zone": // HGX_Chassis_0 fabric zone
return false
default:
return true
}
}
func redfishCollectionHasNumericMemberRefs(memberRefs []string) bool {
for _, memberPath := range memberRefs {
if redfishPathTailIsNumeric(memberPath) {
return true
}
}
return false
}
func redfishPathTailIsNumeric(path string) bool {
normalized := normalizeRedfishPath(path)
if normalized == "" {
return false
}
parts := strings.Split(strings.Trim(normalized, "/"), "/")
if len(parts) == 0 {
return false
}
tail := strings.TrimSpace(parts[len(parts)-1])
if tail == "" {
return false
}
for _, r := range tail {
if r < '0' || r > '9' {
return false
}
}
return true
}
func looksLikeRedfishResource(doc map[string]interface{}) bool {
if len(doc) == 0 {
return false
}
if asString(doc["@odata.id"]) != "" {
return true
}
if asString(doc["Id"]) != "" || asString(doc["Name"]) != "" {
return true
}
if _, ok := doc["Status"]; ok {
return true
}
if _, ok := doc["Reading"]; ok {
return true
}
if _, ok := doc["ReadingCelsius"]; ok {
return true
}
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 {
return false
}
for _, suffix := range []string{
"/Processors",
"/Memory",
"/Storage",
"/Drives",
"/Volumes",
"/PCIeDevices",
"/PCIeFunctions",
"/NetworkAdapters",
"/EthernetInterfaces",
"/NetworkInterfaces",
"/Sensors",
"/Fans",
"/Temperatures",
"/Voltages",
} {
if strings.HasSuffix(p, suffix) {
return true
}
}
return false
}
func mergeRedfishPaths(groups ...[]string) []string {
seen := make(map[string]struct{})
out := make([]string, 0)
for _, group := range groups {
for _, path := range group {
path = normalizeRedfishPath(path)
if path == "" {
continue
}
if _, ok := seen[path]; ok {
continue
}
seen[path] = struct{}{}
out = append(out, path)
}
}
return out
}
func redfishFetchErrorListToMap(list []map[string]interface{}) map[string]string {
out := make(map[string]string, len(list))
for _, item := range list {
p := normalizeRedfishPath(asString(item["path"]))
if p == "" {
continue
}
out[p] = asString(item["error"])
}
return out
}
func redfishFetchErrorMapToList(m map[string]string) []map[string]interface{} {
if len(m) == 0 {
return nil
}
out := make([]map[string]interface{}, 0, len(m))
for p, msg := range m {
out = append(out, map[string]interface{}{"path": p, "error": msg})
}
sort.Slice(out, func(i, j int) bool {
return asString(out[i]["path"]) < asString(out[j]["path"])
})
return out
}
func isRetryableRedfishFetchError(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
if strings.Contains(msg, "timeout") || strings.Contains(msg, "deadline exceeded") || strings.Contains(msg, "connection reset") || strings.Contains(msg, "unexpected eof") {
return true
}
if strings.HasPrefix(msg, "status 500 ") || strings.HasPrefix(msg, "status 502 ") || strings.HasPrefix(msg, "status 503 ") || strings.HasPrefix(msg, "status 504 ") {
return true
}
return false
}
func redfishSnapshotRequestTimeout() time.Duration {
if v := strings.TrimSpace(os.Getenv("LOGPILE_REDFISH_SNAPSHOT_TIMEOUT")); v != "" {
if d, err := time.ParseDuration(v); err == nil && d > 0 {
return d
}
}
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
}
if v := strings.TrimSpace(os.Getenv("LOGPILE_REDFISH_SNAPSHOT_WORKERS")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 1 && n <= 16 {
return n
}
}
return 4
}
func redfishPrefetchEnabled(tuning redfishprofile.AcquisitionTuning) bool {
if tuning.PrefetchEnabled != nil {
return *tuning.PrefetchEnabled
}
if v := strings.TrimSpace(os.Getenv("LOGPILE_REDFISH_PREFETCH_ENABLED")); v != "" {
switch strings.ToLower(v) {
case "0", "false", "off", "no":
return false
default:
return true
}
}
return true
}
func redfishPrefetchRequestTimeout() time.Duration {
if v := strings.TrimSpace(os.Getenv("LOGPILE_REDFISH_PREFETCH_TIMEOUT")); v != "" {
if d, err := time.ParseDuration(v); err == nil && d > 0 {
return d
}
}
return 20 * time.Second
}
func redfishPrefetchRetryAttempts() int {
if v := strings.TrimSpace(os.Getenv("LOGPILE_REDFISH_PREFETCH_RETRIES")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 1 && n <= 8 {
return n
}
}
return 2
}
func redfishPrefetchMemberRetryAttempts() int {
if v := strings.TrimSpace(os.Getenv("LOGPILE_REDFISH_PREFETCH_MEMBER_RETRIES")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 1 && n <= 6 {
return n
}
}
return 1
}
func redfishPrefetchMemberRecoveryMax() int {
if v := strings.TrimSpace(os.Getenv("LOGPILE_REDFISH_PREFETCH_MEMBER_RECOVERY_MAX")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 1 && n <= 512 {
return n
}
}
return 48
}
func redfishPrefetchRetryBackoff() time.Duration {
if v := strings.TrimSpace(os.Getenv("LOGPILE_REDFISH_PREFETCH_BACKOFF")); v != "" {
if d, err := time.ParseDuration(v); err == nil && d >= 0 {
return d
}
}
return 900 * time.Millisecond
}
func redfishPrefetchWorkers(tuning redfishprofile.AcquisitionTuning) int {
if tuning.PrefetchWorkers >= 1 && tuning.PrefetchWorkers <= 8 {
return tuning.PrefetchWorkers
}
if v := strings.TrimSpace(os.Getenv("LOGPILE_REDFISH_PREFETCH_WORKERS")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 1 && n <= 8 {
return n
}
}
return 2
}
func redfishSnapshotSensorPostProbeEnabled() bool {
if v := strings.TrimSpace(os.Getenv("LOGPILE_REDFISH_SENSOR_POSTPROBE")); v != "" {
switch strings.ToLower(v) {
case "1", "true", "on", "yes":
return true
default:
return false
}
}
return false
}
func redfishNVMePostProbeEnabled(tuning redfishprofile.AcquisitionTuning) bool {
if tuning.NVMePostProbeEnabled != nil {
return *tuning.NVMePostProbeEnabled
}
return true
}
func redfishPrefetchTargets(criticalPaths []string, tuning redfishprofile.AcquisitionTuning) []string {
if len(criticalPaths) == 0 {
return nil
}
out := make([]string, 0, len(criticalPaths))
seen := make(map[string]struct{}, len(criticalPaths))
for _, p := range criticalPaths {
p = normalizeRedfishPath(p)
if p == "" || !shouldPrefetchCriticalPath(p, tuning) {
continue
}
if _, ok := seen[p]; ok {
continue
}
seen[p] = struct{}{}
out = append(out, p)
}
return out
}
func shouldPrefetchCriticalPath(p string, tuning redfishprofile.AcquisitionTuning) bool {
p = normalizeRedfishPath(p)
if p == "" {
return false
}
for _, noisy := range tuning.PrefetchPolicy.ExcludeContains {
if strings.Contains(p, noisy) {
return false
}
}
for _, suffix := range tuning.PrefetchPolicy.IncludeSuffixes {
if strings.HasSuffix(p, suffix) {
return true
}
}
parts := strings.Split(strings.Trim(p, "/"), "/")
return len(parts) == 4 && parts[0] == "redfish" && parts[1] == "v1" &&
(parts[2] == "Systems" || parts[2] == "Chassis" || parts[2] == "Managers")
}
func redfishCriticalRequestTimeout() time.Duration {
if v := strings.TrimSpace(os.Getenv("LOGPILE_REDFISH_CRITICAL_TIMEOUT")); v != "" {
if d, err := time.ParseDuration(v); err == nil && d > 0 {
return d
}
}
return 45 * time.Second
}
func redfishCriticalRetryAttempts() int {
if v := strings.TrimSpace(os.Getenv("LOGPILE_REDFISH_CRITICAL_RETRIES")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 1 && n <= 10 {
return n
}
}
return 3
}
func redfishCriticalPlanBAttempts() int {
if v := strings.TrimSpace(os.Getenv("LOGPILE_REDFISH_CRITICAL_PLANB_RETRIES")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 1 && n <= 10 {
return n
}
}
return 3
}
func redfishCriticalMemberRetryAttempts() int {
if v := strings.TrimSpace(os.Getenv("LOGPILE_REDFISH_CRITICAL_MEMBER_RETRIES")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 1 && n <= 6 {
return n
}
}
return 1
}
func redfishCriticalMemberRecoveryMax() int {
if v := strings.TrimSpace(os.Getenv("LOGPILE_REDFISH_CRITICAL_MEMBER_RECOVERY_MAX")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 1 && n <= 1024 {
return n
}
}
return 48
}
func redfishCriticalRetryBackoff() time.Duration {
if v := strings.TrimSpace(os.Getenv("LOGPILE_REDFISH_CRITICAL_BACKOFF")); v != "" {
if d, err := time.ParseDuration(v); err == nil && d >= 0 {
return d
}
}
return 1500 * time.Millisecond
}
func redfishCriticalCooldown() time.Duration {
if v := strings.TrimSpace(os.Getenv("LOGPILE_REDFISH_CRITICAL_COOLDOWN")); v != "" {
if d, err := time.ParseDuration(v); err == nil && d >= 0 {
return d
}
}
return 4 * time.Second
}
func redfishCriticalSlowGap() time.Duration {
if v := strings.TrimSpace(os.Getenv("LOGPILE_REDFISH_CRITICAL_SLOW_GAP")); v != "" {
if d, err := time.ParseDuration(v); err == nil && d >= 0 {
return d
}
}
return 1200 * time.Millisecond
}
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 {
return d
}
}
return 25 * time.Second
}
func redfishSnapshotMemoryRetryAttempts() int {
if v := strings.TrimSpace(os.Getenv("LOGPILE_REDFISH_MEMORY_RETRIES")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 1 && n <= 8 {
return n
}
}
return 2
}
func redfishSnapshotMemoryRetryBackoff() time.Duration {
if v := strings.TrimSpace(os.Getenv("LOGPILE_REDFISH_MEMORY_BACKOFF")); v != "" {
if d, err := time.ParseDuration(v); err == nil && d >= 0 {
return d
}
}
return 800 * time.Millisecond
}
func redfishSnapshotMemoryConcurrency() int {
if v := strings.TrimSpace(os.Getenv("LOGPILE_REDFISH_MEMORY_WORKERS")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 1 && n <= 8 {
return n
}
}
return 1
}
func redfishSnapshotBranchConcurrency() int {
if v := strings.TrimSpace(os.Getenv("LOGPILE_REDFISH_BRANCH_WORKERS")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 1 && n <= 8 {
return n
}
}
return 1
}
func redfishSnapshotBranchRequeueBackoff() time.Duration {
if v := strings.TrimSpace(os.Getenv("LOGPILE_REDFISH_BRANCH_BACKOFF")); v != "" {
if d, err := time.ParseDuration(v); err == nil && d >= 0 {
return d
}
}
return 35 * time.Millisecond
}
type redfishSnapshotBranchLimiter struct {
limit int
mu sync.Mutex
inFlight map[string]int
}
func newRedfishSnapshotBranchLimiter(limit int) *redfishSnapshotBranchLimiter {
if limit < 1 {
limit = 1
}
return &redfishSnapshotBranchLimiter{
limit: limit,
inFlight: make(map[string]int),
}
}
func (l *redfishSnapshotBranchLimiter) tryAcquire(path string) bool {
branch := redfishSnapshotBranchKey(path)
if branch == "" {
return true
}
l.mu.Lock()
defer l.mu.Unlock()
if l.inFlight[branch] >= l.limit {
return false
}
l.inFlight[branch]++
return true
}
func (l *redfishSnapshotBranchLimiter) waitAcquire(ctx context.Context, path string, backoff time.Duration) bool {
branch := redfishSnapshotBranchKey(path)
if branch == "" {
return true
}
if backoff < 0 {
backoff = 0
}
for {
if l.tryAcquire(path) {
return true
}
if ctx.Err() != nil {
return false
}
select {
case <-time.After(backoff):
case <-ctx.Done():
return false
}
}
}
func (l *redfishSnapshotBranchLimiter) release(path string) {
branch := redfishSnapshotBranchKey(path)
if branch == "" {
return
}
l.mu.Lock()
defer l.mu.Unlock()
switch n := l.inFlight[branch]; {
case n <= 1:
delete(l.inFlight, branch)
default:
l.inFlight[branch] = n - 1
}
}
func redfishLinkRefs(doc map[string]interface{}, topKey, nestedKey string) []string {
top, ok := doc[topKey].(map[string]interface{})
if !ok {
return nil
}
items, ok := top[nestedKey].([]interface{})
if !ok {
return nil
}
out := make([]string, 0, len(items))
for _, itemAny := range items {
item, ok := itemAny.(map[string]interface{})
if !ok {
continue
}
if p := asString(item["@odata.id"]); p != "" {
out = append(out, p)
}
}
return out
}
func pcieDeviceDedupKey(dev models.PCIeDevice) string {
if bdf := strings.TrimSpace(dev.BDF); looksLikeCanonicalBDF(bdf) {
return strings.ToLower(bdf)
}
if s := strings.TrimSpace(dev.SerialNumber); s != "" {
return s
}
return firstNonEmpty(
strings.TrimSpace(dev.Slot)+"|"+strings.TrimSpace(dev.PartNumber)+"|"+strings.TrimSpace(dev.DeviceClass),
strings.TrimSpace(dev.Slot)+"|"+strings.TrimSpace(dev.DeviceClass),
strings.TrimSpace(dev.PartNumber)+"|"+strings.TrimSpace(dev.DeviceClass),
strings.TrimSpace(dev.Description)+"|"+strings.TrimSpace(dev.DeviceClass),
)
}
func looksLikeCanonicalBDF(bdf string) bool {
bdf = strings.TrimSpace(strings.ToLower(bdf))
if bdf == "" {
return false
}
// Accept common forms: 0000:65:00.0 or 65:00.0
if strings.Count(bdf, ":") == 2 && strings.Contains(bdf, ".") {
return true
}
if strings.Count(bdf, ":") == 1 && strings.Contains(bdf, ".") {
return true
}
return false
}
func sanitizeRedfishBDF(bdf string) string {
bdf = strings.TrimSpace(bdf)
if looksLikeCanonicalBDF(bdf) {
return bdf
}
return ""
}
func shouldCrawlPath(path string) bool {
if path == "" {
return false
}
normalized := normalizeRedfishPath(path)
if isAllowedNVSwitchFabricPath(normalized) {
return true
}
if strings.Contains(normalized, "/Chassis/") &&
strings.Contains(normalized, "/PCIeDevices/") &&
strings.HasSuffix(normalized, "/PCIeFunctions") {
// Avoid crawling entire chassis PCIeFunctions collections. Concrete member
// docs can still be reached through direct links such as
// NetworkDeviceFunction Links.PCIeFunction.
return false
}
if strings.Contains(normalized, "/Memory/") {
after := strings.SplitN(normalized, "/Memory/", 2)
if len(after) == 2 && strings.Count(after[1], "/") >= 1 {
// Keep direct DIMM resources and selected metrics subresources, but skip
// unrelated nested branches like Assembly.
return strings.HasSuffix(normalized, "/MemoryMetrics")
}
}
if strings.Contains(normalized, "/Processors/") {
after := strings.SplitN(normalized, "/Processors/", 2)
if len(after) == 2 && strings.Count(after[1], "/") >= 1 {
return strings.HasSuffix(normalized, "/ProcessorMetrics")
}
}
// Non-inventory top-level service branches.
for _, prefix := range []string{
"/redfish/v1/AccountService",
"/redfish/v1/CertificateService",
"/redfish/v1/EventService",
"/redfish/v1/Registries",
"/redfish/v1/SessionService",
"/redfish/v1/TaskService",
} {
if strings.HasPrefix(normalized, prefix) {
return false
}
}
// Manager-specific configuration paths (not hardware inventory).
if strings.Contains(normalized, "/Managers/") {
for _, part := range []string{
"/FirewallRules",
"/KvmService",
"/LldpService",
"/SecurityService",
"/SmtpService",
"/SnmpService",
"/SyslogService",
"/VirtualMedia",
"/VncService",
"/Certificates",
} {
if strings.Contains(normalized, part) {
return false
}
}
}
// Per-CPU operating frequency configurations — not hardware inventory.
if strings.HasSuffix(normalized, "/OperatingConfigs") {
return false
}
// Per-core/thread sub-processors — inventory is captured at the top processor level.
if strings.Contains(normalized, "/SubProcessors") {
return false
}
// Non-inventory system endpoints.
for _, part := range []string{
"/BootOptions",
"/HostPostCode",
"/Bios/Settings",
"/GetServerAllUSBStatus",
"/Oem/Public/KVM",
"/SecureBoot/SecureBootDatabases",
// HPE iLO WorkloadPerformanceAdvisor — operational/advisory data, not inventory.
"/WorkloadPerformanceAdvisor",
} {
if strings.Contains(normalized, part) {
return false
}
}
heavyParts := []string{
"/JsonSchemas",
"/LogServices/",
"/Entries/",
"/TelemetryService/",
"/MetricReports/",
"/SessionService/Sessions",
"/TaskService/Tasks",
}
for _, part := range heavyParts {
if strings.Contains(path, part) {
return false
}
}
return true
}
func isAllowedNVSwitchFabricPath(path string) bool {
if !strings.HasPrefix(path, "/redfish/v1/Fabrics/") {
return false
}
if !strings.Contains(path, "/Switches/NVSwitch_") {
return false
}
if strings.HasSuffix(path, "/Switches") || strings.Contains(path, "/Ports/NVLink_") || strings.HasSuffix(path, "/Ports") {
return true
}
if strings.Contains(path, "/Switches/NVSwitch_") && !strings.Contains(path, "/Ports/") {
return true
}
return false
}
func isRedfishMemoryMemberPath(path string) bool {
normalized := normalizeRedfishPath(path)
if !strings.Contains(normalized, "/Systems/") {
return false
}
if !strings.Contains(normalized, "/Memory/") {
return false
}
if strings.Contains(normalized, "/MemoryMetrics") || strings.Contains(normalized, "/Assembly") {
return false
}
after := strings.SplitN(normalized, "/Memory/", 2)
if len(after) != 2 {
return false
}
suffix := strings.TrimSpace(after[1])
if suffix == "" || strings.Contains(suffix, "/") {
return false
}
return true
}
func redfishCollectionHasExplicitMembers(doc map[string]interface{}) bool {
return len(redfishCollectionMemberRefs(doc)) > 0
}
func redfishSnapshotBranchKey(path string) string {
normalized := normalizeRedfishPath(path)
if normalized == "" || normalized == "/redfish/v1" {
return ""
}
parts := strings.Split(strings.Trim(normalized, "/"), "/")
if len(parts) < 3 {
return normalized
}
if parts[0] != "redfish" || parts[1] != "v1" {
return normalized
}
// Keep subsystem branches independent, e.g. Systems/1/Memory vs Systems/1/PCIeDevices.
if len(parts) >= 5 && (parts[2] == "Systems" || parts[2] == "Chassis" || parts[2] == "Managers") {
return "/" + strings.Join(parts[:5], "/")
}
if len(parts) >= 4 {
return "/" + strings.Join(parts[:4], "/")
}
return "/" + strings.Join(parts[:3], "/")
}
func (c *RedfishConnector) getLinkedPCIeFunctions(ctx context.Context, client *http.Client, req Request, baseURL string, doc map[string]interface{}) []map[string]interface{} {
// Newer Redfish payloads often keep function references in Links.PCIeFunctions.
if links, ok := doc["Links"].(map[string]interface{}); ok {
if refs, ok := links["PCIeFunctions"].([]interface{}); ok && len(refs) > 0 {
out := make([]map[string]interface{}, 0, len(refs))
for _, refAny := range refs {
ref, ok := refAny.(map[string]interface{})
if !ok {
continue
}
memberPath := asString(ref["@odata.id"])
if memberPath == "" {
continue
}
memberDoc, err := c.getJSON(ctx, client, req, baseURL, memberPath)
if err != nil {
continue
}
out = append(out, memberDoc)
}
return out
}
if ref, ok := links["PCIeFunction"].(map[string]interface{}); ok {
memberPath := asString(ref["@odata.id"])
if memberPath != "" {
memberDoc, err := c.getJSON(ctx, client, req, baseURL, memberPath)
if err == nil {
return []map[string]interface{}{memberDoc}
}
}
}
}
// Some implementations expose a collection object in PCIeFunctions.@odata.id.
if pcieFunctions, ok := doc["PCIeFunctions"].(map[string]interface{}); ok {
if collectionPath := asString(pcieFunctions["@odata.id"]); collectionPath != "" {
memberDocs, err := c.getCollectionMembers(ctx, client, req, baseURL, collectionPath)
if err == nil {
return memberDocs
}
}
}
return nil
}
func (c *RedfishConnector) getNetworkAdapterFunctionDocs(ctx context.Context, client *http.Client, req Request, baseURL string, adapterDoc map[string]interface{}) []map[string]interface{} {
ndfCol, ok := adapterDoc["NetworkDeviceFunctions"].(map[string]interface{})
if !ok {
return nil
}
colPath := asString(ndfCol["@odata.id"])
if colPath == "" {
return nil
}
funcDocs, err := c.getCollectionMembers(ctx, client, req, baseURL, colPath)
if err != nil {
return nil
}
return funcDocs
}
func (c *RedfishConnector) getCollectionMembers(ctx context.Context, client *http.Client, req Request, baseURL, collectionPath string) ([]map[string]interface{}, error) {
collection, err := c.getJSON(ctx, client, req, baseURL, collectionPath)
if err != nil {
return nil, err
}
memberPaths := redfishCollectionMemberRefs(collection)
if len(memberPaths) == 0 {
return []map[string]interface{}{}, nil
}
out := make([]map[string]interface{}, 0, len(memberPaths))
for _, memberPath := range memberPaths {
memberDoc, err := c.getJSON(ctx, client, req, baseURL, memberPath)
if err != nil {
continue
}
if strings.TrimSpace(asString(memberDoc["@odata.id"])) == "" {
memberDoc["@odata.id"] = normalizeRedfishPath(memberPath)
}
out = append(out, memberDoc)
}
return out, nil
}
func (c *RedfishConnector) getJSON(ctx context.Context, client *http.Client, req Request, baseURL, requestPath string) (map[string]interface{}, error) {
start := time.Now()
rel := requestPath
if rel == "" {
rel = "/"
}
if !strings.HasPrefix(rel, "/") {
rel = "/" + rel
}
u, err := url.Parse(baseURL)
if err != nil {
return nil, err
}
u.Path = path.Join(strings.TrimSuffix(u.Path, "/"), rel)
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return nil, err
}
httpReq.Header.Set("Accept", "application/json")
switch req.AuthType {
case "password":
httpReq.SetBasicAuth(req.Username, req.Password)
case "token":
httpReq.Header.Set("X-Auth-Token", req.Token)
httpReq.Header.Set("Authorization", "Bearer "+req.Token)
}
resp, err := client.Do(httpReq)
if err != nil {
recordRedfishTelemetry(ctx, time.Since(start), true)
c.debugf("http get path=%s error=%v dur=%s", requestPath, err, time.Since(start).Round(time.Millisecond))
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
err := fmt.Errorf("status %d from %s: %s", resp.StatusCode, requestPath, strings.TrimSpace(string(body)))
recordRedfishTelemetry(ctx, time.Since(start), true)
c.debugf("http get path=%s status=%d dur=%s", requestPath, resp.StatusCode, time.Since(start).Round(time.Millisecond))
return nil, err
}
var doc map[string]interface{}
dec := json.NewDecoder(resp.Body)
dec.UseNumber()
if err := dec.Decode(&doc); err != nil {
recordRedfishTelemetry(ctx, time.Since(start), true)
c.debugf("http get path=%s decode_error=%v dur=%s", requestPath, err, time.Since(start).Round(time.Millisecond))
return nil, err
}
recordRedfishTelemetry(ctx, time.Since(start), false)
c.debugf("http get path=%s status=%d dur=%s", requestPath, resp.StatusCode, time.Since(start).Round(time.Millisecond))
return doc, nil
}
func (c *RedfishConnector) getJSONWithRetry(ctx context.Context, client *http.Client, req Request, baseURL, requestPath string, attempts int, backoff time.Duration) (map[string]interface{}, error) {
if attempts < 1 {
attempts = 1
}
var lastErr error
for i := 0; i < attempts; i++ {
doc, err := c.getJSON(ctx, client, req, baseURL, requestPath)
if err == nil {
return doc, nil
}
lastErr = err
if i == attempts-1 || !isRetryableRedfishFetchError(err) {
break
}
if backoff > 0 {
select {
case <-time.After(backoff * time.Duration(i+1)):
case <-ctx.Done():
return nil, ctx.Err()
}
}
}
return nil, lastErr
}
func (c *RedfishConnector) collectCriticalCollectionMembersSequential(
ctx context.Context,
client *http.Client,
req Request,
baseURL string,
collectionDoc map[string]interface{},
rawTree map[string]interface{},
fetchErrs map[string]string,
) (map[string]interface{}, bool) {
memberPaths := redfishCollectionMemberRefs(collectionDoc)
if len(memberPaths) == 0 {
return nil, false
}
retryableMissing := make([]string, 0, len(memberPaths))
unknownMissing := make([]string, 0, len(memberPaths))
for _, memberPath := range memberPaths {
memberPath = normalizeRedfishPath(memberPath)
if memberPath == "" {
continue
}
if _, exists := rawTree[memberPath]; exists {
continue
}
if msg, hasErr := fetchErrs[memberPath]; hasErr {
if !isRetryableRedfishFetchError(fmt.Errorf("%s", msg)) {
continue
}
retryableMissing = append(retryableMissing, memberPath)
continue
}
unknownMissing = append(unknownMissing, memberPath)
}
candidates := append(retryableMissing, unknownMissing...)
if len(candidates) == 0 {
return nil, false
}
if maxMembers := redfishCriticalMemberRecoveryMax(); maxMembers > 0 && len(candidates) > maxMembers {
candidates = candidates[:maxMembers]
}
out := make(map[string]interface{}, len(candidates))
for _, memberPath := range candidates {
doc, err := c.getJSONWithRetry(ctx, client, req, baseURL, memberPath, redfishCriticalMemberRetryAttempts(), redfishCriticalRetryBackoff())
if err != nil {
continue
}
out[memberPath] = doc
}
return out, true
}
func (c *RedfishConnector) recoverCriticalRedfishDocsPlanB(ctx context.Context, client *http.Client, req Request, baseURL string, criticalPaths []string, rawTree map[string]interface{}, fetchErrs map[string]string, tuning redfishprofile.AcquisitionTuning, emit ProgressFn) int {
planBStart := time.Now()
timings := newRedfishPathTimingCollector(4)
var targets []string
seenTargets := make(map[string]struct{})
skippedDiagnosticTargets := 0
addTarget := func(path string) {
path = normalizeRedfishPath(path)
if path == "" {
return
}
if !shouldIncludeCriticalPlanBPath(req, path) {
skippedDiagnosticTargets++
return
}
if _, ok := seenTargets[path]; ok {
return
}
seenTargets[path] = struct{}{}
targets = append(targets, path)
}
for _, p := range criticalPaths {
p = normalizeRedfishPath(p)
if p == "" {
continue
}
if _, ok := rawTree[p]; ok {
continue
}
errMsg, hasErr := fetchErrs[p]
if hasErr && !isRetryableRedfishFetchError(fmt.Errorf("%s", errMsg)) {
continue
}
addTarget(p)
}
// If a critical collection document was fetched, but some of its members
// failed during the initial crawl (common for /Drives on partially loaded BMCs),
// retry those member resources in plan-B too.
for _, p := range criticalPaths {
p = normalizeRedfishPath(p)
if p == "" {
continue
}
docAny, ok := rawTree[p]
if !ok {
continue
}
doc, ok := docAny.(map[string]interface{})
if !ok {
continue
}
for _, memberPath := range redfishCollectionMemberRefs(doc) {
if _, exists := rawTree[memberPath]; exists {
continue
}
errMsg, hasErr := fetchErrs[memberPath]
if hasErr && !isRetryableRedfishFetchError(fmt.Errorf("%s", errMsg)) {
continue
}
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
}
if emit != nil {
if skippedDiagnosticTargets > 0 {
emit(Progress{
Status: "running",
Progress: 97,
Message: fmt.Sprintf("Redfish: расширенная диагностика выключена, пропущено %d тяжелых diagnostic endpoint", skippedDiagnosticTargets),
})
}
totalETA := redfishCriticalCooldown() + estimatePlanBETA(len(targets))
emit(Progress{
Status: "running",
Progress: 97,
Message: fmt.Sprintf("Redfish: cooldown перед повторным добором критичных endpoint... ETA≈%s", formatETA(totalETA)),
CurrentPhase: "critical_plan_b",
ETASeconds: int(totalETA.Seconds()),
})
}
select {
case <-time.After(redfishCriticalCooldown()):
case <-ctx.Done():
return 0
}
recovered := 0
for i, p := range targets {
if emit != nil {
remaining := len(targets) - i
emit(Progress{
Status: "running",
Progress: 97,
Message: fmt.Sprintf("Redfish: plan-B (%d/%d, ETA≈%s) %s", i+1, len(targets), formatETA(estimatePlanBETA(remaining)), compactProgressPath(p)),
CurrentPhase: "critical_plan_b",
ETASeconds: int(estimatePlanBETA(remaining).Seconds()),
})
}
if i > 0 {
select {
case <-time.After(redfishCriticalSlowGap()):
case <-ctx.Done():
return recovered
}
}
reqStart := time.Now()
doc, err := c.getJSONWithRetry(ctx, client, req, baseURL, p, redfishCriticalPlanBAttempts(), redfishCriticalRetryBackoff())
timings.Observe(p, time.Since(reqStart), err != nil)
if err == nil {
rawTree[p] = doc
delete(fetchErrs, p)
recovered++
if tuning.RecoveryPolicy.EnableCriticalCollectionMemberRetry {
if members, ok := c.collectCriticalCollectionMembersSequential(ctx, client, req, baseURL, doc, rawTree, fetchErrs); ok {
for mp, md := range members {
if _, exists := rawTree[mp]; exists {
continue
}
rawTree[mp] = md
delete(fetchErrs, mp)
recovered++
}
}
}
// Numeric slow-probe is expensive; skip it when collection already advertises explicit members.
if shouldSlowProbeCriticalCollection(p, tuning) && !redfishCollectionHasExplicitMembers(doc) {
if children := c.probeDirectRedfishCollectionChildrenSlow(ctx, client, req, baseURL, p); len(children) > 0 {
for cp, cd := range children {
if _, exists := rawTree[cp]; exists {
continue
}
rawTree[cp] = cd
recovered++
}
}
}
continue
}
fetchErrs[p] = err.Error()
// If collection endpoint times out, still try direct child probing for common numeric paths.
if shouldSlowProbeCriticalCollection(p, tuning) {
if children := c.probeDirectRedfishCollectionChildrenSlow(ctx, client, req, baseURL, p); len(children) > 0 {
for cp, cd := range children {
if _, exists := rawTree[cp]; exists {
continue
}
rawTree[cp] = cd
recovered++
}
delete(fetchErrs, p)
}
}
}
if emit != nil {
if summary := timings.Summary(3); summary != "" {
emit(Progress{
Status: "running",
Progress: 97,
Message: fmt.Sprintf("Redfish: plan-B топ веток по времени: %s", summary),
})
}
emit(Progress{
Status: "running",
Progress: 97,
Message: fmt.Sprintf("Redfish: plan-B завершен за %s (targets=%d, recovered=%d)", time.Since(planBStart).Round(time.Second), len(targets), recovered),
})
}
if summary := timings.Summary(12); summary != "" {
log.Printf("redfish-planb-timing: %s", summary)
}
return recovered
}
func shouldIncludeCriticalPlanBPath(req Request, path string) bool {
if req.DebugPayloads {
return true
}
return !isExtendedDiagnosticCriticalPlanBPath(path)
}
func isExtendedDiagnosticCriticalPlanBPath(path string) bool {
path = normalizeRedfishPath(path)
if path == "" {
return false
}
parts := strings.Split(strings.Trim(path, "/"), "/")
if len(parts) < 5 || parts[0] != "redfish" || parts[1] != "v1" || parts[2] != "Chassis" {
return false
}
if !strings.HasPrefix(parts[3], "HGX_") {
return false
}
for _, suffix := range []string{
"/Accelerators",
"/Assembly",
"/Drives",
"/NetworkAdapters",
"/PCIeDevices",
} {
if strings.HasSuffix(path, suffix) {
return true
}
}
return false
}
func (c *RedfishConnector) recoverProfilePlanBDocs(ctx context.Context, client *http.Client, req Request, baseURL string, plan redfishprofile.AcquisitionPlan, rawTree map[string]interface{}, emit ProgressFn) int {
if len(plan.PlanBPaths) == 0 || plan.Mode == redfishprofile.ModeFallback || !plan.Tuning.RecoveryPolicy.EnableProfilePlanB {
return 0
}
planBStart := time.Now()
targets := make([]string, 0, len(plan.PlanBPaths))
for _, p := range plan.PlanBPaths {
p = normalizeRedfishPath(p)
if p == "" {
continue
}
if _, exists := rawTree[p]; exists {
continue
}
targets = append(targets, p)
}
if len(targets) == 0 {
return 0
}
if emit != nil {
emit(Progress{
Status: "running",
Progress: 98,
Message: fmt.Sprintf("Redfish: profile plan-B добирает %d endpoint...", len(targets)),
CurrentPhase: "profile_plan_b",
ETASeconds: int(estimateProgressETA(planBStart, 0, len(targets), 2*time.Second).Seconds()),
})
}
recovered := 0
for i, p := range targets {
if emit != nil {
emit(Progress{
Status: "running",
Progress: 98,
Message: fmt.Sprintf("Redfish: profile plan-B (%d/%d) %s", i+1, len(targets), compactProgressPath(p)),
CurrentPhase: "profile_plan_b",
ETASeconds: int(estimateProgressETA(planBStart, i, len(targets), 2*time.Second).Seconds()),
})
}
doc, err := c.getJSONWithRetry(ctx, client, req, baseURL, p, redfishCriticalPlanBAttempts(), redfishCriticalRetryBackoff())
if err != nil {
continue
}
rawTree[p] = doc
recovered++
}
if recovered > 0 {
log.Printf(
"redfish-profile-planb: mode=%s profiles=%s targets=%d recovered=%d",
plan.Mode,
strings.Join(plan.Profiles, ","),
len(targets),
recovered,
)
}
if emit != nil {
emit(Progress{
Status: "running",
Progress: 98,
Message: fmt.Sprintf("Redfish: profile plan-B завершен за %s (targets=%d, recovered=%d)", time.Since(planBStart).Round(time.Second), len(targets), recovered),
CurrentPhase: "profile_plan_b",
})
}
return recovered
}
func parseBoardInfo(system map[string]interface{}) models.BoardInfo {
return models.BoardInfo{
Manufacturer: normalizeRedfishIdentityField(asString(system["Manufacturer"])),
ProductName: normalizeRedfishIdentityField(firstNonEmpty(
asString(system["Model"]),
asString(system["ProductName"]),
asString(system["Name"]),
)),
SerialNumber: normalizeRedfishIdentityField(asString(system["SerialNumber"])),
PartNumber: normalizeRedfishIdentityField(firstNonEmpty(
asString(system["PartNumber"]),
asString(system["SKU"]),
)),
UUID: normalizeRedfishIdentityField(asString(system["UUID"])),
}
}
func parseBoardInfoWithFallback(system, chassis, fru map[string]interface{}) models.BoardInfo {
board := parseBoardInfo(system)
chassisBoard := parseBoardInfo(chassis)
fruBoard := parseBoardInfoFromFRUDoc(fru)
if board.Manufacturer == "" {
board.Manufacturer = firstNonEmpty(chassisBoard.Manufacturer, fruBoard.Manufacturer)
}
if board.ProductName == "" {
board.ProductName = firstNonEmpty(chassisBoard.ProductName, fruBoard.ProductName)
}
if board.SerialNumber == "" {
board.SerialNumber = firstNonEmpty(chassisBoard.SerialNumber, fruBoard.SerialNumber)
}
if board.PartNumber == "" {
board.PartNumber = firstNonEmpty(chassisBoard.PartNumber, fruBoard.PartNumber)
}
if board.UUID == "" {
board.UUID = chassisBoard.UUID
}
return board
}
func parseBoardInfoFromFRUDoc(doc map[string]interface{}) models.BoardInfo {
if len(doc) == 0 {
return models.BoardInfo{}
}
return models.BoardInfo{
Manufacturer: findFirstNormalizedStringByKeys(doc, "Manufacturer", "BoardManufacturer", "Vendor"),
ProductName: findFirstNormalizedStringByKeys(doc, "ProductName", "BoardName", "PlatformId", "PlatformName", "MachineTypeModel", "Model"),
SerialNumber: findFirstNormalizedStringByKeys(doc, "SerialNumber", "BoardSerialNumber"),
PartNumber: findFirstNormalizedStringByKeys(doc, "PartNumber", "BoardPartNumber", "ProductPartNumber"),
}
}
func findFirstNormalizedStringByKeys(doc map[string]interface{}, keys ...string) string {
if len(doc) == 0 || len(keys) == 0 {
return ""
}
keySet := make(map[string]struct{}, len(keys))
for _, key := range keys {
k := strings.ToLower(strings.TrimSpace(key))
if k != "" {
keySet[k] = struct{}{}
}
}
stack := []any{doc}
for len(stack) > 0 {
last := len(stack) - 1
node := stack[last]
stack = stack[:last]
switch v := node.(type) {
case map[string]interface{}:
for k, raw := range v {
if _, ok := keySet[strings.ToLower(strings.TrimSpace(k))]; ok {
if s, ok := raw.(string); ok {
if normalized := normalizeRedfishIdentityField(s); normalized != "" {
return normalized
}
}
}
switch nested := raw.(type) {
case map[string]interface{}, []interface{}:
stack = append(stack, nested)
}
}
case []interface{}:
for _, item := range v {
switch nested := item.(type) {
case map[string]interface{}, []interface{}:
stack = append(stack, nested)
}
}
}
}
return ""
}
func parseCPUs(docs []map[string]interface{}) []models.CPU {
cpus := make([]models.CPU, 0, len(docs))
socketIdx := 0
for _, doc := range docs {
// Skip non-CPU processors (GPUs, FPGAs, etc.) that some BMCs list in the
// same Processors collection.
if pt := strings.TrimSpace(asString(doc["ProcessorType"])); pt != "" &&
!strings.EqualFold(pt, "CPU") && !strings.EqualFold(pt, "General") {
continue
}
socket := socketIdx
socketIdx++
if s := strings.TrimSpace(asString(doc["Socket"])); s != "" {
// Parse numeric suffix from labels like "CPU0", "Processor 1", etc.
trimmed := strings.TrimLeft(strings.ToUpper(s), "ABCDEFGHIJKLMNOPQRSTUVWXYZ _")
if n, err := strconv.Atoi(trimmed); err == nil {
socket = n
}
}
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: 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,
Status: mapStatus(doc["Status"]),
Details: redfishCPUDetails(doc),
})
}
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) {
mem, _ := doc["ProcessorMemory"].([]interface{})
for _, mAny := range mem {
m, ok := mAny.(map[string]interface{})
if !ok {
continue
}
capMiB := asInt(m["CapacityMiB"])
if capMiB == 0 {
continue
}
capKB := capMiB * 1024
switch strings.ToUpper(strings.TrimSpace(asString(m["MemoryType"]))) {
case "L1CACHE":
l1 = capKB
case "L2CACHE":
l2 = capKB
case "L3CACHE":
l3 = capKB
}
}
return
}
func parseMemory(docs []map[string]interface{}) []models.MemoryDIMM {
out := make([]models.MemoryDIMM, 0, len(docs))
for _, doc := range docs {
slot := firstNonEmpty(
asString(doc["DeviceLocator"]),
redfishLocationLabel(doc["Location"]),
asString(doc["Name"]),
asString(doc["Id"]),
)
present := true
if strings.EqualFold(asString(doc["Status"]), "Absent") {
present = false
}
if status, ok := doc["Status"].(map[string]interface{}); ok {
state := asString(status["State"])
if strings.EqualFold(state, "Absent") || strings.EqualFold(state, "Disabled") {
present = false
}
}
out = append(out, models.MemoryDIMM{
Slot: slot,
Location: slot,
Present: present,
SizeMB: asInt(doc["CapacityMiB"]),
Type: firstNonEmpty(asString(doc["MemoryDeviceType"]), asString(doc["MemoryType"])),
MaxSpeedMHz: asInt(doc["MaxSpeedMHz"]),
CurrentSpeedMHz: asInt(doc["OperatingSpeedMhz"]),
Manufacturer: asString(doc["Manufacturer"]),
SerialNumber: findFirstNormalizedStringByKeys(doc, "SerialNumber"),
PartNumber: asString(doc["PartNumber"]),
Status: mapStatus(doc["Status"]),
Details: redfishMemoryDetails(doc),
})
}
return out
}
func redfishCPUDetails(doc map[string]interface{}) map[string]any {
return redfishCPUDetailsAcrossDocs(doc)
}
func redfishCPUDetailsAcrossDocs(doc map[string]interface{}, supplementalDocs ...map[string]interface{}) map[string]any {
lookupDocs := append([]map[string]interface{}{doc}, supplementalDocs...)
details := make(map[string]any)
addFloatDetail(details, "temperature_c", redfishFirstNumericAcrossDocs(lookupDocs,
"TemperatureCelsius", "TemperatureC", "Temperature", "CurrentTemperature", "temperature",
))
addFloatDetail(details, "power_w", redfishFirstNumericAcrossDocs(lookupDocs,
"PowerConsumedWatts", "PowerWatts", "PowerConsumptionWatts",
))
addBoolDetail(details, "throttled", redfishFirstBoolAcrossDocs(lookupDocs,
"Throttled", "ThermalThrottled", "PerformanceThrottled",
))
addInt64Detail(details, "correctable_error_count", redfishFirstInt64AcrossDocs(lookupDocs,
"CorrectableErrorCount", "CorrectableErrors", "CorrectableECCErrorCount",
))
addInt64Detail(details, "uncorrectable_error_count", redfishFirstInt64AcrossDocs(lookupDocs,
"UncorrectableErrorCount", "UncorrectableErrors", "UncorrectableECCErrorCount",
))
addFloatDetail(details, "life_remaining_pct", redfishFirstNumericAcrossDocs(lookupDocs,
"LifeRemainingPercent", "PredictedLifeLeftPercent",
))
addFloatDetail(details, "life_used_pct", redfishFirstNumericAcrossDocs(lookupDocs,
"LifeUsedPercent", "PercentageLifeUsed",
))
for _, lookupDoc := range lookupDocs {
if microcode, ok := redfishLookupValue(lookupDoc, "MicrocodeVersion"); ok {
if s := strings.TrimSpace(asString(microcode)); s != "" {
details["microcode"] = s
break
}
}
if microcode, ok := redfishLookupValue(lookupDoc, "Microcode"); ok {
if s := strings.TrimSpace(asString(microcode)); s != "" {
details["microcode"] = s
break
}
}
}
if len(details) == 0 {
return nil
}
return details
}
func redfishMemoryDetails(doc map[string]interface{}) map[string]any {
return redfishMemoryDetailsAcrossDocs(doc)
}
func redfishMemoryDetailsAcrossDocs(doc map[string]interface{}, supplementalDocs ...map[string]interface{}) map[string]any {
lookupDocs := append([]map[string]interface{}{doc}, supplementalDocs...)
details := make(map[string]any)
addFloatDetail(details, "temperature_c", redfishFirstNumericAcrossDocs(lookupDocs,
"TemperatureCelsius", "TemperatureC", "Temperature", "CurrentTemperature", "temperature",
))
addInt64Detail(details, "correctable_ecc_error_count", redfishFirstInt64AcrossDocs(lookupDocs,
"CorrectableECCErrorCount", "CorrectableErrorCount", "CorrectableErrors",
))
addInt64Detail(details, "uncorrectable_ecc_error_count", redfishFirstInt64AcrossDocs(lookupDocs,
"UncorrectableECCErrorCount", "UncorrectableErrorCount", "UncorrectableErrors",
))
addFloatDetail(details, "life_remaining_pct", redfishFirstNumericAcrossDocs(lookupDocs,
"LifeRemainingPercent", "PredictedLifeLeftPercent",
))
addFloatDetail(details, "life_used_pct", redfishFirstNumericAcrossDocs(lookupDocs,
"LifeUsedPercent", "PercentageLifeUsed",
))
addFloatDetail(details, "spare_blocks_remaining_pct", redfishFirstNumericAcrossDocs(lookupDocs,
"SpareBlocksRemainingPercent", "SpareBlocksRemainingPct",
))
addBoolDetail(details, "performance_degraded", redfishFirstBoolAcrossDocs(lookupDocs,
"PerformanceDegraded", "Degraded",
))
addBoolDetail(details, "data_loss_detected", redfishFirstBoolAcrossDocs(lookupDocs,
"DataLossDetected", "DataLoss",
))
if len(details) == 0 {
return nil
}
return details
}
func parseDrive(doc map[string]interface{}) models.Storage {
return parseDriveWithSupplementalDocs(doc)
}
func parseDriveWithSupplementalDocs(doc map[string]interface{}, supplementalDocs ...map[string]interface{}) models.Storage {
sizeGB := 0
if capBytes := asInt64(doc["CapacityBytes"]); capBytes > 0 {
sizeGB = int(capBytes / (1024 * 1024 * 1024))
}
if sizeGB == 0 {
sizeGB = asInt(doc["CapacityGB"])
}
if sizeGB == 0 {
sizeGB = asInt(doc["CapacityMiB"]) / 1024
}
storageType := classifyStorageType(doc)
slot := normalizeRAIDDriveSlot(firstNonEmpty(asString(doc["Id"]), asString(doc["Name"])))
return models.Storage{
Slot: slot,
Type: storageType,
Model: firstNonEmpty(asString(doc["Model"]), asString(doc["Name"])),
SizeGB: sizeGB,
SerialNumber: findFirstNormalizedStringByKeys(doc, "SerialNumber"),
Manufacturer: asString(doc["Manufacturer"]),
Firmware: asString(doc["Revision"]),
Interface: asString(doc["Protocol"]),
Present: true,
Details: redfishDriveDetailsWithSupplementalDocs(doc, supplementalDocs...),
}
}
// isNumericString returns true if s is a non-empty string of only ASCII digits.
func isNumericString(s string) bool {
if s == "" {
return false
}
for _, c := range s {
if c < '0' || c > '9' {
return false
}
}
return true
}
// normalizeRAIDDriveSlot converts Inspur-style RAID drive IDs to canonical BP notation.
// Example: "PCIe8_RAID_Disk_1:0" → "BP0:0" (enclosure_id - 1 = backplane_index)
// Other slot names are returned unchanged.
func normalizeRAIDDriveSlot(slot string) string {
// Pattern: {anything}_RAID_Disk_{enclosure}:{slot}
const marker = "_RAID_Disk_"
idx := strings.Index(slot, marker)
if idx < 0 {
return slot
}
rest := slot[idx+len(marker):] // e.g. "1:0"
colonIdx := strings.Index(rest, ":")
if colonIdx < 0 {
return slot
}
encStr := rest[:colonIdx]
slotStr := rest[colonIdx+1:]
enc, err := strconv.Atoi(encStr)
if err != nil || enc < 1 {
return slot
}
return fmt.Sprintf("BP%d:%s", enc-1, slotStr)
}
func parseStorageVolume(doc map[string]interface{}, controller string) models.StorageVolume {
sizeGB := 0
capBytes := asInt64(doc["CapacityBytes"])
if capBytes > 0 {
sizeGB = int(capBytes / (1024 * 1024 * 1024))
}
if sizeGB == 0 {
sizeGB = asInt(doc["CapacityGB"])
}
raidLevel := firstNonEmpty(asString(doc["RAIDType"]), asString(doc["VolumeType"]))
if raidLevel == "" {
if v, ok := doc["Oem"].(map[string]interface{}); ok {
if smc, ok := v["Supermicro"].(map[string]interface{}); ok {
raidLevel = firstNonEmpty(raidLevel, asString(smc["RAIDType"]), asString(smc["VolumeType"]))
}
}
}
return models.StorageVolume{
ID: asString(doc["Id"]),
Name: firstNonEmpty(asString(doc["Name"]), asString(doc["Id"])),
Controller: strings.TrimSpace(controller),
RAIDLevel: raidLevel,
SizeGB: sizeGB,
CapacityBytes: capBytes,
Status: mapStatus(doc["Status"]),
Bootable: asBool(doc["Bootable"]),
Encrypted: asBool(doc["Encrypted"]),
}
}
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"])
model := firstNonEmpty(asString(doc["Model"]), asString(doc["Name"]))
if isMissingOrRawPCIModel(model) {
if resolved := pciids.DeviceName(vendorID, deviceID); resolved != "" {
model = resolved
}
}
vendor := asString(doc["Manufacturer"])
if strings.TrimSpace(vendor) == "" {
vendor = pciids.VendorName(vendorID)
}
location := redfishLocationLabel(doc["Location"])
slot := firstNonEmpty(redfishLocationLabel(doc["Location"]), asString(doc["Id"]), asString(doc["Name"]))
var firmware string
var portCount int
var linkWidth int
var maxLinkWidth int
var linkSpeed string
var maxLinkSpeed string
if controllers, ok := doc["Controllers"].([]interface{}); ok && len(controllers) > 0 {
totalPortCount := 0
for _, ctrlAny := range controllers {
ctrl, ok := ctrlAny.(map[string]interface{})
if !ok {
continue
}
ctrlLocation := redfishLocationLabel(ctrl["Location"])
location = firstNonEmpty(location, ctrlLocation)
if isWeakRedfishNICSlotLabel(slot) {
slot = firstNonEmpty(ctrlLocation, slot)
}
if normalizeRedfishIdentityField(firmware) == "" {
firmware = findFirstNormalizedStringByKeys(ctrl, "FirmwarePackageVersion", "FirmwareVersion")
}
if caps, ok := ctrl["ControllerCapabilities"].(map[string]interface{}); ok {
totalPortCount += sanitizeNetworkPortCount(asInt(caps["NetworkPortCount"]))
}
if pcieIf, ok := ctrl["PCIeInterface"].(map[string]interface{}); ok && linkWidth == 0 && maxLinkWidth == 0 && linkSpeed == "" && maxLinkSpeed == "" {
linkWidth = asInt(pcieIf["LanesInUse"])
maxLinkWidth = firstNonZeroInt(asInt(pcieIf["MaxLanes"]), asInt(pcieIf["Maxlanes"]))
linkSpeed = firstNonEmpty(asString(pcieIf["PCIeType"]), asString(pcieIf["CurrentLinkSpeedGTs"]), asString(pcieIf["CurrentLinkSpeed"]))
maxLinkSpeed = firstNonEmpty(asString(pcieIf["MaxPCIeType"]), asString(pcieIf["MaxLinkSpeedGTs"]), asString(pcieIf["MaxLinkSpeed"]))
}
}
portCount = sanitizeNetworkPortCount(totalPortCount)
}
return models.NetworkAdapter{
Slot: slot,
Location: location,
Present: !strings.EqualFold(mapStatus(doc["Status"]), "Absent"),
BDF: sanitizeRedfishBDF(asString(doc["BDF"])),
Model: strings.TrimSpace(model),
Vendor: strings.TrimSpace(vendor),
VendorID: vendorID,
DeviceID: deviceID,
SerialNumber: findFirstNormalizedStringByKeys(doc, "SerialNumber"),
PartNumber: asString(doc["PartNumber"]),
Firmware: firmware,
PortCount: portCount,
LinkWidth: linkWidth,
LinkSpeed: linkSpeed,
MaxLinkWidth: maxLinkWidth,
MaxLinkSpeed: maxLinkSpeed,
Status: mapStatus(doc["Status"]),
Details: redfishPCIeDetails(doc, nil),
}
}
func isWeakRedfishNICSlotLabel(slot string) bool {
slot = strings.TrimSpace(slot)
if slot == "" {
return true
}
lower := strings.ToLower(slot)
if isNumericString(slot) {
return true
}
if strings.EqualFold(slot, "nic") || strings.HasPrefix(lower, "nic") && !strings.Contains(lower, "slot") {
return true
}
if strings.HasPrefix(lower, "devtype") {
return true
}
return false
}
func networkAdapterPCIeDevicePaths(doc map[string]interface{}) []string {
var out []string
if controllers, ok := doc["Controllers"].([]interface{}); ok {
for _, ctrlAny := range controllers {
ctrl, ok := ctrlAny.(map[string]interface{})
if !ok {
continue
}
links, ok := ctrl["Links"].(map[string]interface{})
if !ok {
continue
}
refs, ok := links["PCIeDevices"].([]interface{})
if !ok {
continue
}
for _, refAny := range refs {
ref, ok := refAny.(map[string]interface{})
if !ok {
continue
}
if p := asString(ref["@odata.id"]); p != "" {
out = append(out, p)
}
}
}
}
return out
}
func enrichNICFromPCIe(nic *models.NetworkAdapter, pcieDoc map[string]interface{}, functionDocs []map[string]interface{}, supplementalDocs []map[string]interface{}) {
if nic == nil {
return
}
pcieSlot := redfishLocationLabel(pcieDoc["Slot"])
if pcieSlot == "" {
pcieSlot = redfishLocationLabel(pcieDoc["Location"])
}
if isWeakRedfishNICSlotLabel(nic.Slot) && pcieSlot != "" {
nic.Slot = pcieSlot
}
if strings.TrimSpace(nic.Location) == "" && pcieSlot != "" {
nic.Location = pcieSlot
}
if strings.TrimSpace(nic.BDF) == "" {
nic.BDF = firstNonEmpty(asString(pcieDoc["BDF"]), buildBDFfromOemPublic(pcieDoc))
}
if nic.VendorID == 0 {
nic.VendorID = asHexOrInt(pcieDoc["VendorId"])
}
if nic.DeviceID == 0 {
nic.DeviceID = asHexOrInt(pcieDoc["DeviceId"])
}
if nic.LinkWidth == 0 {
nic.LinkWidth = asInt(pcieDoc["CurrentLinkWidth"])
}
if nic.MaxLinkWidth == 0 {
nic.MaxLinkWidth = asInt(pcieDoc["MaxLinkWidth"])
}
if strings.TrimSpace(nic.LinkSpeed) == "" {
nic.LinkSpeed = firstNonEmpty(asString(pcieDoc["CurrentLinkSpeedGTs"]), asString(pcieDoc["CurrentLinkSpeed"]))
}
if strings.TrimSpace(nic.MaxLinkSpeed) == "" {
nic.MaxLinkSpeed = firstNonEmpty(asString(pcieDoc["MaxLinkSpeedGTs"]), asString(pcieDoc["MaxLinkSpeed"]))
}
if nic.LinkWidth == 0 || nic.MaxLinkWidth == 0 || nic.LinkSpeed == "" || nic.MaxLinkSpeed == "" {
redfishEnrichFromOEMxFusionPCIeLink(pcieDoc, &nic.LinkWidth, &nic.MaxLinkWidth, &nic.LinkSpeed, &nic.MaxLinkSpeed)
}
if normalizeRedfishIdentityField(nic.SerialNumber) == "" {
nic.SerialNumber = findFirstNormalizedStringByKeys(pcieDoc, "SerialNumber")
}
if normalizeRedfishIdentityField(nic.PartNumber) == "" {
nic.PartNumber = findFirstNormalizedStringByKeys(pcieDoc, "PartNumber", "ProductPartNumber")
}
if normalizeRedfishIdentityField(nic.Firmware) == "" {
nic.Firmware = findFirstNormalizedStringByKeys(pcieDoc, "FirmwareVersion", "FirmwarePackageVersion")
}
for _, fn := range functionDocs {
if strings.TrimSpace(nic.BDF) == "" {
nic.BDF = sanitizeRedfishBDF(asString(fn["FunctionId"]))
}
if nic.VendorID == 0 {
nic.VendorID = asHexOrInt(fn["VendorId"])
}
if nic.DeviceID == 0 {
nic.DeviceID = asHexOrInt(fn["DeviceId"])
}
if nic.LinkWidth == 0 {
nic.LinkWidth = asInt(fn["CurrentLinkWidth"])
}
if nic.MaxLinkWidth == 0 {
nic.MaxLinkWidth = asInt(fn["MaxLinkWidth"])
}
if strings.TrimSpace(nic.LinkSpeed) == "" {
nic.LinkSpeed = firstNonEmpty(asString(fn["CurrentLinkSpeedGTs"]), asString(fn["CurrentLinkSpeed"]))
}
if strings.TrimSpace(nic.MaxLinkSpeed) == "" {
nic.MaxLinkSpeed = firstNonEmpty(asString(fn["MaxLinkSpeedGTs"]), asString(fn["MaxLinkSpeed"]))
}
if nic.LinkWidth == 0 || nic.MaxLinkWidth == 0 || nic.LinkSpeed == "" || nic.MaxLinkSpeed == "" {
redfishEnrichFromOEMxFusionPCIeLink(fn, &nic.LinkWidth, &nic.MaxLinkWidth, &nic.LinkSpeed, &nic.MaxLinkSpeed)
}
if normalizeRedfishIdentityField(nic.SerialNumber) == "" {
nic.SerialNumber = findFirstNormalizedStringByKeys(fn, "SerialNumber")
}
if normalizeRedfishIdentityField(nic.PartNumber) == "" {
nic.PartNumber = findFirstNormalizedStringByKeys(fn, "PartNumber", "ProductPartNumber")
}
if normalizeRedfishIdentityField(nic.Firmware) == "" {
nic.Firmware = findFirstNormalizedStringByKeys(fn, "FirmwareVersion", "FirmwarePackageVersion")
}
}
if strings.TrimSpace(nic.Vendor) == "" {
nic.Vendor = pciids.VendorName(nic.VendorID)
}
if isMissingOrRawPCIModel(nic.Model) {
if resolved := pciids.DeviceName(nic.VendorID, nic.DeviceID); resolved != "" {
nic.Model = resolved
}
}
nic.Details = mergeGenericDetails(nic.Details, redfishPCIeDetailsWithSupplementalDocs(pcieDoc, functionDocs, supplementalDocs))
}
func parsePSU(doc map[string]interface{}, idx int) models.PSU {
return parsePSUWithSupplementalDocs(doc, idx)
}
func parsePSUWithSupplementalDocs(doc map[string]interface{}, idx int, supplementalDocs ...map[string]interface{}) models.PSU {
status := mapStatus(doc["Status"])
present := true
if statusMap, ok := doc["Status"].(map[string]interface{}); ok {
state := asString(statusMap["State"])
if strings.EqualFold(state, "Absent") || strings.EqualFold(state, "Disabled") {
present = false
}
}
slot := firstNonEmpty(
asString(doc["MemberId"]),
asString(doc["Id"]),
asString(doc["Name"]),
)
if slot == "" {
slot = fmt.Sprintf("PSU%d", idx)
}
// Normalize numeric-only slots ("0", "1") to "PSU0", "PSU1" for consistency
// with BMC log parsers (Inspur, Dell etc.) that use the PSU prefix.
if isNumericString(slot) {
slot = "PSU" + slot
}
return models.PSU{
Slot: slot,
Present: present,
Model: firstNonEmpty(asString(doc["Model"]), asString(doc["Name"])),
Vendor: asString(doc["Manufacturer"]),
WattageW: redfishPSUNominalWattage(doc),
SerialNumber: findFirstNormalizedStringByKeys(doc, "SerialNumber"),
PartNumber: asString(doc["PartNumber"]),
Firmware: asString(doc["FirmwareVersion"]),
Status: status,
InputType: asString(doc["LineInputVoltageType"]),
InputPowerW: asInt(doc["PowerInputWatts"]),
OutputPowerW: asInt(doc["LastPowerOutputWatts"]),
InputVoltage: asFloat(doc["LineInputVoltage"]),
Details: redfishPSUDetailsWithSupplementalDocs(doc, supplementalDocs...),
}
}
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)
}
func redfishDriveDetailsWithSupplementalDocs(doc map[string]interface{}, supplementalDocs ...map[string]interface{}) map[string]any {
details := make(map[string]any)
lookupDocs := append([]map[string]interface{}{doc}, supplementalDocs...)
addFloatDetail(details, "temperature_c", redfishFirstNumericAcrossDocs(lookupDocs,
"TemperatureCelsius", "TemperatureC", "Temperature", "CurrentTemperature", "temperature",
))
addInt64Detail(details, "power_on_hours", redfishFirstInt64AcrossDocs(lookupDocs,
"PowerOnHours", "PowerOnHour",
))
addInt64Detail(details, "power_cycles", redfishFirstInt64AcrossDocs(lookupDocs,
"PowerCycles", "PowerCycleCount",
))
addInt64Detail(details, "unsafe_shutdowns", redfishFirstInt64AcrossDocs(lookupDocs,
"UnsafeShutdowns", "UnsafeShutdownCount",
))
addInt64Detail(details, "media_errors", redfishFirstInt64AcrossDocs(lookupDocs,
"MediaErrors", "MediaErrorCount",
))
addInt64Detail(details, "error_log_entries", redfishFirstInt64AcrossDocs(lookupDocs,
"ErrorLogEntries", "ErrorLogEntryCount",
))
addInt64Detail(details, "written_bytes", redfishFirstInt64AcrossDocs(lookupDocs,
"WrittenBytes", "BytesWritten",
))
addInt64Detail(details, "read_bytes", redfishFirstInt64AcrossDocs(lookupDocs,
"ReadBytes", "BytesRead",
))
addFloatDetail(details, "life_remaining_pct", redfishFirstNumericAcrossDocs(lookupDocs,
"PredictedMediaLifeLeftPercent", "LifeRemainingPercent", "PercentageDriveLifeUsedInverse", "PercentLifeRemaining",
))
addFloatDetail(details, "life_used_pct", redfishFirstNumericAcrossDocs(lookupDocs,
"PercentageDriveLifeUsed", "LifeUsedPercent", "PercentageUsed", "PercentLifeUsed",
))
addFloatDetail(details, "available_spare_pct", redfishFirstNumericAcrossDocs(lookupDocs,
"AvailableSparePercent", "AvailableSpare", "PercentAvailableSpare",
))
addInt64Detail(details, "reallocated_sectors", redfishFirstInt64AcrossDocs(lookupDocs,
"ReallocatedSectors", "ReallocatedSectorCount",
))
addInt64Detail(details, "current_pending_sectors", redfishFirstInt64AcrossDocs(lookupDocs,
"CurrentPendingSectors", "CurrentPendingSectorCount",
))
addInt64Detail(details, "offline_uncorrectable", redfishFirstInt64AcrossDocs(lookupDocs,
"OfflineUncorrectable", "OfflineUncorrectableSectorCount",
))
if len(details) == 0 {
return nil
}
return details
}
func redfishPSUDetails(doc map[string]interface{}) map[string]any {
return redfishPSUDetailsWithSupplementalDocs(doc)
}
func redfishPSUDetailsWithSupplementalDocs(doc map[string]interface{}, supplementalDocs ...map[string]interface{}) map[string]any {
details := make(map[string]any)
lookupDocs := append([]map[string]interface{}{doc}, supplementalDocs...)
addFloatDetail(details, "temperature_c", redfishFirstNumericAcrossDocs(lookupDocs,
"TemperatureCelsius", "TemperatureC", "Temperature",
))
addFloatDetail(details, "life_remaining_pct", redfishFirstNumericAcrossDocs(lookupDocs,
"LifeRemainingPercent", "PredictedLifeLeftPercent",
))
addFloatDetail(details, "life_used_pct", redfishFirstNumericAcrossDocs(lookupDocs,
"LifeUsedPercent", "PercentageLifeUsed",
))
if len(details) == 0 {
return nil
}
return details
}
func redfishPCIeDetails(doc map[string]interface{}, functionDocs []map[string]interface{}) map[string]any {
return redfishPCIeDetailsWithSupplementalDocs(doc, functionDocs, nil)
}
func redfishPCIeDetailsWithSupplementalDocs(doc map[string]interface{}, functionDocs []map[string]interface{}, supplementalDocs []map[string]interface{}) map[string]any {
lookupDocs := make([]map[string]interface{}, 0, 1+len(functionDocs)+len(supplementalDocs))
lookupDocs = append(lookupDocs, doc)
lookupDocs = append(lookupDocs, functionDocs...)
lookupDocs = append(lookupDocs, supplementalDocs...)
details := make(map[string]any)
temperatureC := redfishFirstNumericAcrossDocs(lookupDocs,
"TemperatureCelsius", "TemperatureC", "Temperature",
)
if temperatureC == 0 {
temperatureC = redfishSupplementalThermalMetricForPCIe(doc, supplementalDocs)
}
addFloatDetail(details, "temperature_c", temperatureC)
addFloatDetail(details, "power_w", redfishFirstNumericAcrossDocs(lookupDocs,
"PowerConsumedWatts", "PowerWatts", "PowerConsumptionWatts",
))
addFloatDetail(details, "life_remaining_pct", redfishFirstNumericAcrossDocs(lookupDocs,
"LifeRemainingPercent", "PredictedLifeLeftPercent",
))
addFloatDetail(details, "life_used_pct", redfishFirstNumericAcrossDocs(lookupDocs,
"LifeUsedPercent", "PercentageLifeUsed",
))
addInt64Detail(details, "ecc_corrected_total", redfishFirstInt64AcrossDocs(lookupDocs,
"ECCCorrectedTotal", "CorrectableECCErrorCount", "CorrectableErrorCount",
))
addInt64Detail(details, "ecc_uncorrected_total", redfishFirstInt64AcrossDocs(lookupDocs,
"ECCUncorrectedTotal", "UncorrectableECCErrorCount", "UncorrectableErrorCount",
))
addBoolDetail(details, "hw_slowdown", redfishFirstBoolAcrossDocs(lookupDocs,
"HWSlowdown", "HardwareSlowdown",
))
addFloatDetail(details, "battery_charge_pct", redfishFirstNumericAcrossDocs(lookupDocs,
"BatteryChargePercent", "BatteryChargePct",
))
addFloatDetail(details, "battery_health_pct", redfishFirstNumericAcrossDocs(lookupDocs,
"BatteryHealthPercent", "BatteryHealthPct",
))
addFloatDetail(details, "battery_temperature_c", redfishFirstNumericAcrossDocs(lookupDocs,
"BatteryTemperatureCelsius", "BatteryTemperatureC",
))
addFloatDetail(details, "battery_voltage_v", redfishFirstNumericAcrossDocs(lookupDocs,
"BatteryVoltage", "BatteryVoltageV",
))
addBoolDetail(details, "battery_replace_required", redfishFirstBoolAcrossDocs(lookupDocs,
"BatteryReplaceRequired", "ReplaceBattery",
))
addFloatDetail(details, "sfp_temperature_c", redfishFirstNumericAcrossDocs(lookupDocs,
"SFPTemperatureCelsius", "SFPTemperatureC", "TransceiverTemperatureCelsius",
))
addFloatDetail(details, "sfp_tx_power_dbm", redfishFirstNumericAcrossDocs(lookupDocs,
"SFPTXPowerDBm", "SFPTransmitPowerDBm", "TxPowerDBm",
))
addFloatDetail(details, "sfp_rx_power_dbm", redfishFirstNumericAcrossDocs(lookupDocs,
"SFPRXPowerDBm", "SFPReceivePowerDBm", "RxPowerDBm",
))
addFloatDetail(details, "sfp_voltage_v", redfishFirstNumericAcrossDocs(lookupDocs,
"SFPVoltageV", "TransceiverVoltageV",
))
addFloatDetail(details, "sfp_bias_ma", redfishFirstNumericAcrossDocs(lookupDocs,
"SFPBiasMA", "BiasCurrentMA", "LaserBiasCurrentMA",
))
if len(details) == 0 {
return nil
}
return details
}
func redfishSupplementalThermalMetricForPCIe(doc map[string]interface{}, supplementalDocs []map[string]interface{}) float64 {
if len(supplementalDocs) == 0 {
return 0
}
deviceNames := redfishPCIeSupplementalDeviceNames(doc)
if len(deviceNames) == 0 {
return 0
}
for _, supplemental := range supplementalDocs {
readings, ok := supplemental["TemperatureReadingsCelsius"].([]interface{})
if !ok || len(readings) == 0 {
continue
}
for _, readingAny := range readings {
reading, ok := readingAny.(map[string]interface{})
if !ok {
continue
}
deviceName := strings.TrimSpace(asString(reading["DeviceName"]))
if deviceName == "" || !matchesAnyFold(deviceName, deviceNames) {
continue
}
if value := asFloat(reading["Reading"]); value != 0 {
return value
}
}
}
return 0
}
func redfishPCIeSupplementalDeviceNames(doc map[string]interface{}) []string {
names := make([]string, 0, 3)
for _, raw := range []string{
asString(doc["Id"]),
asString(doc["Name"]),
asString(doc["Model"]),
} {
name := strings.TrimSpace(raw)
if name == "" {
continue
}
if !matchesAnyFold(name, names) {
names = append(names, name)
}
}
return names
}
func matchesAnyFold(value string, candidates []string) bool {
for _, candidate := range candidates {
if strings.EqualFold(strings.TrimSpace(value), strings.TrimSpace(candidate)) {
return true
}
}
return false
}
func looksLikeNVSwitchPCIeDoc(doc map[string]interface{}) bool {
joined := strings.ToLower(strings.TrimSpace(strings.Join([]string{
asString(doc["Id"]),
asString(doc["Name"]),
asString(doc["Model"]),
asString(doc["Manufacturer"]),
}, " ")))
return strings.Contains(joined, "nvswitch")
}
func chassisPathForPCIeDoc(docPath string) string {
docPath = normalizeRedfishPath(docPath)
if !strings.Contains(docPath, "/PCIeDevices/") {
return ""
}
idx := strings.Index(docPath, "/PCIeDevices/")
if idx <= 0 {
return ""
}
chassisPath := docPath[:idx]
if !strings.HasPrefix(chassisPath, "/redfish/v1/Chassis/") {
return ""
}
return chassisPath
}
func redfishFirstNumeric(doc map[string]interface{}, keys ...string) float64 {
for _, key := range keys {
if v, ok := redfishLookupValue(doc, key); ok {
if f := asFloat(v); f != 0 {
return f
}
}
}
return 0
}
func redfishFirstNumericAcrossDocs(docs []map[string]interface{}, keys ...string) float64 {
for _, doc := range docs {
if v := redfishFirstNumeric(doc, keys...); v != 0 {
return v
}
}
return 0
}
func redfishFirstNumericWithFunctions(doc map[string]interface{}, functionDocs []map[string]interface{}, keys ...string) float64 {
if v := redfishFirstNumeric(doc, keys...); v != 0 {
return v
}
for _, fn := range functionDocs {
if v := redfishFirstNumeric(fn, keys...); v != 0 {
return v
}
}
return 0
}
func redfishFirstInt64(doc map[string]interface{}, keys ...string) int64 {
for _, key := range keys {
if v, ok := redfishLookupValue(doc, key); ok {
if n := asInt64(v); n != 0 {
return n
}
if n := int64(asInt(v)); n != 0 {
return n
}
}
}
return 0
}
func redfishFirstInt64AcrossDocs(docs []map[string]interface{}, keys ...string) int64 {
for _, doc := range docs {
if v := redfishFirstInt64(doc, keys...); v != 0 {
return v
}
}
return 0
}
func redfishFirstInt64WithFunctions(doc map[string]interface{}, functionDocs []map[string]interface{}, keys ...string) int64 {
if v := redfishFirstInt64(doc, keys...); v != 0 {
return v
}
for _, fn := range functionDocs {
if v := redfishFirstInt64(fn, keys...); v != 0 {
return v
}
}
return 0
}
func redfishFirstBoolAcrossDocs(docs []map[string]interface{}, keys ...string) *bool {
for _, doc := range docs {
if v := redfishFirstBool(doc, keys...); v != nil {
return v
}
}
return nil
}
func 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
}
if v, ok := doc[key]; ok {
return v, true
}
if oem, ok := doc["Oem"].(map[string]interface{}); ok {
if v, ok := redfishLookupNestedValue(oem, key); ok {
return v, true
}
}
return nil, false
}
func redfishFirstBool(doc map[string]interface{}, keys ...string) *bool {
for _, key := range keys {
if v, ok := redfishLookupValue(doc, key); ok {
if b, ok := asBoolPtr(v); ok {
return &b
}
}
}
return nil
}
func redfishFirstBoolWithFunctions(doc map[string]interface{}, functionDocs []map[string]interface{}, keys ...string) *bool {
if v := redfishFirstBool(doc, keys...); v != nil {
return v
}
for _, fn := range functionDocs {
if v := redfishFirstBool(fn, keys...); v != nil {
return v
}
}
return nil
}
func redfishLookupNestedValue(doc map[string]interface{}, key string) (any, bool) {
if doc == nil {
return nil, false
}
if v, ok := doc[key]; ok {
return v, true
}
for _, value := range doc {
nested, ok := value.(map[string]interface{})
if !ok {
continue
}
if v, ok := redfishLookupNestedValue(nested, key); ok {
return v, true
}
}
return nil, false
}
func addFloatDetail(dst map[string]any, key string, value float64) {
if value == 0 {
return
}
dst[key] = value
}
func addInt64Detail(dst map[string]any, key string, value int64) {
if value == 0 {
return
}
dst[key] = value
}
func addBoolDetail(dst map[string]any, key string, value *bool) {
if value == nil {
return
}
dst[key] = *value
}
func asBoolPtr(v any) (bool, bool) {
switch x := v.(type) {
case bool:
return x, true
case string:
switch strings.ToLower(strings.TrimSpace(x)) {
case "true", "yes", "enabled", "1":
return true, true
case "false", "no", "disabled", "0":
return false, true
}
case float64:
return x != 0, true
case int:
return x != 0, true
case int64:
return x != 0, true
}
return false, false
}
func parseGPU(doc map[string]interface{}, functionDocs []map[string]interface{}, idx int) models.GPU {
return parseGPUWithSupplementalDocs(doc, functionDocs, nil, idx)
}
func parseGPUWithSupplementalDocs(doc map[string]interface{}, functionDocs []map[string]interface{}, supplementalDocs []map[string]interface{}, idx int) models.GPU {
slot := firstNonEmpty(
redfishLocationLabel(doc["Slot"]),
redfishLocationLabel(doc["Location"]),
redfishLocationLabel(doc["PhysicalLocation"]),
asString(doc["Name"]),
asString(doc["Id"]),
)
if slot == "" {
slot = fmt.Sprintf("GPU%d", idx)
}
gpu := models.GPU{
Slot: slot,
Location: firstNonEmpty(redfishLocationLabel(doc["Location"]), redfishLocationLabel(doc["PhysicalLocation"])),
Model: firstNonEmpty(asString(doc["Model"]), asString(doc["Name"])),
Manufacturer: asString(doc["Manufacturer"]),
SerialNumber: findFirstNormalizedStringByKeys(doc, "SerialNumber"),
PartNumber: asString(doc["PartNumber"]),
Firmware: asString(doc["FirmwareVersion"]),
Status: mapStatus(doc["Status"]),
Details: redfishPCIeDetailsWithSupplementalDocs(doc, functionDocs, supplementalDocs),
}
if bdf := sanitizeRedfishBDF(asString(doc["BDF"])); bdf != "" {
gpu.BDF = bdf
}
if gpu.BDF == "" {
gpu.BDF = buildBDFfromOemPublic(doc)
}
if gpu.VendorID == 0 {
gpu.VendorID = asHexOrInt(doc["VendorId"])
}
if gpu.DeviceID == 0 {
gpu.DeviceID = asHexOrInt(doc["DeviceId"])
}
if pcieIf, ok := doc["PCIeInterface"].(map[string]interface{}); ok {
if gpu.CurrentLinkWidth == 0 {
gpu.CurrentLinkWidth = asInt(pcieIf["LanesInUse"])
}
if gpu.MaxLinkWidth == 0 {
gpu.MaxLinkWidth = firstNonZeroInt(asInt(pcieIf["MaxLanes"]), asInt(pcieIf["Maxlanes"]))
}
if gpu.CurrentLinkSpeed == "" {
gpu.CurrentLinkSpeed = firstNonEmpty(asString(pcieIf["PCIeType"]), asString(pcieIf["CurrentLinkSpeedGTs"]), asString(pcieIf["CurrentLinkSpeed"]))
}
if gpu.MaxLinkSpeed == "" {
gpu.MaxLinkSpeed = firstNonEmpty(asString(pcieIf["MaxPCIeType"]), asString(pcieIf["MaxLinkSpeedGTs"]), asString(pcieIf["MaxLinkSpeed"]))
}
}
for _, fn := range functionDocs {
if gpu.BDF == "" {
gpu.BDF = sanitizeRedfishBDF(asString(fn["FunctionId"]))
}
if gpu.VendorID == 0 {
gpu.VendorID = asHexOrInt(fn["VendorId"])
}
if gpu.DeviceID == 0 {
gpu.DeviceID = asHexOrInt(fn["DeviceId"])
}
if gpu.MaxLinkWidth == 0 {
gpu.MaxLinkWidth = asInt(fn["MaxLinkWidth"])
}
if gpu.CurrentLinkWidth == 0 {
gpu.CurrentLinkWidth = asInt(fn["CurrentLinkWidth"])
}
if gpu.MaxLinkSpeed == "" {
gpu.MaxLinkSpeed = firstNonEmpty(asString(fn["MaxLinkSpeedGTs"]), asString(fn["MaxLinkSpeed"]))
}
if gpu.CurrentLinkSpeed == "" {
gpu.CurrentLinkSpeed = firstNonEmpty(asString(fn["CurrentLinkSpeedGTs"]), asString(fn["CurrentLinkSpeed"]))
}
if gpu.CurrentLinkWidth == 0 || gpu.MaxLinkWidth == 0 || gpu.CurrentLinkSpeed == "" || gpu.MaxLinkSpeed == "" {
redfishEnrichFromOEMxFusionPCIeLink(fn, &gpu.CurrentLinkWidth, &gpu.MaxLinkWidth, &gpu.CurrentLinkSpeed, &gpu.MaxLinkSpeed)
}
}
if isMissingOrRawPCIModel(gpu.Model) {
if resolved := pciids.DeviceName(gpu.VendorID, gpu.DeviceID); resolved != "" {
gpu.Model = resolved
}
}
if strings.TrimSpace(gpu.Manufacturer) == "" {
gpu.Manufacturer = pciids.VendorName(gpu.VendorID)
}
return gpu
}
func parsePCIeDevice(doc map[string]interface{}, functionDocs []map[string]interface{}) models.PCIeDevice {
return parsePCIeDeviceWithSupplementalDocs(doc, functionDocs, nil)
}
func parsePCIeDeviceWithSupplementalDocs(doc map[string]interface{}, functionDocs []map[string]interface{}, supplementalDocs []map[string]interface{}) models.PCIeDevice {
supplementalSlot := redfishFirstLocationAcrossDocs(supplementalDocs, "Slot", "Location", "PhysicalLocation")
dev := models.PCIeDevice{
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"]),
PartNumber: asString(doc["PartNumber"]),
SerialNumber: findFirstNormalizedStringByKeys(doc, "SerialNumber"),
VendorID: asHexOrInt(doc["VendorId"]),
DeviceID: asHexOrInt(doc["DeviceId"]),
Details: redfishPCIeDetailsWithSupplementalDocs(doc, functionDocs, supplementalDocs),
}
if strings.TrimSpace(dev.BDF) == "" {
dev.BDF = buildBDFfromOemPublic(doc)
}
for _, fn := range functionDocs {
if dev.BDF == "" {
dev.BDF = sanitizeRedfishBDF(asString(fn["FunctionId"]))
}
if dev.DeviceClass == "" || isGenericPCIeClassLabel(dev.DeviceClass) {
dev.DeviceClass = firstNonEmpty(asString(fn["DeviceClass"]), asString(fn["ClassCode"]))
}
if dev.VendorID == 0 {
dev.VendorID = asHexOrInt(fn["VendorId"])
}
if dev.DeviceID == 0 {
dev.DeviceID = asHexOrInt(fn["DeviceId"])
}
if dev.LinkWidth == 0 {
dev.LinkWidth = asInt(fn["CurrentLinkWidth"])
}
if dev.MaxLinkWidth == 0 {
dev.MaxLinkWidth = asInt(fn["MaxLinkWidth"])
}
if dev.LinkSpeed == "" {
dev.LinkSpeed = firstNonEmpty(asString(fn["CurrentLinkSpeedGTs"]), asString(fn["CurrentLinkSpeed"]))
}
if dev.MaxLinkSpeed == "" {
dev.MaxLinkSpeed = firstNonEmpty(asString(fn["MaxLinkSpeedGTs"]), asString(fn["MaxLinkSpeed"]))
}
if dev.LinkWidth == 0 || dev.MaxLinkWidth == 0 || dev.LinkSpeed == "" || dev.MaxLinkSpeed == "" {
redfishEnrichFromOEMxFusionPCIeLink(fn, &dev.LinkWidth, &dev.MaxLinkWidth, &dev.LinkSpeed, &dev.MaxLinkSpeed)
}
}
if dev.DeviceClass == "" || isGenericPCIeClassLabel(dev.DeviceClass) {
dev.DeviceClass = firstNonEmpty(redfishFirstStringAcrossDocs(supplementalDocs, "DeviceType"), dev.DeviceClass)
}
if dev.DeviceClass == "" {
dev.DeviceClass = "PCIe device"
}
if isGenericPCIeClassLabel(dev.DeviceClass) {
if resolved := pciids.DeviceName(dev.VendorID, dev.DeviceID); resolved != "" {
dev.DeviceClass = resolved
}
}
if isGenericPCIeClassLabel(dev.DeviceClass) {
dev.DeviceClass = "PCIe device"
}
if strings.TrimSpace(dev.Manufacturer) == "" {
dev.Manufacturer = firstNonEmpty(
redfishFirstStringAcrossDocs(supplementalDocs, "Manufacturer"),
pciids.VendorName(dev.VendorID),
)
}
if strings.TrimSpace(dev.PartNumber) == "" {
dev.PartNumber = firstNonEmpty(
redfishFirstStringAcrossDocs(supplementalDocs, "ProductPartNumber", "PartNumber"),
pciids.DeviceName(dev.VendorID, dev.DeviceID),
)
}
if normalizeRedfishIdentityField(dev.SerialNumber) == "" {
dev.SerialNumber = redfishFirstStringAcrossDocs(supplementalDocs, "SerialNumber")
}
return dev
}
func parsePCIeFunction(doc map[string]interface{}, idx int) models.PCIeDevice {
return parsePCIeFunctionWithSupplementalDocs(doc, nil, idx)
}
func parsePCIeFunctionWithSupplementalDocs(doc map[string]interface{}, supplementalDocs []map[string]interface{}, idx int) models.PCIeDevice {
slot := firstNonEmpty(redfishLocationLabel(doc["Location"]), asString(doc["Id"]), asString(doc["Name"]))
if slot == "" {
slot = fmt.Sprintf("PCIeFn%d", idx)
}
dev := models.PCIeDevice{
Slot: slot,
BDF: sanitizeRedfishBDF(asString(doc["BDF"])),
VendorID: asHexOrInt(doc["VendorId"]),
DeviceID: asHexOrInt(doc["DeviceId"]),
DeviceClass: firstNonEmpty(asString(doc["DeviceClass"]), asString(doc["ClassCode"]), "PCIe device"),
Manufacturer: asString(doc["Manufacturer"]),
SerialNumber: findFirstNormalizedStringByKeys(doc, "SerialNumber"),
LinkWidth: asInt(doc["CurrentLinkWidth"]),
LinkSpeed: firstNonEmpty(asString(doc["CurrentLinkSpeedGTs"]), asString(doc["CurrentLinkSpeed"])),
MaxLinkWidth: asInt(doc["MaxLinkWidth"]),
MaxLinkSpeed: firstNonEmpty(asString(doc["MaxLinkSpeedGTs"]), asString(doc["MaxLinkSpeed"])),
Details: redfishPCIeDetailsWithSupplementalDocs(doc, nil, supplementalDocs),
}
if dev.BDF == "" {
dev.BDF = firstNonEmpty(buildBDFfromOemPublic(doc), sanitizeRedfishBDF(asString(doc["FunctionId"])))
}
if isGenericPCIeClassLabel(dev.DeviceClass) {
if resolved := pciids.DeviceName(dev.VendorID, dev.DeviceID); resolved != "" {
dev.DeviceClass = resolved
}
}
if strings.TrimSpace(dev.Manufacturer) == "" {
dev.Manufacturer = pciids.VendorName(dev.VendorID)
}
if strings.TrimSpace(dev.PartNumber) == "" {
dev.PartNumber = pciids.DeviceName(dev.VendorID, dev.DeviceID)
}
return dev
}
func isMissingOrRawPCIModel(model string) bool {
model = strings.TrimSpace(model)
if model == "" {
return true
}
l := strings.ToLower(model)
if l == "unknown" || l == "n/a" || l == "na" || l == "none" {
return true
}
if isGenericRedfishInventoryName(l) {
return true
}
if strings.HasPrefix(l, "0x") && len(l) <= 6 {
return true
}
if len(model) <= 4 {
isHex := true
for _, c := range l {
if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
isHex = false
break
}
}
if isHex {
return true
}
}
return false
}
func isGenericRedfishInventoryName(value string) bool {
value = strings.ToLower(strings.TrimSpace(value))
switch {
case value == "":
return false
case value == "networkadapter", strings.HasPrefix(value, "networkadapter_"), strings.HasPrefix(value, "networkadapter "):
return true
case value == "pciedevice", strings.HasPrefix(value, "pciedevice_"), strings.HasPrefix(value, "pciedevice "):
return true
case value == "pciefunction", strings.HasPrefix(value, "pciefunction_"), strings.HasPrefix(value, "pciefunction "):
return true
case value == "ethernetinterface", strings.HasPrefix(value, "ethernetinterface_"), strings.HasPrefix(value, "ethernetinterface "):
return true
case value == "networkport", strings.HasPrefix(value, "networkport_"), strings.HasPrefix(value, "networkport "):
return true
default:
return false
}
}
// isUnidentifiablePCIeDevice returns true for PCIe topology entries that carry no
// useful inventory information: generic class (SingleFunction/MultiFunction), no
// resolved model or serial, and no PCI vendor/device IDs for future resolution.
// These are typically PCH bridges, root ports, or other bus infrastructure that
// some BMCs (e.g. MSI) enumerate exhaustively in their PCIeDevices collection.
func isUnidentifiablePCIeDevice(dev models.PCIeDevice) bool {
if !isGenericPCIeClassLabel(dev.DeviceClass) {
return false
}
if normalizeRedfishIdentityField(dev.PartNumber) != "" {
return false
}
if normalizeRedfishIdentityField(dev.SerialNumber) != "" {
return false
}
if dev.VendorID > 0 || dev.DeviceID > 0 {
return false
}
return true
}
func isGenericPCIeClassLabel(v string) bool {
switch strings.ToLower(strings.TrimSpace(v)) {
case "", "pcie device", "display", "display controller", "vga", "3d controller", "network", "network controller", "storage", "storage controller", "other", "unknown", "singlefunction", "multifunction", "simulated":
return true
default:
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(v)), "0x")
}
}
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 ""
}
oem, ok := doc["Oem"].(map[string]interface{})
if !ok {
return ""
}
public, ok := oem["Public"].(map[string]interface{})
if !ok {
return ""
}
bus := asHexOrInt(public["BusNumber"])
dev := asHexOrInt(public["DeviceNumber"])
fn := asHexOrInt(public["FunctionNumber"])
if bus < 0 || dev < 0 || fn < 0 {
return ""
}
segment := asHexOrInt(public["Segment"])
if segment < 0 {
segment = 0
}
// Require at least bus + dev numbers to avoid inventing meaningless BDFs.
if bus == 0 && dev == 0 && fn == 0 {
return ""
}
return fmt.Sprintf("%04x:%02x:%02x.%x", segment, bus, dev, fn)
}
// redfishEnrichFromOEMxFusionPCIeLink fills in missing PCIe link width/speed
// from the xFusion OEM namespace. xFusion reports link width as a string like
// "X8" in Oem.xFusion.LinkWidth / Oem.xFusion.LinkWidthAbility, and link speed
// as a string like "Gen4 (16.0GT/s)" in Oem.xFusion.LinkSpeed /
// Oem.xFusion.LinkSpeedAbility. These fields appear on PCIeFunction docs.
func redfishEnrichFromOEMxFusionPCIeLink(doc map[string]interface{}, linkWidth, maxLinkWidth *int, linkSpeed, maxLinkSpeed *string) {
oem, _ := doc["Oem"].(map[string]interface{})
if oem == nil {
return
}
xf, _ := oem["xFusion"].(map[string]interface{})
if xf == nil {
return
}
if *linkWidth == 0 {
*linkWidth = parseXFusionLinkWidth(asString(xf["LinkWidth"]))
}
if *maxLinkWidth == 0 {
*maxLinkWidth = parseXFusionLinkWidth(asString(xf["LinkWidthAbility"]))
}
if strings.TrimSpace(*linkSpeed) == "" {
*linkSpeed = strings.TrimSpace(asString(xf["LinkSpeed"]))
}
if strings.TrimSpace(*maxLinkSpeed) == "" {
*maxLinkSpeed = strings.TrimSpace(asString(xf["LinkSpeedAbility"]))
}
}
// parseXFusionLinkWidth converts an xFusion link-width string like "X8" or
// "x16" to the integer lane count. Returns 0 for unrecognised values.
func parseXFusionLinkWidth(s string) int {
s = strings.TrimSpace(s)
if s == "" {
return 0
}
s = strings.TrimPrefix(strings.ToUpper(s), "X")
v := asInt(s)
if v <= 0 {
return 0
}
return v
}
// firstNonZeroInt returns the first argument that is non-zero.
func firstNonZeroInt(vals ...int) int {
for _, v := range vals {
if v != 0 {
return v
}
}
return 0
}
func normalizeRedfishIdentityField(v string) string {
v = strings.TrimSpace(v)
if v == "" {
return ""
}
switch strings.ToLower(v) {
case "n/a", "na", "none", "null", "unknown", "0":
return ""
default:
return v
}
}
func gpuDedupKey(gpu models.GPU) string {
if serial := normalizeRedfishIdentityField(gpu.SerialNumber); serial != "" {
return serial
}
if bdf := strings.TrimSpace(gpu.BDF); bdf != "" {
return bdf
}
return firstNonEmpty(strings.TrimSpace(gpu.Slot)+"|"+strings.TrimSpace(gpu.Model), strings.TrimSpace(gpu.Slot))
}
func gpuDocDedupKey(doc map[string]interface{}, gpu models.GPU) string {
// Prefer stable GPU identifiers (serial, BDF) over path so that the same
// physical GPU exposed under multiple Chassis PCIeDevice trees (e.g. Supermicro
// HGX: Chassis/1/PCIeDevices/GPU1 and Chassis/HGX_GPU_SXM_1/PCIeDevices/GPU_SXM_1)
// is correctly deduplicated.
//
// Only stable identifiers (serial, BDF) are used for cross-path dedup.
// When neither is present we fall back to path, so two genuinely distinct GPUs
// that happen to share the same model name (e.g. in GraphicsControllers) are
// not incorrectly collapsed into one.
if serial := normalizeRedfishIdentityField(gpu.SerialNumber); serial != "" {
return serial
}
if bdf := strings.TrimSpace(gpu.BDF); bdf != "" {
return bdf
}
if path := normalizeRedfishPath(asString(doc["@odata.id"])); path != "" {
return "path:" + path
}
return ""
}
func shouldSkipGenericGPUDuplicate(existing []models.GPU, candidate models.GPU) bool {
if len(existing) == 0 {
return false
}
if normalizeRedfishIdentityField(candidate.SerialNumber) != "" || strings.TrimSpace(candidate.BDF) != "" {
return false
}
slot := strings.TrimSpace(candidate.Slot)
model := strings.TrimSpace(candidate.Model)
if slot == "" || model == "" {
return false
}
// Typical GraphicsControllers fallback on some BMCs reports only model/name
// as slot and lacks stable identifiers. If we already have concrete GPUs of the
// same model/manufacturer from PCIe inventory, this candidate is a duplicate.
if !strings.EqualFold(slot, model) {
return false
}
for _, gpu := range existing {
if !strings.EqualFold(strings.TrimSpace(gpu.Model), model) {
continue
}
existingMfr := strings.TrimSpace(gpu.Manufacturer)
candidateMfr := strings.TrimSpace(candidate.Manufacturer)
if existingMfr != "" && candidateMfr != "" && !strings.EqualFold(existingMfr, candidateMfr) {
continue
}
if normalizeRedfishIdentityField(gpu.SerialNumber) != "" || strings.TrimSpace(gpu.BDF) != "" {
return true
}
}
return false
}
func dropModelOnlyGPUPlaceholders(items []models.GPU) []models.GPU {
if len(items) < 2 {
return items
}
// Merge serial from generic GraphicsControllers placeholders (slot ~= model)
// into concrete PCIe rows (with BDF) when mapping is unambiguous.
mergedPlaceholder := make(map[int]struct{})
usedConcrete := make(map[int]struct{})
unresolvedByGroup := make(map[string][]int)
for i := range items {
serial := normalizeRedfishIdentityField(items[i].SerialNumber)
if serial == "" || strings.TrimSpace(items[i].BDF) != "" || !isModelOnlyGPUPlaceholder(items[i]) {
continue
}
candidates := matchingConcreteGPUIndexes(items, i, usedConcrete)
candidate := -1
if len(candidates) == 1 {
candidate = candidates[0]
}
if candidate >= 0 {
mergeGPUPlaceholderIntoConcrete(&items[candidate], items[i])
usedConcrete[candidate] = struct{}{}
mergedPlaceholder[i] = struct{}{}
continue
}
group := gpuModelVendorKey(items[i])
if group == "" {
continue
}
unresolvedByGroup[group] = append(unresolvedByGroup[group], i)
}
// Fallback mapping by order for ambiguous groups (e.g. same model x8).
for group, placeholders := range unresolvedByGroup {
donors := make([]int, 0, len(placeholders))
for j := range items {
if _, used := usedConcrete[j]; used {
continue
}
if !isConcreteGPUDonor(items[j]) {
continue
}
if gpuModelVendorKey(items[j]) != group {
continue
}
if normalizeRedfishIdentityField(items[j].SerialNumber) != "" {
continue
}
donors = append(donors, j)
}
limit := len(placeholders)
if len(donors) < limit {
limit = len(donors)
}
for k := 0; k < limit; k++ {
pi := placeholders[k]
di := donors[k]
if normalizeRedfishIdentityField(items[pi].SerialNumber) == "" {
continue
}
mergeGPUPlaceholderIntoConcrete(&items[di], items[pi])
usedConcrete[di] = struct{}{}
mergedPlaceholder[pi] = struct{}{}
}
}
concreteByModel := make(map[string]struct{}, len(items))
for _, gpu := range items {
modelKey := strings.ToLower(strings.TrimSpace(gpu.Model))
if modelKey == "" {
continue
}
if normalizeRedfishIdentityField(gpu.SerialNumber) != "" || strings.TrimSpace(gpu.BDF) != "" {
concreteByModel[modelKey] = struct{}{}
}
}
if len(concreteByModel) == 0 {
return items
}
out := make([]models.GPU, 0, len(items))
for i, gpu := range items {
modelKey := strings.ToLower(strings.TrimSpace(gpu.Model))
if _, hasConcrete := concreteByModel[modelKey]; hasConcrete &&
strings.TrimSpace(gpu.BDF) == "" &&
isModelOnlyGPUPlaceholder(gpu) &&
(normalizeRedfishIdentityField(gpu.SerialNumber) == "" || hasMergedPlaceholderIndex(mergedPlaceholder, i)) {
continue
}
out = append(out, gpu)
}
return out
}
func isModelOnlyGPUPlaceholder(gpu models.GPU) bool {
slot := strings.TrimSpace(gpu.Slot)
model := strings.TrimSpace(gpu.Model)
if slot == "" || model == "" {
return false
}
return strings.EqualFold(slot, model) || strings.HasPrefix(strings.ToUpper(slot), "GPU")
}
func isConcreteGPUDonor(gpu models.GPU) bool {
if strings.TrimSpace(gpu.BDF) == "" {
return false
}
return !isModelOnlyGPUPlaceholder(gpu)
}
func gpuModelVendorKey(gpu models.GPU) string {
model := strings.ToLower(strings.TrimSpace(gpu.Model))
if model == "" {
return ""
}
mfr := strings.ToLower(strings.TrimSpace(gpu.Manufacturer))
return model + "|" + mfr
}
func matchingConcreteGPUIndexes(items []models.GPU, placeholderIdx int, usedConcrete map[int]struct{}) []int {
out := make([]int, 0, 2)
ph := items[placeholderIdx]
for j := range items {
if j == placeholderIdx {
continue
}
if _, used := usedConcrete[j]; used {
continue
}
if !isConcreteGPUDonor(items[j]) {
continue
}
if !strings.EqualFold(strings.TrimSpace(items[j].Model), strings.TrimSpace(ph.Model)) {
continue
}
otherMfr := strings.TrimSpace(items[j].Manufacturer)
phMfr := strings.TrimSpace(ph.Manufacturer)
if phMfr != "" && otherMfr != "" && !strings.EqualFold(phMfr, otherMfr) {
continue
}
if normalizeRedfishIdentityField(items[j].SerialNumber) != "" {
continue
}
out = append(out, j)
}
return out
}
func mergeGPUPlaceholderIntoConcrete(concrete *models.GPU, placeholder models.GPU) {
if concrete == nil {
return
}
if normalizeRedfishIdentityField(concrete.SerialNumber) == "" {
if serial := normalizeRedfishIdentityField(placeholder.SerialNumber); serial != "" {
concrete.SerialNumber = serial
}
}
if strings.TrimSpace(concrete.UUID) == "" && strings.TrimSpace(placeholder.UUID) != "" {
concrete.UUID = placeholder.UUID
}
if strings.TrimSpace(concrete.PartNumber) == "" && strings.TrimSpace(placeholder.PartNumber) != "" {
concrete.PartNumber = placeholder.PartNumber
}
if strings.TrimSpace(concrete.Firmware) == "" && strings.TrimSpace(placeholder.Firmware) != "" {
concrete.Firmware = placeholder.Firmware
}
if strings.TrimSpace(concrete.Status) == "" && strings.TrimSpace(placeholder.Status) != "" {
concrete.Status = placeholder.Status
}
}
func hasMergedPlaceholderIndex(indexes map[int]struct{}, idx int) bool {
_, ok := indexes[idx]
return ok
}
func looksLikeGPU(doc map[string]interface{}, functionDocs []map[string]interface{}) bool {
// "Display Device" is how MSI labels H100 secondary display/audio controller
// functions — these are not compute GPUs and should be excluded.
if strings.EqualFold(strings.TrimSpace(asString(doc["Description"])), "Display Device") {
return false
}
// NVSwitch is an NVIDIA NVLink interconnect switch, not a compute GPU.
if strings.Contains(strings.ToLower(strings.TrimSpace(asString(doc["Model"]))), "nvswitch") {
return false
}
deviceType := strings.ToLower(asString(doc["DeviceType"]))
if strings.Contains(deviceType, "gpu") || strings.Contains(deviceType, "graphics") || strings.Contains(deviceType, "accelerator") {
return true
}
if strings.Contains(deviceType, "network") {
return false
}
if oem, ok := doc["Oem"].(map[string]interface{}); ok {
if public, ok := oem["Public"].(map[string]interface{}); ok {
if dc := strings.ToLower(asString(public["DeviceClass"])); strings.Contains(dc, "network") {
return false
}
}
}
modelText := strings.ToLower(strings.Join([]string{
asString(doc["Name"]),
asString(doc["Model"]),
asString(doc["Manufacturer"]),
}, " "))
gpuHints := []string{"gpu", "nvidia", "tesla", "a100", "h100", "l40", "rtx", "radeon", "instinct"}
for _, hint := range gpuHints {
if strings.Contains(modelText, hint) {
return true
}
}
for _, fn := range functionDocs {
classCode := strings.ToLower(strings.TrimPrefix(asString(fn["ClassCode"]), "0x"))
if strings.HasPrefix(classCode, "03") || strings.HasPrefix(classCode, "12") {
return true
}
}
return false
}
// isVirtualStorageDrive returns true for BMC-virtual drives that should not
// appear in hardware inventory (e.g. AMI virtual CD/USB sticks with 0 capacity).
func isVirtualStorageDrive(doc map[string]interface{}) bool {
if strings.EqualFold(asString(doc["Protocol"]), "USB") && asInt64(doc["CapacityBytes"]) == 0 {
return true
}
mfr := strings.ToUpper(strings.TrimSpace(asString(doc["Manufacturer"])))
model := strings.ToUpper(strings.TrimSpace(asString(doc["Model"])))
name := strings.ToUpper(strings.TrimSpace(asString(doc["Name"])))
joined := strings.Join([]string{mfr, model, name}, " ")
if strings.Contains(mfr, "AMI") && strings.Contains(joined, "VIRTUAL") {
return true
}
for _, marker := range []string{
"VIRTUAL CDROM",
"VIRTUAL CD/DVD",
"VIRTUAL FLOPPY",
"VIRTUAL FDD",
"VIRTUAL USB",
"VIRTUAL MEDIA",
} {
if strings.Contains(joined, marker) {
return true
}
}
if strings.Contains(mfr, "AMERICAN MEGATRENDS") && (strings.Contains(joined, "CDROM") || strings.Contains(joined, "FLOPPY") || strings.Contains(joined, "FDD")) {
return true
}
return false
}
// 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
}
if asString(doc["Protocol"]) != "" && (asInt(doc["CapacityGB"]) > 0 || asInt(doc["CapacityBytes"]) > 0) {
return true
}
if asString(doc["Type"]) != "" && (asString(doc["Model"]) != "" || asInt(doc["CapacityGB"]) > 0 || asInt(doc["CapacityBytes"]) > 0) {
return true
}
return false
}
func classifyStorageType(doc map[string]interface{}) string {
protocol := strings.ToUpper(asString(doc["Protocol"]))
if strings.Contains(protocol, "NVME") {
return "NVMe"
}
media := strings.ToUpper(asString(doc["MediaType"]))
if media == "SSD" {
return "SSD"
}
if media == "HDD" || media == "HDDT" {
return "HDD"
}
nameModel := strings.ToUpper(strings.Join([]string{
asString(doc["Name"]),
asString(doc["Model"]),
asString(doc["Description"]),
}, " "))
if strings.Contains(nameModel, "NVME") {
return "NVMe"
}
if strings.Contains(nameModel, "SSD") {
return "SSD"
}
if strings.Contains(nameModel, "HDD") {
return "HDD"
}
if protocol != "" {
return protocol
}
return firstNonEmpty(asString(doc["Type"]), "Storage")
}
func looksLikeVolume(doc map[string]interface{}) bool {
if redfishVolumeCapabilitiesDoc(doc) {
return false
}
if asString(doc["RAIDType"]) != "" || asString(doc["VolumeType"]) != "" {
return true
}
if strings.Contains(strings.ToLower(asString(doc["@odata.type"])), "volume") && (asInt64(doc["CapacityBytes"]) > 0 || asString(doc["Name"]) != "") {
return true
}
return false
}
func dedupeStorage(items []models.Storage) []models.Storage {
if len(items) <= 1 {
return items
}
// Pass 1: drop exact duplicates by identity and keep the richer variant.
out := dedupeStorageByIdentityPreferRich(items)
if len(out) <= 1 {
return out
}
// Pass 2: replace placeholder slots with rich drive data (slot is preserved).
merged, consumedDonors := mergeStoragePlaceholders(out)
if len(consumedDonors) > 0 {
compacted := make([]models.Storage, 0, len(merged)-len(consumedDonors))
for i, item := range merged {
if _, consumed := consumedDonors[i]; consumed {
continue
}
compacted = append(compacted, item)
}
out = compacted
} else {
out = merged
}
// Pass 3: final identity dedupe after placeholder merge.
return dedupeStorageByIdentityPreferRich(out)
}
func dedupeStorageByIdentityPreferRich(items []models.Storage) []models.Storage {
if len(items) == 0 {
return nil
}
out := make([]models.Storage, 0, len(items))
seen := make(map[string]int, len(items))
for _, item := range items {
key := storageIdentityKey(item)
if key == "" {
continue
}
if idx, ok := seen[key]; ok {
out[idx] = richerStorageEntry(out[idx], item)
continue
}
seen[key] = len(out)
out = append(out, item)
}
return out
}
func storageIdentityKey(item models.Storage) string {
if serial := normalizeRedfishIdentityField(item.SerialNumber); serial != "" {
return "sn:" + serial
}
slot := strings.TrimSpace(item.Slot)
model := strings.TrimSpace(item.Model)
if slot == "" && model == "" {
return ""
}
return "slotmodel:" + slot + "|" + model
}
func richerStorageEntry(a, b models.Storage) models.Storage {
if storageRichnessScore(b) > storageRichnessScore(a) {
b.Details = mergeGenericDetails(b.Details, a.Details)
return b
}
a.Details = mergeGenericDetails(a.Details, b.Details)
return a
}
func storageRichnessScore(item models.Storage) int {
score := 0
if normalizeRedfishIdentityField(item.SerialNumber) != "" {
score += 100
}
if item.SizeGB > 0 {
score += 40
}
if normalizedStorageModel(item) != "" {
score += 20
}
if normalizeRedfishIdentityField(item.Manufacturer) != "" {
score += 10
}
if normalizeRedfishIdentityField(item.Firmware) != "" {
score += 8
}
if strings.TrimSpace(item.Interface) != "" {
score += 5
}
if strings.TrimSpace(item.Description) != "" {
score += 3
}
if item.Present {
score++
}
return score
}
func normalizedStorageModel(item models.Storage) string {
model := normalizeRedfishIdentityField(item.Model)
if model == "" {
return ""
}
slot := strings.TrimSpace(item.Slot)
if slot != "" && strings.EqualFold(model, slot) {
return ""
}
return model
}
func isStoragePlaceholder(item models.Storage) bool {
if normalizeRedfishIdentityField(item.SerialNumber) != "" {
return false
}
if item.SizeGB > 0 {
return false
}
if normalizedStorageModel(item) != "" {
return false
}
if normalizeRedfishIdentityField(item.Manufacturer) != "" {
return false
}
if normalizeRedfishIdentityField(item.Firmware) != "" {
return false
}
if strings.TrimSpace(item.Description) != "" {
return false
}
return true
}
func isRichStorageDonor(item models.Storage) bool {
if isStoragePlaceholder(item) {
return false
}
return normalizeRedfishIdentityField(item.SerialNumber) != "" ||
item.SizeGB > 0 ||
normalizedStorageModel(item) != "" ||
normalizeRedfishIdentityField(item.Manufacturer) != "" ||
normalizeRedfishIdentityField(item.Firmware) != ""
}
func mergeStoragePlaceholders(items []models.Storage) ([]models.Storage, map[int]struct{}) {
if len(items) <= 1 {
return items, nil
}
out := make([]models.Storage, len(items))
copy(out, items)
placeholderIdx := make([]int, 0, len(out))
donorIdx := make([]int, 0, len(out))
for i, item := range out {
if isStoragePlaceholder(item) {
placeholderIdx = append(placeholderIdx, i)
continue
}
if isRichStorageDonor(item) {
donorIdx = append(donorIdx, i)
}
}
if len(placeholderIdx) == 0 || len(donorIdx) == 0 {
return out, nil
}
consumed := make(map[int]struct{}, len(donorIdx))
for _, pi := range placeholderIdx {
di := findStorageDonorIndex(out, donorIdx, consumed, out[pi].Type)
if di < 0 {
continue
}
out[pi] = mergeStorageIntoPlaceholder(out[pi], out[di])
consumed[di] = struct{}{}
}
if len(consumed) == 0 {
return out, nil
}
return out, consumed
}
func findStorageDonorIndex(items []models.Storage, donors []int, consumed map[int]struct{}, placeholderType string) int {
placeholderType = strings.TrimSpace(strings.ToUpper(placeholderType))
if placeholderType != "" {
for _, idx := range donors {
if _, used := consumed[idx]; used {
continue
}
if strings.TrimSpace(strings.ToUpper(items[idx].Type)) == placeholderType {
return idx
}
}
}
for _, idx := range donors {
if _, used := consumed[idx]; !used {
return idx
}
}
return -1
}
func mergeStorageIntoPlaceholder(placeholder, donor models.Storage) models.Storage {
out := placeholder
if strings.TrimSpace(out.Type) == "" {
out.Type = donor.Type
}
if normalizedStorageModel(out) == "" && normalizedStorageModel(donor) != "" {
out.Model = donor.Model
}
if out.SizeGB <= 0 && donor.SizeGB > 0 {
out.SizeGB = donor.SizeGB
}
if normalizeRedfishIdentityField(out.SerialNumber) == "" && normalizeRedfishIdentityField(donor.SerialNumber) != "" {
out.SerialNumber = donor.SerialNumber
}
if normalizeRedfishIdentityField(out.Manufacturer) == "" && normalizeRedfishIdentityField(donor.Manufacturer) != "" {
out.Manufacturer = donor.Manufacturer
}
if normalizeRedfishIdentityField(out.Firmware) == "" && normalizeRedfishIdentityField(donor.Firmware) != "" {
out.Firmware = donor.Firmware
}
if strings.TrimSpace(out.Interface) == "" && strings.TrimSpace(donor.Interface) != "" {
out.Interface = donor.Interface
}
if strings.TrimSpace(out.Location) == "" && strings.TrimSpace(donor.Location) != "" {
out.Location = donor.Location
}
if out.BackplaneID == 0 && donor.BackplaneID != 0 {
out.BackplaneID = donor.BackplaneID
}
if strings.TrimSpace(out.Status) == "" && strings.TrimSpace(donor.Status) != "" {
out.Status = donor.Status
}
if strings.TrimSpace(out.Description) == "" && strings.TrimSpace(donor.Description) != "" {
out.Description = donor.Description
}
if !out.Present {
out.Present = donor.Present
}
return out
}
func dedupeNetworkAdapters(items []models.NetworkAdapter) []models.NetworkAdapter {
if len(items) <= 1 {
return items
}
out := make([]models.NetworkAdapter, 0, len(items))
bySerial := make(map[string]int, len(items))
bySlotModel := make(map[string]int, len(items))
bySlot := make(map[string]int, len(items))
for _, item := range items {
serialKey := normalizeRedfishIdentityField(item.SerialNumber)
slotModelKey := networkAdapterSlotModelKey(item)
slotKey := strings.TrimSpace(item.Slot)
idx := -1
if serialKey != "" {
if existing, ok := bySerial[serialKey]; ok {
idx = existing
}
}
if idx < 0 && slotModelKey != "" {
if existing, ok := bySlotModel[slotModelKey]; ok {
idx = existing
}
}
if idx < 0 && slotKey != "" {
if existing, ok := bySlot[slotKey]; ok {
idx = existing
}
}
if idx >= 0 {
out[idx] = mergeNetworkAdapterEntries(out[idx], item)
} else {
idx = len(out)
out = append(out, item)
}
merged := out[idx]
if serial := normalizeRedfishIdentityField(merged.SerialNumber); serial != "" {
bySerial[serial] = idx
}
if slotModel := networkAdapterSlotModelKey(merged); slotModel != "" {
bySlotModel[slotModel] = idx
}
if slot := strings.TrimSpace(merged.Slot); slot != "" {
bySlot[slot] = idx
}
}
return out
}
func networkAdapterSlotModelKey(nic models.NetworkAdapter) string {
slot := strings.TrimSpace(nic.Slot)
model := normalizeNetworkAdapterModel(nic)
if slot == "" && model == "" {
return ""
}
return slot + "|" + model
}
func normalizeNetworkAdapterModel(nic models.NetworkAdapter) string {
model := normalizeRedfishIdentityField(nic.Model)
if model == "" {
return ""
}
if isMissingOrRawPCIModel(model) {
return ""
}
slot := strings.TrimSpace(nic.Slot)
if slot != "" && strings.EqualFold(slot, model) {
return ""
}
return model
}
func networkAdapterRichnessScore(nic models.NetworkAdapter) int {
score := 0
if normalizeRedfishIdentityField(nic.SerialNumber) != "" {
score += 80
}
if normalizeNetworkAdapterModel(nic) != "" {
score += 20
}
if normalizeRedfishIdentityField(nic.Vendor) != "" {
score += 10
}
if normalizeRedfishIdentityField(nic.Firmware) != "" {
score += 8
}
if looksLikeCanonicalBDF(strings.TrimSpace(nic.BDF)) {
score += 10
}
if normalizeRedfishIdentityField(nic.PartNumber) != "" {
score += 6
}
if nic.VendorID > 0 {
score += 5
}
if nic.DeviceID > 0 {
score += 5
}
if nic.PortCount > 0 {
score += 4
}
if nic.LinkWidth > 0 || nic.MaxLinkWidth > 0 {
score += 4
}
if strings.TrimSpace(nic.LinkSpeed) != "" || strings.TrimSpace(nic.MaxLinkSpeed) != "" {
score += 4
}
if len(nic.MACAddresses) > 0 {
score += 4
}
if strings.TrimSpace(nic.Location) != "" {
score += 2
}
if nic.Present {
score++
}
return score
}
func mergeNetworkAdapterEntries(a, b models.NetworkAdapter) models.NetworkAdapter {
base, donor := a, b
if networkAdapterRichnessScore(donor) > networkAdapterRichnessScore(base) {
base, donor = donor, base
}
out := base
out.PortCount = sanitizeNetworkPortCount(out.PortCount)
donor.PortCount = sanitizeNetworkPortCount(donor.PortCount)
if strings.TrimSpace(out.Slot) == "" && strings.TrimSpace(donor.Slot) != "" {
out.Slot = donor.Slot
}
if strings.TrimSpace(out.Location) == "" && strings.TrimSpace(donor.Location) != "" {
out.Location = donor.Location
}
if strings.TrimSpace(out.BDF) == "" && strings.TrimSpace(donor.BDF) != "" {
out.BDF = donor.BDF
}
if normalizeNetworkAdapterModel(out) == "" && normalizeNetworkAdapterModel(donor) != "" {
out.Model = donor.Model
}
if strings.TrimSpace(out.Description) == "" && strings.TrimSpace(donor.Description) != "" {
out.Description = donor.Description
}
if normalizeRedfishIdentityField(out.Vendor) == "" && normalizeRedfishIdentityField(donor.Vendor) != "" {
out.Vendor = donor.Vendor
}
if out.VendorID == 0 && donor.VendorID != 0 {
out.VendorID = donor.VendorID
}
if out.DeviceID == 0 && donor.DeviceID != 0 {
out.DeviceID = donor.DeviceID
}
if normalizeRedfishIdentityField(out.SerialNumber) == "" && normalizeRedfishIdentityField(donor.SerialNumber) != "" {
out.SerialNumber = donor.SerialNumber
}
if normalizeRedfishIdentityField(out.PartNumber) == "" && normalizeRedfishIdentityField(donor.PartNumber) != "" {
out.PartNumber = donor.PartNumber
}
if normalizeRedfishIdentityField(out.Firmware) == "" && normalizeRedfishIdentityField(donor.Firmware) != "" {
out.Firmware = donor.Firmware
}
if out.PortCount == 0 && donor.PortCount > 0 {
out.PortCount = donor.PortCount
}
if strings.TrimSpace(out.PortType) == "" && strings.TrimSpace(donor.PortType) != "" {
out.PortType = donor.PortType
}
if out.LinkWidth == 0 && donor.LinkWidth > 0 {
out.LinkWidth = donor.LinkWidth
}
if strings.TrimSpace(out.LinkSpeed) == "" && strings.TrimSpace(donor.LinkSpeed) != "" {
out.LinkSpeed = donor.LinkSpeed
}
if out.MaxLinkWidth == 0 && donor.MaxLinkWidth > 0 {
out.MaxLinkWidth = donor.MaxLinkWidth
}
if strings.TrimSpace(out.MaxLinkSpeed) == "" && strings.TrimSpace(donor.MaxLinkSpeed) != "" {
out.MaxLinkSpeed = donor.MaxLinkSpeed
}
if strings.TrimSpace(out.Status) == "" && strings.TrimSpace(donor.Status) != "" {
out.Status = donor.Status
}
out.Present = out.Present || donor.Present
if len(donor.MACAddresses) > 0 {
out.MACAddresses = dedupeStrings(append(append([]string{}, out.MACAddresses...), donor.MACAddresses...))
}
out.Details = mergeGenericDetails(out.Details, donor.Details)
return out
}
const maxReasonableNetworkPortCount = 256
func sanitizeNetworkPortCount(v int) int {
if v <= 0 || v > maxReasonableNetworkPortCount {
return 0
}
return v
}
func dedupePCIeDevices(items []models.PCIeDevice) []models.PCIeDevice {
if len(items) <= 1 {
return items
}
out := make([]models.PCIeDevice, 0, len(items))
byPrimary := make(map[string]int, len(items))
byLoose := make(map[string]int, len(items))
for _, item := range items {
primaryKey := pcieDeviceDedupKey(item)
looseKey := pcieDeviceLooseKey(item)
idx := -1
if primaryKey != "" {
if existing, ok := byPrimary[primaryKey]; ok {
idx = existing
}
}
if idx < 0 && looseKey != "" {
if existing, ok := byLoose[looseKey]; ok {
idx = existing
}
}
if idx >= 0 {
out[idx] = mergePCIeDeviceEntries(out[idx], item)
} else {
idx = len(out)
out = append(out, item)
}
merged := out[idx]
if k := pcieDeviceDedupKey(merged); k != "" {
byPrimary[k] = idx
}
if k := pcieDeviceLooseKey(merged); k != "" {
byLoose[k] = idx
}
}
return out
}
func pcieDeviceLooseKey(dev models.PCIeDevice) string {
return firstNonEmpty(
strings.TrimSpace(dev.Slot)+"|"+strings.TrimSpace(dev.PartNumber)+"|"+strings.TrimSpace(dev.DeviceClass),
strings.TrimSpace(dev.Slot)+"|"+strings.TrimSpace(dev.DeviceClass),
strings.TrimSpace(dev.PartNumber)+"|"+strings.TrimSpace(dev.DeviceClass),
strings.TrimSpace(dev.Description)+"|"+strings.TrimSpace(dev.DeviceClass),
)
}
func pcieDeviceRichnessScore(dev models.PCIeDevice) int {
score := 0
if bdf := strings.TrimSpace(dev.BDF); looksLikeCanonicalBDF(bdf) {
score += 120
}
if normalizeRedfishIdentityField(dev.SerialNumber) != "" {
score += 80
}
if normalizeRedfishIdentityField(dev.PartNumber) != "" {
score += 20
}
if normalizeRedfishIdentityField(dev.Manufacturer) != "" {
score += 10
}
if dev.VendorID > 0 {
score += 8
}
if dev.DeviceID > 0 {
score += 8
}
if !isGenericPCIeClassLabel(dev.DeviceClass) {
score += 8
}
if dev.LinkWidth > 0 || dev.MaxLinkWidth > 0 {
score += 6
}
if strings.TrimSpace(dev.LinkSpeed) != "" || strings.TrimSpace(dev.MaxLinkSpeed) != "" {
score += 6
}
if strings.TrimSpace(dev.Description) != "" {
score += 3
}
if strings.TrimSpace(dev.Slot) != "" {
score += 2
}
return score
}
func mergePCIeDeviceEntries(a, b models.PCIeDevice) models.PCIeDevice {
base, donor := a, b
if pcieDeviceRichnessScore(donor) > pcieDeviceRichnessScore(base) {
base, donor = donor, base
}
out := base
if strings.TrimSpace(out.Slot) == "" && strings.TrimSpace(donor.Slot) != "" {
out.Slot = donor.Slot
}
if strings.TrimSpace(out.Description) == "" && strings.TrimSpace(donor.Description) != "" {
out.Description = donor.Description
}
if out.VendorID == 0 && donor.VendorID != 0 {
out.VendorID = donor.VendorID
}
if out.DeviceID == 0 && donor.DeviceID != 0 {
out.DeviceID = donor.DeviceID
}
if strings.TrimSpace(out.BDF) == "" && strings.TrimSpace(donor.BDF) != "" {
out.BDF = donor.BDF
}
if isGenericPCIeClassLabel(out.DeviceClass) && !isGenericPCIeClassLabel(donor.DeviceClass) {
out.DeviceClass = donor.DeviceClass
}
if normalizeRedfishIdentityField(out.Manufacturer) == "" && normalizeRedfishIdentityField(donor.Manufacturer) != "" {
out.Manufacturer = donor.Manufacturer
}
if out.LinkWidth == 0 && donor.LinkWidth > 0 {
out.LinkWidth = donor.LinkWidth
}
if strings.TrimSpace(out.LinkSpeed) == "" && strings.TrimSpace(donor.LinkSpeed) != "" {
out.LinkSpeed = donor.LinkSpeed
}
if out.MaxLinkWidth == 0 && donor.MaxLinkWidth > 0 {
out.MaxLinkWidth = donor.MaxLinkWidth
}
if strings.TrimSpace(out.MaxLinkSpeed) == "" && strings.TrimSpace(donor.MaxLinkSpeed) != "" {
out.MaxLinkSpeed = donor.MaxLinkSpeed
}
if normalizeRedfishIdentityField(out.PartNumber) == "" && normalizeRedfishIdentityField(donor.PartNumber) != "" {
out.PartNumber = donor.PartNumber
}
if normalizeRedfishIdentityField(out.SerialNumber) == "" && normalizeRedfishIdentityField(donor.SerialNumber) != "" {
out.SerialNumber = donor.SerialNumber
}
if strings.TrimSpace(out.Status) == "" && strings.TrimSpace(donor.Status) != "" {
out.Status = donor.Status
}
if len(donor.MACAddresses) > 0 {
out.MACAddresses = dedupeStrings(append(append([]string{}, out.MACAddresses...), donor.MACAddresses...))
}
out.Details = mergeGenericDetails(out.Details, donor.Details)
return out
}
func psuIdentityKeys(psu models.PSU) []string {
keys := make([]string, 0, 3)
if serial := normalizeRedfishIdentityField(psu.SerialNumber); serial != "" {
keys = append(keys, "sn:"+serial)
}
slot := strings.TrimSpace(psu.Slot)
model := strings.TrimSpace(psu.Model)
if slot != "" && model != "" {
keys = append(keys, "slotmodel:"+slot+"|"+model)
}
if slot != "" {
keys = append(keys, "slot:"+slot)
}
if len(keys) == 0 && model != "" {
keys = append(keys, "model:"+model)
}
return keys
}
func psuRichnessScore(psu models.PSU) int {
score := 0
if normalizeRedfishIdentityField(psu.SerialNumber) != "" {
score += 100
}
if normalizeRedfishIdentityField(psu.Model) != "" {
score += 20
}
if psu.WattageW > 0 {
score += 20
}
if normalizeRedfishIdentityField(psu.Vendor) != "" {
score += 8
}
if normalizeRedfishIdentityField(psu.PartNumber) != "" {
score += 8
}
if normalizeRedfishIdentityField(psu.Firmware) != "" {
score += 8
}
if psu.InputPowerW > 0 || psu.OutputPowerW > 0 {
score += 6
}
if psu.InputVoltage > 0 {
score += 4
}
if psu.Present {
score++
}
return score
}
func mergePSUEntries(a, b models.PSU) models.PSU {
base, donor := a, b
if psuRichnessScore(donor) > psuRichnessScore(base) {
base, donor = donor, base
}
out := base
if strings.TrimSpace(out.Slot) == "" && strings.TrimSpace(donor.Slot) != "" {
out.Slot = donor.Slot
}
out.Present = out.Present || donor.Present
if normalizeRedfishIdentityField(out.Model) == "" && normalizeRedfishIdentityField(donor.Model) != "" {
out.Model = donor.Model
}
if strings.TrimSpace(out.Description) == "" && strings.TrimSpace(donor.Description) != "" {
out.Description = donor.Description
}
if normalizeRedfishIdentityField(out.Vendor) == "" && normalizeRedfishIdentityField(donor.Vendor) != "" {
out.Vendor = donor.Vendor
}
if out.WattageW == 0 && donor.WattageW > 0 {
out.WattageW = donor.WattageW
}
if normalizeRedfishIdentityField(out.SerialNumber) == "" && normalizeRedfishIdentityField(donor.SerialNumber) != "" {
out.SerialNumber = donor.SerialNumber
}
if normalizeRedfishIdentityField(out.PartNumber) == "" && normalizeRedfishIdentityField(donor.PartNumber) != "" {
out.PartNumber = donor.PartNumber
}
if normalizeRedfishIdentityField(out.Firmware) == "" && normalizeRedfishIdentityField(donor.Firmware) != "" {
out.Firmware = donor.Firmware
}
if strings.TrimSpace(out.Status) == "" && strings.TrimSpace(donor.Status) != "" {
out.Status = donor.Status
}
if strings.TrimSpace(out.InputType) == "" && strings.TrimSpace(donor.InputType) != "" {
out.InputType = donor.InputType
}
if out.InputPowerW == 0 && donor.InputPowerW > 0 {
out.InputPowerW = donor.InputPowerW
}
if out.OutputPowerW == 0 && donor.OutputPowerW > 0 {
out.OutputPowerW = donor.OutputPowerW
}
if out.InputVoltage == 0 && donor.InputVoltage > 0 {
out.InputVoltage = donor.InputVoltage
}
if out.OutputVoltage == 0 && donor.OutputVoltage > 0 {
out.OutputVoltage = donor.OutputVoltage
}
if out.TemperatureC == 0 && donor.TemperatureC > 0 {
out.TemperatureC = donor.TemperatureC
}
out.Details = mergeGenericDetails(out.Details, donor.Details)
return out
}
func mergeGenericDetails(primary, secondary map[string]any) map[string]any {
if len(secondary) == 0 {
return primary
}
if primary == nil {
primary = make(map[string]any, len(secondary))
}
for key, value := range secondary {
if _, ok := primary[key]; !ok {
primary[key] = value
}
}
return primary
}
func dedupeStorageVolumes(items []models.StorageVolume) []models.StorageVolume {
seen := make(map[string]struct{}, len(items))
out := make([]models.StorageVolume, 0, len(items))
for _, v := range items {
key := firstNonEmpty(strings.TrimSpace(v.ID), strings.TrimSpace(v.Name), strings.TrimSpace(v.Controller)+"|"+fmt.Sprintf("%d", v.CapacityBytes))
if key == "" {
continue
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, v)
}
return out
}
func storageControllerFromPath(path string) string {
p := normalizeRedfishPath(path)
parts := strings.Split(p, "/")
for i := 0; i < len(parts)-1; i++ {
if parts[i] == "Storage" && i+1 < len(parts) {
return parts[i+1]
}
}
return ""
}
func parseFirmware(system, bios, manager, networkProtocol map[string]interface{}) []models.FirmwareInfo {
var out []models.FirmwareInfo
appendFW := func(name, version string) {
version = strings.TrimSpace(version)
if version == "" {
return
}
out = append(out, models.FirmwareInfo{DeviceName: name, Version: version})
}
appendFW("BIOS", asString(system["BiosVersion"]))
appendFW("BIOS", asString(bios["Version"]))
appendFW("BMC", asString(manager["FirmwareVersion"]))
return out
}
func mapStatus(statusAny interface{}) string {
if statusAny == nil {
return ""
}
if statusMap, ok := statusAny.(map[string]interface{}); ok {
health := asString(statusMap["Health"])
state := asString(statusMap["State"])
return firstNonEmpty(health, state)
}
return asString(statusAny)
}
func asString(v interface{}) string {
switch value := v.(type) {
case nil:
return ""
case string:
return strings.TrimSpace(value)
case json.Number:
return value.String()
default:
return strings.TrimSpace(fmt.Sprintf("%v", value))
}
}
func asInt(v interface{}) int {
switch value := v.(type) {
case nil:
return 0
case int:
return value
case int64:
return int(value)
case float64:
return int(value)
case json.Number:
if i, err := value.Int64(); err == nil {
return int(i)
}
if f, err := value.Float64(); err == nil {
return int(f)
}
case string:
if value == "" {
return 0
}
if i, err := strconv.Atoi(value); err == nil {
return i
}
}
return 0
}
func asInt64(v interface{}) int64 {
switch value := v.(type) {
case nil:
return 0
case int:
return int64(value)
case int64:
return value
case float64:
return int64(value)
case json.Number:
if i, err := value.Int64(); err == nil {
return i
}
if f, err := value.Float64(); err == nil {
return int64(f)
}
case string:
if value == "" {
return 0
}
if i, err := strconv.ParseInt(value, 10, 64); err == nil {
return i
}
}
return 0
}
func asBool(v interface{}) bool {
switch t := v.(type) {
case bool:
return t
case string:
return strings.EqualFold(strings.TrimSpace(t), "true")
default:
return false
}
}
func asFloat(v interface{}) float64 {
switch value := v.(type) {
case nil:
return 0
case float64:
return value
case int:
return float64(value)
case int64:
return float64(value)
case json.Number:
if f, err := value.Float64(); err == nil {
return f
}
case string:
if value == "" {
return 0
}
if f, err := strconv.ParseFloat(value, 64); err == nil {
return f
}
}
return 0
}
func asHexOrInt(v interface{}) int {
switch value := v.(type) {
case nil:
return 0
case int:
return value
case int64:
return int(value)
case float64:
return int(value)
case json.Number:
if i, err := value.Int64(); err == nil {
return int(i)
}
if f, err := value.Float64(); err == nil {
return int(f)
}
case string:
s := strings.TrimSpace(value)
s = strings.TrimPrefix(strings.ToLower(s), "0x")
if s == "" {
return 0
}
if i, err := strconv.ParseInt(s, 16, 64); err == nil {
return int(i)
}
if i, err := strconv.Atoi(s); err == nil {
return i
}
}
return 0
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
}
return ""
}
func joinPath(base, suffix string) string {
base = strings.TrimRight(base, "/")
if suffix == "" {
return base
}
if strings.HasPrefix(suffix, "/") {
return base + suffix
}
return base + "/" + suffix
}
func firstPathOrDefault(paths []string, fallback string) string {
if len(paths) == 0 {
return fallback
}
return paths[0]
}
func normalizeRedfishPath(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
if i := strings.Index(raw, "#"); i >= 0 {
raw = raw[:i]
}
if strings.HasPrefix(raw, "http://") || strings.HasPrefix(raw, "https://") {
u, err := url.Parse(raw)
if err != nil {
return ""
}
raw = u.Path
}
if !strings.HasPrefix(raw, "/") {
raw = "/" + raw
}
if !strings.HasPrefix(raw, "/redfish/") {
return ""
}
return raw
}
func redfishCollectionMemberRefs(collection map[string]interface{}) []string {
if len(collection) == 0 {
return nil
}
var out []string
seen := make(map[string]struct{})
addRefs := func(raw any) {
refs, ok := raw.([]interface{})
if !ok || len(refs) == 0 {
return
}
for _, refAny := range refs {
ref, ok := refAny.(map[string]interface{})
if !ok {
continue
}
memberPath := normalizeRedfishPath(asString(ref["@odata.id"]))
if memberPath == "" {
continue
}
if _, exists := seen[memberPath]; exists {
continue
}
seen[memberPath] = struct{}{}
out = append(out, memberPath)
}
}
addRefs(collection["Members"])
if oem, ok := collection["Oem"].(map[string]interface{}); ok {
if public, ok := oem["Public"].(map[string]interface{}); ok {
addRefs(public["Members"])
}
}
return out
}
func extractODataIDs(v interface{}) []string {
var refs []string
var walk func(any)
walk = func(node any) {
switch typed := node.(type) {
case map[string]interface{}:
for k, child := range typed {
if k == "@odata.id" {
if ref := asString(child); ref != "" {
refs = append(refs, ref)
}
continue
}
walk(child)
}
case []interface{}:
for _, child := range typed {
walk(child)
}
}
}
walk(v)
return refs
}
func redfishTopRoot(path string) string {
path = strings.TrimPrefix(path, "/")
parts := strings.Split(path, "/")
if len(parts) < 3 {
return "root"
}
return parts[2]
}
func topRoots(counts map[string]int, limit int) []string {
if len(counts) == 0 {
return []string{"n/a"}
}
type rootCount struct {
root string
count int
}
items := make([]rootCount, 0, len(counts))
for root, count := range counts {
items = append(items, rootCount{root: root, count: count})
}
sort.Slice(items, func(i, j int) bool {
return items[i].count > items[j].count
})
if len(items) > limit {
items = items[:limit]
}
out := make([]string, 0, len(items))
for _, item := range items {
out = append(out, fmt.Sprintf("%s(%d)", item.root, item.count))
}
return out
}
func redfishLocationLabel(v interface{}) string {
switch typed := v.(type) {
case nil:
return ""
case string:
return strings.TrimSpace(typed)
case map[string]interface{}:
// Common shapes:
// Slot.Location.PartLocation.ServiceLabel
// Location.PartLocation.ServiceLabel
// PartLocation.ServiceLabel
if nested := redfishLocationLabel(typed["Location"]); nested != "" {
return nested
}
if nested := redfishLocationLabel(typed["PartLocation"]); nested != "" {
return nested
}
serviceLabel := asString(typed["ServiceLabel"])
locationType := asString(typed["LocationType"])
ordinal := asString(typed["LocationOrdinalValue"])
if serviceLabel != "" {
return serviceLabel
}
if locationType != "" && ordinal != "" {
return fmt.Sprintf("%s %s", locationType, ordinal)
}
if locationType != "" {
return locationType
}
if ordinal != "" {
return "Slot " + ordinal
}
return ""
default:
// Avoid fmt.Sprint(map[]) style garbage for complex objects in UI/export.
return ""
}
}
type redfishPathTiming struct {
Path string
Duration time.Duration
Requests int
Errors int
}
type redfishPathTimingCollector struct {
depth int
mu sync.Mutex
byKey map[string]redfishPathTiming
}
func newRedfishPathTimingCollector(depth int) *redfishPathTimingCollector {
if depth < 1 {
depth = 1
}
return &redfishPathTimingCollector{
depth: depth,
byKey: make(map[string]redfishPathTiming),
}
}
func (c *redfishPathTimingCollector) Observe(path string, d time.Duration, failed bool) {
if c == nil {
return
}
key := redfishBranchPathForTiming(path, c.depth)
if key == "" {
return
}
c.mu.Lock()
item := c.byKey[key]
item.Path = key
item.Duration += d
item.Requests++
if failed {
item.Errors++
}
c.byKey[key] = item
c.mu.Unlock()
}
func (c *redfishPathTimingCollector) Summary(limit int) string {
if c == nil || limit == 0 {
return ""
}
c.mu.Lock()
items := make([]redfishPathTiming, 0, len(c.byKey))
for _, item := range c.byKey {
items = append(items, item)
}
c.mu.Unlock()
if len(items) == 0 {
return ""
}
sort.Slice(items, func(i, j int) bool {
if items[i].Duration == items[j].Duration {
if items[i].Requests == items[j].Requests {
return items[i].Path < items[j].Path
}
return items[i].Requests > items[j].Requests
}
return items[i].Duration > items[j].Duration
})
if limit < 0 || limit > len(items) {
limit = len(items)
}
parts := make([]string, 0, limit)
for i := 0; i < limit; i++ {
item := items[i]
parts = append(parts, fmt.Sprintf("%s=%s(req=%d,err=%d)", item.Path, item.Duration.Round(time.Millisecond), item.Requests, item.Errors))
}
return strings.Join(parts, "; ")
}
func redfishBranchPathForTiming(path string, depth int) string {
normalized := normalizeRedfishPath(path)
if normalized == "" {
return ""
}
parts := strings.Split(strings.Trim(normalized, "/"), "/")
if len(parts) < 2 || parts[0] != "redfish" || parts[1] != "v1" {
return normalized
}
if depth < 1 {
depth = 1
}
maxParts := 2 + depth
if len(parts) > maxParts {
parts = parts[:maxParts]
}
return "/" + strings.Join(parts, "/")
}
func compactProgressPath(p string) string {
const maxLen = 72
if len(p) <= maxLen {
return p
}
return "..." + p[len(p)-maxLen+3:]
}
func boolPointerValue(v *bool) interface{} {
if v == nil {
return nil
}
return *v
}
func redfishSnapshotMaxDocuments(tuning redfishprofile.AcquisitionTuning) int {
// Default is intentionally high enough to capture vendor-specific PCIe/GPU trees
// on modern HGX-class systems while staying within memory budgets of a typical
// developer workstation.
const (
def = 100000
min = 1200
max = 500000
)
if v := strings.TrimSpace(os.Getenv("LOGPILE_REDFISH_SNAPSHOT_MAX_DOCS")); v != "" {
if n, err := strconv.Atoi(v); err == nil {
if n < min {
return min
}
if n > max {
return max
}
return n
}
}
if tuning.SnapshotMaxDocuments > 0 {
if tuning.SnapshotMaxDocuments < min {
return min
}
if tuning.SnapshotMaxDocuments > max {
return max
}
return tuning.SnapshotMaxDocuments
}
return def
}
func formatActiveModuleLog(modules []ModuleActivation) string {
if len(modules) == 0 {
return "-"
}
parts := make([]string, 0, len(modules))
for _, module := range modules {
parts = append(parts, fmt.Sprintf("%s(%d)", module.Name, module.Score))
}
return strings.Join(parts, ",")
}
func formatModuleScoreLog(scores []ModuleScore) string {
if len(scores) == 0 {
return "-"
}
parts := make([]string, 0, len(scores))
for _, score := range scores {
state := "inactive"
if score.Active {
state = "active"
}
parts = append(parts, fmt.Sprintf("%s=%d[%s]", score.Name, score.Score, state))
}
return strings.Join(parts, ",")
}
func newRedfishRequestTelemetry() *redfishRequestTelemetry {
return &redfishRequestTelemetry{
overall: redfishPhaseTelemetry{
durations: make([]time.Duration, 0, 128),
},
byPhase: make(map[string]*redfishPhaseTelemetry),
}
}
func withRedfishTelemetryPhase(ctx context.Context, phase string) context.Context {
if strings.TrimSpace(phase) == "" {
return ctx
}
return context.WithValue(ctx, redfishTelemetryPhaseContextKey{}, phase)
}
func recordRedfishTelemetry(ctx context.Context, duration time.Duration, failed bool) {
telemetry, _ := ctx.Value(redfishTelemetryContextKey{}).(*redfishRequestTelemetry)
if telemetry == nil {
return
}
phase, _ := ctx.Value(redfishTelemetryPhaseContextKey{}).(string)
telemetry.Observe(phase, duration, failed)
}
func (t *redfishRequestTelemetry) Observe(phase string, duration time.Duration, failed bool) {
if t == nil {
return
}
t.mu.Lock()
defer t.mu.Unlock()
observeRedfishPhaseTelemetry(&t.overall, duration, failed)
phase = strings.TrimSpace(phase)
if phase == "" {
return
}
phaseTelemetry := t.byPhase[phase]
if phaseTelemetry == nil {
phaseTelemetry = &redfishPhaseTelemetry{durations: make([]time.Duration, 0, 64)}
t.byPhase[phase] = phaseTelemetry
}
observeRedfishPhaseTelemetry(phaseTelemetry, duration, failed)
}
func (t *redfishRequestTelemetry) Snapshot() redfishTelemetrySummary {
if t == nil {
return redfishTelemetrySummary{}
}
t.mu.Lock()
defer t.mu.Unlock()
return snapshotRedfishPhaseTelemetry(&t.overall)
}
func (t *redfishRequestTelemetry) PhaseSnapshots() map[string]redfishTelemetrySummary {
if t == nil {
return nil
}
t.mu.Lock()
defer t.mu.Unlock()
out := make(map[string]redfishTelemetrySummary, len(t.byPhase))
for phase, telemetry := range t.byPhase {
out[phase] = snapshotRedfishPhaseTelemetry(telemetry)
}
return out
}
func observeRedfishPhaseTelemetry(t *redfishPhaseTelemetry, duration time.Duration, failed bool) {
if t == nil {
return
}
t.requests++
if failed {
t.errors++
}
t.durations = append(t.durations, duration)
if len(t.durations) > 512 {
t.durations = t.durations[len(t.durations)-512:]
}
t.lastAvg, t.lastP95 = summarizeRedfishDurations(t.durations)
}
func snapshotRedfishPhaseTelemetry(t *redfishPhaseTelemetry) redfishTelemetrySummary {
if t == nil {
return redfishTelemetrySummary{}
}
out := redfishTelemetrySummary{
Requests: t.requests,
Errors: t.errors,
Avg: t.lastAvg,
P95: t.lastP95,
}
if t.requests > 0 {
out.ErrorRate = float64(t.errors) / float64(t.requests)
}
return out
}
func summarizeRedfishDurations(durations []time.Duration) (time.Duration, time.Duration) {
if len(durations) == 0 {
return 0, 0
}
total := time.Duration(0)
items := append([]time.Duration(nil), durations...)
for _, duration := range items {
total += duration
}
sort.Slice(items, func(i, j int) bool { return items[i] < items[j] })
p95Idx := (len(items)*95 - 1) / 100
if p95Idx < 0 {
p95Idx = 0
}
if p95Idx >= len(items) {
p95Idx = len(items) - 1
}
return total / time.Duration(len(items)), items[p95Idx]
}
func adaptRedfishAcquisitionTuning(tuning redfishprofile.AcquisitionTuning, summary redfishTelemetrySummary) (redfishprofile.AcquisitionTuning, bool) {
if summary.Requests < 4 {
return tuning, false
}
policy := tuning.RatePolicy
shouldThrottle := false
if policy.ThrottleP95LatencyMS > 0 && summary.P95 >= time.Duration(policy.ThrottleP95LatencyMS)*time.Millisecond {
shouldThrottle = true
}
if policy.DisablePrefetchOnErrors && summary.ErrorRate >= 0.20 {
shouldThrottle = true
if tuning.PrefetchEnabled == nil {
tuning.PrefetchEnabled = new(bool)
}
*tuning.PrefetchEnabled = false
}
if !shouldThrottle {
return tuning, false
}
if tuning.SnapshotWorkers == 0 {
tuning.SnapshotWorkers = redfishSnapshotWorkers(redfishprofile.AcquisitionTuning{})
}
if tuning.SnapshotWorkers > 1 {
tuning.SnapshotWorkers = maxInt(policy.MinSnapshotWorkers, tuning.SnapshotWorkers/2)
}
if tuning.PrefetchWorkers > 1 {
tuning.PrefetchWorkers = maxInt(policy.MinPrefetchWorkers, tuning.PrefetchWorkers/2)
}
return tuning, true
}
func redfishPhaseTelemetryPayload(phases map[string]redfishTelemetrySummary) map[string]any {
if len(phases) == 0 {
return nil
}
keys := make([]string, 0, len(phases))
for phase := range phases {
keys = append(keys, phase)
}
sort.Strings(keys)
out := make(map[string]any, len(keys))
for _, phase := range keys {
summary := phases[phase]
out[phase] = map[string]any{
"requests": summary.Requests,
"errors": summary.Errors,
"error_rate": summary.ErrorRate,
"avg_ms": summary.Avg.Milliseconds(),
"p95_ms": summary.P95.Milliseconds(),
}
}
return out
}
func redfishPhaseTelemetryLogLines(phases map[string]redfishTelemetrySummary) []string {
if len(phases) == 0 {
return nil
}
keys := make([]string, 0, len(phases))
for phase := range phases {
keys = append(keys, phase)
}
sort.Strings(keys)
lines := make([]string, 0, len(keys))
for _, phase := range keys {
summary := phases[phase]
lines = append(lines, fmt.Sprintf("%s req=%d err=%d err_rate=%.2f avg=%dms p95=%dms",
phase,
summary.Requests,
summary.Errors,
summary.ErrorRate,
summary.Avg.Milliseconds(),
summary.P95.Milliseconds(),
))
}
return lines
}
func buildCollectPhaseTelemetry(phases map[string]redfishTelemetrySummary) []PhaseTelemetry {
if len(phases) == 0 {
return nil
}
keys := make([]string, 0, len(phases))
for phase := range phases {
keys = append(keys, phase)
}
sort.Strings(keys)
out := make([]PhaseTelemetry, 0, len(keys))
for _, phase := range keys {
summary := phases[phase]
out = append(out, PhaseTelemetry{
Phase: phase,
Requests: summary.Requests,
Errors: summary.Errors,
ErrorRate: summary.ErrorRate,
AvgMS: summary.Avg.Milliseconds(),
P95MS: summary.P95.Milliseconds(),
})
}
return out
}
func shouldReportSnapshotFetchError(err error) bool {
if err == nil {
return false
}
msg := err.Error()
if strings.HasPrefix(msg, "status 404 ") ||
strings.HasPrefix(msg, "status 405 ") ||
strings.HasPrefix(msg, "status 410 ") ||
strings.HasPrefix(msg, "status 501 ") {
return false
}
return true
}
func minInt32(a, b int32) int32 {
if a < b {
return a
}
return b
}
func maxInt(values ...int) int {
if len(values) == 0 {
return 0
}
max := values[0]
for _, v := range values[1:] {
if v > max {
max = v
}
}
return max
}
func estimateSnapshotETA(start time.Time, processed, seen, queueLen, workers int, requestTimeout time.Duration) time.Duration {
remaining := maxInt(seen-processed, queueLen, 0)
if remaining == 0 {
return 0
}
if workers <= 0 {
workers = 1
}
if requestTimeout <= 0 {
requestTimeout = 10 * time.Second
}
timeoutBased := time.Duration(float64(requestTimeout) * float64(remaining) / float64(workers))
if processed <= 0 {
return timeoutBased
}
elapsed := time.Since(start)
if elapsed <= 0 {
return timeoutBased
}
rateBased := time.Duration(float64(elapsed) * float64(remaining) / float64(processed))
if rateBased <= 0 {
return timeoutBased
}
// Blend observed throughput with configured per-request timeout to keep ETA stable
// and still bounded by timeout assumptions on slower Redfish branches.
return (rateBased + timeoutBased) / 2
}
func estimatePlanBETA(targets int) time.Duration {
if targets <= 0 {
return 0
}
attempts := redfishCriticalPlanBAttempts()
if attempts < 1 {
attempts = 1
}
timeoutPart := time.Duration(attempts) * redfishCriticalRequestTimeout()
backoffPart := time.Duration(attempts-1) * redfishCriticalRetryBackoff()
gapPart := redfishCriticalSlowGap()
perTarget := timeoutPart + backoffPart + gapPart
return time.Duration(targets) * perTarget
}
func estimateProgressETA(start time.Time, processed, total int, fallbackPerItem time.Duration) time.Duration {
if total <= 0 || processed >= total {
return 0
}
remaining := total - processed
if processed <= 0 {
if fallbackPerItem <= 0 {
fallbackPerItem = time.Second
}
return time.Duration(remaining) * fallbackPerItem
}
elapsed := time.Since(start)
if elapsed <= 0 {
return 0
}
return time.Duration(float64(elapsed) * float64(remaining) / float64(processed))
}
func formatETA(d time.Duration) string {
if d <= 0 {
return "<1s"
}
if d < time.Second {
return "<1s"
}
if d < time.Minute {
return fmt.Sprintf("%ds", int(d.Round(time.Second).Seconds()))
}
totalSec := int(d.Round(time.Second).Seconds())
hours := totalSec / 3600
minutes := (totalSec % 3600) / 60
seconds := totalSec % 60
if hours > 0 {
return fmt.Sprintf("%dh%02dm%02ds", hours, minutes, seconds)
}
return fmt.Sprintf("%dm%02ds", minutes, seconds)
}