package collector import ( "context" "crypto/tls" "encoding/json" "fmt" "io" "net/http" "net/url" "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 } func NewRedfishConnector() *RedfishConnector { return &RedfishConnector{ timeout: 10 * time.Second, } } func (c *RedfishConnector) Protocol() string { return "redfish" } 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..."}) } rawTree := c.collectRawRedfishTree(ctx, client, req, baseURL, emit) 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//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, emit ProgressFn) map[string]interface{} { const maxDocuments = 1200 const workers = 6 out := make(map[string]interface{}, maxDocuments) seen := make(map[string]struct{}, maxDocuments) rootCounts := make(map[string]int) var mu sync.Mutex var processed int32 jobs := make(chan string, 256) 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 i := 0; i < workers; i++ { go func() { for current := range 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 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) emit(Progress{ Status: "running", Progress: 92 + int(minInt32(n/200, 6)), Message: fmt.Sprintf("Redfish snapshot: документов=%d, корни=%s", n, strings.Join(roots, ", ")), }) } wg.Done() } }() } wg.Wait() 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) { 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 { return nil, err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) return nil, fmt.Errorf("status %d from %s: %s", resp.StatusCode, requestPath, strings.TrimSpace(string(body))) } var doc map[string]interface{} dec := json.NewDecoder(resp.Body) dec.UseNumber() if err := dec.Decode(&doc); err != nil { return nil, err } 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(asString(doc["Slot"]), asString(doc["Name"]), asString(doc["Id"])) if slot == "" { slot = fmt.Sprintf("GPU%d", idx) } gpu := models.GPU{ Slot: slot, Location: firstNonEmpty(asString(doc["Location"]), asString(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(asString(doc["Slot"]), 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(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 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 minInt32(a, b int32) int32 { if a < b { return a } return b }