415 lines
12 KiB
Go
415 lines
12 KiB
Go
package collector
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"path"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.mchus.pro/mchus/logpile/internal/models"
|
|
)
|
|
|
|
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)
|
|
}
|
|
|
|
if emit != nil {
|
|
emit(Progress{Status: "running", Progress: 30, Message: "Redfish: чтение данных системы..."})
|
|
}
|
|
systemDoc, err := c.getJSON(ctx, client, req, baseURL, "/redfish/v1/Systems/1")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("system info: %w", err)
|
|
}
|
|
biosDoc, _ := c.getJSON(ctx, client, req, baseURL, "/redfish/v1/Systems/1/Bios")
|
|
secureBootDoc, _ := c.getJSON(ctx, client, req, baseURL, "/redfish/v1/Systems/1/SecureBoot")
|
|
|
|
if emit != nil {
|
|
emit(Progress{Status: "running", Progress: 55, Message: "Redfish: чтение CPU/RAM/Storage..."})
|
|
}
|
|
processors, _ := c.getCollectionMembers(ctx, client, req, baseURL, "/redfish/v1/Systems/1/Processors")
|
|
memory, _ := c.getCollectionMembers(ctx, client, req, baseURL, "/redfish/v1/Systems/1/Memory")
|
|
storageMembers, _ := c.getCollectionMembers(ctx, client, req, baseURL, "/redfish/v1/Systems/1/Storage")
|
|
storageDevices := c.collectStorage(ctx, client, req, baseURL, storageMembers)
|
|
|
|
if emit != nil {
|
|
emit(Progress{Status: "running", Progress: 80, Message: "Redfish: чтение сетевых и BMC настроек..."})
|
|
}
|
|
nics := c.collectNICs(ctx, client, req, baseURL)
|
|
managerDoc, _ := c.getJSON(ctx, client, req, baseURL, "/redfish/v1/Managers/1")
|
|
networkProtocolDoc, _ := c.getJSON(ctx, client, req, baseURL, "/redfish/v1/Managers/1/NetworkProtocol")
|
|
|
|
result := &models.AnalysisResult{
|
|
Events: make([]models.Event, 0),
|
|
FRU: make([]models.FRUInfo, 0),
|
|
Sensors: make([]models.SensorReading, 0),
|
|
Hardware: &models.HardwareConfig{
|
|
BoardInfo: parseBoardInfo(systemDoc),
|
|
CPUs: parseCPUs(processors),
|
|
Memory: parseMemory(memory),
|
|
Storage: storageDevices,
|
|
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 string, storageMembers []map[string]interface{}) []models.Storage {
|
|
var out []models.Storage
|
|
for _, member := range storageMembers {
|
|
drives, ok := member["Drives"].([]interface{})
|
|
if !ok {
|
|
continue
|
|
}
|
|
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))
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (c *RedfishConnector) collectNICs(ctx context.Context, client *http.Client, req Request, baseURL string) []models.NetworkAdapter {
|
|
adapterDocs, err := c.getCollectionMembers(ctx, client, req, baseURL, "/redfish/v1/Chassis/1/NetworkAdapters")
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
nics := make([]models.NetworkAdapter, 0, len(adapterDocs))
|
|
for _, doc := range adapterDocs {
|
|
nics = append(nics, parseNIC(doc))
|
|
}
|
|
return nics
|
|
}
|
|
|
|
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 := asInt(doc["CapacityBytes"]) / (1024 * 1024 * 1024)
|
|
if sizeGB == 0 {
|
|
sizeGB = asInt(doc["CapacityGB"])
|
|
}
|
|
|
|
return models.Storage{
|
|
Slot: firstNonEmpty(asString(doc["Id"]), asString(doc["Name"])),
|
|
Type: firstNonEmpty(asString(doc["MediaType"]), asString(doc["Protocol"])),
|
|
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 {
|
|
return models.NetworkAdapter{
|
|
Slot: firstNonEmpty(asString(doc["Id"]), asString(doc["Name"])),
|
|
Location: asString(doc["Location"]),
|
|
Present: !strings.EqualFold(mapStatus(doc["Status"]), "Absent"),
|
|
Model: firstNonEmpty(asString(doc["Model"]), asString(doc["Name"])),
|
|
Vendor: asString(doc["Manufacturer"]),
|
|
SerialNumber: asString(doc["SerialNumber"]),
|
|
PartNumber: asString(doc["PartNumber"]),
|
|
Status: mapStatus(doc["Status"]),
|
|
}
|
|
}
|
|
|
|
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 firstNonEmpty(values ...string) string {
|
|
for _, v := range values {
|
|
if strings.TrimSpace(v) != "" {
|
|
return strings.TrimSpace(v)
|
|
}
|
|
}
|
|
return ""
|
|
}
|