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