Files
logpile/internal/collector/redfish.go
Mikhail Chusavitin 1eb639e6bf redfish: skip NVMe bay probe for non-storage chassis types (Module/Component/Zone)
On Supermicro HGX systems (SYS-A21GE-NBRT) ~35 sub-chassis (GPU, NVSwitch,
PCIeRetimer, ERoT/IRoT, BMC, FPGA) all carry ChassisType=Module/Component/Zone
and expose empty /Drives collections. shouldAdaptiveNVMeProbe returned true for
all of them, triggering 35 × 384 = 13 440 HTTP requests → ~22 min wasted per
collection (more than half of total 35 min collection time).

Fix: chassisTypeCanHaveNVMe returns false for Module, Component, Zone. The
candidate selection loop in collectRawRedfishTree now checks the parent chassis
doc before adding a /Drives path to the probe list. Enclosure (NVMe backplane),
RackMount, and unknown types are unaffected.

Tests:
- TestChassisTypeCanHaveNVMe: table-driven, covers excluded and storage-capable types
- TestNVMePostProbeSkipsNonStorageChassis: topology integration, GPU chassis +
  backplane with empty /Drives → exactly 1 candidate selected (backplane only)

Docs:
- ADL-018 in bible-local/10-decisions.md
- Candidate-selection test matrix in bible-local/09-testing.md
- SYS-A21GE-NBRT baseline row in docs/test_server_collection_memory.md

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

