export: align reanimator contract v2.7

This commit is contained in:
Mikhail Chusavitin
2026-03-15 23:27:32 +03:00
parent 9007f1b360
commit 476630190d
31 changed files with 3502 additions and 689 deletions

View File

@@ -69,6 +69,32 @@ func (c *RedfishConnector) Protocol() string {
return "redfish"
}
func (c *RedfishConnector) Probe(ctx context.Context, req Request) (*ProbeResult, error) {
baseURL, err := c.baseURL(req)
if err != nil {
return nil, err
}
client := c.httpClientWithTimeout(req, c.timeout)
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")
primarySystem := firstNonEmptyPath(systemPaths, "/redfish/v1/Systems/1")
systemDoc, err := c.getJSON(ctx, client, req, baseURL, primarySystem)
if err != nil {
return nil, fmt.Errorf("redfish system: %w", err)
}
powerState := strings.TrimSpace(asString(systemDoc["PowerState"]))
return &ProbeResult{
Reachable: true,
Protocol: "redfish",
HostPowerState: powerState,
HostPoweredOn: isRedfishHostPoweredOn(powerState),
PowerControlAvailable: redfishResetActionTarget(systemDoc) != "",
SystemPath: primarySystem,
}, nil
}
func (c *RedfishConnector) debugf(format string, args ...interface{}) {
if !c.debug {
return
@@ -102,6 +128,21 @@ func (c *RedfishConnector) Collect(ctx context.Context, req Request, emit Progre
}
systemPaths := c.discoverMemberPaths(ctx, snapshotClient, req, baseURL, "/redfish/v1/Systems", "/redfish/v1/Systems/1")
primarySystem := firstNonEmptyPath(systemPaths, "/redfish/v1/Systems/1")
poweredOnByCollector := false
if primarySystem != "" {
if on, changed := c.ensureHostPowerForCollection(ctx, snapshotClient, req, baseURL, primarySystem, emit); on {
poweredOnByCollector = changed
}
}
defer func() {
if !poweredOnByCollector || primarySystem == "" {
return
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
c.restoreHostPowerAfterCollection(shutdownCtx, snapshotClient, req, baseURL, primarySystem, emit)
}()
chassisPaths := c.discoverMemberPaths(ctx, snapshotClient, req, baseURL, "/redfish/v1/Chassis", "/redfish/v1/Chassis/1")
managerPaths := c.discoverMemberPaths(ctx, snapshotClient, req, baseURL, "/redfish/v1/Managers", "/redfish/v1/Managers/1")
criticalPaths := redfishCriticalEndpoints(systemPaths, chassisPaths, managerPaths)
@@ -186,6 +227,257 @@ func (c *RedfishConnector) Collect(ctx context.Context, req Request, emit Progre
return result, nil
}
func (c *RedfishConnector) ensureHostPowerForCollection(ctx context.Context, client *http.Client, req Request, baseURL, systemPath string, emit ProgressFn) (hostOn bool, poweredOnByCollector bool) {
systemDoc, err := c.getJSON(ctx, client, req, baseURL, systemPath)
if err != nil {
if emit != nil {
emit(Progress{Status: "running", Progress: 18, Message: "Redfish: не удалось проверить PowerState host, сбор продолжается без power-control"})
}
return false, false
}
powerState := strings.TrimSpace(asString(systemDoc["PowerState"]))
if isRedfishHostPoweredOn(powerState) {
if emit != nil {
emit(Progress{Status: "running", Progress: 18, Message: fmt.Sprintf("Redfish: host включен (%s)", firstNonEmpty(powerState, "On"))})
}
return true, false
}
if emit != nil {
emit(Progress{Status: "running", Progress: 18, Message: fmt.Sprintf("Redfish: host выключен (%s)", firstNonEmpty(powerState, "Off"))})
}
if !req.PowerOnIfHostOff {
if emit != nil {
emit(Progress{Status: "running", Progress: 19, Message: "Redfish: включение host не запрошено, сбор продолжается на выключенном host"})
}
return false, false
}
resetTarget := redfishResetActionTarget(systemDoc)
resetType := redfishPickResetType(systemDoc, "On", "ForceOn")
if resetTarget == "" || resetType == "" {
if emit != nil {
emit(Progress{Status: "running", Progress: 19, Message: "Redfish: action ComputerSystem.Reset недоступен, сбор продолжается на выключенном host"})
}
return false, false
}
waitWindows := []time.Duration{5 * time.Second, 10 * time.Second, 30 * time.Second}
for i, waitFor := range waitWindows {
if emit != nil {
emit(Progress{Status: "running", Progress: 19, Message: fmt.Sprintf("Redfish: попытка включения host (%d/%d), ожидание %s", i+1, len(waitWindows), waitFor)})
}
if err := c.postJSON(ctx, client, req, baseURL, resetTarget, map[string]any{"ResetType": resetType}); err != nil {
if emit != nil {
emit(Progress{Status: "running", Progress: 19, Message: fmt.Sprintf("Redfish: включение host не удалось (%v)", err)})
}
continue
}
if c.waitForHostPowerState(ctx, client, req, baseURL, systemPath, true, waitFor) {
if !c.waitForStablePoweredOnHost(ctx, client, req, baseURL, systemPath, emit) {
if emit != nil {
emit(Progress{Status: "running", Progress: 20, Message: "Redfish: host включился, но не подтвердил стабильное состояние; сбор продолжается на выключенном host"})
}
return false, false
}
if emit != nil {
emit(Progress{Status: "running", Progress: 20, Message: "Redfish: host успешно включен и стабилен перед сбором"})
}
return true, true
}
if emit != nil {
emit(Progress{Status: "running", Progress: 20, Message: fmt.Sprintf("Redfish: host не включился за %s", waitFor)})
}
}
if emit != nil {
emit(Progress{Status: "running", Progress: 20, Message: "Redfish: host не удалось включить, сбор продолжается на выключенном host"})
}
return false, false
}
func (c *RedfishConnector) waitForStablePoweredOnHost(ctx context.Context, client *http.Client, req Request, baseURL, systemPath string, emit ProgressFn) bool {
stabilizationDelay := redfishPowerOnStabilizationDelay()
if stabilizationDelay > 0 {
if emit != nil {
emit(Progress{
Status: "running",
Progress: 20,
Message: fmt.Sprintf("Redfish: host включен, ожидание стабилизации %s перед началом сбора", stabilizationDelay),
})
}
timer := time.NewTimer(stabilizationDelay)
defer timer.Stop()
select {
case <-ctx.Done():
return false
case <-timer.C:
}
}
if emit != nil {
emit(Progress{
Status: "running",
Progress: 20,
Message: "Redfish: повторная проверка PowerState после стабилизации host",
})
}
return c.waitForHostPowerState(ctx, client, req, baseURL, systemPath, true, 5*time.Second)
}
func (c *RedfishConnector) restoreHostPowerAfterCollection(ctx context.Context, client *http.Client, req Request, baseURL, systemPath string, emit ProgressFn) {
systemDoc, err := c.getJSON(ctx, client, req, baseURL, systemPath)
if err != nil {
if emit != nil {
emit(Progress{Status: "running", Progress: 100, Message: "Redfish: не удалось повторно прочитать system перед выключением host"})
}
return
}
resetTarget := redfishResetActionTarget(systemDoc)
resetType := redfishPickResetType(systemDoc, "GracefulShutdown", "ForceOff", "PushPowerButton")
if resetTarget == "" || resetType == "" {
if emit != nil {
emit(Progress{Status: "running", Progress: 100, Message: "Redfish: выключение host после сбора недоступно"})
}
return
}
if emit != nil {
emit(Progress{Status: "running", Progress: 100, Message: "Redfish: выключаем host после завершения сбора"})
}
if err := c.postJSON(ctx, client, req, baseURL, resetTarget, map[string]any{"ResetType": resetType}); err != nil {
if emit != nil {
emit(Progress{Status: "running", Progress: 100, Message: fmt.Sprintf("Redfish: не удалось выключить host после сбора (%v)", err)})
}
return
}
if c.waitForHostPowerState(ctx, client, req, baseURL, systemPath, false, 20*time.Second) {
if emit != nil {
emit(Progress{Status: "running", Progress: 100, Message: "Redfish: host выключен после завершения сбора"})
}
return
}
if emit != nil {
emit(Progress{Status: "running", Progress: 100, Message: "Redfish: не удалось подтвердить выключение host после сбора"})
}
}
func (c *RedfishConnector) waitForHostPowerState(ctx context.Context, client *http.Client, req Request, baseURL, systemPath string, wantOn bool, timeout time.Duration) bool {
deadline := time.Now().Add(timeout)
for {
systemDoc, err := c.getJSON(ctx, client, req, baseURL, systemPath)
if err == nil {
if isRedfishHostPoweredOn(strings.TrimSpace(asString(systemDoc["PowerState"]))) == wantOn {
return true
}
}
if time.Now().After(deadline) {
return false
}
select {
case <-ctx.Done():
return false
case <-time.After(1 * time.Second):
}
}
}
func firstNonEmptyPath(paths []string, fallback string) string {
for _, p := range paths {
if strings.TrimSpace(p) != "" {
return p
}
}
return fallback
}
func isRedfishHostPoweredOn(state string) bool {
switch strings.ToLower(strings.TrimSpace(state)) {
case "on", "poweringon":
return true
default:
return false
}
}
func redfishResetActionTarget(systemDoc map[string]interface{}) string {
if systemDoc == nil {
return ""
}
actions, _ := systemDoc["Actions"].(map[string]interface{})
reset, _ := actions["#ComputerSystem.Reset"].(map[string]interface{})
target := strings.TrimSpace(asString(reset["target"]))
if target != "" {
return target
}
odataID := strings.TrimSpace(asString(systemDoc["@odata.id"]))
if odataID == "" {
return ""
}
return joinPath(odataID, "/Actions/ComputerSystem.Reset")
}
func redfishPickResetType(systemDoc map[string]interface{}, preferred ...string) string {
actions, _ := systemDoc["Actions"].(map[string]interface{})
reset, _ := actions["#ComputerSystem.Reset"].(map[string]interface{})
allowedRaw, _ := reset["ResetType@Redfish.AllowableValues"].([]interface{})
if len(allowedRaw) == 0 {
if len(preferred) > 0 {
return preferred[0]
}
return ""
}
allowed := make([]string, 0, len(allowedRaw))
for _, item := range allowedRaw {
if v := strings.TrimSpace(asString(item)); v != "" {
allowed = append(allowed, v)
}
}
for _, want := range preferred {
for _, have := range allowed {
if strings.EqualFold(want, have) {
return have
}
}
}
return ""
}
func (c *RedfishConnector) postJSON(ctx context.Context, client *http.Client, req Request, baseURL, resourcePath string, payload map[string]any) error {
body, err := json.Marshal(payload)
if err != nil {
return err
}
target := strings.TrimSpace(resourcePath)
if !strings.HasPrefix(strings.ToLower(target), "http://") && !strings.HasPrefix(strings.ToLower(target), "https://") {
target = baseURL + normalizeRedfishPath(target)
}
u, err := url.Parse(target)
if err != nil {
return err
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), strings.NewReader(string(body)))
if err != nil {
return err
}
httpReq.Header.Set("Content-Type", "application/json")
switch req.AuthType {
case "password":
httpReq.SetBasicAuth(req.Username, req.Password)
case "token":
httpReq.Header.Set("Authorization", "Bearer "+req.Token)
}
resp, err := client.Do(httpReq)
if err != nil {
return err
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("status %d from %s: %s", resp.StatusCode, resourcePath, strings.TrimSpace(string(respBody)))
}
return nil
}
func (c *RedfishConnector) prefetchCriticalRedfishDocs(
ctx context.Context,
client *http.Client,
@@ -417,11 +709,17 @@ func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Clie
driveDocs, err := c.getCollectionMembers(ctx, client, req, baseURL, driveCollectionPath)
if err == nil {
for _, driveDoc := range driveDocs {
if isVirtualStorageDrive(driveDoc) {
continue
}
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
}
if len(driveDocs) == 0 {
for _, driveDoc := range c.probeDirectDiskBayChildren(ctx, client, req, baseURL, driveCollectionPath) {
if isVirtualStorageDrive(driveDoc) {
continue
}
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
}
@@ -444,6 +742,9 @@ func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Clie
if err != nil {
continue
}
if isVirtualStorageDrive(driveDoc) {
continue
}
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
}
@@ -452,6 +753,9 @@ func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Clie
// Some implementations return drive fields right in storage member object.
if looksLikeDrive(member) {
if isVirtualStorageDrive(member) {
continue
}
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, member, "DriveMetrics", "EnvironmentMetrics", "Metrics")
out = append(out, parseDriveWithSupplementalDocs(member, supplementalDocs...))
}
@@ -462,13 +766,16 @@ func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Clie
driveDocs, err := c.getCollectionMembers(ctx, client, req, baseURL, joinPath(enclosurePath, "/Drives"))
if err == nil {
for _, driveDoc := range driveDocs {
if looksLikeDrive(driveDoc) {
if looksLikeDrive(driveDoc) && !isVirtualStorageDrive(driveDoc) {
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
}
}
if len(driveDocs) == 0 {
for _, driveDoc := range c.probeDirectDiskBayChildren(ctx, client, req, baseURL, joinPath(enclosurePath, "/Drives")) {
if isVirtualStorageDrive(driveDoc) {
continue
}
out = append(out, parseDrive(driveDoc))
}
}
@@ -481,7 +788,7 @@ func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Clie
"/Storage/IntelVROC/Drives",
"/Storage/IntelVROC/Controllers/1/Drives",
}) {
if looksLikeDrive(driveDoc) {
if looksLikeDrive(driveDoc) && !isVirtualStorageDrive(driveDoc) {
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
}
@@ -496,7 +803,7 @@ func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Clie
}
for _, devAny := range devices {
devDoc, ok := devAny.(map[string]interface{})
if !ok || !looksLikeDrive(devDoc) {
if !ok || !looksLikeDrive(devDoc) || isVirtualStorageDrive(devDoc) {
continue
}
out = append(out, parseDriveWithSupplementalDocs(devDoc))
@@ -511,7 +818,7 @@ func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Clie
continue
}
for _, driveDoc := range driveDocs {
if !looksLikeDrive(driveDoc) {
if !looksLikeDrive(driveDoc) || isVirtualStorageDrive(driveDoc) {
continue
}
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
@@ -523,7 +830,7 @@ func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Clie
continue
}
for _, driveDoc := range c.probeSupermicroNVMeDiskBays(ctx, client, req, baseURL, chassisPath) {
if !looksLikeDrive(driveDoc) {
if !looksLikeDrive(driveDoc) || isVirtualStorageDrive(driveDoc) {
continue
}
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
@@ -773,6 +1080,7 @@ func (c *RedfishConnector) collectPCIeDevices(ctx context.Context, client *http.
continue
}
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, doc, "EnvironmentMetrics", "Metrics")
supplementalDocs = append(supplementalDocs, c.getChassisScopedPCIeSupplementalDocs(ctx, client, req, baseURL, doc)...)
for _, fn := range functionDocs {
supplementalDocs = append(supplementalDocs, c.getLinkedSupplementalDocs(ctx, client, req, baseURL, fn, "EnvironmentMetrics", "Metrics")...)
}
@@ -800,6 +1108,39 @@ func (c *RedfishConnector) collectPCIeDevices(ctx context.Context, client *http.
return dedupePCIeDevices(out)
}
func (c *RedfishConnector) getChassisScopedPCIeSupplementalDocs(ctx context.Context, client *http.Client, req Request, baseURL string, doc map[string]interface{}) []map[string]interface{} {
if !looksLikeNVSwitchPCIeDoc(doc) {
return nil
}
docPath := normalizeRedfishPath(asString(doc["@odata.id"]))
chassisPath := chassisPathForPCIeDoc(docPath)
if chassisPath == "" {
return nil
}
out := make([]map[string]interface{}, 0, 4)
seen := make(map[string]struct{})
add := func(path string) {
path = normalizeRedfishPath(path)
if path == "" {
return
}
if _, ok := seen[path]; ok {
return
}
supplementalDoc, err := c.getJSON(ctx, client, req, baseURL, path)
if err != nil || len(supplementalDoc) == 0 {
return
}
seen[path] = struct{}{}
out = append(out, supplementalDoc)
}
add(joinPath(chassisPath, "/EnvironmentMetrics"))
add(joinPath(chassisPath, "/ThermalSubsystem/ThermalMetrics"))
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 {
@@ -1810,6 +2151,15 @@ func redfishCriticalSlowGap() time.Duration {
return 1200 * time.Millisecond
}
func redfishPowerOnStabilizationDelay() time.Duration {
if v := strings.TrimSpace(os.Getenv("LOGPILE_REDFISH_POWERON_STABILIZATION")); v != "" {
if d, err := time.ParseDuration(v); err == nil && d >= 0 {
return d
}
}
return 60 * time.Second
}
func redfishSnapshotMemoryRequestTimeout() time.Duration {
if v := strings.TrimSpace(os.Getenv("LOGPILE_REDFISH_MEMORY_TIMEOUT")); v != "" {
if d, err := time.ParseDuration(v); err == nil && d > 0 {
@@ -1984,11 +2334,22 @@ func looksLikeCanonicalBDF(bdf string) bool {
return false
}
func sanitizeRedfishBDF(bdf string) string {
bdf = strings.TrimSpace(bdf)
if looksLikeCanonicalBDF(bdf) {
return bdf
}
return ""
}
func shouldCrawlPath(path string) bool {
if path == "" {
return false
}
normalized := normalizeRedfishPath(path)
if isAllowedNVSwitchFabricPath(normalized) {
return true
}
if strings.Contains(normalized, "/Chassis/") &&
strings.Contains(normalized, "/PCIeDevices/") &&
strings.Contains(normalized, "/PCIeFunctions/") {
@@ -2079,6 +2440,22 @@ func shouldCrawlPath(path string) bool {
return true
}
func isAllowedNVSwitchFabricPath(path string) bool {
if !strings.HasPrefix(path, "/redfish/v1/Fabrics/") {
return false
}
if !strings.Contains(path, "/Switches/NVSwitch_") {
return false
}
if strings.HasSuffix(path, "/Switches") || strings.Contains(path, "/Ports/NVLink_") || strings.HasSuffix(path, "/Ports") {
return true
}
if strings.Contains(path, "/Switches/NVSwitch_") && !strings.Contains(path, "/Ports/") {
return true
}
return false
}
func isRedfishMemoryMemberPath(path string) bool {
normalized := normalizeRedfishPath(path)
if !strings.Contains(normalized, "/Systems/") {
@@ -2880,23 +3257,38 @@ func parseNIC(doc map[string]interface{}) models.NetworkAdapter {
vendor = pciids.VendorName(vendorID)
}
location := redfishLocationLabel(doc["Location"])
slot := firstNonEmpty(redfishLocationLabel(doc["Location"]), asString(doc["Id"]), asString(doc["Name"]))
var firmware string
var portCount int
var linkWidth int
var maxLinkWidth int
var linkSpeed string
var maxLinkSpeed string
if controllers, ok := doc["Controllers"].([]interface{}); ok && len(controllers) > 0 {
if ctrl, ok := controllers[0].(map[string]interface{}); ok {
location = firstNonEmpty(location, redfishLocationLabel(ctrl["Location"]))
ctrlLocation := redfishLocationLabel(ctrl["Location"])
location = firstNonEmpty(location, ctrlLocation)
if isWeakRedfishNICSlotLabel(slot) {
slot = firstNonEmpty(ctrlLocation, slot)
}
firmware = asString(ctrl["FirmwarePackageVersion"])
if caps, ok := ctrl["ControllerCapabilities"].(map[string]interface{}); ok {
portCount = sanitizeNetworkPortCount(asInt(caps["NetworkPortCount"]))
}
if pcieIf, ok := ctrl["PCIeInterface"].(map[string]interface{}); ok {
linkWidth = asInt(pcieIf["LanesInUse"])
maxLinkWidth = asInt(pcieIf["MaxLanes"])
linkSpeed = firstNonEmpty(asString(pcieIf["PCIeType"]), asString(pcieIf["CurrentLinkSpeedGTs"]), asString(pcieIf["CurrentLinkSpeed"]))
maxLinkSpeed = firstNonEmpty(asString(pcieIf["MaxPCIeType"]), asString(pcieIf["MaxLinkSpeedGTs"]), asString(pcieIf["MaxLinkSpeed"]))
}
}
}
return models.NetworkAdapter{
Slot: firstNonEmpty(asString(doc["Id"]), asString(doc["Name"])),
Slot: slot,
Location: location,
Present: !strings.EqualFold(mapStatus(doc["Status"]), "Absent"),
BDF: asString(doc["BDF"]),
BDF: sanitizeRedfishBDF(asString(doc["BDF"])),
Model: strings.TrimSpace(model),
Vendor: strings.TrimSpace(vendor),
VendorID: vendorID,
@@ -2905,11 +3297,29 @@ func parseNIC(doc map[string]interface{}) models.NetworkAdapter {
PartNumber: asString(doc["PartNumber"]),
Firmware: firmware,
PortCount: portCount,
LinkWidth: linkWidth,
LinkSpeed: linkSpeed,
MaxLinkWidth: maxLinkWidth,
MaxLinkSpeed: maxLinkSpeed,
Status: mapStatus(doc["Status"]),
Details: redfishPCIeDetails(doc, nil),
}
}
func isWeakRedfishNICSlotLabel(slot string) bool {
slot = strings.TrimSpace(slot)
if slot == "" {
return true
}
if isNumericString(slot) {
return true
}
if strings.EqualFold(slot, "nic") || strings.HasPrefix(strings.ToLower(slot), "nic") && !strings.Contains(strings.ToLower(slot), "slot") {
return true
}
return false
}
func networkAdapterPCIeDevicePaths(doc map[string]interface{}) []string {
var out []string
if controllers, ok := doc["Controllers"].([]interface{}); ok {
@@ -2967,7 +3377,7 @@ func enrichNICFromPCIe(nic *models.NetworkAdapter, pcieDoc map[string]interface{
}
for _, fn := range functionDocs {
if strings.TrimSpace(nic.BDF) == "" {
nic.BDF = asString(fn["FunctionId"])
nic.BDF = sanitizeRedfishBDF(asString(fn["FunctionId"]))
}
if nic.VendorID == 0 {
nic.VendorID = asHexOrInt(fn["VendorId"])
@@ -3132,9 +3542,13 @@ func redfishPCIeDetailsWithSupplementalDocs(doc map[string]interface{}, function
lookupDocs = append(lookupDocs, functionDocs...)
lookupDocs = append(lookupDocs, supplementalDocs...)
details := make(map[string]any)
addFloatDetail(details, "temperature_c", redfishFirstNumericAcrossDocs(lookupDocs,
temperatureC := redfishFirstNumericAcrossDocs(lookupDocs,
"TemperatureCelsius", "TemperatureC", "Temperature",
))
)
if temperatureC == 0 {
temperatureC = redfishSupplementalThermalMetricForPCIe(doc, supplementalDocs)
}
addFloatDetail(details, "temperature_c", temperatureC)
addFloatDetail(details, "power_w", redfishFirstNumericAcrossDocs(lookupDocs,
"PowerConsumedWatts", "PowerWatts", "PowerConsumptionWatts",
))
@@ -3189,6 +3603,89 @@ func redfishPCIeDetailsWithSupplementalDocs(doc map[string]interface{}, function
return details
}
func redfishSupplementalThermalMetricForPCIe(doc map[string]interface{}, supplementalDocs []map[string]interface{}) float64 {
if len(supplementalDocs) == 0 {
return 0
}
deviceNames := redfishPCIeSupplementalDeviceNames(doc)
if len(deviceNames) == 0 {
return 0
}
for _, supplemental := range supplementalDocs {
readings, ok := supplemental["TemperatureReadingsCelsius"].([]interface{})
if !ok || len(readings) == 0 {
continue
}
for _, readingAny := range readings {
reading, ok := readingAny.(map[string]interface{})
if !ok {
continue
}
deviceName := strings.TrimSpace(asString(reading["DeviceName"]))
if deviceName == "" || !matchesAnyFold(deviceName, deviceNames) {
continue
}
if value := asFloat(reading["Reading"]); value != 0 {
return value
}
}
}
return 0
}
func redfishPCIeSupplementalDeviceNames(doc map[string]interface{}) []string {
names := make([]string, 0, 3)
for _, raw := range []string{
asString(doc["Id"]),
asString(doc["Name"]),
asString(doc["Model"]),
} {
name := strings.TrimSpace(raw)
if name == "" {
continue
}
if !matchesAnyFold(name, names) {
names = append(names, name)
}
}
return names
}
func matchesAnyFold(value string, candidates []string) bool {
for _, candidate := range candidates {
if strings.EqualFold(strings.TrimSpace(value), strings.TrimSpace(candidate)) {
return true
}
}
return false
}
func looksLikeNVSwitchPCIeDoc(doc map[string]interface{}) bool {
joined := strings.ToLower(strings.TrimSpace(strings.Join([]string{
asString(doc["Id"]),
asString(doc["Name"]),
asString(doc["Model"]),
asString(doc["Manufacturer"]),
}, " ")))
return strings.Contains(joined, "nvswitch")
}
func chassisPathForPCIeDoc(docPath string) string {
docPath = normalizeRedfishPath(docPath)
if !strings.Contains(docPath, "/PCIeDevices/") {
return ""
}
idx := strings.Index(docPath, "/PCIeDevices/")
if idx <= 0 {
return ""
}
chassisPath := docPath[:idx]
if !strings.HasPrefix(chassisPath, "/redfish/v1/Chassis/") {
return ""
}
return chassisPath
}
func redfishFirstNumeric(doc map[string]interface{}, keys ...string) float64 {
for _, key := range keys {
if v, ok := redfishLookupValue(doc, key); ok {
@@ -3392,7 +3889,7 @@ func parseGPUWithSupplementalDocs(doc map[string]interface{}, functionDocs []map
Details: redfishPCIeDetailsWithSupplementalDocs(doc, functionDocs, supplementalDocs),
}
if bdf := asString(doc["BDF"]); bdf != "" {
if bdf := sanitizeRedfishBDF(asString(doc["BDF"])); bdf != "" {
gpu.BDF = bdf
}
if gpu.BDF == "" {
@@ -3407,7 +3904,7 @@ func parseGPUWithSupplementalDocs(doc map[string]interface{}, functionDocs []map
for _, fn := range functionDocs {
if gpu.BDF == "" {
gpu.BDF = asString(fn["FunctionId"])
gpu.BDF = sanitizeRedfishBDF(asString(fn["FunctionId"]))
}
if gpu.VendorID == 0 {
gpu.VendorID = asHexOrInt(fn["VendorId"])
@@ -3448,7 +3945,7 @@ func parsePCIeDevice(doc map[string]interface{}, functionDocs []map[string]inter
func parsePCIeDeviceWithSupplementalDocs(doc map[string]interface{}, functionDocs []map[string]interface{}, supplementalDocs []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"]),
BDF: sanitizeRedfishBDF(asString(doc["BDF"])),
DeviceClass: asString(doc["DeviceType"]),
Manufacturer: asString(doc["Manufacturer"]),
PartNumber: asString(doc["PartNumber"]),
@@ -3463,7 +3960,7 @@ func parsePCIeDeviceWithSupplementalDocs(doc map[string]interface{}, functionDoc
for _, fn := range functionDocs {
if dev.BDF == "" {
dev.BDF = asString(fn["FunctionId"])
dev.BDF = sanitizeRedfishBDF(asString(fn["FunctionId"]))
}
if dev.DeviceClass == "" || isGenericPCIeClassLabel(dev.DeviceClass) {
dev.DeviceClass = firstNonEmpty(asString(fn["DeviceClass"]), asString(fn["ClassCode"]))
@@ -3522,7 +4019,7 @@ func parsePCIeFunctionWithSupplementalDocs(doc map[string]interface{}, supplemen
dev := models.PCIeDevice{
Slot: slot,
BDF: asString(doc["FunctionId"]),
BDF: sanitizeRedfishBDF(asString(doc["BDF"])),
VendorID: asHexOrInt(doc["VendorId"]),
DeviceID: asHexOrInt(doc["DeviceId"]),
DeviceClass: firstNonEmpty(asString(doc["DeviceClass"]), asString(doc["ClassCode"]), "PCIe device"),
@@ -3534,6 +4031,9 @@ func parsePCIeFunctionWithSupplementalDocs(doc map[string]interface{}, supplemen
MaxLinkSpeed: firstNonEmpty(asString(doc["MaxLinkSpeedGTs"]), asString(doc["MaxLinkSpeed"])),
Details: redfishPCIeDetailsWithSupplementalDocs(doc, nil, supplementalDocs),
}
if dev.BDF == "" {
dev.BDF = firstNonEmpty(buildBDFfromOemPublic(doc), sanitizeRedfishBDF(asString(doc["FunctionId"])))
}
if isGenericPCIeClassLabel(dev.DeviceClass) {
if resolved := pciids.DeviceName(dev.VendorID, dev.DeviceID); resolved != "" {
dev.DeviceClass = resolved
@@ -3951,7 +4451,24 @@ func isVirtualStorageDrive(doc map[string]interface{}) bool {
}
mfr := strings.ToUpper(strings.TrimSpace(asString(doc["Manufacturer"])))
model := strings.ToUpper(strings.TrimSpace(asString(doc["Model"])))
if strings.Contains(mfr, "AMI") && strings.Contains(model, "VIRTUAL") {
name := strings.ToUpper(strings.TrimSpace(asString(doc["Name"])))
joined := strings.Join([]string{mfr, model, name}, " ")
if strings.Contains(mfr, "AMI") && strings.Contains(joined, "VIRTUAL") {
return true
}
for _, marker := range []string{
"VIRTUAL CDROM",
"VIRTUAL CD/DVD",
"VIRTUAL FLOPPY",
"VIRTUAL FDD",
"VIRTUAL USB",
"VIRTUAL MEDIA",
} {
if strings.Contains(joined, marker) {
return true
}
}
if strings.Contains(mfr, "AMERICAN MEGATRENDS") && (strings.Contains(joined, "CDROM") || strings.Contains(joined, "FLOPPY") || strings.Contains(joined, "FDD")) {
return true
}
return false

View File

@@ -93,12 +93,12 @@ func ReplayRedfishFromRawPayloads(rawPayloads map[string]any, emit ProgressFn) (
collectedAt, sourceTimezone := inferRedfishCollectionTime(managerDoc, rawPayloads)
result := &models.AnalysisResult{
CollectedAt: collectedAt,
CollectedAt: collectedAt,
SourceTimezone: sourceTimezone,
Events: append(append(append(make([]models.Event, 0, len(discreteEvents)+len(healthEvents)+len(driveFetchWarningEvents)+1), healthEvents...), discreteEvents...), driveFetchWarningEvents...),
FRU: assemblyFRU,
Sensors: dedupeSensorReadings(append(append(thresholdSensors, thermalSensors...), powerSensors...)),
RawPayloads: cloneRawPayloads(rawPayloads),
Events: append(append(append(make([]models.Event, 0, len(discreteEvents)+len(healthEvents)+len(driveFetchWarningEvents)+1), healthEvents...), discreteEvents...), driveFetchWarningEvents...),
FRU: assemblyFRU,
Sensors: dedupeSensorReadings(append(append(thresholdSensors, thermalSensors...), powerSensors...)),
RawPayloads: cloneRawPayloads(rawPayloads),
Hardware: &models.HardwareConfig{
BoardInfo: boardInfo,
CPUs: processors,
@@ -273,11 +273,7 @@ func (r redfishSnapshotReader) collectFirmwareInventory() []models.FirmwareInfo
if strings.TrimSpace(version) == "" {
continue
}
name := firstNonEmpty(
asString(doc["DeviceName"]),
asString(doc["Name"]),
asString(doc["Id"]),
)
name := firmwareInventoryDeviceName(doc)
name = strings.TrimSpace(name)
if name == "" {
continue
@@ -291,6 +287,25 @@ func (r redfishSnapshotReader) collectFirmwareInventory() []models.FirmwareInfo
return out
}
func firmwareInventoryDeviceName(doc map[string]interface{}) string {
name := strings.TrimSpace(asString(doc["DeviceName"]))
if name != "" {
return name
}
id := strings.TrimSpace(asString(doc["Id"]))
rawName := strings.TrimSpace(asString(doc["Name"]))
if strings.EqualFold(rawName, "Software Inventory") || strings.EqualFold(rawName, "Firmware Inventory") {
if id != "" {
return id
}
}
if rawName != "" {
return rawName
}
return id
}
func dedupeFirmwareInfo(items []models.FirmwareInfo) []models.FirmwareInfo {
seen := make(map[string]struct{}, len(items))
out := make([]models.FirmwareInfo, 0, len(items))
@@ -1094,6 +1109,9 @@ func (r redfishSnapshotReader) collectStorage(systemPath string) []models.Storag
continue
}
if looksLikeDrive(member) {
if isVirtualStorageDrive(member) {
continue
}
supplementalDocs := r.getLinkedSupplementalDocs(member, "DriveMetrics", "EnvironmentMetrics", "Metrics")
out = append(out, parseDriveWithSupplementalDocs(member, supplementalDocs...))
}
@@ -1102,13 +1120,16 @@ func (r redfishSnapshotReader) collectStorage(systemPath string) []models.Storag
driveDocs, err := r.getCollectionMembers(joinPath(enclosurePath, "/Drives"))
if err == nil {
for _, driveDoc := range driveDocs {
if looksLikeDrive(driveDoc) {
if looksLikeDrive(driveDoc) && !isVirtualStorageDrive(driveDoc) {
supplementalDocs := r.getLinkedSupplementalDocs(driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
}
}
if len(driveDocs) == 0 {
for _, driveDoc := range r.probeDirectDiskBayChildren(joinPath(enclosurePath, "/Drives")) {
if isVirtualStorageDrive(driveDoc) {
continue
}
out = append(out, parseDrive(driveDoc))
}
}
@@ -1120,7 +1141,7 @@ func (r redfishSnapshotReader) collectStorage(systemPath string) []models.Storag
"/Storage/IntelVROC/Drives",
"/Storage/IntelVROC/Controllers/1/Drives",
}) {
if looksLikeDrive(driveDoc) {
if looksLikeDrive(driveDoc) && !isVirtualStorageDrive(driveDoc) {
supplementalDocs := r.getLinkedSupplementalDocs(driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
}
@@ -1134,7 +1155,7 @@ func (r redfishSnapshotReader) collectStorage(systemPath string) []models.Storag
}
for _, devAny := range devices {
devDoc, ok := devAny.(map[string]interface{})
if !ok || !looksLikeDrive(devDoc) {
if !ok || !looksLikeDrive(devDoc) || isVirtualStorageDrive(devDoc) {
continue
}
out = append(out, parseDrive(devDoc))
@@ -1148,7 +1169,7 @@ func (r redfishSnapshotReader) collectStorage(systemPath string) []models.Storag
continue
}
for _, driveDoc := range driveDocs {
if !looksLikeDrive(driveDoc) {
if !looksLikeDrive(driveDoc) || isVirtualStorageDrive(driveDoc) {
continue
}
out = append(out, parseDrive(driveDoc))
@@ -1159,7 +1180,7 @@ func (r redfishSnapshotReader) collectStorage(systemPath string) []models.Storag
continue
}
for _, driveDoc := range r.probeSupermicroNVMeDiskBays(chassisPath) {
if !looksLikeDrive(driveDoc) {
if !looksLikeDrive(driveDoc) || isVirtualStorageDrive(driveDoc) {
continue
}
out = append(out, parseDrive(driveDoc))
@@ -1353,6 +1374,7 @@ func (r redfishSnapshotReader) collectPCIeDevices(systemPaths, chassisPaths []st
continue
}
supplementalDocs := r.getLinkedSupplementalDocs(doc, "EnvironmentMetrics", "Metrics")
supplementalDocs = append(supplementalDocs, r.getChassisScopedPCIeSupplementalDocs(doc)...)
for _, fn := range functionDocs {
supplementalDocs = append(supplementalDocs, r.getLinkedSupplementalDocs(fn, "EnvironmentMetrics", "Metrics")...)
}
@@ -1377,6 +1399,29 @@ func (r redfishSnapshotReader) collectPCIeDevices(systemPaths, chassisPaths []st
return dedupePCIeDevices(out)
}
func (r redfishSnapshotReader) getChassisScopedPCIeSupplementalDocs(doc map[string]interface{}) []map[string]interface{} {
if !looksLikeNVSwitchPCIeDoc(doc) {
return nil
}
docPath := normalizeRedfishPath(asString(doc["@odata.id"]))
chassisPath := chassisPathForPCIeDoc(docPath)
if chassisPath == "" {
return nil
}
out := make([]map[string]interface{}, 0, 4)
for _, path := range []string{
joinPath(chassisPath, "/EnvironmentMetrics"),
joinPath(chassisPath, "/ThermalSubsystem/ThermalMetrics"),
} {
supplementalDoc, err := r.getJSON(path)
if err != nil || len(supplementalDoc) == 0 {
continue
}
out = append(out, supplementalDoc)
}
return out
}
func stringsTrimTrailingSlash(s string) string {
for len(s) > 1 && s[len(s)-1] == '/' {
s = s[:len(s)-1]

View File

@@ -6,6 +6,7 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
@@ -206,6 +207,177 @@ func TestRedfishConnectorCollect(t *testing.T) {
}
}
func TestRedfishConnectorProbe(t *testing.T) {
mux := http.NewServeMux()
register := func(path string, payload interface{}) {
mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(payload)
})
}
register("/redfish/v1", map[string]interface{}{"Name": "ServiceRoot"})
register("/redfish/v1/Systems/1", map[string]interface{}{
"@odata.id": "/redfish/v1/Systems/1",
"PowerState": "Off",
"Actions": map[string]interface{}{
"#ComputerSystem.Reset": map[string]interface{}{
"target": "/redfish/v1/Systems/1/Actions/ComputerSystem.Reset",
"ResetType@Redfish.AllowableValues": []interface{}{"On", "ForceOff"},
},
},
})
ts := httptest.NewTLSServer(mux)
defer ts.Close()
connector := NewRedfishConnector()
port := 443
host := ""
if u, err := url.Parse(ts.URL); err == nil {
host = u.Hostname()
if p := u.Port(); p != "" {
fmt.Sscanf(p, "%d", &port)
}
}
got, err := connector.Probe(context.Background(), Request{
Host: host,
Protocol: "redfish",
Port: port,
Username: "admin",
AuthType: "password",
Password: "secret",
TLSMode: "insecure",
})
if err != nil {
t.Fatalf("probe failed: %v", err)
}
if got == nil || !got.Reachable {
t.Fatalf("expected reachable probe result, got %+v", got)
}
if got.HostPoweredOn {
t.Fatalf("expected powered off host")
}
if got.HostPowerState != "Off" {
t.Fatalf("expected power state Off, got %q", got.HostPowerState)
}
if !got.PowerControlAvailable {
t.Fatalf("expected power control available")
}
}
func TestEnsureHostPowerForCollection_WaitsForStablePowerOn(t *testing.T) {
t.Setenv("LOGPILE_REDFISH_POWERON_STABILIZATION", "1ms")
powerState := "Off"
resetCalls := 0
mux := http.NewServeMux()
mux.HandleFunc("/redfish/v1/Systems/1", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"@odata.id": "/redfish/v1/Systems/1",
"PowerState": powerState,
"Actions": map[string]interface{}{
"#ComputerSystem.Reset": map[string]interface{}{
"target": "/redfish/v1/Systems/1/Actions/ComputerSystem.Reset",
"ResetType@Redfish.AllowableValues": []interface{}{"On"},
},
},
})
})
mux.HandleFunc("/redfish/v1/Systems/1/Actions/ComputerSystem.Reset", func(w http.ResponseWriter, r *http.Request) {
resetCalls++
powerState = "On"
w.WriteHeader(http.StatusOK)
})
ts := httptest.NewTLSServer(mux)
defer ts.Close()
u, err := url.Parse(ts.URL)
if err != nil {
t.Fatalf("parse server url: %v", err)
}
port := 443
if u.Port() != "" {
fmt.Sscanf(u.Port(), "%d", &port)
}
c := NewRedfishConnector()
hostOn, changed := c.ensureHostPowerForCollection(context.Background(), c.httpClientWithTimeout(Request{TLSMode: "insecure"}, 5*time.Second), Request{
Host: u.Hostname(),
Protocol: "redfish",
Port: port,
Username: "admin",
AuthType: "password",
Password: "secret",
TLSMode: "insecure",
PowerOnIfHostOff: true,
}, ts.URL, "/redfish/v1/Systems/1", nil)
if !hostOn || !changed {
t.Fatalf("expected stable power-on result, got hostOn=%v changed=%v", hostOn, changed)
}
if resetCalls != 1 {
t.Fatalf("expected one reset call, got %d", resetCalls)
}
}
func TestEnsureHostPowerForCollection_FailsIfHostDoesNotStayOnAfterStabilization(t *testing.T) {
t.Setenv("LOGPILE_REDFISH_POWERON_STABILIZATION", "1ms")
powerState := "Off"
mux := http.NewServeMux()
mux.HandleFunc("/redfish/v1/Systems/1", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
current := powerState
if powerState == "On" {
powerState = "Off"
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"@odata.id": "/redfish/v1/Systems/1",
"PowerState": current,
"Actions": map[string]interface{}{
"#ComputerSystem.Reset": map[string]interface{}{
"target": "/redfish/v1/Systems/1/Actions/ComputerSystem.Reset",
"ResetType@Redfish.AllowableValues": []interface{}{"On"},
},
},
})
})
mux.HandleFunc("/redfish/v1/Systems/1/Actions/ComputerSystem.Reset", func(w http.ResponseWriter, r *http.Request) {
powerState = "On"
w.WriteHeader(http.StatusOK)
})
ts := httptest.NewTLSServer(mux)
defer ts.Close()
u, err := url.Parse(ts.URL)
if err != nil {
t.Fatalf("parse server url: %v", err)
}
port := 443
if u.Port() != "" {
fmt.Sscanf(u.Port(), "%d", &port)
}
c := NewRedfishConnector()
hostOn, changed := c.ensureHostPowerForCollection(context.Background(), c.httpClientWithTimeout(Request{TLSMode: "insecure"}, 5*time.Second), Request{
Host: u.Hostname(),
Protocol: "redfish",
Port: port,
Username: "admin",
AuthType: "password",
Password: "secret",
TLSMode: "insecure",
PowerOnIfHostOff: true,
}, ts.URL, "/redfish/v1/Systems/1", nil)
if hostOn || changed {
t.Fatalf("expected unstable power-on result to fail, got hostOn=%v changed=%v", hostOn, changed)
}
}
func TestParsePCIeDeviceSlot_FromNestedRedfishSlotLocation(t *testing.T) {
doc := map[string]interface{}{
"Id": "NIC1",
@@ -629,6 +801,42 @@ func TestParseNIC_PortCountFromControllerCapabilities(t *testing.T) {
}
}
func TestParseNIC_PrefersControllerSlotLabelAndPCIeInterface(t *testing.T) {
nic := parseNIC(map[string]interface{}{
"Id": "1",
"Model": "MCX75310AAS-NEAT",
"Manufacturer": "Supermicro",
"Controllers": []interface{}{
map[string]interface{}{
"Location": map[string]interface{}{
"PartLocation": map[string]interface{}{
"ServiceLabel": "PCIe Slot 1 (1)",
},
},
"PCIeInterface": map[string]interface{}{
"LanesInUse": 16,
"MaxLanes": 16,
"PCIeType": "Gen5",
"MaxPCIeType": "Gen5",
},
},
},
})
if nic.Slot != "PCIe Slot 1 (1)" {
t.Fatalf("expected slot from controller location, got %q", nic.Slot)
}
if nic.Location != "PCIe Slot 1 (1)" {
t.Fatalf("expected location from controller location, got %q", nic.Location)
}
if nic.LinkWidth != 16 || nic.MaxLinkWidth != 16 {
t.Fatalf("expected link widths from controller PCIeInterface, got current=%d max=%d", nic.LinkWidth, nic.MaxLinkWidth)
}
if nic.LinkSpeed != "Gen5" || nic.MaxLinkSpeed != "Gen5" {
t.Fatalf("expected link speeds from controller PCIeInterface, got current=%q max=%q", nic.LinkSpeed, nic.MaxLinkSpeed)
}
}
func TestParseNIC_DropsUnrealisticPortCount(t *testing.T) {
nic := parseNIC(map[string]interface{}{
"Id": "1",
@@ -670,6 +878,48 @@ func TestParsePCIeDevice_PrefersFunctionClassOverDeviceType(t *testing.T) {
}
}
func TestParsePCIeComponents_DoNotTreatNumericFunctionIDAsBDF(t *testing.T) {
pcieFn := parsePCIeFunction(map[string]interface{}{
"Id": "1",
"FunctionId": "1",
"DeviceClass": "NetworkController",
"VendorId": "0x15b3",
"DeviceId": "0x1021",
}, 1)
if pcieFn.BDF != "" {
t.Fatalf("expected empty BDF for numeric FunctionId, got %q", pcieFn.BDF)
}
gpu := parseGPU(map[string]interface{}{
"Id": "GPU1",
"Name": "GPU1",
"PCIeFunctions": map[string]interface{}{
"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/GPU1/PCIeFunctions",
},
}, []map[string]interface{}{
{
"FunctionId": "1",
"VendorId": "0x10de",
"DeviceId": "0x2331",
},
}, 1)
if gpu.BDF != "" {
t.Fatalf("expected GPU BDF to stay empty when only numeric FunctionId exists, got %q", gpu.BDF)
}
nic := parseNIC(map[string]interface{}{"Id": "1"})
enrichNICFromPCIe(&nic, map[string]interface{}{}, []map[string]interface{}{
{
"FunctionId": "1",
"VendorId": "0x15b3",
"DeviceId": "0x1021",
},
}, nil)
if nic.BDF != "" {
t.Fatalf("expected NIC BDF to stay empty when only numeric FunctionId exists, got %q", nic.BDF)
}
}
func TestParseComponents_UseNestedSerialNumberFallback(t *testing.T) {
doc := map[string]interface{}{
"Name": "dev0",
@@ -727,9 +977,9 @@ func TestParseCPUAndMemory_CollectOemDetails(t *testing.T) {
"TemperatureCelsius": 63,
"Oem": map[string]interface{}{
"VendorX": map[string]interface{}{
"MicrocodeVersion": "0x2b000643",
"MicrocodeVersion": "0x2b000643",
"UncorrectableErrors": 1,
"ThermalThrottled": true,
"ThermalThrottled": true,
},
},
},
@@ -749,18 +999,18 @@ func TestParseCPUAndMemory_CollectOemDetails(t *testing.T) {
dimms := parseMemory([]map[string]interface{}{
{
"Id": "DIMM0",
"Id": "DIMM0",
"DeviceLocator": "CPU0_C0D0",
"CapacityMiB": 32768,
"SerialNumber": "DIMM-001",
"CapacityMiB": 32768,
"SerialNumber": "DIMM-001",
"Oem": map[string]interface{}{
"VendorX": map[string]interface{}{
"CorrectableECCErrorCount": 12,
"UncorrectableECCErrorCount": 2,
"TemperatureC": 41.5,
"CorrectableECCErrorCount": 12,
"UncorrectableECCErrorCount": 2,
"TemperatureC": 41.5,
"SpareBlocksRemainingPercent": 88,
"PerformanceDegraded": true,
"DataLossDetected": false,
"PerformanceDegraded": true,
"DataLossDetected": false,
},
},
},
@@ -831,9 +1081,9 @@ func TestReplayRedfishFromRawPayloads_UsesProcessorAndMemoryMetrics(t *testing.T
},
},
"/redfish/v1/Systems/1/Memory/DIMM0/MemoryMetrics": map[string]interface{}{
"CorrectableECCErrorCount": 14,
"TemperatureCelsius": 42,
"PerformanceDegraded": true,
"CorrectableECCErrorCount": 14,
"TemperatureCelsius": 42,
"PerformanceDegraded": true,
"SpareBlocksRemainingPercent": 91,
},
},
@@ -897,10 +1147,10 @@ func TestReplayRedfishFromRawPayloads_UsesDriveMetrics(t *testing.T) {
},
},
"/redfish/v1/Systems/1/Storage/RAID1/Drives/Drive0/DriveMetrics": map[string]interface{}{
"PowerOnHours": 1001,
"MediaErrors": 3,
"PowerOnHours": 1001,
"MediaErrors": 3,
"AvailableSparePercent": 92,
"TemperatureCelsius": 37,
"TemperatureCelsius": 37,
},
},
}
@@ -978,9 +1228,9 @@ func TestParseDriveAndPSU_CollectComponentMetricsIntoDetails(t *testing.T) {
"SerialNumber": "SSD-002",
"Oem": map[string]interface{}{
"Public": map[string]interface{}{
"temperature": 19,
"temperature": 19,
"PercentAvailableSpare": 93,
"PercentageUsed": 7,
"PercentageUsed": 7,
},
},
})
@@ -1076,9 +1326,9 @@ func TestParseComponentDetails_UseLinkedSupplementalMetrics(t *testing.T) {
"SerialNumber": "SSD-001",
},
map[string]interface{}{
"PowerOnHours": 5001,
"MediaErrors": 2,
"TemperatureC": 39.5,
"PowerOnHours": 5001,
"MediaErrors": 2,
"TemperatureC": 39.5,
"LifeUsedPercent": 9,
},
)
@@ -1093,7 +1343,7 @@ func TestParseComponentDetails_UseLinkedSupplementalMetrics(t *testing.T) {
},
1,
map[string]interface{}{
"Temperature": 44,
"Temperature": 44,
"LifeRemainingPercent": 97,
},
)
@@ -2206,6 +2456,48 @@ func TestLooksLikeGPU_NVSwitchExcluded(t *testing.T) {
}
}
func TestFirmwareInventoryDeviceName_PrefersIDForGenericSoftwareInventory(t *testing.T) {
doc := map[string]interface{}{
"Id": "HGX_FW_NVSwitch_0",
"Name": "Software Inventory",
"Version": "96.10.73.00.01",
}
got := firmwareInventoryDeviceName(doc)
if got != "HGX_FW_NVSwitch_0" {
t.Fatalf("expected firmware inventory id to be used, got %q", got)
}
}
func TestParsePCIeDeviceWithSupplementalDocs_NVSwitchThermalMetrics(t *testing.T) {
doc := map[string]interface{}{
"@odata.id": "/redfish/v1/Chassis/HGX_NVSwitch_0/PCIeDevices/NVSwitch_0",
"Id": "NVSwitch_0",
"Model": "NVSwitch",
"Manufacturer": "NVIDIA",
"DeviceType": "SingleFunction",
}
supplementalDocs := []map[string]interface{}{
{
"TemperatureReadingsCelsius": []interface{}{
map[string]interface{}{
"DeviceName": "NVSwitch_0",
"Reading": "31.593750",
},
},
},
}
got := parsePCIeDeviceWithSupplementalDocs(doc, nil, supplementalDocs)
if got.Details == nil {
t.Fatalf("expected NVSwitch details to be populated")
}
if temp := got.Details["temperature_c"]; temp != 31.59375 {
t.Fatalf("expected NVSwitch thermal metric, got %#v", got.Details)
}
}
func TestShouldCrawlPath_MemoryAndProcessorMetricsAreAllowed(t *testing.T) {
if !shouldCrawlPath("/redfish/v1/Systems/1/Memory/CPU0_C0D0") {
t.Fatalf("expected direct DIMM resource to be crawlable")
@@ -2222,6 +2514,12 @@ func TestShouldCrawlPath_MemoryAndProcessorMetricsAreAllowed(t *testing.T) {
if shouldCrawlPath("/redfish/v1/Chassis/1/PCIeDevices/0/PCIeFunctions/1") {
t.Fatalf("expected noisy chassis pciefunctions branch to be skipped")
}
if !shouldCrawlPath("/redfish/v1/Fabrics/HGX_NVLinkFabric_0/Switches/NVSwitch_0") {
t.Fatalf("expected NVSwitch fabric resource to be crawlable")
}
if !shouldCrawlPath("/redfish/v1/Fabrics/HGX_NVLinkFabric_0/Switches/NVSwitch_0/Ports/NVLink_0/Metrics") {
t.Fatalf("expected NVLink port metrics to be crawlable")
}
}
func TestIsRedfishMemoryMemberPath(t *testing.T) {
@@ -2531,3 +2829,19 @@ func TestNVMePostProbeSkipsNonStorageChassis(t *testing.T) {
t.Fatalf("expected StorageBackplane to be selected, got %q", selected[0])
}
}
func TestIsVirtualStorageDrive_AMIVirtualMedia(t *testing.T) {
doc := map[string]interface{}{
"Id": "USB_Device1_Port4",
"Name": "Virtual Cdrom Device",
"Model": "Virtual Cdrom Device",
"Manufacturer": "American Megatrends Inc.",
"Protocol": "USB",
"CapacityBytes": 0,
"SerialNumber": "AAAABBBBCCCC1",
}
if !isVirtualStorageDrive(doc) {
t.Fatalf("expected AMI virtual media to be filtered")
}
}

View File

@@ -7,14 +7,15 @@ import (
)
type Request struct {
Host string
Protocol string
Port int
Username string
AuthType string
Password string
Token string
TLSMode string
Host string
Protocol string
Port int
Username string
AuthType string
Password string
Token string
TLSMode string
PowerOnIfHostOff bool
}
type Progress struct {
@@ -25,7 +26,20 @@ type Progress struct {
type ProgressFn func(Progress)
type ProbeResult struct {
Reachable bool
Protocol string
HostPowerState string
HostPoweredOn bool
PowerControlAvailable bool
SystemPath string
}
type Connector interface {
Protocol() string
Collect(ctx context.Context, req Request, emit ProgressFn) (*models.AnalysisResult, error)
}
type Prober interface {
Probe(ctx context.Context, req Request) (*ProbeResult, error)
}