1578 lines
43 KiB
Go
1578 lines
43 KiB
Go
package collector
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"git.mchus.pro/mchus/logpile/internal/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))
|
|
}
|
|
}
|
|
|
|
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)
|
|
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)
|
|
|
|
if emit != nil {
|
|
emit(Progress{
|
|
Status: "running",
|
|
Progress: 98,
|
|
Message: fmt.Sprintf("Redfish snapshot: собрано %d документов", len(out)),
|
|
})
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
return models.NetworkAdapter{
|
|
Slot: firstNonEmpty(asString(doc["Id"]), asString(doc["Name"])),
|
|
Location: asString(doc["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"]),
|
|
Status: mapStatus(doc["Status"]),
|
|
}
|
|
}
|
|
|
|
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: firstNonEmpty(asString(doc["DeviceType"]), asString(doc["PCIeType"])),
|
|
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 == "" {
|
|
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 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":
|
|
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
|
|
}
|