6899 lines
211 KiB
Go
6899 lines
211 KiB
Go
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),
|
||
PowerControlAvailable: redfishResetActionTarget(systemDoc) != "",
|
||
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")
|
||
if primarySystem != "" {
|
||
c.ensureHostPowerForCollection(ctx, snapshotClient, req, baseURL, primarySystem, emit)
|
||
}
|
||
defer func() {
|
||
if primarySystem == "" || !req.StopHostAfterCollect {
|
||
return
|
||
}
|
||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||
defer cancel()
|
||
c.restoreHostPowerAfterCollection(shutdownCtx, snapshotClient, req, baseURL, primarySystem, emit)
|
||
}()
|
||
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})
|
||
}
|
||
c.debugSnapshotf("snapshot crawl start host=%s port=%d", req.Host, req.Port)
|
||
rawTree, fetchErrors, postProbeMetrics, snapshotTimingSummary := c.collectRawRedfishTree(withRedfishTelemetryPhase(ctx, "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(ctx, "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(ctx, "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(ctx, "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,
|
||
"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
|
||
}
|
||
|
||
func (c *RedfishConnector) ensureHostPowerForCollection(ctx context.Context, client *http.Client, req Request, baseURL, systemPath string, emit ProgressFn) (hostOn bool, poweredOnByCollector bool) {
|
||
systemDoc, err := c.getJSON(ctx, client, req, baseURL, systemPath)
|
||
if err != nil {
|
||
if emit != nil {
|
||
emit(Progress{Status: "running", Progress: 18, Message: "Redfish: не удалось проверить PowerState host, сбор продолжается без power-control"})
|
||
}
|
||
return false, false
|
||
}
|
||
|
||
powerState := redfishSystemPowerState(systemDoc)
|
||
if isRedfishHostPoweredOn(powerState) {
|
||
if emit != nil {
|
||
emit(Progress{Status: "running", Progress: 18, Message: fmt.Sprintf("Redfish: host включен (%s)", firstNonEmpty(powerState, "On"))})
|
||
}
|
||
return true, false
|
||
}
|
||
|
||
if emit != nil {
|
||
emit(Progress{Status: "running", Progress: 18, Message: fmt.Sprintf("Redfish: host выключен (%s)", firstNonEmpty(powerState, "Off"))})
|
||
}
|
||
if !req.PowerOnIfHostOff {
|
||
if emit != nil {
|
||
emit(Progress{Status: "running", Progress: 19, Message: "Redfish: включение host не запрошено, сбор продолжается на выключенном host"})
|
||
}
|
||
return false, false
|
||
}
|
||
|
||
// Invalidate all inventory CRC groups before powering on so the BMC accepts
|
||
// fresh inventory from the host after boot. Best-effort: failure is logged but
|
||
// does not block power-on.
|
||
c.invalidateRedfishInventory(ctx, client, req, baseURL, systemPath, emit)
|
||
|
||
resetTarget := redfishResetActionTarget(systemDoc)
|
||
resetType := redfishPickResetType(systemDoc, "On", "ForceOn")
|
||
if resetTarget == "" || resetType == "" {
|
||
if emit != nil {
|
||
emit(Progress{Status: "running", Progress: 19, Message: "Redfish: action ComputerSystem.Reset недоступен, сбор продолжается на выключенном host"})
|
||
}
|
||
return false, false
|
||
}
|
||
|
||
waitWindows := []time.Duration{5 * time.Second, 10 * time.Second, 30 * time.Second}
|
||
for i, waitFor := range waitWindows {
|
||
if emit != nil {
|
||
emit(Progress{Status: "running", Progress: 19, Message: fmt.Sprintf("Redfish: попытка включения host (%d/%d), ожидание %s", i+1, len(waitWindows), waitFor)})
|
||
}
|
||
if err := c.postJSON(ctx, client, req, baseURL, resetTarget, map[string]any{"ResetType": resetType}); err != nil {
|
||
if emit != nil {
|
||
emit(Progress{Status: "running", Progress: 19, Message: fmt.Sprintf("Redfish: включение host не удалось (%v)", err)})
|
||
}
|
||
continue
|
||
}
|
||
if c.waitForHostPowerState(ctx, client, req, baseURL, systemPath, true, waitFor) {
|
||
if !c.waitForStablePoweredOnHost(ctx, client, req, baseURL, systemPath, emit) {
|
||
if emit != nil {
|
||
emit(Progress{Status: "running", Progress: 20, Message: "Redfish: host включился, но не подтвердил стабильное состояние; сбор продолжается на выключенном host"})
|
||
}
|
||
return false, false
|
||
}
|
||
if emit != nil {
|
||
emit(Progress{Status: "running", Progress: 20, Message: "Redfish: host успешно включен и стабилен перед сбором"})
|
||
}
|
||
return true, true
|
||
}
|
||
if emit != nil {
|
||
emit(Progress{Status: "running", Progress: 20, Message: fmt.Sprintf("Redfish: host не включился за %s", waitFor)})
|
||
}
|
||
}
|
||
|
||
if emit != nil {
|
||
emit(Progress{Status: "running", Progress: 20, Message: "Redfish: host не удалось включить, сбор продолжается на выключенном host"})
|
||
}
|
||
return false, false
|
||
}
|
||
|
||
func (c *RedfishConnector) waitForStablePoweredOnHost(ctx context.Context, client *http.Client, req Request, baseURL, systemPath string, emit ProgressFn) bool {
|
||
stabilizationDelay := redfishPowerOnStabilizationDelay()
|
||
if stabilizationDelay > 0 {
|
||
if emit != nil {
|
||
emit(Progress{
|
||
Status: "running",
|
||
Progress: 20,
|
||
Message: fmt.Sprintf("Redfish: host включен, ожидание стабилизации %s перед началом сбора", stabilizationDelay),
|
||
})
|
||
}
|
||
timer := time.NewTimer(stabilizationDelay)
|
||
select {
|
||
case <-ctx.Done():
|
||
timer.Stop()
|
||
return false
|
||
case <-timer.C:
|
||
timer.Stop()
|
||
}
|
||
}
|
||
if emit != nil {
|
||
emit(Progress{
|
||
Status: "running",
|
||
Progress: 20,
|
||
Message: "Redfish: повторная проверка PowerState после стабилизации host",
|
||
})
|
||
}
|
||
if !c.waitForHostPowerState(ctx, client, req, baseURL, systemPath, true, 5*time.Second) {
|
||
return false
|
||
}
|
||
|
||
// After the initial stabilization wait, the BMC may still be populating its
|
||
// hardware inventory (PCIeDevices, memory summary). Poll readiness with
|
||
// increasing back-off (default: +60s, +120s), then warn and proceed.
|
||
readinessWaits := redfishBMCReadinessWaits()
|
||
for attempt, extraWait := range readinessWaits {
|
||
ready, reason := c.isBMCInventoryReady(ctx, client, req, baseURL, systemPath)
|
||
if ready {
|
||
if emit != nil {
|
||
emit(Progress{
|
||
Status: "running",
|
||
Progress: 20,
|
||
Message: fmt.Sprintf("Redfish: BMC готов (%s)", reason),
|
||
})
|
||
}
|
||
return true
|
||
}
|
||
if emit != nil {
|
||
emit(Progress{
|
||
Status: "running",
|
||
Progress: 20,
|
||
Message: fmt.Sprintf("Redfish: BMC не готов (%s), ожидание %s (попытка %d/%d)", reason, extraWait, attempt+1, len(readinessWaits)),
|
||
})
|
||
}
|
||
timer := time.NewTimer(extraWait)
|
||
select {
|
||
case <-ctx.Done():
|
||
timer.Stop()
|
||
return false
|
||
case <-timer.C:
|
||
timer.Stop()
|
||
}
|
||
if emit != nil {
|
||
emit(Progress{
|
||
Status: "running",
|
||
Progress: 20,
|
||
Message: fmt.Sprintf("Redfish: повторная проверка готовности BMC (%d/%d)...", attempt+1, len(readinessWaits)),
|
||
})
|
||
}
|
||
}
|
||
ready, reason := c.isBMCInventoryReady(ctx, client, req, baseURL, systemPath)
|
||
if !ready {
|
||
if emit != nil {
|
||
emit(Progress{
|
||
Status: "running",
|
||
Progress: 20,
|
||
Message: fmt.Sprintf("Redfish: WARNING — BMC не подтвердил готовность (%s), сбор может быть неполным", reason),
|
||
})
|
||
}
|
||
} else if emit != nil {
|
||
emit(Progress{
|
||
Status: "running",
|
||
Progress: 20,
|
||
Message: fmt.Sprintf("Redfish: BMC готов (%s)", reason),
|
||
})
|
||
}
|
||
return true
|
||
}
|
||
|
||
// isBMCInventoryReady checks whether the BMC has finished populating its
|
||
// hardware inventory after a power-on. Returns (ready, reason).
|
||
// It considers the BMC ready if either the system memory summary reports
|
||
// a non-zero total or the PCIeDevices collection is non-empty.
|
||
func (c *RedfishConnector) isBMCInventoryReady(ctx context.Context, client *http.Client, req Request, baseURL, systemPath string) (bool, string) {
|
||
systemDoc, err := c.getJSON(ctx, client, req, baseURL, systemPath)
|
||
if err != nil {
|
||
return false, "не удалось прочитать System"
|
||
}
|
||
if summary, ok := systemDoc["MemorySummary"].(map[string]interface{}); ok {
|
||
if asFloat(summary["TotalSystemMemoryGiB"]) > 0 {
|
||
return true, "MemorySummary заполнен"
|
||
}
|
||
}
|
||
pcieDoc, err := c.getJSON(ctx, client, req, baseURL, joinPath(systemPath, "/PCIeDevices"))
|
||
if err == nil {
|
||
if asInt(pcieDoc["Members@odata.count"]) > 0 {
|
||
return true, "PCIeDevices не пуст"
|
||
}
|
||
if members, ok := pcieDoc["Members"].([]interface{}); ok && len(members) > 0 {
|
||
return true, "PCIeDevices не пуст"
|
||
}
|
||
}
|
||
return false, "MemorySummary=0, PCIeDevices пуст"
|
||
}
|
||
|
||
func (c *RedfishConnector) restoreHostPowerAfterCollection(ctx context.Context, client *http.Client, req Request, baseURL, systemPath string, emit ProgressFn) {
|
||
systemDoc, err := c.getJSON(ctx, client, req, baseURL, systemPath)
|
||
if err != nil {
|
||
if emit != nil {
|
||
emit(Progress{Status: "running", Progress: 100, Message: "Redfish: не удалось повторно прочитать system перед выключением host"})
|
||
}
|
||
return
|
||
}
|
||
resetTarget := redfishResetActionTarget(systemDoc)
|
||
resetType := redfishPickResetType(systemDoc, "GracefulShutdown", "ForceOff", "PushPowerButton")
|
||
if resetTarget == "" || resetType == "" {
|
||
if emit != nil {
|
||
emit(Progress{Status: "running", Progress: 100, Message: "Redfish: выключение host после сбора недоступно"})
|
||
}
|
||
return
|
||
}
|
||
if emit != nil {
|
||
emit(Progress{Status: "running", Progress: 100, Message: "Redfish: выключаем host после завершения сбора"})
|
||
}
|
||
if err := c.postJSON(ctx, client, req, baseURL, resetTarget, map[string]any{"ResetType": resetType}); err != nil {
|
||
if emit != nil {
|
||
emit(Progress{Status: "running", Progress: 100, Message: fmt.Sprintf("Redfish: не удалось выключить host после сбора (%v)", err)})
|
||
}
|
||
return
|
||
}
|
||
if c.waitForHostPowerState(ctx, client, req, baseURL, systemPath, false, 20*time.Second) {
|
||
if emit != nil {
|
||
emit(Progress{Status: "running", Progress: 100, Message: "Redfish: host выключен после завершения сбора"})
|
||
}
|
||
return
|
||
}
|
||
if emit != nil {
|
||
emit(Progress{Status: "running", Progress: 100, Message: "Redfish: не удалось подтвердить выключение host после сбора"})
|
||
}
|
||
}
|
||
|
||
// collectDebugPayloads fetches vendor-specific diagnostic endpoints on a best-effort basis.
|
||
// Results are stored in rawPayloads["redfish_debug_payloads"] and exported with the bundle.
|
||
// Enabled only when Request.DebugPayloads is true.
|
||
func (c *RedfishConnector) collectDebugPayloads(ctx context.Context, client *http.Client, req Request, baseURL string, systemPaths []string) map[string]any {
|
||
out := map[string]any{}
|
||
for _, systemPath := range systemPaths {
|
||
// AMI/MSI: inventory CRC groups — reveals which groups are supported by this BMC.
|
||
if doc, err := c.getJSON(ctx, client, req, baseURL, joinPath(systemPath, "/Oem/Ami/Inventory/Crc")); err == nil {
|
||
out[joinPath(systemPath, "/Oem/Ami/Inventory/Crc")] = doc
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// invalidateRedfishInventory POSTs to the AMI/MSI InventoryCrc endpoint to zero out
|
||
// all known CRC groups before a host power-on. This causes the BMC to accept fresh
|
||
// inventory from the host after boot, preventing stale inventory (ghost GPUs, wrong
|
||
// BIOS version, etc.) from persisting across hardware changes.
|
||
// Best-effort: any error is logged and the call silently returns.
|
||
func (c *RedfishConnector) invalidateRedfishInventory(ctx context.Context, client *http.Client, req Request, baseURL, systemPath string, emit ProgressFn) {
|
||
crcPath := joinPath(systemPath, "/Oem/Ami/Inventory/Crc")
|
||
body := map[string]any{
|
||
"GroupCrcList": []map[string]any{
|
||
{"CPU": 0},
|
||
{"DIMM": 0},
|
||
{"PCIE": 0},
|
||
},
|
||
}
|
||
if err := c.postJSON(ctx, client, req, baseURL, crcPath, body); err != nil {
|
||
log.Printf("redfish: inventory invalidation skipped (not AMI/MSI or endpoint unavailable): %v", err)
|
||
return
|
||
}
|
||
log.Printf("redfish: inventory CRC groups invalidated at %s before host power-on", crcPath)
|
||
if emit != nil {
|
||
emit(Progress{Status: "running", Progress: 19, Message: "Redfish: инвентарь BMC инвалидирован перед включением host (все CRC группы сброшены)"})
|
||
}
|
||
}
|
||
|
||
func (c *RedfishConnector) waitForHostPowerState(ctx context.Context, client *http.Client, req Request, baseURL, systemPath string, wantOn bool, timeout time.Duration) bool {
|
||
deadline := time.Now().Add(timeout)
|
||
for {
|
||
systemDoc, err := c.getJSON(ctx, client, req, baseURL, systemPath)
|
||
if err == nil {
|
||
if isRedfishHostPoweredOn(redfishSystemPowerState(systemDoc)) == wantOn {
|
||
return true
|
||
}
|
||
}
|
||
if time.Now().After(deadline) {
|
||
return false
|
||
}
|
||
select {
|
||
case <-ctx.Done():
|
||
return false
|
||
case <-time.After(1 * time.Second):
|
||
}
|
||
}
|
||
}
|
||
|
||
func firstNonEmptyPath(paths []string, fallback string) string {
|
||
for _, p := range paths {
|
||
if strings.TrimSpace(p) != "" {
|
||
return p
|
||
}
|
||
}
|
||
return fallback
|
||
}
|
||
|
||
func isRedfishHostPoweredOn(state string) bool {
|
||
switch strings.ToLower(strings.TrimSpace(state)) {
|
||
case "on", "poweringon":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func redfishSystemPowerState(systemDoc map[string]interface{}) string {
|
||
if len(systemDoc) == 0 {
|
||
return ""
|
||
}
|
||
if state := strings.TrimSpace(asString(systemDoc["PowerState"])); state != "" {
|
||
return state
|
||
}
|
||
if summary, ok := systemDoc["PowerSummary"].(map[string]interface{}); ok {
|
||
return strings.TrimSpace(asString(summary["PowerState"]))
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func redfishResetActionTarget(systemDoc map[string]interface{}) string {
|
||
if systemDoc == nil {
|
||
return ""
|
||
}
|
||
actions, _ := systemDoc["Actions"].(map[string]interface{})
|
||
reset, _ := actions["#ComputerSystem.Reset"].(map[string]interface{})
|
||
target := strings.TrimSpace(asString(reset["target"]))
|
||
if target != "" {
|
||
return target
|
||
}
|
||
odataID := strings.TrimSpace(asString(systemDoc["@odata.id"]))
|
||
if odataID == "" {
|
||
return ""
|
||
}
|
||
return joinPath(odataID, "/Actions/ComputerSystem.Reset")
|
||
}
|
||
|
||
func redfishPickResetType(systemDoc map[string]interface{}, preferred ...string) string {
|
||
actions, _ := systemDoc["Actions"].(map[string]interface{})
|
||
reset, _ := actions["#ComputerSystem.Reset"].(map[string]interface{})
|
||
allowedRaw, _ := reset["ResetType@Redfish.AllowableValues"].([]interface{})
|
||
if len(allowedRaw) == 0 {
|
||
if len(preferred) > 0 {
|
||
return preferred[0]
|
||
}
|
||
return ""
|
||
}
|
||
allowed := make([]string, 0, len(allowedRaw))
|
||
for _, item := range allowedRaw {
|
||
if v := strings.TrimSpace(asString(item)); v != "" {
|
||
allowed = append(allowed, v)
|
||
}
|
||
}
|
||
for _, want := range preferred {
|
||
for _, have := range allowed {
|
||
if strings.EqualFold(want, have) {
|
||
return have
|
||
}
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func (c *RedfishConnector) postJSON(ctx context.Context, client *http.Client, req Request, baseURL, resourcePath string, payload map[string]any) error {
|
||
body, err := json.Marshal(payload)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
target := strings.TrimSpace(resourcePath)
|
||
if !strings.HasPrefix(strings.ToLower(target), "http://") && !strings.HasPrefix(strings.ToLower(target), "https://") {
|
||
target = baseURL + normalizeRedfishPath(target)
|
||
}
|
||
u, err := url.Parse(target)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), strings.NewReader(string(body)))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
httpReq.Header.Set("Content-Type", "application/json")
|
||
switch req.AuthType {
|
||
case "password":
|
||
httpReq.SetBasicAuth(req.Username, req.Password)
|
||
case "token":
|
||
httpReq.Header.Set("Authorization", "Bearer "+req.Token)
|
||
}
|
||
resp, err := client.Do(httpReq)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer resp.Body.Close()
|
||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||
return fmt.Errorf("status %d from %s: %s", resp.StatusCode, resourcePath, strings.TrimSpace(string(respBody)))
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (c *RedfishConnector) prefetchCriticalRedfishDocs(
|
||
ctx context.Context,
|
||
client *http.Client,
|
||
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)
|
||
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)
|
||
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
|
||
}
|
||
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 redfishPowerOnStabilizationDelay() time.Duration {
|
||
if v := strings.TrimSpace(os.Getenv("LOGPILE_REDFISH_POWERON_STABILIZATION")); v != "" {
|
||
if d, err := time.ParseDuration(v); err == nil && d >= 0 {
|
||
return d
|
||
}
|
||
}
|
||
return 60 * time.Second
|
||
}
|
||
|
||
// redfishBMCReadinessWaits returns the extra wait durations used when polling
|
||
// BMC inventory readiness after power-on. Defaults: [60s, 120s].
|
||
// Override with LOGPILE_REDFISH_BMC_READY_WAITS (comma-separated durations,
|
||
// e.g. "60s,120s").
|
||
func redfishBMCReadinessWaits() []time.Duration {
|
||
if v := strings.TrimSpace(os.Getenv("LOGPILE_REDFISH_BMC_READY_WAITS")); v != "" {
|
||
var out []time.Duration
|
||
for _, part := range strings.Split(v, ",") {
|
||
if d, err := time.ParseDuration(strings.TrimSpace(part)); err == nil && d >= 0 {
|
||
out = append(out, d)
|
||
}
|
||
}
|
||
if len(out) > 0 {
|
||
return out
|
||
}
|
||
}
|
||
return []time.Duration{60 * time.Second, 120 * time.Second}
|
||
}
|
||
|
||
func redfishSnapshotMemoryRequestTimeout() time.Duration {
|
||
if v := strings.TrimSpace(os.Getenv("LOGPILE_REDFISH_MEMORY_TIMEOUT")); v != "" {
|
||
if d, err := time.ParseDuration(v); err == nil && d > 0 {
|
||
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, "/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
|
||
}
|
||
}
|
||
|
||
// 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) 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{})
|
||
addTarget := func(path string) {
|
||
path = normalizeRedfishPath(path)
|
||
if path == "" {
|
||
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 {
|
||
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 (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 = 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 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 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"])
|
||
}
|
||
|
||
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 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.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 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
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
|
||
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 ""
|
||
}
|
||
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)
|
||
}
|