diff --git a/bible-local/05-collectors.md b/bible-local/05-collectors.md index 1412b1c..ae87301 100644 --- a/bible-local/05-collectors.md +++ b/bible-local/05-collectors.md @@ -100,6 +100,13 @@ Live Redfish collection must expose profile-match diagnostics: - the collect page should render active modules as chips from structured status data, not by parsing log lines +Profile matching may use stable platform grammar signals in addition to vendor strings: +- discovered member/resource naming from lightweight discovery collections +- firmware inventory member IDs +- OEM action names and linked target paths embedded in discovery documents +- replay-only snapshot hints such as OEM assembly/type markers when they are present in + `raw_payloads.redfish_tree` + On replay, profile-derived analysis directives may enable vendor-specific inventory linking helpers such as processor-GPU fallback, chassis-ID alias resolution, and bounded storage recovery. Replay should now resolve a structured analysis plan inside `redfishprofile/`, analogous to the diff --git a/bible-local/10-decisions.md b/bible-local/10-decisions.md index cc23109..78df2ca 100644 --- a/bible-local/10-decisions.md +++ b/bible-local/10-decisions.md @@ -918,3 +918,34 @@ hardware change. - Hardware event history (last 7 days) visible in Reanimator `EventLogs` section. - No impact on existing inventory pipeline or offline archive replay (archives without `redfish_log_entries` key silently skip parsing). - Adds extra HTTP requests during live collection (sequential, after tree-walk completes). + +--- + +## ADL-036 — Redfish profile matching may use platform grammar hints beyond vendor strings + +**Date:** 2026-03-25 +**Context:** +Some BMCs expose unusable `Manufacturer` / `Model` values (`NULL`, placeholders, or generic SoC +names) while still exposing a stable platform-specific Redfish grammar: repeated member names, +firmware inventory IDs, OEM action names, and target-path quirks. Matching only on vendor +strings forced such systems into fallback mode even when the platform shape was consistent. + +**Decision:** +- Extend `redfishprofile.MatchSignals` with doc-derived hint tokens collected from discovery docs + and replay snapshots. +- Allow profile matchers to score on stable platform grammar such as: + - collection member naming (`outboardPCIeCard*`, drive slot grammars) + - firmware inventory member IDs + - OEM action/type markers and linked target paths +- During live collection, gather only lightweight extra hint collections needed for matching + (`NetworkInterfaces`, `NetworkAdapters`, `Drives`, `UpdateService/FirmwareInventory`), not slow + deep inventory branches. +- Keep such profiles out of fallback aggregation unless they are proven safe as broad additive + hints. + +**Consequences:** +- Platform-family profiles can activate even when vendor strings are absent or set to `NULL`. +- Matching logic becomes more robust for OEM BMC implementations that differ mainly by Redfish + grammar rather than by explicit vendor strings. +- Live collection gains a small amount of extra discovery I/O to harvest stable member IDs, but + avoids slow deep probes such as `Assembly` just for profile selection. diff --git a/internal/collector/redfish.go b/internal/collector/redfish.go index 5713576..3e1df47 100644 --- a/internal/collector/redfish.go +++ b/internal/collector/redfish.go @@ -147,6 +147,7 @@ func (c *RedfishConnector) Collect(ctx context.Context, req Request, emit Progre snapshotClient := c.httpClientWithTimeout(req, redfishSnapshotRequestTimeout()) prefetchClient := c.httpClientWithTimeout(req, redfishPrefetchRequestTimeout()) criticalClient := c.httpClientWithTimeout(req, redfishCriticalRequestTimeout()) + hintClient := c.httpClientWithTimeout(req, 4*time.Second) if emit != nil { emit(Progress{Status: "running", Progress: 10, Message: "Redfish: подключение к BMC..."}) @@ -178,7 +179,8 @@ func (c *RedfishConnector) Collect(ctx context.Context, req Request, emit Progre chassisDoc, _ := c.getJSON(discoveryCtx, snapshotClient, req, baseURL, primaryChassis) managerDoc, _ := c.getJSON(discoveryCtx, snapshotClient, req, baseURL, primaryManager) resourceHints := append(append([]string{}, systemPaths...), append(chassisPaths, managerPaths...)...) - signals := redfishprofile.CollectSignals(serviceRootDoc, systemDoc, chassisDoc, managerDoc, resourceHints) + hintDocs := c.collectProfileHintDocs(discoveryCtx, hintClient, req, baseURL, primarySystem, primaryChassis) + signals := redfishprofile.CollectSignals(serviceRootDoc, systemDoc, chassisDoc, managerDoc, resourceHints, hintDocs...) matchResult := redfishprofile.MatchProfiles(signals) acquisitionPlan := redfishprofile.BuildAcquisitionPlan(signals) telemetrySummary := telemetry.Snapshot() @@ -1557,6 +1559,33 @@ func (c *RedfishConnector) discoverMemberPaths(ctx context.Context, client *http return nil } +func (c *RedfishConnector) collectProfileHintDocs(ctx context.Context, client *http.Client, req Request, baseURL, systemPath, chassisPath string) []map[string]interface{} { + paths := []string{ + "/redfish/v1/UpdateService/FirmwareInventory", + joinPath(systemPath, "/NetworkInterfaces"), + joinPath(chassisPath, "/Drives"), + joinPath(chassisPath, "/NetworkAdapters"), + } + seen := make(map[string]struct{}, len(paths)) + docs := make([]map[string]interface{}, 0, len(paths)) + for _, path := range paths { + path = normalizeRedfishPath(path) + if path == "" { + continue + } + if _, ok := seen[path]; ok { + continue + } + seen[path] = struct{}{} + doc, err := c.getJSON(ctx, client, req, baseURL, path) + if err != nil { + continue + } + docs = append(docs, doc) + } + return docs +} + func (c *RedfishConnector) collectRawRedfishTree(ctx context.Context, client *http.Client, req Request, baseURL string, seedPaths []string, tuning redfishprofile.AcquisitionTuning, emit ProgressFn) (map[string]interface{}, []map[string]interface{}, redfishPostProbeMetrics, string) { maxDocuments := redfishSnapshotMaxDocuments(tuning) workers := redfishSnapshotWorkers(tuning) @@ -3476,14 +3505,20 @@ func parseCPUs(docs []map[string]interface{}) []models.CPU { } } l1, l2, l3 := parseCPUCachesFromProcessorMemory(doc) + publicSerial := redfishCPUPublicSerial(doc) + serial := normalizeRedfishIdentityField(asString(doc["SerialNumber"])) + if serial == "" && publicSerial == "" { + serial = findFirstNormalizedStringByKeys(doc, "SerialNumber") + } cpus = append(cpus, models.CPU{ Socket: socket, 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: findFirstNormalizedStringByKeys(doc, "SerialNumber"), + FrequencyMHz: int(redfishFirstNumeric(doc, "OperatingSpeedMHz", "CurrentSpeedMHz", "FrequencyMHz")), + MaxFreqMHz: int(redfishFirstNumeric(doc, "MaxSpeedMHz", "TurboEnableMaxSpeedMHz", "TurboDisableMaxSpeedMHz")), + PPIN: firstNonEmpty(findFirstNormalizedStringByKeys(doc, "PPIN", "ProtectedIdentificationNumber"), publicSerial), + SerialNumber: serial, L1CacheKB: l1, L2CacheKB: l2, L3CacheKB: l3, @@ -3494,6 +3529,12 @@ func parseCPUs(docs []map[string]interface{}) []models.CPU { return cpus } +func redfishCPUPublicSerial(doc map[string]interface{}) string { + oem, _ := doc["Oem"].(map[string]interface{}) + public, _ := oem["Public"].(map[string]interface{}) + return normalizeRedfishIdentityField(asString(public["SerialNumber"])) +} + // parseCPUCachesFromProcessorMemory reads L1/L2/L3 cache sizes from the // Redfish ProcessorMemory array (Processor.v1_x spec). func parseCPUCachesFromProcessorMemory(doc map[string]interface{}) (l1, l2, l3 int) { @@ -3942,7 +3983,7 @@ func parsePSUWithSupplementalDocs(doc map[string]interface{}, idx int, supplemen Present: present, Model: firstNonEmpty(asString(doc["Model"]), asString(doc["Name"])), Vendor: asString(doc["Manufacturer"]), - WattageW: asInt(doc["PowerCapacityWatts"]), + WattageW: redfishPSUNominalWattage(doc), SerialNumber: findFirstNormalizedStringByKeys(doc, "SerialNumber"), PartNumber: asString(doc["PartNumber"]), Firmware: asString(doc["FirmwareVersion"]), @@ -3955,6 +3996,25 @@ func parsePSUWithSupplementalDocs(doc map[string]interface{}, idx int, supplemen } } +func redfishPSUNominalWattage(doc map[string]interface{}) int { + if ranges, ok := doc["InputRanges"].([]interface{}); ok { + best := 0 + for _, rawRange := range ranges { + rangeDoc, ok := rawRange.(map[string]interface{}) + if !ok { + continue + } + if wattage := asInt(rangeDoc["OutputWattage"]); wattage > best { + best = wattage + } + } + if best > 0 { + return best + } + } + return asInt(doc["PowerCapacityWatts"]) +} + func redfishDriveDetails(doc map[string]interface{}) map[string]any { return redfishDriveDetailsWithSupplementalDocs(doc) } @@ -5781,7 +5841,6 @@ func parseFirmware(system, bios, manager, networkProtocol map[string]interface{} return out } - func mapStatus(statusAny interface{}) string { if statusAny == nil { return "" diff --git a/internal/collector/redfish_replay.go b/internal/collector/redfish_replay.go index 0934ed9..a1a9f59 100644 --- a/internal/collector/redfish_replay.go +++ b/internal/collector/redfish_replay.go @@ -31,8 +31,7 @@ func ReplayRedfishFromRawPayloads(rawPayloads map[string]any, emit ProgressFn) ( if emit != nil { emit(Progress{Status: "running", Progress: 10, Message: "Redfish snapshot: replay service root..."}) } - serviceRootDoc, err := r.getJSON("/redfish/v1") - if err != nil { + if _, err := r.getJSON("/redfish/v1"); err != nil { log.Printf("redfish replay: service root /redfish/v1 missing from snapshot, continuing with defaults: %v", err) } @@ -61,8 +60,7 @@ func ReplayRedfishFromRawPayloads(rawPayloads map[string]any, emit ProgressFn) ( fruDoc = chassisFRUDoc } boardFallbackDocs := r.collectBoardFallbackDocs(systemPaths, chassisPaths) - resourceHints := append(append([]string{}, systemPaths...), append(chassisPaths, managerPaths...)...) - profileSignals := redfishprofile.CollectSignals(serviceRootDoc, systemDoc, chassisDoc, managerDoc, resourceHints) + profileSignals := redfishprofile.CollectSignalsFromTree(tree) profileMatch := redfishprofile.MatchProfiles(profileSignals) analysisPlan := redfishprofile.ResolveAnalysisPlan(profileMatch, tree, redfishprofile.DiscoveredResources{ SystemPaths: systemPaths, @@ -107,11 +105,11 @@ func ReplayRedfishFromRawPayloads(rawPayloads map[string]any, emit ProgressFn) ( 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...), - FRU: assemblyFRU, - Sensors: dedupeSensorReadings(append(append(thresholdSensors, thermalSensors...), powerSensors...)), - RawPayloads: cloneRawPayloads(rawPayloads), + SourceTimezone: sourceTimezone, + Events: append(append(append(append(make([]models.Event, 0, len(discreteEvents)+len(healthEvents)+len(driveFetchWarningEvents)+len(logEntryEvents)+1), healthEvents...), discreteEvents...), driveFetchWarningEvents...), logEntryEvents...), + FRU: assemblyFRU, + Sensors: dedupeSensorReadings(append(append(thresholdSensors, thermalSensors...), powerSensors...)), + RawPayloads: cloneRawPayloads(rawPayloads), Hardware: &models.HardwareConfig{ BoardInfo: boardInfo, CPUs: processors, @@ -123,7 +121,7 @@ func ReplayRedfishFromRawPayloads(rawPayloads map[string]any, emit ProgressFn) ( PowerSupply: psus, NetworkAdapters: nics, Firmware: firmware, - }, + }, } match := profileMatch for _, profile := range match.Profiles { @@ -277,7 +275,6 @@ func redfishFetchErrorsFromRawPayloads(rawPayloads map[string]any) map[string]st } } - func buildDriveFetchWarningEvents(rawPayloads map[string]any) []models.Event { errs := redfishFetchErrorsFromRawPayloads(rawPayloads) if len(errs) == 0 { diff --git a/internal/collector/redfish_test.go b/internal/collector/redfish_test.go index 84c01b2..fd8867b 100644 --- a/internal/collector/redfish_test.go +++ b/internal/collector/redfish_test.go @@ -938,7 +938,9 @@ func TestParseComponents_UseNestedSerialNumberFallback(t *testing.T) { "Manufacturer": "vendor0", "SerialNumber": "N/A", "Oem": map[string]interface{}{ - "SerialNumber": "SN-OK-001", + "VendorX": map[string]interface{}{ + "SerialNumber": "SN-OK-001", + }, }, } @@ -978,6 +980,38 @@ func TestParseComponents_UseNestedSerialNumberFallback(t *testing.T) { } } +func TestParseCPU_UsesPublicSerialAsPPINAndCurrentSpeedMHz(t *testing.T) { + cpus := parseCPUs([]map[string]interface{}{ + { + "Id": "CPU0", + "Model": "Intel Xeon", + "TotalCores": 48, + "TotalThreads": 96, + "MaxSpeedMHz": 4000, + "OperatingSpeedMHz": 0, + "Oem": map[string]interface{}{ + "Public": map[string]interface{}{ + "SerialNumber": "6FB5241E81CECDFD", + "CurrentSpeedMHz": 2700, + }, + }, + }, + }) + + if len(cpus) != 1 { + t.Fatalf("expected one CPU, got %d", len(cpus)) + } + if cpus[0].PPIN != "6FB5241E81CECDFD" { + t.Fatalf("expected PPIN from Oem.Public.SerialNumber, got %+v", cpus[0]) + } + if cpus[0].SerialNumber != "" { + t.Fatalf("expected empty CPU serial number when only Public serial exists, got %+v", cpus[0]) + } + if cpus[0].FrequencyMHz != 2700 { + t.Fatalf("expected CPU frequency from Oem.Public.CurrentSpeedMHz, got %+v", cpus[0]) + } +} + func TestParseCPUAndMemory_CollectOemDetails(t *testing.T) { cpus := parseCPUs([]map[string]interface{}{ { @@ -1687,7 +1721,7 @@ func TestReplayCollectStorage_UsesKnownControllerRecoveryWhenEnabled(t *testing. }} got := r.collectStorage("/redfish/v1/Systems/1", redfishprofile.ResolvedAnalysisPlan{ - Directives: redfishprofile.AnalysisDirectives{EnableKnownStorageControllerRecovery: true}, + Directives: redfishprofile.AnalysisDirectives{EnableKnownStorageControllerRecovery: true}, KnownStorageDriveCollections: []string{"/Storage/IntelVROC/Drives"}, }) if len(got) != 1 { @@ -2357,6 +2391,20 @@ func TestAppendPSU_MergesRicherDuplicate(t *testing.T) { } } +func TestRedfishPSUNominalWattage_PrefersInputRangeOutputWattage(t *testing.T) { + doc := map[string]interface{}{ + "PowerCapacityWatts": 22600, + "InputRanges": []interface{}{ + map[string]interface{}{"OutputWattage": 2700}, + map[string]interface{}{"OutputWattage": 3200}, + }, + } + + if got := redfishPSUNominalWattage(doc); got != 3200 { + t.Fatalf("redfishPSUNominalWattage() = %d, want 3200", got) + } +} + func TestReplayCollectGPUs_DropsModelOnlyPlaceholderWhenConcreteDiscoveredLater(t *testing.T) { r := redfishSnapshotReader{tree: map[string]interface{}{ "/redfish/v1/Systems/1/GraphicsControllers": map[string]interface{}{ @@ -2677,7 +2725,7 @@ func TestCollectGPUsFromProcessors_SupermicroHGXUsesChassisAliasSerial(t *testin gpus := r.collectGPUs(systemPaths, chassisPaths, testAnalysisPlan(redfishprofile.AnalysisDirectives{EnableGenericGraphicsControllerDedup: true})) gpus = r.collectGPUsFromProcessors(systemPaths, chassisPaths, gpus, redfishprofile.ResolvedAnalysisPlan{ - Directives: redfishprofile.AnalysisDirectives{EnableProcessorGPUFallback: true, EnableProcessorGPUChassisAlias: true}, + Directives: redfishprofile.AnalysisDirectives{EnableProcessorGPUFallback: true, EnableProcessorGPUChassisAlias: true}, ProcessorGPUChassisLookupModes: []string{"hgx-alias"}, }) @@ -2715,7 +2763,7 @@ func TestCollectGPUsFromProcessors_MSIUsesIndexedChassisLookup(t *testing.T) { []string{"/redfish/v1/Chassis/GPU1"}, nil, redfishprofile.ResolvedAnalysisPlan{ - Directives: redfishprofile.AnalysisDirectives{EnableProcessorGPUFallback: true, EnableMSIProcessorGPUChassisLookup: true}, + Directives: redfishprofile.AnalysisDirectives{EnableProcessorGPUFallback: true, EnableMSIProcessorGPUChassisLookup: true}, ProcessorGPUChassisLookupModes: []string{"msi-index"}, }, ) diff --git a/internal/collector/redfishprofile/profile_inspur_group_oem_platforms.go b/internal/collector/redfishprofile/profile_inspur_group_oem_platforms.go new file mode 100644 index 0000000..9cee549 --- /dev/null +++ b/internal/collector/redfishprofile/profile_inspur_group_oem_platforms.go @@ -0,0 +1,149 @@ +package redfishprofile + +import ( + "regexp" + "strings" +) + +var ( + outboardCardHintRe = regexp.MustCompile(`/outboardPCIeCard\d+(?:/|$)`) + obDriveHintRe = regexp.MustCompile(`/Drives/OB\d+$`) + fpDriveHintRe = regexp.MustCompile(`/Drives/FP00HDD\d+$`) + vrFirmwareHintRe = regexp.MustCompile(`^CPU\d+_PVCC.*_VR$`) +) + +var inspurGroupOEMFirmwareHints = map[string]struct{}{ + "Front_HDD_CPLD0": {}, + "MainBoard0CPLD": {}, + "MainBoardCPLD": {}, + "PDBBoardCPLD": {}, + "SCMCPLD": {}, + "SWBoardCPLD": {}, +} + +func inspurGroupOEMPlatformsProfile() Profile { + return staticProfile{ + name: "inspur-group-oem-platforms", + priority: 25, + safeForFallback: false, + matchFn: func(s MatchSignals) int { + topologyScore := 0 + boardScore := 0 + chassisOutboard := matchedPathTokens(s.ResourceHints, "/redfish/v1/Chassis/", outboardCardHintRe) + systemOutboard := matchedPathTokens(s.ResourceHints, "/redfish/v1/Systems/", outboardCardHintRe) + obDrives := matchedPathTokens(s.ResourceHints, "", obDriveHintRe) + fpDrives := matchedPathTokens(s.ResourceHints, "", fpDriveHintRe) + firmwareNames, vrFirmwareNames := inspurGroupOEMFirmwareMatches(s.ResourceHints) + + if len(chassisOutboard) > 0 { + topologyScore += 20 + } + if len(systemOutboard) > 0 { + topologyScore += 10 + } + switch { + case len(obDrives) > 0 && len(fpDrives) > 0: + topologyScore += 15 + } + switch { + case len(firmwareNames) >= 2: + boardScore += 15 + } + switch { + case len(vrFirmwareNames) >= 2: + boardScore += 10 + } + if anySignalContains(s, "COMMONbAssembly") { + boardScore += 12 + } + if anySignalContains(s, "EnvironmentMetrcs") { + boardScore += 8 + } + if anySignalContains(s, "GetServerAllUSBStatus") { + boardScore += 8 + } + if topologyScore == 0 || boardScore == 0 { + return 0 + } + return min(topologyScore+boardScore, 100) + }, + extendAcquisition: func(plan *AcquisitionPlan, _ MatchSignals) { + addPlanNote(plan, "Inspur Group OEM platform fingerprint matched") + }, + applyAnalysisDirectives: func(d *AnalysisDirectives, _ MatchSignals) { + d.EnableGenericGraphicsControllerDedup = true + }, + } +} + +func matchedPathTokens(paths []string, requiredPrefix string, re *regexp.Regexp) []string { + seen := make(map[string]struct{}) + for _, rawPath := range paths { + path := normalizePath(rawPath) + if path == "" || (requiredPrefix != "" && !strings.HasPrefix(path, requiredPrefix)) { + continue + } + token := re.FindString(path) + if token == "" { + continue + } + token = strings.Trim(token, "/") + if token == "" { + continue + } + seen[token] = struct{}{} + } + out := make([]string, 0, len(seen)) + for token := range seen { + out = append(out, token) + } + return dedupeSorted(out) +} + +func inspurGroupOEMFirmwareMatches(paths []string) ([]string, []string) { + firmwareNames := make(map[string]struct{}) + vrNames := make(map[string]struct{}) + for _, rawPath := range paths { + path := normalizePath(rawPath) + if !strings.HasPrefix(path, "/redfish/v1/UpdateService/FirmwareInventory/") { + continue + } + name := strings.TrimSpace(path[strings.LastIndex(path, "/")+1:]) + if name == "" { + continue + } + if _, ok := inspurGroupOEMFirmwareHints[name]; ok { + firmwareNames[name] = struct{}{} + } + if vrFirmwareHintRe.MatchString(name) { + vrNames[name] = struct{}{} + } + } + return mapKeysSorted(firmwareNames), mapKeysSorted(vrNames) +} + +func anySignalContains(signals MatchSignals, needle string) bool { + needle = strings.TrimSpace(needle) + if needle == "" { + return false + } + for _, signal := range signals.ResourceHints { + if strings.Contains(signal, needle) { + return true + } + } + for _, signal := range signals.DocHints { + if strings.Contains(signal, needle) { + return true + } + } + return false +} + +func mapKeysSorted(items map[string]struct{}) []string { + out := make([]string, 0, len(items)) + for item := range items { + out = append(out, item) + } + return dedupeSorted(out) +} diff --git a/internal/collector/redfishprofile/profile_inspur_group_oem_platforms_test.go b/internal/collector/redfishprofile/profile_inspur_group_oem_platforms_test.go new file mode 100644 index 0000000..156a4ef --- /dev/null +++ b/internal/collector/redfishprofile/profile_inspur_group_oem_platforms_test.go @@ -0,0 +1,182 @@ +package redfishprofile + +import ( + "archive/zip" + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestCollectSignalsFromTree_InspurGroupOEMPlatformsSelectsMatchedMode(t *testing.T) { + tree := map[string]interface{}{ + "/redfish/v1": map[string]interface{}{ + "@odata.id": "/redfish/v1", + }, + "/redfish/v1/Systems": map[string]interface{}{ + "Members": []interface{}{ + map[string]interface{}{"@odata.id": "/redfish/v1/Systems/1"}, + }, + }, + "/redfish/v1/Systems/1": map[string]interface{}{ + "@odata.id": "/redfish/v1/Systems/1", + "Oem": map[string]interface{}{ + "Public": map[string]interface{}{ + "USB": map[string]interface{}{ + "@odata.id": "/redfish/v1/Systems/1/Oem/Public/GetServerAllUSBStatus", + }, + }, + }, + "NetworkInterfaces": map[string]interface{}{ + "@odata.id": "/redfish/v1/Systems/1/NetworkInterfaces", + }, + }, + "/redfish/v1/Systems/1/NetworkInterfaces": map[string]interface{}{ + "Members": []interface{}{ + map[string]interface{}{"@odata.id": "/redfish/v1/Systems/1/NetworkInterfaces/outboardPCIeCard0"}, + map[string]interface{}{"@odata.id": "/redfish/v1/Systems/1/NetworkInterfaces/outboardPCIeCard1"}, + }, + }, + "/redfish/v1/Chassis": map[string]interface{}{ + "Members": []interface{}{ + map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1"}, + }, + }, + "/redfish/v1/Chassis/1": map[string]interface{}{ + "@odata.id": "/redfish/v1/Chassis/1", + "Actions": map[string]interface{}{ + "Oem": map[string]interface{}{ + "Public": map[string]interface{}{ + "NvGpuPowerLimitWatts": map[string]interface{}{ + "target": "/redfish/v1/Chassis/1/GPU/EnvironmentMetrcs", + }, + }, + }, + }, + "Drives": map[string]interface{}{ + "@odata.id": "/redfish/v1/Chassis/1/Drives", + }, + "NetworkAdapters": map[string]interface{}{ + "@odata.id": "/redfish/v1/Chassis/1/NetworkAdapters", + }, + }, + "/redfish/v1/Chassis/1/Drives": map[string]interface{}{ + "Members": []interface{}{ + map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/Drives/OB01"}, + map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/Drives/FP00HDD00"}, + }, + }, + "/redfish/v1/Chassis/1/NetworkAdapters": map[string]interface{}{ + "Members": []interface{}{ + map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/NetworkAdapters/outboardPCIeCard0"}, + map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/NetworkAdapters/outboardPCIeCard1"}, + }, + }, + "/redfish/v1/Chassis/1/Assembly": map[string]interface{}{ + "Assemblies": []interface{}{ + map[string]interface{}{ + "Oem": map[string]interface{}{ + "COMMONb": map[string]interface{}{ + "COMMONbAssembly": map[string]interface{}{ + "@odata.type": "#COMMONbAssembly.v1_0_0.COMMONbAssembly", + }, + }, + }, + }, + }, + }, + "/redfish/v1/Managers": map[string]interface{}{ + "Members": []interface{}{ + map[string]interface{}{"@odata.id": "/redfish/v1/Managers/1"}, + }, + }, + "/redfish/v1/Managers/1": map[string]interface{}{ + "Actions": map[string]interface{}{ + "Oem": map[string]interface{}{ + "#PublicManager.ExportConfFile": map[string]interface{}{ + "target": "/redfish/v1/Managers/1/Actions/Oem/Public/ExportConfFile", + }, + }, + }, + }, + "/redfish/v1/UpdateService/FirmwareInventory": map[string]interface{}{ + "Members": []interface{}{ + map[string]interface{}{"@odata.id": "/redfish/v1/UpdateService/FirmwareInventory/Front_HDD_CPLD0"}, + map[string]interface{}{"@odata.id": "/redfish/v1/UpdateService/FirmwareInventory/SCMCPLD"}, + map[string]interface{}{"@odata.id": "/redfish/v1/UpdateService/FirmwareInventory/CPU0_PVCCD_HV_VR"}, + map[string]interface{}{"@odata.id": "/redfish/v1/UpdateService/FirmwareInventory/CPU1_PVCCIN_VR"}, + }, + }, + } + + signals := CollectSignalsFromTree(tree) + match := MatchProfiles(signals) + + if match.Mode != ModeMatched { + t.Fatalf("expected matched mode, got %q", match.Mode) + } + assertProfileSelected(t, match, "inspur-group-oem-platforms") +} + +func TestCollectSignalsFromTree_InspurGroupOEMPlatformsDoesNotFalsePositiveOnExampleRawExports(t *testing.T) { + examples := []string{ + "2026-03-18 (G5500 V7) - 210619KUGGXGS2000015.zip", + "2026-03-11 (SYS-821GE-TNHR) - A514359X5C08846.zip", + "2026-03-15 (CG480-S5063) - P5T0006091.zip", + "2026-03-18 (CG290-S3063) - PAT0011258.zip", + "2024-04-25 (AS -4124GQ-TNMI) - S490387X4418273.zip", + } + for _, name := range examples { + t.Run(name, func(t *testing.T) { + tree := loadRawExportTreeFromExampleZip(t, name) + match := MatchProfiles(CollectSignalsFromTree(tree)) + assertProfileNotSelected(t, match, "inspur-group-oem-platforms") + }) + } +} + +func loadRawExportTreeFromExampleZip(t *testing.T, name string) map[string]interface{} { + t.Helper() + path := filepath.Join("..", "..", "..", "example", name) + f, err := os.Open(path) + if err != nil { + t.Fatalf("open example zip %s: %v", path, err) + } + defer f.Close() + + info, err := f.Stat() + if err != nil { + t.Fatalf("stat example zip %s: %v", path, err) + } + + zr, err := zip.NewReader(f, info.Size()) + if err != nil { + t.Fatalf("read example zip %s: %v", path, err) + } + for _, file := range zr.File { + if file.Name != "raw_export.json" { + continue + } + rc, err := file.Open() + if err != nil { + t.Fatalf("open %s in %s: %v", file.Name, path, err) + } + defer rc.Close() + var payload struct { + Source struct { + RawPayloads struct { + RedfishTree map[string]interface{} `json:"redfish_tree"` + } `json:"raw_payloads"` + } `json:"source"` + } + if err := json.NewDecoder(rc).Decode(&payload); err != nil { + t.Fatalf("decode raw_export.json from %s: %v", path, err) + } + if len(payload.Source.RawPayloads.RedfishTree) == 0 { + t.Fatalf("example %s has empty redfish_tree", path) + } + return payload.Source.RawPayloads.RedfishTree + } + t.Fatalf("raw_export.json not found in %s", path) + return nil +} diff --git a/internal/collector/redfishprofile/profiles_common.go b/internal/collector/redfishprofile/profiles_common.go index b66bb89..55f2e7b 100644 --- a/internal/collector/redfishprofile/profiles_common.go +++ b/internal/collector/redfishprofile/profiles_common.go @@ -55,6 +55,7 @@ func BuiltinProfiles() []Profile { msiProfile(), supermicroProfile(), dellProfile(), + inspurGroupOEMPlatformsProfile(), hgxProfile(), xfusionProfile(), } diff --git a/internal/collector/redfishprofile/signals.go b/internal/collector/redfishprofile/signals.go index 8f1a6b4..9ea9eae 100644 --- a/internal/collector/redfishprofile/signals.go +++ b/internal/collector/redfishprofile/signals.go @@ -2,7 +2,14 @@ package redfishprofile import "strings" -func CollectSignals(serviceRootDoc, systemDoc, chassisDoc, managerDoc map[string]interface{}, resourceHints []string) MatchSignals { +func CollectSignals(serviceRootDoc, systemDoc, chassisDoc, managerDoc map[string]interface{}, resourceHints []string, hintDocs ...map[string]interface{}) MatchSignals { + resourceHints = append([]string{}, resourceHints...) + docHints := make([]string, 0) + for _, doc := range append([]map[string]interface{}{serviceRootDoc, systemDoc, chassisDoc, managerDoc}, hintDocs...) { + embeddedPaths, embeddedHints := collectDocSignalHints(doc) + resourceHints = append(resourceHints, embeddedPaths...) + docHints = append(docHints, embeddedHints...) + } signals := MatchSignals{ ServiceRootVendor: lookupString(serviceRootDoc, "Vendor"), ServiceRootProduct: lookupString(serviceRootDoc, "Product"), @@ -13,6 +20,7 @@ func CollectSignals(serviceRootDoc, systemDoc, chassisDoc, managerDoc map[string ChassisModel: lookupString(chassisDoc, "Model"), ManagerManufacturer: lookupString(managerDoc, "Manufacturer"), ResourceHints: resourceHints, + DocHints: docHints, } signals.OEMNamespaces = dedupeSorted(append( oemNamespaces(serviceRootDoc), @@ -50,6 +58,7 @@ func CollectSignalsFromTree(tree map[string]interface{}) MatchSignals { managerPath := memberPath("/redfish/v1/Managers", "/redfish/v1/Managers/1") resourceHints := make([]string, 0, len(tree)) + hintDocs := make([]map[string]interface{}, 0, len(tree)) for path := range tree { path = strings.TrimSpace(path) if path == "" { @@ -57,6 +66,13 @@ func CollectSignalsFromTree(tree map[string]interface{}) MatchSignals { } resourceHints = append(resourceHints, path) } + for _, v := range tree { + doc, ok := v.(map[string]interface{}) + if !ok { + continue + } + hintDocs = append(hintDocs, doc) + } return CollectSignals( getDoc("/redfish/v1"), @@ -64,9 +80,72 @@ func CollectSignalsFromTree(tree map[string]interface{}) MatchSignals { getDoc(chassisPath), getDoc(managerPath), resourceHints, + hintDocs..., ) } +func collectDocSignalHints(doc map[string]interface{}) ([]string, []string) { + if len(doc) == 0 { + return nil, nil + } + paths := make([]string, 0) + hints := make([]string, 0) + var walk func(any) + walk = func(v any) { + switch x := v.(type) { + case map[string]interface{}: + for rawKey, child := range x { + key := strings.TrimSpace(rawKey) + if key != "" { + hints = append(hints, key) + } + if s, ok := child.(string); ok { + s = strings.TrimSpace(s) + if s != "" { + switch key { + case "@odata.id", "target": + paths = append(paths, s) + case "@odata.type": + hints = append(hints, s) + default: + if isInterestingSignalString(s) { + hints = append(hints, s) + if strings.HasPrefix(s, "/") { + paths = append(paths, s) + } + } + } + } + } + walk(child) + } + case []interface{}: + for _, child := range x { + walk(child) + } + } + } + walk(doc) + return paths, hints +} + +func isInterestingSignalString(s string) bool { + switch { + case strings.HasPrefix(s, "/"): + return true + case strings.HasPrefix(s, "#"): + return true + case strings.Contains(s, "COMMONb"): + return true + case strings.Contains(s, "EnvironmentMetrcs"): + return true + case strings.Contains(s, "GetServerAllUSBStatus"): + return true + default: + return false + } +} + func lookupString(doc map[string]interface{}, key string) string { if len(doc) == 0 { return "" diff --git a/internal/collector/redfishprofile/types.go b/internal/collector/redfishprofile/types.go index e83729e..1a538db 100644 --- a/internal/collector/redfishprofile/types.go +++ b/internal/collector/redfishprofile/types.go @@ -17,6 +17,7 @@ type MatchSignals struct { ManagerManufacturer string OEMNamespaces []string ResourceHints []string + DocHints []string } type AcquisitionPlan struct { @@ -110,12 +111,12 @@ type AnalysisDirectives struct { } type ResolvedAnalysisPlan struct { - Match MatchResult - Directives AnalysisDirectives - Notes []string - ProcessorGPUChassisLookupModes []string - KnownStorageDriveCollections []string - KnownStorageVolumeCollections []string + Match MatchResult + Directives AnalysisDirectives + Notes []string + ProcessorGPUChassisLookupModes []string + KnownStorageDriveCollections []string + KnownStorageVolumeCollections []string } type Profile interface { @@ -146,6 +147,7 @@ type ProfileScore struct { func normalizeSignals(signals MatchSignals) MatchSignals { signals.OEMNamespaces = dedupeSorted(signals.OEMNamespaces) signals.ResourceHints = dedupeSorted(signals.ResourceHints) + signals.DocHints = dedupeSorted(signals.DocHints) return signals }