From 99f0d6217cda5d233852c36a55ca90cf16d585af Mon Sep 17 00:00:00 2001 From: Mikhail Chusavitin Date: Thu, 26 Mar 2026 18:41:02 +0300 Subject: [PATCH] Improve Multillect Redfish replay and power detection --- internal/collector/redfish.go | 19 +- internal/collector/redfish_replay.go | 295 +++++++++++- .../collector/redfish_replay_inventory.go | 152 ++++++- internal/collector/redfish_test.go | 428 ++++++++++++++++++ 4 files changed, 884 insertions(+), 10 deletions(-) diff --git a/internal/collector/redfish.go b/internal/collector/redfish.go index 3e1df47..54da9bf 100644 --- a/internal/collector/redfish.go +++ b/internal/collector/redfish.go @@ -110,7 +110,7 @@ func (c *RedfishConnector) Probe(ctx context.Context, req Request) (*ProbeResult if err != nil { return nil, fmt.Errorf("redfish system: %w", err) } - powerState := strings.TrimSpace(asString(systemDoc["PowerState"])) + powerState := redfishSystemPowerState(systemDoc) return &ProbeResult{ Reachable: true, Protocol: "redfish", @@ -494,7 +494,7 @@ func (c *RedfishConnector) ensureHostPowerForCollection(ctx context.Context, cli return false, false } - powerState := strings.TrimSpace(asString(systemDoc["PowerState"])) + powerState := redfishSystemPowerState(systemDoc) if isRedfishHostPoweredOn(powerState) { if emit != nil { emit(Progress{Status: "running", Progress: 18, Message: fmt.Sprintf("Redfish: host включен (%s)", firstNonEmpty(powerState, "On"))}) @@ -753,7 +753,7 @@ func (c *RedfishConnector) waitForHostPowerState(ctx context.Context, client *ht for { systemDoc, err := c.getJSON(ctx, client, req, baseURL, systemPath) if err == nil { - if isRedfishHostPoweredOn(strings.TrimSpace(asString(systemDoc["PowerState"]))) == wantOn { + if isRedfishHostPoweredOn(redfishSystemPowerState(systemDoc)) == wantOn { return true } } @@ -786,6 +786,19 @@ func isRedfishHostPoweredOn(state string) bool { } } +func redfishSystemPowerState(systemDoc map[string]interface{}) string { + if len(systemDoc) == 0 { + return "" + } + if state := strings.TrimSpace(asString(systemDoc["PowerState"])); state != "" { + return state + } + if summary, ok := systemDoc["PowerSummary"].(map[string]interface{}); ok { + return strings.TrimSpace(asString(summary["PowerState"])) + } + return "" +} + func redfishResetActionTarget(systemDoc map[string]interface{}) string { if systemDoc == nil { return "" diff --git a/internal/collector/redfish_replay.go b/internal/collector/redfish_replay.go index a1a9f59..f9cf189 100644 --- a/internal/collector/redfish_replay.go +++ b/internal/collector/redfish_replay.go @@ -96,17 +96,23 @@ func ReplayRedfishFromRawPayloads(rawPayloads map[string]any, emit ProgressFn) ( networkProtocolDoc, _ := r.getJSON(joinPath(primaryManager, "/NetworkProtocol")) firmware := parseFirmware(systemDoc, biosDoc, managerDoc, networkProtocolDoc) firmware = dedupeFirmwareInfo(append(firmware, r.collectFirmwareInventory()...)) - boardInfo.BMCMACAddress = r.collectBMCMAC(managerPaths) + bmcManagementSummary := r.collectBMCManagementSummary(managerPaths) + boardInfo.BMCMACAddress = strings.TrimSpace(firstNonEmpty( + asString(bmcManagementSummary["mac_address"]), + r.collectBMCMAC(managerPaths), + )) assemblyFRU := r.collectAssemblyFRU(chassisPaths) collectedAt, sourceTimezone := inferRedfishCollectionTime(managerDoc, rawPayloads) inventoryLastModifiedAt := inferInventoryLastModifiedTime(r.tree) logEntryEvents := parseRedfishLogEntries(rawPayloads, collectedAt) + sensorHintSummary, sensorHintEvents := r.collectSensorsListHints(chassisPaths, collectedAt) + bmcManagementEvent := buildBMCManagementSummaryEvent(bmcManagementSummary, collectedAt) result := &models.AnalysisResult{ CollectedAt: collectedAt, InventoryLastModifiedAt: inventoryLastModifiedAt, SourceTimezone: sourceTimezone, - Events: append(append(append(append(make([]models.Event, 0, len(discreteEvents)+len(healthEvents)+len(driveFetchWarningEvents)+len(logEntryEvents)+1), healthEvents...), discreteEvents...), driveFetchWarningEvents...), logEntryEvents...), + Events: append(append(append(append(append(append(make([]models.Event, 0, len(discreteEvents)+len(healthEvents)+len(driveFetchWarningEvents)+len(logEntryEvents)+len(sensorHintEvents)+2), healthEvents...), discreteEvents...), driveFetchWarningEvents...), logEntryEvents...), sensorHintEvents...), bmcManagementEvent...), FRU: assemblyFRU, Sensors: dedupeSensorReadings(append(append(thresholdSensors, thermalSensors...), powerSensors...)), RawPayloads: cloneRawPayloads(rawPayloads), @@ -155,6 +161,12 @@ func ReplayRedfishFromRawPayloads(rawPayloads map[string]any, emit ProgressFn) ( if strings.TrimSpace(sourceTimezone) != "" { result.RawPayloads["source_timezone"] = sourceTimezone } + if len(sensorHintSummary) > 0 { + result.RawPayloads["redfish_sensor_hints"] = sensorHintSummary + } + if len(bmcManagementSummary) > 0 { + result.RawPayloads["redfish_bmc_network_summary"] = bmcManagementSummary + } appendMissingServerModelWarning(result, systemDoc, joinPath(primarySystem, "/Oem/Public/FRU"), joinPath(primaryChassis, "/Oem/Public/FRU")) return result, nil } @@ -324,6 +336,153 @@ func buildDriveFetchWarningEvents(rawPayloads map[string]any) []models.Event { } } +func (r redfishSnapshotReader) collectSensorsListHints(chassisPaths []string, collectedAt time.Time) (map[string]any, []models.Event) { + summary := make(map[string]any) + var events []models.Event + var presentDIMMs []string + dimmTotal := 0 + dimmPresent := 0 + physicalDriveSlots := 0 + activePhysicalDriveSlots := 0 + logicalDriveStatus := "" + + for _, chassisPath := range chassisPaths { + doc, err := r.getJSON(joinPath(chassisPath, "/SensorsList")) + if err != nil || len(doc) == 0 { + continue + } + sensors, ok := doc["SensorsList"].([]interface{}) + if !ok { + continue + } + for _, item := range sensors { + sensor, ok := item.(map[string]interface{}) + if !ok { + continue + } + name := strings.TrimSpace(asString(sensor["SensorName"])) + sensorType := strings.TrimSpace(asString(sensor["SensorType"])) + status := strings.TrimSpace(asString(sensor["Status"])) + switch { + case strings.HasPrefix(name, "DIMM") && strings.HasSuffix(name, "_Status") && strings.EqualFold(sensorType, "Memory"): + dimmTotal++ + if redfishSlotStatusLooksPresent(status) { + dimmPresent++ + presentDIMMs = append(presentDIMMs, strings.TrimSuffix(name, "_Status")) + } + case strings.EqualFold(sensorType, "Drive Slot"): + if strings.EqualFold(name, "Logical_Drive") { + logicalDriveStatus = firstNonEmpty(logicalDriveStatus, status) + continue + } + physicalDriveSlots++ + if redfishSlotStatusLooksPresent(status) { + activePhysicalDriveSlots++ + } + } + } + } + + if dimmTotal > 0 { + sort.Strings(presentDIMMs) + summary["memory_slots"] = map[string]any{ + "total": dimmTotal, + "present_count": dimmPresent, + "present_slots": presentDIMMs, + "source": "SensorsList", + } + events = append(events, models.Event{ + Timestamp: replayEventTimestamp(collectedAt), + Source: "Redfish", + EventType: "Collection Info", + Severity: models.SeverityInfo, + Description: fmt.Sprintf("Memory slot sensors report %d populated positions out of %d", dimmPresent, dimmTotal), + RawData: firstNonEmpty(strings.Join(presentDIMMs, ", "), "no populated DIMM slots reported"), + }) + } + if physicalDriveSlots > 0 || logicalDriveStatus != "" { + summary["drive_slots"] = map[string]any{ + "physical_total": physicalDriveSlots, + "physical_active_count": activePhysicalDriveSlots, + "logical_drive_status": logicalDriveStatus, + "source": "SensorsList", + } + rawParts := []string{ + fmt.Sprintf("physical_active=%d/%d", activePhysicalDriveSlots, physicalDriveSlots), + } + if logicalDriveStatus != "" { + rawParts = append(rawParts, "logical_drive="+logicalDriveStatus) + } + events = append(events, models.Event{ + Timestamp: replayEventTimestamp(collectedAt), + Source: "Redfish", + EventType: "Collection Info", + Severity: models.SeverityInfo, + Description: fmt.Sprintf("Drive slot sensors report %d active physical slots out of %d", activePhysicalDriveSlots, physicalDriveSlots), + RawData: strings.Join(rawParts, "; "), + }) + } + + return summary, events +} + +func buildBMCManagementSummaryEvent(summary map[string]any, collectedAt time.Time) []models.Event { + if len(summary) == 0 { + return nil + } + desc := fmt.Sprintf( + "BMC management interface %s link=%s ip=%s", + firstNonEmpty(asString(summary["interface_id"]), "unknown"), + firstNonEmpty(asString(summary["link_status"]), "unknown"), + firstNonEmpty(asString(summary["ipv4_address"]), "n/a"), + ) + rawParts := make([]string, 0, 8) + for _, part := range []string{ + "mac_address=" + strings.TrimSpace(asString(summary["mac_address"])), + "speed_mbps=" + strings.TrimSpace(asString(summary["speed_mbps"])), + "lldp_chassis_name=" + strings.TrimSpace(asString(summary["lldp_chassis_name"])), + "lldp_port_desc=" + strings.TrimSpace(asString(summary["lldp_port_desc"])), + "lldp_port_id=" + strings.TrimSpace(asString(summary["lldp_port_id"])), + "ipv4_gateway=" + strings.TrimSpace(asString(summary["ipv4_gateway"])), + } { + if !strings.HasSuffix(part, "=") { + rawParts = append(rawParts, part) + } + } + if vlan := asInt(summary["lldp_vlan_id"]); vlan > 0 { + rawParts = append(rawParts, fmt.Sprintf("lldp_vlan_id=%d", vlan)) + } + if asBool(summary["ncsi_enabled"]) { + rawParts = append(rawParts, "ncsi_enabled=true") + } + return []models.Event{ + { + Timestamp: replayEventTimestamp(collectedAt), + Source: "Redfish", + EventType: "Collection Info", + Severity: models.SeverityInfo, + Description: desc, + RawData: strings.Join(rawParts, "; "), + }, + } +} + +func redfishSlotStatusLooksPresent(status string) bool { + switch strings.ToLower(strings.TrimSpace(status)) { + case "ok", "enabled", "present", "warning", "critical": + return true + default: + return false + } +} + +func replayEventTimestamp(collectedAt time.Time) time.Time { + if !collectedAt.IsZero() { + return collectedAt + } + return time.Now() +} + func (r redfishSnapshotReader) collectFirmwareInventory() []models.FirmwareInfo { docs, err := r.getCollectionMembers("/redfish/v1/UpdateService/FirmwareInventory") if err != nil || len(docs) == 0 { @@ -856,6 +1015,9 @@ func (r redfishSnapshotReader) fallbackCollectionMembers(collectionPath string, if err != nil { continue } + if redfishFallbackMemberLooksLikePlaceholder(collectionPath, doc) { + continue + } if strings.TrimSpace(asString(doc["@odata.id"])) == "" { doc["@odata.id"] = normalizeRedfishPath(p) } @@ -864,6 +1026,135 @@ func (r redfishSnapshotReader) fallbackCollectionMembers(collectionPath string, return out, nil } +func redfishFallbackMemberLooksLikePlaceholder(collectionPath string, doc map[string]interface{}) bool { + if len(doc) == 0 { + return true + } + path := normalizeRedfishPath(collectionPath) + switch { + case strings.HasSuffix(path, "/NetworkAdapters"): + return redfishNetworkAdapterPlaceholderDoc(doc) + case strings.HasSuffix(path, "/PCIeDevices"): + return redfishPCIePlaceholderDoc(doc) + case strings.Contains(path, "/Storage"): + return redfishStoragePlaceholderDoc(doc) + default: + return false + } +} + +func redfishNetworkAdapterPlaceholderDoc(doc map[string]interface{}) bool { + if normalizeRedfishIdentityField(asString(doc["Model"])) != "" || + normalizeRedfishIdentityField(asString(doc["Manufacturer"])) != "" || + normalizeRedfishIdentityField(asString(doc["SerialNumber"])) != "" || + normalizeRedfishIdentityField(asString(doc["PartNumber"])) != "" || + normalizeRedfishIdentityField(asString(doc["BDF"])) != "" || + asHexOrInt(doc["VendorId"]) != 0 || + asHexOrInt(doc["DeviceId"]) != 0 { + return false + } + return redfishDocHasOnlyAllowedKeys(doc, + "@odata.context", + "@odata.id", + "@odata.type", + "Id", + "Name", + ) +} + +func redfishPCIePlaceholderDoc(doc map[string]interface{}) bool { + if normalizeRedfishIdentityField(asString(doc["Model"])) != "" || + normalizeRedfishIdentityField(asString(doc["Manufacturer"])) != "" || + normalizeRedfishIdentityField(asString(doc["SerialNumber"])) != "" || + normalizeRedfishIdentityField(asString(doc["PartNumber"])) != "" || + normalizeRedfishIdentityField(asString(doc["BDF"])) != "" || + asHexOrInt(doc["VendorId"]) != 0 || + asHexOrInt(doc["DeviceId"]) != 0 { + return false + } + return redfishDocHasOnlyAllowedKeys(doc, + "@odata.context", + "@odata.id", + "@odata.type", + "Id", + "Name", + ) +} + +func redfishStoragePlaceholderDoc(doc map[string]interface{}) bool { + if normalizeRedfishIdentityField(asString(doc["Model"])) != "" || + normalizeRedfishIdentityField(asString(doc["Manufacturer"])) != "" || + normalizeRedfishIdentityField(asString(doc["SerialNumber"])) != "" || + normalizeRedfishIdentityField(asString(doc["PartNumber"])) != "" || + normalizeRedfishIdentityField(asString(doc["BDF"])) != "" || + asHexOrInt(doc["VendorId"]) != 0 || + asHexOrInt(doc["DeviceId"]) != 0 { + return false + } + if !redfishDocHasOnlyAllowedKeys(doc, + "@odata.id", + "@odata.type", + "Drives", + "Drives@odata.count", + "LogicalDisk", + "PhysicalDisk", + "Name", + ) { + return false + } + return redfishFieldIsEmptyCollection(doc["Drives"]) && + redfishFieldIsZeroLike(doc["Drives@odata.count"]) && + redfishFieldIsEmptyCollection(doc["LogicalDisk"]) && + redfishFieldIsEmptyCollection(doc["PhysicalDisk"]) +} + +func redfishDocHasOnlyAllowedKeys(doc map[string]interface{}, allowed ...string) bool { + if len(doc) == 0 { + return false + } + allowedSet := make(map[string]struct{}, len(allowed)) + for _, key := range allowed { + allowedSet[key] = struct{}{} + } + for key := range doc { + if _, ok := allowedSet[key]; !ok { + return false + } + } + return true +} + +func redfishFieldIsEmptyCollection(v any) bool { + switch x := v.(type) { + case nil: + return true + case []interface{}: + return len(x) == 0 + default: + return false + } +} + +func redfishFieldIsZeroLike(v any) bool { + switch x := v.(type) { + case nil: + return true + case int: + return x == 0 + case int32: + return x == 0 + case int64: + return x == 0 + case float64: + return x == 0 + case string: + x = strings.TrimSpace(x) + return x == "" || x == "0" + default: + return false + } +} + func cloneRawPayloads(src map[string]any) map[string]any { if len(src) == 0 { return nil diff --git a/internal/collector/redfish_replay_inventory.go b/internal/collector/redfish_replay_inventory.go index 42f67c1..39cf313 100644 --- a/internal/collector/redfish_replay_inventory.go +++ b/internal/collector/redfish_replay_inventory.go @@ -165,12 +165,25 @@ func (r redfishSnapshotReader) getChassisScopedPCIeSupplementalDocs(doc map[stri return out } -// collectBMCMAC returns the MAC address of the first active BMC management -// interface found in Managers/*/EthernetInterfaces. Returns empty string if -// no MAC is available. +// collectBMCMAC returns the MAC address of the best BMC management interface +// found in Managers/*/EthernetInterfaces. Prefer an active link with an IP +// address over a passive sideband interface. func (r redfishSnapshotReader) collectBMCMAC(managerPaths []string) string { + summary := r.collectBMCManagementSummary(managerPaths) + if len(summary) == 0 { + return "" + } + return strings.ToUpper(strings.TrimSpace(asString(summary["mac_address"]))) +} + +func (r redfishSnapshotReader) collectBMCManagementSummary(managerPaths []string) map[string]any { + bestScore := -1 + var best map[string]any for _, managerPath := range managerPaths { - members, err := r.getCollectionMembers(joinPath(managerPath, "/EthernetInterfaces")) + collectionPath := joinPath(managerPath, "/EthernetInterfaces") + collectionDoc, _ := r.getJSON(collectionPath) + ncsiEnabled, lldpMode, lldpByEth := redfishManagerEthernetCollectionHints(collectionDoc) + members, err := r.getCollectionMembers(collectionPath) if err != nil || len(members) == 0 { continue } @@ -182,12 +195,141 @@ func (r redfishSnapshotReader) collectBMCMAC(managerPaths []string) string { if mac == "" || strings.EqualFold(mac, "00:00:00:00:00:00") { continue } - return strings.ToUpper(mac) + ifaceID := strings.TrimSpace(firstNonEmpty(asString(doc["Id"]), asString(doc["Name"]))) + summary := map[string]any{ + "manager_path": managerPath, + "interface_id": ifaceID, + "hostname": strings.TrimSpace(asString(doc["HostName"])), + "fqdn": strings.TrimSpace(asString(doc["FQDN"])), + "mac_address": strings.ToUpper(mac), + "link_status": strings.TrimSpace(asString(doc["LinkStatus"])), + "speed_mbps": asInt(doc["SpeedMbps"]), + "interface_name": strings.TrimSpace(asString(doc["Name"])), + "interface_desc": strings.TrimSpace(asString(doc["Description"])), + "ncsi_enabled": ncsiEnabled, + "lldp_mode": lldpMode, + "ipv4_address": redfishManagerIPv4Field(doc, "Address"), + "ipv4_gateway": redfishManagerIPv4Field(doc, "Gateway"), + "ipv4_subnet": redfishManagerIPv4Field(doc, "SubnetMask"), + "ipv6_address": redfishManagerIPv6Field(doc, "Address"), + "link_is_active": strings.EqualFold(strings.TrimSpace(asString(doc["LinkStatus"])), "LinkActive"), + "interface_score": 0, + } + if lldp, ok := lldpByEth[strings.ToLower(ifaceID)]; ok { + summary["lldp_chassis_name"] = lldp["ChassisName"] + summary["lldp_port_desc"] = lldp["PortDesc"] + summary["lldp_port_id"] = lldp["PortId"] + if vlan := asInt(lldp["VlanId"]); vlan > 0 { + summary["lldp_vlan_id"] = vlan + } + } + score := redfishManagerInterfaceScore(summary) + summary["interface_score"] = score + if score > bestScore { + bestScore = score + best = summary + } + } + } + return best +} + +func redfishManagerEthernetCollectionHints(collectionDoc map[string]interface{}) (bool, string, map[string]map[string]interface{}) { + lldpByEth := make(map[string]map[string]interface{}) + if len(collectionDoc) == 0 { + return false, "", lldpByEth + } + oem, _ := collectionDoc["Oem"].(map[string]interface{}) + public, _ := oem["Public"].(map[string]interface{}) + ncsiEnabled := asBool(public["NcsiEnabled"]) + lldp, _ := public["LLDP"].(map[string]interface{}) + lldpMode := strings.TrimSpace(asString(lldp["LLDPMode"])) + if members, ok := lldp["Members"].([]interface{}); ok { + for _, item := range members { + member, ok := item.(map[string]interface{}) + if !ok { + continue + } + ethIndex := strings.ToLower(strings.TrimSpace(asString(member["EthIndex"]))) + if ethIndex == "" { + continue + } + lldpByEth[ethIndex] = member + } + } + return ncsiEnabled, lldpMode, lldpByEth +} + +func redfishManagerIPv4Field(doc map[string]interface{}, key string) string { + if len(doc) == 0 { + return "" + } + for _, field := range []string{"IPv4Addresses", "IPv4StaticAddresses"} { + list, ok := doc[field].([]interface{}) + if !ok { + continue + } + for _, item := range list { + entry, ok := item.(map[string]interface{}) + if !ok { + continue + } + value := strings.TrimSpace(asString(entry[key])) + if value != "" { + return value + } } } return "" } +func redfishManagerIPv6Field(doc map[string]interface{}, key string) string { + if len(doc) == 0 { + return "" + } + list, ok := doc["IPv6Addresses"].([]interface{}) + if !ok { + return "" + } + for _, item := range list { + entry, ok := item.(map[string]interface{}) + if !ok { + continue + } + value := strings.TrimSpace(asString(entry[key])) + if value != "" { + return value + } + } + return "" +} + +func redfishManagerInterfaceScore(summary map[string]any) int { + score := 0 + if strings.EqualFold(strings.TrimSpace(asString(summary["link_status"])), "LinkActive") { + score += 100 + } + if strings.TrimSpace(asString(summary["ipv4_address"])) != "" { + score += 40 + } + if strings.TrimSpace(asString(summary["ipv6_address"])) != "" { + score += 10 + } + if strings.TrimSpace(asString(summary["mac_address"])) != "" { + score += 10 + } + if asInt(summary["speed_mbps"]) > 0 { + score += 5 + } + if ifaceID := strings.ToLower(strings.TrimSpace(asString(summary["interface_id"]))); ifaceID != "" && !strings.HasPrefix(ifaceID, "usb") { + score += 3 + } + if asBool(summary["ncsi_enabled"]) { + score += 1 + } + return score +} + // findNICIndexByLinkedNetworkAdapter resolves a NetworkInterface document to an // existing NIC in bySlot by following Links.NetworkAdapter → the Chassis // NetworkAdapter doc → its slot label. Returns -1 if no match is found. diff --git a/internal/collector/redfish_test.go b/internal/collector/redfish_test.go index fd8867b..af3d506 100644 --- a/internal/collector/redfish_test.go +++ b/internal/collector/redfish_test.go @@ -270,6 +270,71 @@ func TestRedfishConnectorProbe(t *testing.T) { } } +func TestRedfishConnectorProbe_FallsBackToPowerSummary(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", map[string]interface{}{ + "Members": []interface{}{ + map[string]interface{}{"@odata.id": "/redfish/v1/Systems/1"}, + }, + }) + register("/redfish/v1/Systems/1", map[string]interface{}{ + "@odata.id": "/redfish/v1/Systems/1", + "PowerSummary": map[string]interface{}{ + "PowerState": "On", + }, + "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 on host from PowerSummary") + } + if got.HostPowerState != "On" { + t.Fatalf("expected power state On, 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") t.Setenv("LOGPILE_REDFISH_BMC_READY_WAITS", "1ms,1ms") @@ -388,6 +453,104 @@ func TestEnsureHostPowerForCollection_FailsIfHostDoesNotStayOnAfterStabilization } } +func TestEnsureHostPowerForCollection_UsesPowerSummaryState(t *testing.T) { + t.Setenv("LOGPILE_REDFISH_POWERON_STABILIZATION", "1ms") + t.Setenv("LOGPILE_REDFISH_BMC_READY_WAITS", "1ms,1ms") + + powerState := "On" + + 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", + "PowerSummary": map[string]interface{}{ + "PowerState": powerState, + }, + "MemorySummary": map[string]interface{}{ + "TotalSystemMemoryGiB": 128, + }, + "Actions": map[string]interface{}{ + "#ComputerSystem.Reset": map[string]interface{}{ + "target": "/redfish/v1/Systems/1/Actions/ComputerSystem.Reset", + "ResetType@Redfish.AllowableValues": []interface{}{"On"}, + }, + }, + }) + }) + + 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 already-on host from PowerSummary, got hostOn=%v changed=%v", hostOn, changed) + } +} + +func TestWaitForHostPowerState_UsesPowerSummaryState(t *testing.T) { + 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 == "Off" { + powerState = "On" + } + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "@odata.id": "/redfish/v1/Systems/1", + "PowerSummary": map[string]interface{}{ + "PowerState": current, + }, + }) + }) + + 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() + ok := c.waitForHostPowerState(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", + }, ts.URL, "/redfish/v1/Systems/1", true, 3*time.Second) + if !ok { + t.Fatalf("expected waitForHostPowerState to use PowerSummary") + } +} + func TestParsePCIeDeviceSlot_FromNestedRedfishSlotLocation(t *testing.T) { doc := map[string]interface{}{ "Id": "NIC1", @@ -488,6 +651,271 @@ func TestReplayRedfishFromRawPayloads_FallbackCollectionMembersByPrefix(t *testi } } +func TestReplayRedfishFromRawPayloads_FallbackCollectionMembersSkipsPlaceholderNumericDocs(t *testing.T) { + raw := map[string]any{ + "redfish_tree": map[string]interface{}{ + "/redfish/v1": map[string]interface{}{ + "Systems": map[string]interface{}{"@odata.id": "/redfish/v1/Systems"}, + "Chassis": map[string]interface{}{"@odata.id": "/redfish/v1/Chassis"}, + "Managers": map[string]interface{}{"@odata.id": "/redfish/v1/Managers"}, + }, + "/redfish/v1/Systems": map[string]interface{}{ + "Members": []interface{}{ + map[string]interface{}{"@odata.id": "/redfish/v1/Systems/1"}, + }, + }, + "/redfish/v1/Systems/1": map[string]interface{}{ + "Manufacturer": "Multillect", + "Model": "MLT-S06", + "SerialNumber": "430044262001626", + "Storage": map[string]interface{}{"@odata.id": "/redfish/v1/Systems/1/Storage"}, + }, + "/redfish/v1/Systems/1/Storage": map[string]interface{}{ + "@odata.id": "/redfish/v1/Systems/1/Storage", + "Members": []interface{}{}, + "Members@odata.count": 0, + }, + "/redfish/v1/Systems/1/Storage/1": map[string]interface{}{ + "@odata.id": "/redfish/v1/Systems/1/Storage/1", + "@odata.type": "#Storage.v1_7_1.Storage", + "Drives": []interface{}{}, + "Drives@odata.count": "0", + "LogicalDisk": []interface{}{}, + "PhysicalDisk": []interface{}{}, + }, + "/redfish/v1/Chassis": map[string]interface{}{ + "Members": []interface{}{ + map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1"}, + }, + }, + "/redfish/v1/Chassis/1": map[string]interface{}{ + "Id": "1", + "NetworkAdapters": map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/NetworkAdapters"}, + "PCIeDevices": map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices"}, + }, + "/redfish/v1/Chassis/1/NetworkAdapters": map[string]interface{}{ + "@odata.id": "/redfish/v1/Chassis/1/NetworkAdapters", + "Members": []interface{}{}, + "Members@odata.count": 0, + }, + "/redfish/v1/Chassis/1/NetworkAdapters/1": map[string]interface{}{ + "@odata.context": "/redfish/v1/$metadata#Chassis/Members/1/NetworkAdapters/Members/$entity", + "@odata.id": "/redfish/v1/Chassis/1/NetworkAdapters/1", + "@odata.type": "#NetworkAdapter.v1_0_0.Networkadapter", + "Id": "1", + "Name": "1", + }, + "/redfish/v1/Chassis/1/PCIeDevices": map[string]interface{}{ + "@odata.id": "/redfish/v1/Chassis/1/PCIeDevices", + "Members": []interface{}{}, + "Members@odata.count": 0, + }, + "/redfish/v1/Chassis/1/PCIeDevices/1": map[string]interface{}{ + "@odata.context": "/redfish/v1/$metadata#PCIeDevice.PCIeDevice", + "@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/1", + "@odata.type": "#PCIeDevice.v1_4_0.PCIeDevice", + "Id": "1", + "Name": "PCIe Device", + }, + "/redfish/v1/Managers": map[string]interface{}{ + "Members": []interface{}{ + map[string]interface{}{"@odata.id": "/redfish/v1/Managers/1"}, + }, + }, + "/redfish/v1/Managers/1": map[string]interface{}{ + "Id": "1", + }, + }, + } + + got, err := ReplayRedfishFromRawPayloads(raw, nil) + if err != nil { + t.Fatalf("replay failed: %v", err) + } + if got.Hardware == nil { + t.Fatalf("expected hardware") + } + if len(got.Hardware.NetworkAdapters) != 0 { + t.Fatalf("expected placeholder network adapters to be skipped, got %d", len(got.Hardware.NetworkAdapters)) + } + if len(got.Hardware.PCIeDevices) != 0 { + t.Fatalf("expected placeholder PCIe devices to be skipped, got %d", len(got.Hardware.PCIeDevices)) + } + if len(got.Hardware.Storage) != 0 { + t.Fatalf("expected placeholder storage members to be skipped, got %d", len(got.Hardware.Storage)) + } +} + +func TestReplayRedfishFromRawPayloads_PrefersActiveBMCInterfaceForBoardMAC(t *testing.T) { + raw := map[string]any{ + "redfish_tree": map[string]interface{}{ + "/redfish/v1": map[string]interface{}{ + "Systems": map[string]interface{}{"@odata.id": "/redfish/v1/Systems"}, + "Chassis": map[string]interface{}{"@odata.id": "/redfish/v1/Chassis"}, + "Managers": map[string]interface{}{"@odata.id": "/redfish/v1/Managers"}, + }, + "/redfish/v1/Systems": map[string]interface{}{ + "Members": []interface{}{map[string]interface{}{"@odata.id": "/redfish/v1/Systems/1"}}, + }, + "/redfish/v1/Systems/1": map[string]interface{}{ + "Manufacturer": "Multillect", + "Model": "MLT-S06", + "SerialNumber": "430044262001626", + }, + "/redfish/v1/Chassis": map[string]interface{}{ + "Members": []interface{}{map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1"}}, + }, + "/redfish/v1/Chassis/1": map[string]interface{}{"Id": "1"}, + "/redfish/v1/Managers": map[string]interface{}{ + "Members": []interface{}{map[string]interface{}{"@odata.id": "/redfish/v1/Managers/1"}}, + }, + "/redfish/v1/Managers/1": map[string]interface{}{ + "Id": "1", + }, + "/redfish/v1/Managers/1/EthernetInterfaces": map[string]interface{}{ + "Members": []interface{}{ + map[string]interface{}{"@odata.id": "/redfish/v1/Managers/1/EthernetInterfaces/eth0"}, + map[string]interface{}{"@odata.id": "/redfish/v1/Managers/1/EthernetInterfaces/eth1"}, + }, + "Oem": map[string]interface{}{ + "Public": map[string]interface{}{ + "NcsiEnabled": true, + "LLDP": map[string]interface{}{ + "LLDPMode": "Rx", + "Members": []interface{}{ + map[string]interface{}{ + "EthIndex": "eth1", + "ChassisName": "castor.netwell.local", + "PortDesc": "ge-0/0/17", + "PortId": "531", + "VlanId": 20, + }, + }, + }, + }, + }, + }, + "/redfish/v1/Managers/1/EthernetInterfaces/eth0": map[string]interface{}{ + "Id": "eth0", + "MACAddress": "00:25:6c:70:00:13", + "LinkStatus": "NoLink", + "SpeedMbps": 65535, + }, + "/redfish/v1/Managers/1/EthernetInterfaces/eth1": map[string]interface{}{ + "Id": "eth1", + "MACAddress": "00:25:6c:70:00:12", + "LinkStatus": "LinkActive", + "SpeedMbps": 1000, + "IPv4Addresses": []interface{}{ + map[string]interface{}{ + "Address": "172.16.41.42", + "Gateway": "172.16.41.1", + "SubnetMask": "255.255.255.0", + }, + }, + }, + }, + } + + got, err := ReplayRedfishFromRawPayloads(raw, nil) + if err != nil { + t.Fatalf("replay failed: %v", err) + } + if got.Hardware == nil { + t.Fatalf("expected hardware") + } + if got.Hardware.BoardInfo.BMCMACAddress != "00:25:6C:70:00:12" { + t.Fatalf("expected active BMC MAC from eth1, got %q", got.Hardware.BoardInfo.BMCMACAddress) + } + summary, ok := got.RawPayloads["redfish_bmc_network_summary"].(map[string]any) + if !ok { + t.Fatalf("expected redfish_bmc_network_summary") + } + if summary["interface_id"] != "eth1" { + t.Fatalf("expected eth1 summary, got %#v", summary["interface_id"]) + } +} + +func TestReplayRedfishFromRawPayloads_AddsSensorsListHintSummary(t *testing.T) { + raw := map[string]any{ + "redfish_tree": map[string]interface{}{ + "/redfish/v1": map[string]interface{}{ + "Systems": map[string]interface{}{"@odata.id": "/redfish/v1/Systems"}, + "Chassis": map[string]interface{}{"@odata.id": "/redfish/v1/Chassis"}, + "Managers": map[string]interface{}{"@odata.id": "/redfish/v1/Managers"}, + }, + "/redfish/v1/Systems": map[string]interface{}{ + "Members": []interface{}{map[string]interface{}{"@odata.id": "/redfish/v1/Systems/1"}}, + }, + "/redfish/v1/Systems/1": map[string]interface{}{ + "Manufacturer": "Multillect", + "Model": "MLT-S06", + "SerialNumber": "430044262001626", + }, + "/redfish/v1/Chassis": map[string]interface{}{ + "Members": []interface{}{map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1"}}, + }, + "/redfish/v1/Chassis/1": map[string]interface{}{"Id": "1"}, + "/redfish/v1/Chassis/1/SensorsList": map[string]interface{}{ + "SensorsList": []interface{}{ + map[string]interface{}{"SensorName": "DIMM000_Status", "SensorType": "Memory", "Status": "OK"}, + map[string]interface{}{"SensorName": "DIMM001_Status", "SensorType": "Memory", "Status": "nop"}, + map[string]interface{}{"SensorName": "DIMM100_Status", "SensorType": "Memory", "Status": "OK"}, + map[string]interface{}{"SensorName": "HDD0_F_Status", "SensorType": "Drive Slot", "Status": "nop"}, + map[string]interface{}{"SensorName": "NVME0_F_Status", "SensorType": "Drive Slot", "Status": "nop"}, + map[string]interface{}{"SensorName": "Logical_Drive", "SensorType": "Drive Slot", "Status": "OK"}, + }, + }, + "/redfish/v1/Managers": map[string]interface{}{ + "Members": []interface{}{map[string]interface{}{"@odata.id": "/redfish/v1/Managers/1"}}, + }, + "/redfish/v1/Managers/1": map[string]interface{}{"Id": "1"}, + }, + } + + got, err := ReplayRedfishFromRawPayloads(raw, nil) + if err != nil { + t.Fatalf("replay failed: %v", err) + } + hints, ok := got.RawPayloads["redfish_sensor_hints"].(map[string]any) + if !ok { + t.Fatalf("expected redfish_sensor_hints") + } + memHints, ok := hints["memory_slots"].(map[string]any) + if !ok { + t.Fatalf("expected memory_slots hint") + } + if asInt(memHints["present_count"]) != 2 { + t.Fatalf("expected 2 present memory slot hints, got %#v", memHints["present_count"]) + } + driveHints, ok := hints["drive_slots"].(map[string]any) + if !ok { + t.Fatalf("expected drive_slots hint") + } + if asInt(driveHints["physical_total"]) != 2 { + t.Fatalf("expected 2 physical drive slots, got %#v", driveHints["physical_total"]) + } + if driveHints["logical_drive_status"] != "OK" { + t.Fatalf("expected logical drive status OK, got %#v", driveHints["logical_drive_status"]) + } + foundMemoryEvent := false + foundDriveEvent := false + for _, ev := range got.Events { + if strings.Contains(ev.Description, "Memory slot sensors report 2 populated positions out of 3") { + foundMemoryEvent = true + } + if strings.Contains(ev.Description, "Drive slot sensors report 0 active physical slots out of 2") { + foundDriveEvent = true + } + } + if !foundMemoryEvent { + t.Fatalf("expected memory slot hint event") + } + if !foundDriveEvent { + t.Fatalf("expected drive slot hint event") + } +} + func TestReplayRedfishFromRawPayloads_PreservesSourceTimezoneAndUTCCollectedAt(t *testing.T) { raw := map[string]any{ "redfish_tree": map[string]interface{}{