4828 lines
135 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package collector
import (
"context"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"path"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"git.mchus.pro/mchus/logpile/internal/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
}
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) 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()
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())
if emit != nil {
emit(Progress{Status: "running", Progress: 10, Message: "Redfish: подключение к BMC..."})
}
if _, err := c.getJSON(ctx, snapshotClient, req, baseURL, "/redfish/v1"); err != nil {
return nil, fmt.Errorf("redfish service root: %w", err)
}
systemPaths := c.discoverMemberPaths(ctx, snapshotClient, req, baseURL, "/redfish/v1/Systems", "/redfish/v1/Systems/1")
chassisPaths := c.discoverMemberPaths(ctx, snapshotClient, req, baseURL, "/redfish/v1/Chassis", "/redfish/v1/Chassis/1")
managerPaths := c.discoverMemberPaths(ctx, snapshotClient, req, baseURL, "/redfish/v1/Managers", "/redfish/v1/Managers/1")
criticalPaths := redfishCriticalEndpoints(systemPaths, chassisPaths, managerPaths)
if emit != nil {
emit(Progress{Status: "running", Progress: 30, Message: "Redfish: чтение структуры Redfish..."})
}
if emit != nil {
emit(Progress{Status: "running", Progress: 55, Message: "Redfish: подготовка snapshot..."})
emit(Progress{Status: "running", Progress: 80, Message: "Redfish: подготовка расширенного snapshot..."})
emit(Progress{Status: "running", Progress: 90, Message: "Redfish: сбор расширенного snapshot..."})
}
c.debugSnapshotf("snapshot crawl start host=%s port=%d", req.Host, req.Port)
rawTree, fetchErrors, postProbeMetrics := c.collectRawRedfishTree(ctx, snapshotClient, req, baseURL, redfishSnapshotPrioritySeeds(systemPaths, chassisPaths, managerPaths), emit)
c.debugSnapshotf("snapshot crawl done docs=%d", len(rawTree))
fetchErrMap := redfishFetchErrorListToMap(fetchErrors)
prefetchedCritical, prefetchMetrics := c.prefetchCriticalRedfishDocs(ctx, prefetchClient, req, baseURL, criticalPaths, rawTree, fetchErrMap, 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(ctx, criticalClient, req, baseURL, criticalPaths, rawTree, fetchErrMap, emit); recoveredN > 0 {
c.debugSnapshotf("critical 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..."})
}
rawPayloads := map[string]any{
"redfish_tree": rawTree,
}
if len(fetchErrMap) > 0 {
rawPayloads["redfish_fetch_errors"] = redfishFetchErrorMapToList(fetchErrMap)
}
// 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)
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-collect: completed in %s (docs=%d, fetch_errors=%d)", totalElapsed, len(rawTree), len(fetchErrMap))
if emit != nil {
emit(Progress{
Status: "running",
Progress: 100,
Message: fmt.Sprintf("Redfish: сбор завершен за %s", totalElapsed),
})
}
return result, 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,
emit ProgressFn,
) (map[string]interface{}, redfishPrefetchMetrics) {
metrics := redfishPrefetchMetrics{
Enabled: redfishPrefetchEnabled(),
}
if !metrics.Enabled || len(criticalPaths) == 0 {
metrics.SkipReason = "disabled-or-empty"
return nil, metrics
}
candidates := redfishPrefetchTargets(criticalPaths)
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)),
})
}
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()
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)),
})
}
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 {
out = append(out, parseDrive(driveDoc))
}
if len(driveDocs) == 0 {
for _, driveDoc := range c.probeDirectDiskBayChildren(ctx, client, req, baseURL, driveCollectionPath) {
out = append(out, parseDrive(driveDoc))
}
}
}
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
}
out = append(out, parseDrive(driveDoc))
}
continue
}
// Some implementations return drive fields right in storage member object.
if looksLikeDrive(member) {
out = append(out, parseDrive(member))
}
// 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) {
out = append(out, parseDrive(driveDoc))
}
}
if len(driveDocs) == 0 {
for _, driveDoc := range c.probeDirectDiskBayChildren(ctx, client, req, baseURL, joinPath(enclosurePath, "/Drives")) {
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) {
out = append(out, parseDrive(driveDoc))
}
}
// 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) {
continue
}
out = append(out, parseDrive(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) {
continue
}
out = append(out, parseDrive(driveDoc))
}
}
for _, chassisPath := range chassisPaths {
if !isSupermicroNVMeBackplanePath(chassisPath) {
continue
}
for _, driveDoc := range c.probeSupermicroNVMeDiskBays(ctx, client, req, baseURL, chassisPath) {
if !looksLikeDrive(driveDoc) {
continue
}
out = append(out, parseDrive(driveDoc))
}
}
out = dedupeStorage(out)
return out
}
func (c *RedfishConnector) collectStorageVolumes(ctx context.Context, client *http.Client, req Request, baseURL, systemPath string) []models.StorageVolume {
var out []models.StorageVolume
storageMembers, _ := c.getCollectionMembers(ctx, client, req, baseURL, joinPath(systemPath, "/Storage"))
for _, member := range storageMembers {
controller := firstNonEmpty(asString(member["Id"]), asString(member["Name"]))
volumeCollectionPath := redfishLinkedPath(member, "Volumes")
if volumeCollectionPath == "" {
continue
}
volumeDocs, err := c.getCollectionMembers(ctx, client, req, baseURL, volumeCollectionPath)
if err != nil {
continue
}
for _, volDoc := range volumeDocs {
if !looksLikeVolume(volDoc) {
continue
}
out = append(out, parseStorageVolume(volDoc, controller))
}
}
for _, volDoc := range c.collectKnownStorageMembers(ctx, client, req, baseURL, systemPath, []string{
"/Storage/IntelVROC/Volumes",
"/Storage/HA-RAID/Volumes",
"/Storage/MRVL.HA-RAID/Volumes",
}) {
if !looksLikeVolume(volDoc) {
continue
}
out = append(out, parseStorageVolume(volDoc, storageControllerFromPath(asString(volDoc["@odata.id"]))))
}
return dedupeStorageVolumes(out)
}
func (c *RedfishConnector) collectNICs(ctx context.Context, client *http.Client, req Request, baseURL string, chassisPaths []string) []models.NetworkAdapter {
var nics []models.NetworkAdapter
for _, chassisPath := range chassisPaths {
adapterDocs, err := c.getCollectionMembers(ctx, client, req, baseURL, joinPath(chassisPath, "/NetworkAdapters"))
if err != nil {
continue
}
for _, doc := range adapterDocs {
nic := parseNIC(doc)
for _, pciePath := range networkAdapterPCIeDevicePaths(doc) {
pcieDoc, err := c.getJSON(ctx, client, req, baseURL, pciePath)
if err != nil {
continue
}
functionDocs := c.getLinkedPCIeFunctions(ctx, client, req, baseURL, pcieDoc)
enrichNICFromPCIe(&nic, pcieDoc, functionDocs)
}
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 {
idx = appendPSU(&out, seen, parsePSU(doc, idx), idx)
}
continue
}
// Legacy source: embedded array in Chassis/<id>/Power.
if powerDoc, err := c.getJSON(ctx, client, req, baseURL, joinPath(chassisPath, "/Power")); err == nil {
if members, ok := powerDoc["PowerSupplies"].([]interface{}); ok && len(members) > 0 {
for _, item := range members {
doc, ok := item.(map[string]interface{})
if !ok {
continue
}
idx = appendPSU(&out, seen, parsePSU(doc, idx), 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) 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
}
gpu := parseGPU(doc, functionDocs, 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
}
dev := parsePCIeDevice(doc, functionDocs)
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 {
dev := parsePCIeFunction(fn, idx+1)
out = append(out, dev)
}
}
return dedupePCIeDevices(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) collectRawRedfishTree(ctx context.Context, client *http.Client, req Request, baseURL string, seedPaths []string, emit ProgressFn) (map[string]interface{}, []map[string]interface{}, redfishPostProbeMetrics) {
maxDocuments := redfishSnapshotMaxDocuments()
workers := redfishSnapshotWorkers()
const heartbeatInterval = 5 * time.Second
crawlStart := time.Now()
memoryClient := c.httpClientWithTimeout(req, redfishSnapshotMemoryRequestTimeout())
memoryGate := make(chan struct{}, redfishSnapshotMemoryConcurrency())
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)),
})
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)),
})
}
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)
// Some Supermicro BMCs expose NVMe disks at direct Disk.Bay endpoints even when the
// Drives collection returns Members: []. Probe those paths so raw export can be replayed.
postProbeTotalStart := time.Now()
driveCollections := make([]string, 0)
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()
for i, path := range driveCollections {
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)),
})
}
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)
}
}
// Some BMCs under-report collection Members for sensors/PSU subresources but still serve
// direct numeric child endpoints. Probe common collections to maximize raw snapshot fidelity.
postProbeCollections := make([]string, 0)
for path, docAny := range out {
normalized := normalizeRedfishPath(path)
if !shouldPostProbeCollectionPath(normalized) {
continue
}
postProbeMetrics.CollectionCandidates++
doc, _ := docAny.(map[string]interface{})
if shouldAdaptivePostProbeCollectionPath(normalized, doc) {
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)),
})
}
for childPath, doc := range c.probeDirectRedfishCollectionChildren(ctx, client, 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
}
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) bool {
path = normalizeRedfishPath(path)
sensorProbeEnabled := 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{}) bool {
path = normalizeRedfishPath(path)
if !shouldPostProbeCollectionPath(path) {
return false
}
if len(collectionDoc) == 0 {
return true
}
memberRefs := redfishCollectionMemberRefs(collectionDoc)
if len(memberRefs) == 0 {
return true
}
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
}
func shouldSlowProbeCriticalCollection(p string) bool {
p = normalizeRedfishPath(p)
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 redfishCriticalEndpoints(systemPaths, chassisPaths, managerPaths []string) []string {
var out []string
seen := make(map[string]struct{})
add := func(p string) {
p = normalizeRedfishPath(p)
if p == "" {
return
}
if _, ok := seen[p]; ok {
return
}
seen[p] = struct{}{}
out = append(out, p)
}
for _, p := range systemPaths {
add(p)
add(joinPath(p, "/Bios"))
add(joinPath(p, "/SecureBoot"))
add(joinPath(p, "/Oem/Public"))
add(joinPath(p, "/Oem/Public/FRU"))
add(joinPath(p, "/Processors"))
add(joinPath(p, "/Memory"))
add(joinPath(p, "/Storage"))
add(joinPath(p, "/SimpleStorage"))
add(joinPath(p, "/PCIeDevices"))
add(joinPath(p, "/PCIeFunctions"))
add(joinPath(p, "/Accelerators"))
add(joinPath(p, "/GraphicsControllers"))
add(joinPath(p, "/EthernetInterfaces"))
add(joinPath(p, "/NetworkInterfaces"))
}
for _, p := range chassisPaths {
add(p)
add(joinPath(p, "/Oem/Public"))
add(joinPath(p, "/Oem/Public/FRU"))
add(joinPath(p, "/Power"))
add(joinPath(p, "/NetworkAdapters"))
add(joinPath(p, "/PCIeDevices"))
add(joinPath(p, "/Accelerators"))
add(joinPath(p, "/Drives"))
add(joinPath(p, "/Assembly"))
}
for _, p := range managerPaths {
add(p)
add(joinPath(p, "/NetworkProtocol"))
}
add("/redfish/v1")
add("/redfish/v1/UpdateService")
add("/redfish/v1/UpdateService/FirmwareInventory")
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 redfishSnapshotWorkers() int {
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() bool {
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() int {
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 redfishPrefetchTargets(criticalPaths []string) []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) {
continue
}
if _, ok := seen[p]; ok {
continue
}
seen[p] = struct{}{}
out = append(out, p)
}
return out
}
func shouldPrefetchCriticalPath(p string) bool {
p = normalizeRedfishPath(p)
if p == "" {
return false
}
for _, noisy := range []string{
"/Fabrics",
"/Backplanes",
"/Boards",
"/Assembly",
"/Sensors",
"/ThresholdSensors",
"/DiscreteSensors",
"/ThermalConfig",
"/ThermalSubsystem",
"/EnvironmentMetrics",
"/Certificates",
"/LogServices",
} {
if strings.Contains(p, noisy) {
return false
}
}
for _, suffix := range []string{
"/Bios",
"/SecureBoot",
"/Processors",
"/Memory",
"/Storage",
"/SimpleStorage",
"/PCIeDevices",
"/PCIeFunctions",
"/Accelerators",
"/GraphicsControllers",
"/EthernetInterfaces",
"/NetworkInterfaces",
"/NetworkAdapters",
"/Drives",
"/Power",
"/PowerSubsystem/PowerSupplies",
"/NetworkProtocol",
"/Oem/Public",
"/Oem/Public/FRU",
} {
if strings.HasSuffix(p, suffix) {
return true
}
}
switch p {
case "/redfish/v1/UpdateService", "/redfish/v1/UpdateService/FirmwareInventory":
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 shouldCrawlPath(path string) bool {
if path == "" {
return false
}
normalized := normalizeRedfishPath(path)
if strings.Contains(normalized, "/Chassis/") &&
strings.Contains(normalized, "/PCIeDevices/") &&
strings.Contains(normalized, "/PCIeFunctions/") {
// Chassis-level PCIeFunctions links are frequently noisy/slow on some BMCs
// and duplicate data we already collect from PCIe devices/functions elsewhere.
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 (/Memory/<slot>) but skip nested subresources
// like /Memory/<slot>/Assembly and /Memory/<slot>/MemoryMetrics.
return false
}
}
// 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",
} {
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 isRedfishMemoryMemberPath(path string) bool {
normalized := normalizeRedfishPath(path)
if !strings.Contains(normalized, "/Systems/") {
return false
}
if !strings.Contains(normalized, "/Memory/") {
return false
}
if strings.Contains(normalized, "/MemoryMetrics") || strings.Contains(normalized, "/Assembly") {
return false
}
after := strings.SplitN(normalized, "/Memory/", 2)
if len(after) != 2 {
return false
}
suffix := strings.TrimSpace(after[1])
if suffix == "" || strings.Contains(suffix, "/") {
return false
}
return true
}
func redfishCollectionHasExplicitMembers(doc map[string]interface{}) bool {
return len(redfishCollectionMemberRefs(doc)) > 0
}
func redfishSnapshotBranchKey(path string) string {
normalized := normalizeRedfishPath(path)
if normalized == "" || normalized == "/redfish/v1" {
return ""
}
parts := strings.Split(strings.Trim(normalized, "/"), "/")
if len(parts) < 3 {
return normalized
}
if parts[0] != "redfish" || parts[1] != "v1" {
return normalized
}
// Keep subsystem branches independent, e.g. Systems/1/Memory vs Systems/1/PCIeDevices.
if len(parts) >= 5 && (parts[2] == "Systems" || parts[2] == "Chassis" || parts[2] == "Managers") {
return "/" + strings.Join(parts[:5], "/")
}
if len(parts) >= 4 {
return "/" + strings.Join(parts[:4], "/")
}
return "/" + strings.Join(parts[:3], "/")
}
func (c *RedfishConnector) getLinkedPCIeFunctions(ctx context.Context, client *http.Client, req Request, baseURL string, doc map[string]interface{}) []map[string]interface{} {
// Newer Redfish payloads often keep function references in Links.PCIeFunctions.
if links, ok := doc["Links"].(map[string]interface{}); ok {
if refs, ok := links["PCIeFunctions"].([]interface{}); ok && len(refs) > 0 {
out := make([]map[string]interface{}, 0, len(refs))
for _, refAny := range refs {
ref, ok := refAny.(map[string]interface{})
if !ok {
continue
}
memberPath := asString(ref["@odata.id"])
if memberPath == "" {
continue
}
memberDoc, err := c.getJSON(ctx, client, req, baseURL, memberPath)
if err != nil {
continue
}
out = append(out, memberDoc)
}
return out
}
}
// Some implementations expose a collection object in PCIeFunctions.@odata.id.
if pcieFunctions, ok := doc["PCIeFunctions"].(map[string]interface{}); ok {
if collectionPath := asString(pcieFunctions["@odata.id"]); collectionPath != "" {
memberDocs, err := c.getCollectionMembers(ctx, client, req, baseURL, collectionPath)
if err == nil {
return memberDocs
}
}
}
return nil
}
func (c *RedfishConnector) getCollectionMembers(ctx context.Context, client *http.Client, req Request, baseURL, collectionPath string) ([]map[string]interface{}, error) {
collection, err := c.getJSON(ctx, client, req, baseURL, collectionPath)
if err != nil {
return nil, err
}
memberPaths := redfishCollectionMemberRefs(collection)
if len(memberPaths) == 0 {
return []map[string]interface{}{}, nil
}
out := make([]map[string]interface{}, 0, len(memberPaths))
for _, memberPath := range memberPaths {
memberDoc, err := c.getJSON(ctx, client, req, baseURL, memberPath)
if err != nil {
continue
}
if strings.TrimSpace(asString(memberDoc["@odata.id"])) == "" {
memberDoc["@odata.id"] = normalizeRedfishPath(memberPath)
}
out = append(out, memberDoc)
}
return out, nil
}
func (c *RedfishConnector) getJSON(ctx context.Context, client *http.Client, req Request, baseURL, requestPath string) (map[string]interface{}, error) {
start := time.Now()
rel := requestPath
if rel == "" {
rel = "/"
}
if !strings.HasPrefix(rel, "/") {
rel = "/" + rel
}
u, err := url.Parse(baseURL)
if err != nil {
return nil, err
}
u.Path = path.Join(strings.TrimSuffix(u.Path, "/"), rel)
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return nil, err
}
httpReq.Header.Set("Accept", "application/json")
switch req.AuthType {
case "password":
httpReq.SetBasicAuth(req.Username, req.Password)
case "token":
httpReq.Header.Set("X-Auth-Token", req.Token)
httpReq.Header.Set("Authorization", "Bearer "+req.Token)
}
resp, err := client.Do(httpReq)
if err != nil {
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)))
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 {
c.debugf("http get path=%s decode_error=%v dur=%s", requestPath, err, time.Since(start).Round(time.Millisecond))
return nil, err
}
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, emit ProgressFn) int {
planBStart := time.Now()
timings := newRedfishPathTimingCollector(4)
var targets []string
seenTargets := make(map[string]struct{})
addTarget := func(path string) {
path = normalizeRedfishPath(path)
if path == "" {
return
}
if _, ok := seenTargets[path]; ok {
return
}
seenTargets[path] = struct{}{}
targets = append(targets, path)
}
for _, p := range criticalPaths {
p = normalizeRedfishPath(p)
if p == "" {
continue
}
if _, ok := rawTree[p]; ok {
continue
}
errMsg, hasErr := fetchErrs[p]
if hasErr && !isRetryableRedfishFetchError(fmt.Errorf("%s", errMsg)) {
continue
}
addTarget(p)
}
// If a critical collection document was fetched, but some of its members
// failed during the initial crawl (common for /Drives on partially loaded BMCs),
// retry those member resources in plan-B too.
for _, p := range criticalPaths {
p = normalizeRedfishPath(p)
if p == "" {
continue
}
docAny, ok := rawTree[p]
if !ok {
continue
}
doc, ok := docAny.(map[string]interface{})
if !ok {
continue
}
for _, memberPath := range redfishCollectionMemberRefs(doc) {
if _, exists := rawTree[memberPath]; exists {
continue
}
errMsg, hasErr := fetchErrs[memberPath]
if hasErr && !isRetryableRedfishFetchError(fmt.Errorf("%s", errMsg)) {
continue
}
addTarget(memberPath)
}
}
if len(targets) == 0 {
return 0
}
if emit != nil {
totalETA := redfishCriticalCooldown() + estimatePlanBETA(len(targets))
emit(Progress{
Status: "running",
Progress: 97,
Message: fmt.Sprintf("Redfish: cooldown перед повторным добором критичных endpoint... ETA≈%s", formatETA(totalETA)),
})
}
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)),
})
}
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 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) && !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) {
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 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(asString(system["PartNumber"])),
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)
cpus = append(cpus, models.CPU{
Socket: socket,
Model: firstNonEmpty(asString(doc["Model"]), asString(doc["Name"])),
Cores: asInt(doc["TotalCores"]),
Threads: asInt(doc["TotalThreads"]),
FrequencyMHz: asInt(doc["OperatingSpeedMHz"]),
MaxFreqMHz: asInt(doc["MaxSpeedMHz"]),
SerialNumber: findFirstNormalizedStringByKeys(doc, "SerialNumber"),
L1CacheKB: l1,
L2CacheKB: l2,
L3CacheKB: l3,
Status: mapStatus(doc["Status"]),
})
}
return cpus
}
// 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"]),
})
}
return out
}
func parseDrive(doc 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,
}
}
// 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 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"])
var firmware string
var portCount int
if controllers, ok := doc["Controllers"].([]interface{}); ok && len(controllers) > 0 {
if ctrl, ok := controllers[0].(map[string]interface{}); ok {
location = firstNonEmpty(location, redfishLocationLabel(ctrl["Location"]))
firmware = asString(ctrl["FirmwarePackageVersion"])
if caps, ok := ctrl["ControllerCapabilities"].(map[string]interface{}); ok {
portCount = sanitizeNetworkPortCount(asInt(caps["NetworkPortCount"]))
}
}
}
return models.NetworkAdapter{
Slot: firstNonEmpty(asString(doc["Id"]), asString(doc["Name"])),
Location: location,
Present: !strings.EqualFold(mapStatus(doc["Status"]), "Absent"),
Model: strings.TrimSpace(model),
Vendor: strings.TrimSpace(vendor),
VendorID: vendorID,
DeviceID: deviceID,
SerialNumber: findFirstNormalizedStringByKeys(doc, "SerialNumber"),
PartNumber: asString(doc["PartNumber"]),
Firmware: firmware,
PortCount: portCount,
Status: mapStatus(doc["Status"]),
}
}
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{}) {
if nic == nil {
return
}
if nic.VendorID == 0 {
nic.VendorID = asHexOrInt(pcieDoc["VendorId"])
}
if nic.DeviceID == 0 {
nic.DeviceID = asHexOrInt(pcieDoc["DeviceId"])
}
for _, fn := range functionDocs {
if nic.VendorID == 0 {
nic.VendorID = asHexOrInt(fn["VendorId"])
}
if nic.DeviceID == 0 {
nic.DeviceID = asHexOrInt(fn["DeviceId"])
}
}
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
}
}
}
func parsePSU(doc map[string]interface{}, idx int) 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: asInt(doc["PowerCapacityWatts"]),
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"]),
}
}
func parseGPU(doc map[string]interface{}, functionDocs []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"]),
}
if bdf := asString(doc["BDF"]); bdf != "" {
gpu.BDF = bdf
}
if gpu.BDF == "" {
gpu.BDF = buildBDFfromOemPublic(doc)
}
if gpu.VendorID == 0 {
gpu.VendorID = asHexOrInt(doc["VendorId"])
}
if gpu.DeviceID == 0 {
gpu.DeviceID = asHexOrInt(doc["DeviceId"])
}
for _, fn := range functionDocs {
if gpu.BDF == "" {
gpu.BDF = asString(fn["FunctionId"])
}
if gpu.VendorID == 0 {
gpu.VendorID = asHexOrInt(fn["VendorId"])
}
if gpu.DeviceID == 0 {
gpu.DeviceID = asHexOrInt(fn["DeviceId"])
}
if gpu.MaxLinkWidth == 0 {
gpu.MaxLinkWidth = asInt(fn["MaxLinkWidth"])
}
if gpu.CurrentLinkWidth == 0 {
gpu.CurrentLinkWidth = asInt(fn["CurrentLinkWidth"])
}
if gpu.MaxLinkSpeed == "" {
gpu.MaxLinkSpeed = firstNonEmpty(asString(fn["MaxLinkSpeedGTs"]), asString(fn["MaxLinkSpeed"]))
}
if gpu.CurrentLinkSpeed == "" {
gpu.CurrentLinkSpeed = firstNonEmpty(asString(fn["CurrentLinkSpeedGTs"]), asString(fn["CurrentLinkSpeed"]))
}
}
if isMissingOrRawPCIModel(gpu.Model) {
if resolved := pciids.DeviceName(gpu.VendorID, gpu.DeviceID); resolved != "" {
gpu.Model = resolved
}
}
if strings.TrimSpace(gpu.Manufacturer) == "" {
gpu.Manufacturer = pciids.VendorName(gpu.VendorID)
}
return gpu
}
func parsePCIeDevice(doc map[string]interface{}, functionDocs []map[string]interface{}) models.PCIeDevice {
dev := models.PCIeDevice{
Slot: firstNonEmpty(redfishLocationLabel(doc["Slot"]), redfishLocationLabel(doc["Location"]), asString(doc["Name"]), asString(doc["Id"])),
BDF: 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"]),
}
if strings.TrimSpace(dev.BDF) == "" {
dev.BDF = buildBDFfromOemPublic(doc)
}
for _, fn := range functionDocs {
if dev.BDF == "" {
dev.BDF = asString(fn["FunctionId"])
}
if dev.DeviceClass == "" || isGenericPCIeClassLabel(dev.DeviceClass) {
dev.DeviceClass = firstNonEmpty(asString(fn["DeviceClass"]), asString(fn["ClassCode"]))
}
if dev.VendorID == 0 {
dev.VendorID = asHexOrInt(fn["VendorId"])
}
if dev.DeviceID == 0 {
dev.DeviceID = asHexOrInt(fn["DeviceId"])
}
if dev.LinkWidth == 0 {
dev.LinkWidth = asInt(fn["CurrentLinkWidth"])
}
if dev.MaxLinkWidth == 0 {
dev.MaxLinkWidth = asInt(fn["MaxLinkWidth"])
}
if dev.LinkSpeed == "" {
dev.LinkSpeed = firstNonEmpty(asString(fn["CurrentLinkSpeedGTs"]), asString(fn["CurrentLinkSpeed"]))
}
if dev.MaxLinkSpeed == "" {
dev.MaxLinkSpeed = firstNonEmpty(asString(fn["MaxLinkSpeedGTs"]), asString(fn["MaxLinkSpeed"]))
}
}
if dev.DeviceClass == "" {
dev.DeviceClass = "PCIe device"
}
if isGenericPCIeClassLabel(dev.DeviceClass) {
if resolved := pciids.DeviceName(dev.VendorID, dev.DeviceID); resolved != "" {
dev.DeviceClass = resolved
}
}
if isGenericPCIeClassLabel(dev.DeviceClass) {
// Redfish DeviceType (e.g. MultiFunction/Simulated) is a topology attribute,
// not a user-facing device name. Prefer model/part labels when class cannot be resolved.
dev.DeviceClass = firstNonEmpty(asString(doc["Model"]), dev.PartNumber, dev.DeviceClass)
}
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 parsePCIeFunction(doc 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: asString(doc["FunctionId"]),
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"])),
}
if isGenericPCIeClassLabel(dev.DeviceClass) {
if resolved := pciids.DeviceName(dev.VendorID, dev.DeviceID); resolved != "" {
dev.DeviceClass = resolved
}
}
if strings.TrimSpace(dev.Manufacturer) == "" {
dev.Manufacturer = pciids.VendorName(dev.VendorID)
}
if strings.TrimSpace(dev.PartNumber) == "" {
dev.PartNumber = pciids.DeviceName(dev.VendorID, dev.DeviceID)
}
return dev
}
func isMissingOrRawPCIModel(model string) bool {
model = strings.TrimSpace(model)
if model == "" {
return true
}
l := strings.ToLower(model)
if l == "unknown" || l == "n/a" || l == "na" || l == "none" {
return true
}
if strings.HasPrefix(l, "0x") && len(l) <= 6 {
return true
}
if len(model) <= 4 {
isHex := true
for _, c := range l {
if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
isHex = false
break
}
}
if isHex {
return true
}
}
return false
}
// isUnidentifiablePCIeDevice returns true for PCIe topology entries that carry no
// useful inventory information: generic class (SingleFunction/MultiFunction), no
// resolved model or serial, and no PCI vendor/device IDs for future resolution.
// These are typically PCH bridges, root ports, or other bus infrastructure that
// some BMCs (e.g. MSI) enumerate exhaustively in their PCIeDevices collection.
func isUnidentifiablePCIeDevice(dev models.PCIeDevice) bool {
if !isGenericPCIeClassLabel(dev.DeviceClass) {
return false
}
if normalizeRedfishIdentityField(dev.PartNumber) != "" {
return false
}
if normalizeRedfishIdentityField(dev.SerialNumber) != "" {
return false
}
if dev.VendorID > 0 || dev.DeviceID > 0 {
return false
}
return true
}
func isGenericPCIeClassLabel(v string) bool {
switch strings.ToLower(strings.TrimSpace(v)) {
case "", "pcie device", "display", "display controller", "vga", "3d controller", "network", "network controller", "storage", "storage controller", "other", "unknown", "singlefunction", "multifunction", "simulated":
return true
default:
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(v)), "0x")
}
}
func buildBDFfromOemPublic(doc map[string]interface{}) string {
if len(doc) == 0 {
return ""
}
oem, ok := doc["Oem"].(map[string]interface{})
if !ok {
return ""
}
public, ok := oem["Public"].(map[string]interface{})
if !ok {
return ""
}
bus := asHexOrInt(public["BusNumber"])
dev := asHexOrInt(public["DeviceNumber"])
fn := asHexOrInt(public["FunctionNumber"])
if bus < 0 || dev < 0 || fn < 0 {
return ""
}
segment := asHexOrInt(public["Segment"])
if segment < 0 {
segment = 0
}
// Require at least bus + dev numbers to avoid inventing meaningless BDFs.
if bus == 0 && dev == 0 && fn == 0 {
return ""
}
return fmt.Sprintf("%04x:%02x:%02x.%x", segment, bus, dev, fn)
}
func normalizeRedfishIdentityField(v string) string {
v = strings.TrimSpace(v)
if v == "" {
return ""
}
switch strings.ToLower(v) {
case "n/a", "na", "none", "null", "unknown", "0":
return ""
default:
return v
}
}
func gpuDedupKey(gpu models.GPU) string {
if serial := normalizeRedfishIdentityField(gpu.SerialNumber); serial != "" {
return serial
}
if bdf := strings.TrimSpace(gpu.BDF); bdf != "" {
return bdf
}
return firstNonEmpty(strings.TrimSpace(gpu.Slot)+"|"+strings.TrimSpace(gpu.Model), strings.TrimSpace(gpu.Slot))
}
func gpuDocDedupKey(doc map[string]interface{}, gpu models.GPU) string {
// Prefer stable GPU identifiers (serial, BDF) over path so that the same
// physical GPU exposed under multiple Chassis PCIeDevice trees (e.g. Supermicro
// HGX: Chassis/1/PCIeDevices/GPU1 and Chassis/HGX_GPU_SXM_1/PCIeDevices/GPU_SXM_1)
// is correctly deduplicated.
//
// Only stable identifiers (serial, BDF) are used for cross-path dedup.
// When neither is present we fall back to path, so two genuinely distinct GPUs
// that happen to share the same model name (e.g. in GraphicsControllers) are
// not incorrectly collapsed into one.
if serial := normalizeRedfishIdentityField(gpu.SerialNumber); serial != "" {
return serial
}
if bdf := strings.TrimSpace(gpu.BDF); bdf != "" {
return bdf
}
if path := normalizeRedfishPath(asString(doc["@odata.id"])); path != "" {
return "path:" + path
}
return ""
}
func shouldSkipGenericGPUDuplicate(existing []models.GPU, candidate models.GPU) bool {
if len(existing) == 0 {
return false
}
if normalizeRedfishIdentityField(candidate.SerialNumber) != "" || strings.TrimSpace(candidate.BDF) != "" {
return false
}
slot := strings.TrimSpace(candidate.Slot)
model := strings.TrimSpace(candidate.Model)
if slot == "" || model == "" {
return false
}
// Typical GraphicsControllers fallback on some BMCs reports only model/name
// as slot and lacks stable identifiers. If we already have concrete GPUs of the
// same model/manufacturer from PCIe inventory, this candidate is a duplicate.
if !strings.EqualFold(slot, model) {
return false
}
for _, gpu := range existing {
if !strings.EqualFold(strings.TrimSpace(gpu.Model), model) {
continue
}
existingMfr := strings.TrimSpace(gpu.Manufacturer)
candidateMfr := strings.TrimSpace(candidate.Manufacturer)
if existingMfr != "" && candidateMfr != "" && !strings.EqualFold(existingMfr, candidateMfr) {
continue
}
if normalizeRedfishIdentityField(gpu.SerialNumber) != "" || strings.TrimSpace(gpu.BDF) != "" {
return true
}
}
return false
}
func dropModelOnlyGPUPlaceholders(items []models.GPU) []models.GPU {
if len(items) < 2 {
return items
}
// Merge serial from generic GraphicsControllers placeholders (slot ~= model)
// into concrete PCIe rows (with BDF) when mapping is unambiguous.
mergedPlaceholder := make(map[int]struct{})
usedConcrete := make(map[int]struct{})
unresolvedByGroup := make(map[string][]int)
for i := range items {
serial := normalizeRedfishIdentityField(items[i].SerialNumber)
if serial == "" || strings.TrimSpace(items[i].BDF) != "" || !isModelOnlyGPUPlaceholder(items[i]) {
continue
}
candidates := matchingConcreteGPUIndexes(items, i, usedConcrete)
candidate := -1
if len(candidates) == 1 {
candidate = candidates[0]
}
if candidate >= 0 {
mergeGPUPlaceholderIntoConcrete(&items[candidate], items[i])
usedConcrete[candidate] = struct{}{}
mergedPlaceholder[i] = struct{}{}
continue
}
group := gpuModelVendorKey(items[i])
if group == "" {
continue
}
unresolvedByGroup[group] = append(unresolvedByGroup[group], i)
}
// Fallback mapping by order for ambiguous groups (e.g. same model x8).
for group, placeholders := range unresolvedByGroup {
donors := make([]int, 0, len(placeholders))
for j := range items {
if _, used := usedConcrete[j]; used {
continue
}
if !isConcreteGPUDonor(items[j]) {
continue
}
if gpuModelVendorKey(items[j]) != group {
continue
}
if normalizeRedfishIdentityField(items[j].SerialNumber) != "" {
continue
}
donors = append(donors, j)
}
limit := len(placeholders)
if len(donors) < limit {
limit = len(donors)
}
for k := 0; k < limit; k++ {
pi := placeholders[k]
di := donors[k]
if normalizeRedfishIdentityField(items[pi].SerialNumber) == "" {
continue
}
mergeGPUPlaceholderIntoConcrete(&items[di], items[pi])
usedConcrete[di] = struct{}{}
mergedPlaceholder[pi] = struct{}{}
}
}
concreteByModel := make(map[string]struct{}, len(items))
for _, gpu := range items {
modelKey := strings.ToLower(strings.TrimSpace(gpu.Model))
if modelKey == "" {
continue
}
if normalizeRedfishIdentityField(gpu.SerialNumber) != "" || strings.TrimSpace(gpu.BDF) != "" {
concreteByModel[modelKey] = struct{}{}
}
}
if len(concreteByModel) == 0 {
return items
}
out := make([]models.GPU, 0, len(items))
for i, gpu := range items {
modelKey := strings.ToLower(strings.TrimSpace(gpu.Model))
if _, hasConcrete := concreteByModel[modelKey]; hasConcrete &&
strings.TrimSpace(gpu.BDF) == "" &&
isModelOnlyGPUPlaceholder(gpu) &&
(normalizeRedfishIdentityField(gpu.SerialNumber) == "" || hasMergedPlaceholderIndex(mergedPlaceholder, i)) {
continue
}
out = append(out, gpu)
}
return out
}
func isModelOnlyGPUPlaceholder(gpu models.GPU) bool {
slot := strings.TrimSpace(gpu.Slot)
model := strings.TrimSpace(gpu.Model)
if slot == "" || model == "" {
return false
}
return strings.EqualFold(slot, model) || strings.HasPrefix(strings.ToUpper(slot), "GPU")
}
func isConcreteGPUDonor(gpu models.GPU) bool {
if strings.TrimSpace(gpu.BDF) == "" {
return false
}
return !isModelOnlyGPUPlaceholder(gpu)
}
func gpuModelVendorKey(gpu models.GPU) string {
model := strings.ToLower(strings.TrimSpace(gpu.Model))
if model == "" {
return ""
}
mfr := strings.ToLower(strings.TrimSpace(gpu.Manufacturer))
return model + "|" + mfr
}
func matchingConcreteGPUIndexes(items []models.GPU, placeholderIdx int, usedConcrete map[int]struct{}) []int {
out := make([]int, 0, 2)
ph := items[placeholderIdx]
for j := range items {
if j == placeholderIdx {
continue
}
if _, used := usedConcrete[j]; used {
continue
}
if !isConcreteGPUDonor(items[j]) {
continue
}
if !strings.EqualFold(strings.TrimSpace(items[j].Model), strings.TrimSpace(ph.Model)) {
continue
}
otherMfr := strings.TrimSpace(items[j].Manufacturer)
phMfr := strings.TrimSpace(ph.Manufacturer)
if phMfr != "" && otherMfr != "" && !strings.EqualFold(phMfr, otherMfr) {
continue
}
if normalizeRedfishIdentityField(items[j].SerialNumber) != "" {
continue
}
out = append(out, j)
}
return out
}
func mergeGPUPlaceholderIntoConcrete(concrete *models.GPU, placeholder models.GPU) {
if concrete == nil {
return
}
if normalizeRedfishIdentityField(concrete.SerialNumber) == "" {
if serial := normalizeRedfishIdentityField(placeholder.SerialNumber); serial != "" {
concrete.SerialNumber = serial
}
}
if strings.TrimSpace(concrete.UUID) == "" && strings.TrimSpace(placeholder.UUID) != "" {
concrete.UUID = placeholder.UUID
}
if strings.TrimSpace(concrete.PartNumber) == "" && strings.TrimSpace(placeholder.PartNumber) != "" {
concrete.PartNumber = placeholder.PartNumber
}
if strings.TrimSpace(concrete.Firmware) == "" && strings.TrimSpace(placeholder.Firmware) != "" {
concrete.Firmware = placeholder.Firmware
}
if strings.TrimSpace(concrete.Status) == "" && strings.TrimSpace(placeholder.Status) != "" {
concrete.Status = placeholder.Status
}
}
func hasMergedPlaceholderIndex(indexes map[int]struct{}, idx int) bool {
_, ok := indexes[idx]
return ok
}
func looksLikeGPU(doc map[string]interface{}, functionDocs []map[string]interface{}) bool {
// "Display Device" is how MSI labels H100 secondary display/audio controller
// functions — these are not compute GPUs and should be excluded.
if strings.EqualFold(strings.TrimSpace(asString(doc["Description"])), "Display Device") {
return false
}
// NVSwitch is an NVIDIA NVLink interconnect switch, not a compute GPU.
if strings.Contains(strings.ToLower(strings.TrimSpace(asString(doc["Model"]))), "nvswitch") {
return false
}
deviceType := strings.ToLower(asString(doc["DeviceType"]))
if strings.Contains(deviceType, "gpu") || strings.Contains(deviceType, "graphics") || strings.Contains(deviceType, "accelerator") {
return true
}
if strings.Contains(deviceType, "network") {
return false
}
if oem, ok := doc["Oem"].(map[string]interface{}); ok {
if public, ok := oem["Public"].(map[string]interface{}); ok {
if dc := strings.ToLower(asString(public["DeviceClass"])); strings.Contains(dc, "network") {
return false
}
}
}
modelText := strings.ToLower(strings.Join([]string{
asString(doc["Name"]),
asString(doc["Model"]),
asString(doc["Manufacturer"]),
}, " "))
gpuHints := []string{"gpu", "nvidia", "tesla", "a100", "h100", "l40", "rtx", "radeon", "instinct"}
for _, hint := range gpuHints {
if strings.Contains(modelText, hint) {
return true
}
}
for _, fn := range functionDocs {
classCode := strings.ToLower(strings.TrimPrefix(asString(fn["ClassCode"]), "0x"))
if strings.HasPrefix(classCode, "03") || strings.HasPrefix(classCode, "12") {
return true
}
}
return false
}
// isVirtualStorageDrive returns true for BMC-virtual drives that should not
// appear in hardware inventory (e.g. AMI virtual CD/USB sticks with 0 capacity).
func isVirtualStorageDrive(doc map[string]interface{}) bool {
if strings.EqualFold(asString(doc["Protocol"]), "USB") && asInt64(doc["CapacityBytes"]) == 0 {
return true
}
mfr := strings.ToUpper(strings.TrimSpace(asString(doc["Manufacturer"])))
model := strings.ToUpper(strings.TrimSpace(asString(doc["Model"])))
if strings.Contains(mfr, "AMI") && strings.Contains(model, "VIRTUAL") {
return true
}
return false
}
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 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) {
return b
}
return a
}
func storageRichnessScore(item models.Storage) int {
score := 0
if normalizeRedfishIdentityField(item.SerialNumber) != "" {
score += 100
}
if item.SizeGB > 0 {
score += 40
}
if normalizedStorageModel(item) != "" {
score += 20
}
if normalizeRedfishIdentityField(item.Manufacturer) != "" {
score += 10
}
if normalizeRedfishIdentityField(item.Firmware) != "" {
score += 8
}
if strings.TrimSpace(item.Interface) != "" {
score += 5
}
if strings.TrimSpace(item.Description) != "" {
score += 3
}
if item.Present {
score++
}
return score
}
func normalizedStorageModel(item models.Storage) string {
model := normalizeRedfishIdentityField(item.Model)
if model == "" {
return ""
}
slot := strings.TrimSpace(item.Slot)
if slot != "" && strings.EqualFold(model, slot) {
return ""
}
return model
}
func isStoragePlaceholder(item models.Storage) bool {
if normalizeRedfishIdentityField(item.SerialNumber) != "" {
return false
}
if item.SizeGB > 0 {
return false
}
if normalizedStorageModel(item) != "" {
return false
}
if normalizeRedfishIdentityField(item.Manufacturer) != "" {
return false
}
if normalizeRedfishIdentityField(item.Firmware) != "" {
return false
}
if strings.TrimSpace(item.Description) != "" {
return false
}
return true
}
func isRichStorageDonor(item models.Storage) bool {
if isStoragePlaceholder(item) {
return false
}
return normalizeRedfishIdentityField(item.SerialNumber) != "" ||
item.SizeGB > 0 ||
normalizedStorageModel(item) != "" ||
normalizeRedfishIdentityField(item.Manufacturer) != "" ||
normalizeRedfishIdentityField(item.Firmware) != ""
}
func mergeStoragePlaceholders(items []models.Storage) ([]models.Storage, map[int]struct{}) {
if len(items) <= 1 {
return items, nil
}
out := make([]models.Storage, len(items))
copy(out, items)
placeholderIdx := make([]int, 0, len(out))
donorIdx := make([]int, 0, len(out))
for i, item := range out {
if isStoragePlaceholder(item) {
placeholderIdx = append(placeholderIdx, i)
continue
}
if isRichStorageDonor(item) {
donorIdx = append(donorIdx, i)
}
}
if len(placeholderIdx) == 0 || len(donorIdx) == 0 {
return out, nil
}
consumed := make(map[int]struct{}, len(donorIdx))
for _, pi := range placeholderIdx {
di := findStorageDonorIndex(out, donorIdx, consumed, out[pi].Type)
if di < 0 {
continue
}
out[pi] = mergeStorageIntoPlaceholder(out[pi], out[di])
consumed[di] = struct{}{}
}
if len(consumed) == 0 {
return out, nil
}
return out, consumed
}
func findStorageDonorIndex(items []models.Storage, donors []int, consumed map[int]struct{}, placeholderType string) int {
placeholderType = strings.TrimSpace(strings.ToUpper(placeholderType))
if placeholderType != "" {
for _, idx := range donors {
if _, used := consumed[idx]; used {
continue
}
if strings.TrimSpace(strings.ToUpper(items[idx].Type)) == placeholderType {
return idx
}
}
}
for _, idx := range donors {
if _, used := consumed[idx]; !used {
return idx
}
}
return -1
}
func mergeStorageIntoPlaceholder(placeholder, donor models.Storage) models.Storage {
out := placeholder
if strings.TrimSpace(out.Type) == "" {
out.Type = donor.Type
}
if normalizedStorageModel(out) == "" && normalizedStorageModel(donor) != "" {
out.Model = donor.Model
}
if out.SizeGB <= 0 && donor.SizeGB > 0 {
out.SizeGB = donor.SizeGB
}
if normalizeRedfishIdentityField(out.SerialNumber) == "" && normalizeRedfishIdentityField(donor.SerialNumber) != "" {
out.SerialNumber = donor.SerialNumber
}
if normalizeRedfishIdentityField(out.Manufacturer) == "" && normalizeRedfishIdentityField(donor.Manufacturer) != "" {
out.Manufacturer = donor.Manufacturer
}
if normalizeRedfishIdentityField(out.Firmware) == "" && normalizeRedfishIdentityField(donor.Firmware) != "" {
out.Firmware = donor.Firmware
}
if strings.TrimSpace(out.Interface) == "" && strings.TrimSpace(donor.Interface) != "" {
out.Interface = donor.Interface
}
if strings.TrimSpace(out.Location) == "" && strings.TrimSpace(donor.Location) != "" {
out.Location = donor.Location
}
if out.BackplaneID == 0 && donor.BackplaneID != 0 {
out.BackplaneID = donor.BackplaneID
}
if strings.TrimSpace(out.Status) == "" && strings.TrimSpace(donor.Status) != "" {
out.Status = donor.Status
}
if strings.TrimSpace(out.Description) == "" && strings.TrimSpace(donor.Description) != "" {
out.Description = donor.Description
}
if !out.Present {
out.Present = donor.Present
}
return out
}
func dedupeNetworkAdapters(items []models.NetworkAdapter) []models.NetworkAdapter {
if len(items) <= 1 {
return items
}
out := make([]models.NetworkAdapter, 0, len(items))
bySerial := make(map[string]int, len(items))
bySlotModel := make(map[string]int, len(items))
bySlot := make(map[string]int, len(items))
for _, item := range items {
serialKey := normalizeRedfishIdentityField(item.SerialNumber)
slotModelKey := networkAdapterSlotModelKey(item)
slotKey := strings.TrimSpace(item.Slot)
idx := -1
if serialKey != "" {
if existing, ok := bySerial[serialKey]; ok {
idx = existing
}
}
if idx < 0 && slotModelKey != "" {
if existing, ok := bySlotModel[slotModelKey]; ok {
idx = existing
}
}
if idx < 0 && slotKey != "" {
if existing, ok := bySlot[slotKey]; ok {
idx = existing
}
}
if idx >= 0 {
out[idx] = mergeNetworkAdapterEntries(out[idx], item)
} else {
idx = len(out)
out = append(out, item)
}
merged := out[idx]
if serial := normalizeRedfishIdentityField(merged.SerialNumber); serial != "" {
bySerial[serial] = idx
}
if slotModel := networkAdapterSlotModelKey(merged); slotModel != "" {
bySlotModel[slotModel] = idx
}
if slot := strings.TrimSpace(merged.Slot); slot != "" {
bySlot[slot] = idx
}
}
return out
}
func networkAdapterSlotModelKey(nic models.NetworkAdapter) string {
slot := strings.TrimSpace(nic.Slot)
model := normalizeNetworkAdapterModel(nic)
if slot == "" && model == "" {
return ""
}
return slot + "|" + model
}
func normalizeNetworkAdapterModel(nic models.NetworkAdapter) string {
model := normalizeRedfishIdentityField(nic.Model)
if model == "" {
return ""
}
slot := strings.TrimSpace(nic.Slot)
if slot != "" && strings.EqualFold(slot, model) {
return ""
}
return model
}
func networkAdapterRichnessScore(nic models.NetworkAdapter) int {
score := 0
if normalizeRedfishIdentityField(nic.SerialNumber) != "" {
score += 80
}
if normalizeNetworkAdapterModel(nic) != "" {
score += 20
}
if normalizeRedfishIdentityField(nic.Vendor) != "" {
score += 10
}
if normalizeRedfishIdentityField(nic.Firmware) != "" {
score += 8
}
if normalizeRedfishIdentityField(nic.PartNumber) != "" {
score += 6
}
if nic.VendorID > 0 {
score += 5
}
if nic.DeviceID > 0 {
score += 5
}
if nic.PortCount > 0 {
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 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 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...))
}
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...))
}
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
}
return out
}
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, secureBoot, 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"]))
appendFW("SecureBoot", asString(secureBoot["SecureBootMode"]))
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 redfishSnapshotMaxDocuments() 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
}
}
return def
}
func redfishSnapshotPrioritySeeds(systemPaths, chassisPaths, managerPaths []string) []string {
var out []string
add := func(p string) {
if p = normalizeRedfishPath(p); p != "" {
out = append(out, p)
}
}
add("/redfish/v1/UpdateService")
add("/redfish/v1/UpdateService/FirmwareInventory")
for _, p := range systemPaths {
add(p)
add(joinPath(p, "/Bios"))
add(joinPath(p, "/SecureBoot"))
add(joinPath(p, "/Oem/Public"))
add(joinPath(p, "/Oem/Public/FRU"))
add(joinPath(p, "/Processors"))
add(joinPath(p, "/Memory"))
add(joinPath(p, "/EthernetInterfaces"))
add(joinPath(p, "/NetworkInterfaces"))
add(joinPath(p, "/PCIeDevices"))
add(joinPath(p, "/PCIeFunctions"))
add(joinPath(p, "/Accelerators"))
add(joinPath(p, "/GraphicsControllers"))
add(joinPath(p, "/Storage"))
add(joinPath(p, "/SimpleStorage"))
add(joinPath(p, "/Storage/IntelVROC"))
add(joinPath(p, "/Storage/IntelVROC/Drives"))
add(joinPath(p, "/Storage/IntelVROC/Volumes"))
}
for _, p := range chassisPaths {
add(p)
add(joinPath(p, "/Oem/Public"))
add(joinPath(p, "/Oem/Public/FRU"))
add(joinPath(p, "/PCIeDevices"))
add(joinPath(p, "/PCIeSlots"))
add(joinPath(p, "/NetworkAdapters"))
add(joinPath(p, "/Drives"))
add(joinPath(p, "/Power"))
}
for _, p := range managerPaths {
add(p)
add(joinPath(p, "/EthernetInterfaces"))
add(joinPath(p, "/NetworkProtocol"))
}
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)
}