Files
logpile/internal/collector/redfish.go
2026-02-24 18:22:02 +03:00

1721 lines
48 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
}
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) {
baseURL, err := c.baseURL(req)
if err != nil {
return nil, err
}
client := c.httpClient(req)
if emit != nil {
emit(Progress{Status: "running", Progress: 10, Message: "Redfish: подключение к BMC..."})
}
if _, err := c.getJSON(ctx, client, req, baseURL, "/redfish/v1"); err != nil {
return nil, fmt.Errorf("redfish service root: %w", err)
}
systemPaths := c.discoverMemberPaths(ctx, client, req, baseURL, "/redfish/v1/Systems", "/redfish/v1/Systems/1")
chassisPaths := c.discoverMemberPaths(ctx, client, req, baseURL, "/redfish/v1/Chassis", "/redfish/v1/Chassis/1")
managerPaths := c.discoverMemberPaths(ctx, client, req, baseURL, "/redfish/v1/Managers", "/redfish/v1/Managers/1")
primarySystem := firstPathOrDefault(systemPaths, "/redfish/v1/Systems/1")
primaryManager := firstPathOrDefault(managerPaths, "/redfish/v1/Managers/1")
if emit != nil {
emit(Progress{Status: "running", Progress: 30, Message: "Redfish: чтение данных системы..."})
}
systemDoc, err := c.getJSON(ctx, client, req, baseURL, primarySystem)
if err != nil {
return nil, fmt.Errorf("system info: %w", err)
}
biosDoc, _ := c.getJSON(ctx, client, req, baseURL, joinPath(primarySystem, "/Bios"))
secureBootDoc, _ := c.getJSON(ctx, client, req, baseURL, joinPath(primarySystem, "/SecureBoot"))
if emit != nil {
emit(Progress{Status: "running", Progress: 55, Message: "Redfish: чтение CPU/RAM/Storage..."})
}
processors, _ := c.getCollectionMembers(ctx, client, req, baseURL, joinPath(primarySystem, "/Processors"))
memory, _ := c.getCollectionMembers(ctx, client, req, baseURL, joinPath(primarySystem, "/Memory"))
storageDevices := c.collectStorage(ctx, client, req, baseURL, primarySystem)
if emit != nil {
emit(Progress{Status: "running", Progress: 80, Message: "Redfish: чтение сетевых и BMC настроек..."})
}
psus := c.collectPSUs(ctx, client, req, baseURL, chassisPaths)
pcieDevices := c.collectPCIeDevices(ctx, client, req, baseURL, systemPaths, chassisPaths)
gpus := c.collectGPUs(ctx, client, req, baseURL, systemPaths, chassisPaths)
nics := c.collectNICs(ctx, client, req, baseURL, chassisPaths)
managerDoc, _ := c.getJSON(ctx, client, req, baseURL, primaryManager)
networkProtocolDoc, _ := c.getJSON(ctx, client, req, baseURL, joinPath(primaryManager, "/NetworkProtocol"))
if emit != nil {
emit(Progress{Status: "running", Progress: 90, Message: "Redfish: сбор расширенного snapshot..."})
}
c.debugSnapshotf("snapshot crawl start host=%s port=%d", req.Host, req.Port)
rawTree := c.collectRawRedfishTree(ctx, client, req, baseURL, redfishSnapshotPrioritySeeds(systemPaths, chassisPaths, managerPaths), emit)
c.debugSnapshotf("snapshot crawl done docs=%d", len(rawTree))
result := &models.AnalysisResult{
Events: make([]models.Event, 0),
FRU: make([]models.FRUInfo, 0),
Sensors: make([]models.SensorReading, 0),
RawPayloads: map[string]any{
"redfish_tree": rawTree,
},
Hardware: &models.HardwareConfig{
BoardInfo: parseBoardInfo(systemDoc),
CPUs: parseCPUs(processors),
Memory: parseMemory(memory),
Storage: storageDevices,
PCIeDevices: pcieDevices,
GPUs: gpus,
PowerSupply: psus,
NetworkAdapters: nics,
Firmware: parseFirmware(systemDoc, biosDoc, managerDoc, secureBootDoc, networkProtocolDoc),
},
}
return result, nil
}
func (c *RedfishConnector) httpClient(req Request) *http.Client {
transport := &http.Transport{}
if req.TLSMode == "insecure" {
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec
}
return &http.Client{
Transport: transport,
Timeout: c.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))
}
}
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))
}
}
// 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) collectNICs(ctx context.Context, client *http.Client, req Request, baseURL string, chassisPaths []string) []models.NetworkAdapter {
var nics []models.NetworkAdapter
seen := make(map[string]struct{})
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)
}
key := firstNonEmpty(nic.SerialNumber, nic.Slot+"|"+nic.Model)
if key == "" {
continue
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
nics = append(nics, nic)
}
}
return 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]struct{})
idx := 1
for _, chassisPath := range chassisPaths {
// Most implementations expose PSU info in Chassis/<id>/Power as an embedded array.
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
}
psu := parsePSU(doc, idx)
idx++
key := firstNonEmpty(psu.SerialNumber, psu.Slot+"|"+psu.Model)
if key == "" {
continue
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, psu)
}
}
}
// Redfish 2022+ may expose PSU collection via PowerSubsystem.
memberDocs, err := c.getCollectionMembers(ctx, client, req, baseURL, joinPath(chassisPath, "/PowerSubsystem/PowerSupplies"))
if err != nil || len(memberDocs) == 0 {
continue
}
for _, doc := range memberDocs {
psu := parsePSU(doc, idx)
idx++
key := firstNonEmpty(psu.SerialNumber, psu.Slot+"|"+psu.Model)
if key == "" {
continue
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, psu)
}
}
return out
}
func (c *RedfishConnector) collectGPUs(ctx context.Context, client *http.Client, req Request, baseURL string, systemPaths, chassisPaths []string) []models.GPU {
collections := make([]string, 0, len(systemPaths)*2+len(chassisPaths))
for _, systemPath := range systemPaths {
collections = append(collections, joinPath(systemPath, "/PCIeDevices"))
collections = append(collections, joinPath(systemPath, "/Accelerators"))
}
for _, chassisPath := range chassisPaths {
collections = append(collections, joinPath(chassisPath, "/PCIeDevices"))
}
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++
key := firstNonEmpty(gpu.SerialNumber, gpu.BDF, gpu.Slot+"|"+gpu.Model)
if key == "" {
continue
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, gpu)
}
}
return 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
seen := make(map[string]struct{})
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)
dev := parsePCIeDevice(doc, functionDocs)
key := firstNonEmpty(dev.SerialNumber, dev.BDF, dev.Slot+"|"+dev.DeviceClass)
if key == "" {
continue
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
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)
key := firstNonEmpty(dev.BDF, dev.SerialNumber, dev.Slot+"|"+dev.DeviceClass)
if key == "" {
continue
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, dev)
}
}
return out
}
func (c *RedfishConnector) discoverMemberPaths(ctx context.Context, client *http.Client, req Request, baseURL, collectionPath, fallbackPath string) []string {
collection, err := c.getJSON(ctx, client, req, baseURL, collectionPath)
if err == nil {
if refs, ok := collection["Members"].([]interface{}); ok && len(refs) > 0 {
paths := make([]string, 0, len(refs))
for _, refAny := range refs {
ref, ok := refAny.(map[string]interface{})
if !ok {
continue
}
memberPath := asString(ref["@odata.id"])
if memberPath != "" {
paths = append(paths, memberPath)
}
}
if len(paths) > 0 {
return paths
}
}
}
if fallbackPath != "" {
return []string{fallbackPath}
}
return nil
}
func (c *RedfishConnector) collectRawRedfishTree(ctx context.Context, client *http.Client, req Request, baseURL string, seedPaths []string, emit ProgressFn) map[string]interface{} {
maxDocuments := redfishSnapshotMaxDocuments()
const workers = 6
const heartbeatInterval = 5 * time.Second
out := make(map[string]interface{}, maxDocuments)
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
}
}
emit(Progress{
Status: "running",
Progress: 92 + int(minInt32(n/200, 6)),
Message: fmt.Sprintf("Redfish snapshot: heartbeat документов=%d (ok=%d, seen=%d), корни=%s, последний=%s", n, outN, seenN, 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 {
lastPath.Store(current)
c.debugSnapshotf("worker=%d fetch start path=%s queue_len=%d", workerID, current, len(jobs))
doc, err := c.getJSON(ctx, client, req, baseURL, current)
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 {
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
}
mu.Unlock()
roots := topRoots(countsCopy, 2)
last := current
if v := lastPath.Load(); v != nil {
if s, ok := v.(string); ok && s != "" {
last = s
}
}
emit(Progress{
Status: "running",
Progress: 92 + int(minInt32(n/200, 6)),
Message: fmt.Sprintf("Redfish snapshot: документов=%d, корни=%s, последний=%s", n, 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.
for path := range out {
if !isSupermicroNVMeBackplanePath(path) {
continue
}
for _, bayPath := range supermicroNVMeDiskBayCandidates(path) {
doc, err := c.getJSON(ctx, client, req, baseURL, bayPath)
if err != nil {
continue
}
if !looksLikeDrive(doc) {
continue
}
out[normalizeRedfishPath(bayPath)] = doc
c.debugSnapshotf("snapshot nvme bay probe hit path=%s", bayPath)
}
}
if emit != nil {
emit(Progress{
Status: "running",
Progress: 98,
Message: fmt.Sprintf("Redfish snapshot: собрано %d документов", len(out)),
})
}
return out
}
func (c *RedfishConnector) probeSupermicroNVMeDiskBays(ctx context.Context, client *http.Client, req Request, baseURL, backplanePath string) []map[string]interface{} {
var out []map[string]interface{}
for _, path := range supermicroNVMeDiskBayCandidates(backplanePath) {
doc, err := c.getJSON(ctx, client, req, baseURL, path)
if err != nil || !looksLikeDrive(doc) {
continue
}
out = append(out, doc)
}
return out
}
func isSupermicroNVMeBackplanePath(path string) bool {
path = normalizeRedfishPath(path)
return strings.Contains(path, "/Chassis/NVMeSSD.") && strings.Contains(path, ".StorageBackplane")
}
func supermicroNVMeDiskBayCandidates(backplanePath string) []string {
const maxBays = 64
prefix := joinPath(backplanePath, "/Drives")
out := make([]string, 0, maxBays*2)
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))
}
return out
}
func shouldCrawlPath(path string) bool {
if path == "" {
return false
}
heavyParts := []string{
"/LogServices/",
"/Entries/",
"/TelemetryService/",
"/MetricReports/",
"/SessionService/Sessions",
"/TaskService/Tasks",
}
for _, part := range heavyParts {
if strings.Contains(path, part) {
return false
}
}
return true
}
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
}
refs, ok := collection["Members"].([]interface{})
if !ok || len(refs) == 0 {
return []map[string]interface{}{}, nil
}
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, 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 parseBoardInfo(system map[string]interface{}) models.BoardInfo {
return models.BoardInfo{
Manufacturer: asString(system["Manufacturer"]),
ProductName: firstNonEmpty(asString(system["Model"]), asString(system["Name"])),
SerialNumber: asString(system["SerialNumber"]),
PartNumber: asString(system["PartNumber"]),
UUID: asString(system["UUID"]),
}
}
func parseCPUs(docs []map[string]interface{}) []models.CPU {
cpus := make([]models.CPU, 0, len(docs))
for idx, doc := range docs {
cpus = append(cpus, models.CPU{
Socket: idx,
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: asString(doc["SerialNumber"]),
})
}
return cpus
}
func parseMemory(docs []map[string]interface{}) []models.MemoryDIMM {
out := make([]models.MemoryDIMM, 0, len(docs))
for _, doc := range docs {
slot := firstNonEmpty(asString(doc["DeviceLocator"]), 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: asString(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)
return models.Storage{
Slot: firstNonEmpty(asString(doc["Id"]), asString(doc["Name"])),
Type: storageType,
Model: firstNonEmpty(asString(doc["Model"]), asString(doc["Name"])),
SizeGB: sizeGB,
SerialNumber: asString(doc["SerialNumber"]),
Manufacturer: asString(doc["Manufacturer"]),
Firmware: asString(doc["Revision"]),
Interface: asString(doc["Protocol"]),
Present: true,
}
}
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 = 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: asString(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)
}
return models.PSU{
Slot: slot,
Present: present,
Model: firstNonEmpty(asString(doc["Model"]), asString(doc["Name"])),
Vendor: asString(doc["Manufacturer"]),
WattageW: asInt(doc["PowerCapacityWatts"]),
SerialNumber: asString(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: asString(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.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: asString(doc["SerialNumber"]),
VendorID: asHexOrInt(doc["VendorId"]),
DeviceID: asHexOrInt(doc["DeviceId"]),
}
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: asString(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
}
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 looksLikeGPU(doc map[string]interface{}, functionDocs []map[string]interface{}) bool {
deviceType := strings.ToLower(asString(doc["DeviceType"]))
if strings.Contains(deviceType, "gpu") || strings.Contains(deviceType, "graphics") || strings.Contains(deviceType, "accelerator") {
return true
}
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
}
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 dedupeStorage(items []models.Storage) []models.Storage {
if len(items) <= 1 {
return items
}
out := make([]models.Storage, 0, len(items))
seen := make(map[string]struct{}, len(items))
for _, item := range items {
key := firstNonEmpty(item.SerialNumber, item.Slot+"|"+item.Model)
if key == "" {
continue
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, item)
}
return out
}
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["SecureBootCurrentBoot"]))
appendFW("NetworkProtocol", asString(networkProtocol["Id"]))
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 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 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 ""
}
}
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")
add("/redfish/v1/Fabrics")
for _, p := range systemPaths {
add(joinPath(p, "/PCIeDevices"))
add(joinPath(p, "/PCIeFunctions"))
add(joinPath(p, "/Accelerators"))
}
for _, p := range chassisPaths {
add(joinPath(p, "/PCIeDevices"))
add(joinPath(p, "/PCIeSlots"))
add(joinPath(p, "/NetworkAdapters"))
add(joinPath(p, "/Power"))
}
for _, p := range managerPaths {
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
}