diff --git a/bible-local/10-decisions.md b/bible-local/10-decisions.md index 68d6f26..e559c98 100644 --- a/bible-local/10-decisions.md +++ b/bible-local/10-decisions.md @@ -1045,3 +1045,52 @@ logical volumes. - HPE PCIe inventory gets better slot labels like `OCP 3.0 Slot 15` plus concrete classes such as `LOM/NIC` or `SAS/SATA Storage Controller`. - `part_number` remains available separately for model identity, without polluting the class field. + +--- + +## ADL-041 — Redfish replay drops topology-only PCIe noise classes from canonical inventory + +**Date:** 2026-04-01 +**Context:** Some Redfish BMCs, especially MSI/AMI GPU systems, expose a very wide PCIe topology +tree under `Chassis/*/PCIeDevices/*`. Besides real endpoint devices, the replay sees bridge stages, +CPU-side helper functions, IMC/mesh signal-processing nodes, USB/SPI side controllers, and GPU +display-function duplicates reported as generic `Display Device`. Keeping all of them in +`hardware.pcie_devices` pollutes downstream exports such as Reanimator and hides the actual +endpoint inventory signal. + +**Decision:** +- Filter topology-only PCIe records during Redfish replay, not in the UI layer. +- Drop PCIe entries with replay-resolved classes: + - `Bridge` + - `Processor` + - `SignalProcessingController` + - `SerialBusController` +- Drop `DisplayController` entries when the source Redfish PCIe document is the generic MSI-style + `Description: "Display Device"` duplicate. +- Drop PCIe network endpoints when their PCIe functions already link to `NetworkDeviceFunctions`, + because those devices are represented canonically in `hardware.network_adapters`. +- When `Systems/*/NetworkInterfaces/*` links back to a chassis `NetworkAdapter`, match against the + fully enriched chassis NIC identity to avoid creating a second ghost NIC row with the raw + `NetworkAdapter_*` slot/name. +- Treat generic Redfish object names such as `NetworkAdapter_*` and `PCIeDevice_*` as placeholder + models and replace them from PCI IDs when a concrete vendor/device match exists. +- Drop MSI-style storage service PCIe endpoints whose resolved device names are only + `Volume Management Device NVMe RAID Controller` or `PCIe Switch management endpoint`; storage + inventory already comes from the Redfish storage tree. +- Normalize Ethernet-class NICs into the single exported class `NetworkController`; do not split + `EthernetController` into a separate top-level inventory section. +- Keep endpoint classes such as `NetworkController`, `MassStorageController`, and dedicated GPU + inventory coming from `hardware.gpus`. + +**Consequences:** +- `hardware.pcie_devices` becomes closer to real endpoint inventory instead of raw PCIe topology. +- Reanimator exports stop showing MSI bridge/processor/display duplicate noise. +- Reanimator exports no longer duplicate the same MSI NIC as both `PCIeDevice_*` and + `NetworkAdapter_*`. +- Replay no longer creates extra NIC rows from `Systems/NetworkInterfaces` when the same adapter + was already normalized from `Chassis/NetworkAdapters`. +- MSI VMD / PCIe switch storage service endpoints no longer pollute PCIe inventory. +- UI/Reanimator group all Ethernet NICs under the same `NETWORKCONTROLLER` section. +- Canonical NIC inventory prefers resolved PCI product names over generic Redfish placeholder names. +- The raw Redfish snapshot still remains available in `raw_payloads.redfish_tree` for low-level + troubleshooting if topology details are ever needed. diff --git a/internal/collector/redfish.go b/internal/collector/redfish.go index 7747be3..df75046 100644 --- a/internal/collector/redfish.go +++ b/internal/collector/redfish.go @@ -4793,6 +4793,9 @@ func isMissingOrRawPCIModel(model string) bool { if l == "unknown" || l == "n/a" || l == "na" || l == "none" { return true } + if isGenericRedfishInventoryName(l) { + return true + } if strings.HasPrefix(l, "0x") && len(l) <= 6 { return true } @@ -4811,6 +4814,26 @@ func isMissingOrRawPCIModel(model string) bool { return false } +func isGenericRedfishInventoryName(value string) bool { + value = strings.ToLower(strings.TrimSpace(value)) + switch { + case value == "": + return false + case value == "networkadapter", strings.HasPrefix(value, "networkadapter_"), strings.HasPrefix(value, "networkadapter "): + return true + case value == "pciedevice", strings.HasPrefix(value, "pciedevice_"), strings.HasPrefix(value, "pciedevice "): + return true + case value == "pciefunction", strings.HasPrefix(value, "pciefunction_"), strings.HasPrefix(value, "pciefunction "): + return true + case value == "ethernetinterface", strings.HasPrefix(value, "ethernetinterface_"), strings.HasPrefix(value, "ethernetinterface "): + return true + case value == "networkport", strings.HasPrefix(value, "networkport_"), strings.HasPrefix(value, "networkport "): + return true + default: + return false + } +} + // isUnidentifiablePCIeDevice returns true for PCIe topology entries that carry no // useful inventory information: generic class (SingleFunction/MultiFunction), no // resolved model or serial, and no PCI vendor/device IDs for future resolution. @@ -5650,6 +5673,9 @@ func normalizeNetworkAdapterModel(nic models.NetworkAdapter) string { if model == "" { return "" } + if isMissingOrRawPCIModel(model) { + return "" + } slot := strings.TrimSpace(nic.Slot) if slot != "" && strings.EqualFold(slot, model) { return "" diff --git a/internal/collector/redfish_replay_inventory.go b/internal/collector/redfish_replay_inventory.go index bc62085..26c08ea 100644 --- a/internal/collector/redfish_replay_inventory.go +++ b/internal/collector/redfish_replay_inventory.go @@ -31,7 +31,7 @@ func (r redfishSnapshotReader) enrichNICsFromNetworkInterfaces(nics *[]models.Ne // the real NIC that came from Chassis/NetworkAdapters (e.g. "RISER 5 // slot 1 (7)"). Try to find the real NIC via the Links.NetworkAdapter // cross-reference before creating a ghost entry. - if linkedIdx := r.findNICIndexByLinkedNetworkAdapter(iface, bySlot); linkedIdx >= 0 { + if linkedIdx := r.findNICIndexByLinkedNetworkAdapter(iface, *nics, bySlot); linkedIdx >= 0 { idx = linkedIdx ok = true } @@ -75,33 +75,37 @@ func (r redfishSnapshotReader) collectNICs(chassisPaths []string) []models.Netwo continue } for _, doc := range adapterDocs { - nic := parseNIC(doc) - adapterFunctionDocs := r.getNetworkAdapterFunctionDocs(doc) - for _, pciePath := range networkAdapterPCIeDevicePaths(doc) { - pcieDoc, err := r.getJSON(pciePath) - if err != nil { - continue - } - functionDocs := r.getLinkedPCIeFunctions(pcieDoc) - for _, adapterFnDoc := range adapterFunctionDocs { - functionDocs = append(functionDocs, r.getLinkedPCIeFunctions(adapterFnDoc)...) - } - functionDocs = dedupeJSONDocsByPath(functionDocs) - supplementalDocs := r.getLinkedSupplementalDocs(pcieDoc, "EnvironmentMetrics", "Metrics") - for _, fn := range functionDocs { - supplementalDocs = append(supplementalDocs, r.getLinkedSupplementalDocs(fn, "EnvironmentMetrics", "Metrics")...) - } - enrichNICFromPCIe(&nic, pcieDoc, functionDocs, supplementalDocs) - } - if len(nic.MACAddresses) == 0 { - r.enrichNICMACsFromNetworkDeviceFunctions(&nic, doc) - } - nics = append(nics, nic) + nics = append(nics, r.buildNICFromAdapterDoc(doc)) } } return dedupeNetworkAdapters(nics) } +func (r redfishSnapshotReader) buildNICFromAdapterDoc(adapterDoc map[string]interface{}) models.NetworkAdapter { + nic := parseNIC(adapterDoc) + adapterFunctionDocs := r.getNetworkAdapterFunctionDocs(adapterDoc) + for _, pciePath := range networkAdapterPCIeDevicePaths(adapterDoc) { + pcieDoc, err := r.getJSON(pciePath) + if err != nil { + continue + } + functionDocs := r.getLinkedPCIeFunctions(pcieDoc) + for _, adapterFnDoc := range adapterFunctionDocs { + functionDocs = append(functionDocs, r.getLinkedPCIeFunctions(adapterFnDoc)...) + } + functionDocs = dedupeJSONDocsByPath(functionDocs) + supplementalDocs := r.getLinkedSupplementalDocs(pcieDoc, "EnvironmentMetrics", "Metrics") + for _, fn := range functionDocs { + supplementalDocs = append(supplementalDocs, r.getLinkedSupplementalDocs(fn, "EnvironmentMetrics", "Metrics")...) + } + enrichNICFromPCIe(&nic, pcieDoc, functionDocs, supplementalDocs) + } + if len(nic.MACAddresses) == 0 { + r.enrichNICMACsFromNetworkDeviceFunctions(&nic, adapterDoc) + } + return nic +} + func (r redfishSnapshotReader) getNetworkAdapterFunctionDocs(adapterDoc map[string]interface{}) []map[string]interface{} { ndfCol, ok := adapterDoc["NetworkDeviceFunctions"].(map[string]interface{}) if !ok { @@ -137,13 +141,16 @@ func (r redfishSnapshotReader) collectPCIeDevices(systemPaths, chassisPaths []st if looksLikeGPU(doc, functionDocs) { continue } + if replayPCIeDeviceBackedByCanonicalNIC(doc, functionDocs) { + 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")...) } dev := parsePCIeDeviceWithSupplementalDocs(doc, functionDocs, supplementalDocs) - if isUnidentifiablePCIeDevice(dev) { + if shouldSkipReplayPCIeDevice(doc, dev) { continue } out = append(out, dev) @@ -157,12 +164,134 @@ func (r redfishSnapshotReader) collectPCIeDevices(systemPaths, chassisPaths []st for idx, fn := range functionDocs { supplementalDocs := r.getLinkedSupplementalDocs(fn, "EnvironmentMetrics", "Metrics") dev := parsePCIeFunctionWithSupplementalDocs(fn, supplementalDocs, idx+1) + if shouldSkipReplayPCIeDevice(fn, dev) { + continue + } out = append(out, dev) } } return dedupePCIeDevices(out) } +func shouldSkipReplayPCIeDevice(doc map[string]interface{}, dev models.PCIeDevice) bool { + if isUnidentifiablePCIeDevice(dev) { + return true + } + if replayNetworkFunctionBackedByCanonicalNIC(doc, dev) { + return true + } + if isReplayStorageServiceEndpoint(doc, dev) { + return true + } + if isReplayNoisePCIeClass(dev.DeviceClass) { + return true + } + if isReplayDisplayDeviceDuplicate(doc, dev) { + return true + } + return false +} + +func replayPCIeDeviceBackedByCanonicalNIC(doc map[string]interface{}, functionDocs []map[string]interface{}) bool { + if !looksLikeReplayNetworkPCIeDevice(doc, functionDocs) { + return false + } + for _, fn := range functionDocs { + if hasRedfishLinkedMember(fn, "NetworkDeviceFunctions") { + return true + } + } + return false +} + +func replayNetworkFunctionBackedByCanonicalNIC(doc map[string]interface{}, dev models.PCIeDevice) bool { + if !looksLikeReplayNetworkClass(dev.DeviceClass) { + return false + } + return hasRedfishLinkedMember(doc, "NetworkDeviceFunctions") +} + +func looksLikeReplayNetworkPCIeDevice(doc map[string]interface{}, functionDocs []map[string]interface{}) bool { + for _, fn := range functionDocs { + if looksLikeReplayNetworkClass(asString(fn["DeviceClass"])) { + return true + } + } + joined := strings.ToLower(strings.TrimSpace(strings.Join([]string{ + asString(doc["DeviceType"]), + asString(doc["Description"]), + asString(doc["Name"]), + asString(doc["Model"]), + }, " "))) + return strings.Contains(joined, "network") +} + +func looksLikeReplayNetworkClass(class string) bool { + class = strings.ToLower(strings.TrimSpace(class)) + return strings.Contains(class, "network") || strings.Contains(class, "ethernet") +} + +func isReplayStorageServiceEndpoint(doc map[string]interface{}, dev models.PCIeDevice) bool { + class := strings.ToLower(strings.TrimSpace(dev.DeviceClass)) + if class != "massstoragecontroller" && class != "mass storage controller" { + return false + } + name := strings.ToLower(strings.TrimSpace(firstNonEmpty( + dev.PartNumber, + asString(doc["PartNumber"]), + asString(doc["Description"]), + ))) + if strings.Contains(name, "pcie switch management endpoint") { + return true + } + if strings.Contains(name, "volume management device nvme raid controller") { + return true + } + return false +} + +func hasRedfishLinkedMember(doc map[string]interface{}, key string) bool { + links, ok := doc["Links"].(map[string]interface{}) + if !ok { + return false + } + if asInt(links[key+"@odata.count"]) > 0 { + return true + } + linked, ok := links[key] + if !ok { + return false + } + switch v := linked.(type) { + case []interface{}: + return len(v) > 0 + case map[string]interface{}: + if asString(v["@odata.id"]) != "" { + return true + } + return len(v) > 0 + default: + return false + } +} + +func isReplayNoisePCIeClass(class string) bool { + switch strings.ToLower(strings.TrimSpace(class)) { + case "bridge", "processor", "signalprocessingcontroller", "signal processing controller", "serialbuscontroller", "serial bus controller": + return true + default: + return false + } +} + +func isReplayDisplayDeviceDuplicate(doc map[string]interface{}, dev models.PCIeDevice) bool { + class := strings.ToLower(strings.TrimSpace(dev.DeviceClass)) + if class != "displaycontroller" && class != "display controller" { + return false + } + return strings.EqualFold(strings.TrimSpace(asString(doc["Description"])), "Display Device") +} + func (r redfishSnapshotReader) getChassisScopedPCIeSupplementalDocs(doc map[string]interface{}) []map[string]interface{} { docPath := normalizeRedfishPath(asString(doc["@odata.id"])) chassisPath := chassisPathForPCIeDoc(docPath) @@ -362,8 +491,9 @@ func redfishManagerInterfaceScore(summary map[string]any) int { // 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. -func (r redfishSnapshotReader) findNICIndexByLinkedNetworkAdapter(iface map[string]interface{}, bySlot map[string]int) int { +// NetworkAdapter doc and reconstructing the canonical NIC identity. Returns -1 +// if no match is found. +func (r redfishSnapshotReader) findNICIndexByLinkedNetworkAdapter(iface map[string]interface{}, existing []models.NetworkAdapter, bySlot map[string]int) int { links, ok := iface["Links"].(map[string]interface{}) if !ok { return -1 @@ -380,15 +510,58 @@ func (r redfishSnapshotReader) findNICIndexByLinkedNetworkAdapter(iface map[stri if err != nil || len(adapterDoc) == 0 { return -1 } - adapterNIC := parseNIC(adapterDoc) + adapterNIC := r.buildNICFromAdapterDoc(adapterDoc) + if serial := normalizeRedfishIdentityField(adapterNIC.SerialNumber); serial != "" { + for idx, nic := range existing { + if strings.EqualFold(normalizeRedfishIdentityField(nic.SerialNumber), serial) { + return idx + } + } + } + if bdf := strings.TrimSpace(adapterNIC.BDF); bdf != "" { + for idx, nic := range existing { + if strings.EqualFold(strings.TrimSpace(nic.BDF), bdf) { + return idx + } + } + } if slot := strings.ToLower(strings.TrimSpace(adapterNIC.Slot)); slot != "" { if idx, ok := bySlot[slot]; ok { return idx } } + for idx, nic := range existing { + if networkAdaptersShareMACs(nic, adapterNIC) { + return idx + } + } return -1 } +func networkAdaptersShareMACs(a, b models.NetworkAdapter) bool { + if len(a.MACAddresses) == 0 || len(b.MACAddresses) == 0 { + return false + } + seen := make(map[string]struct{}, len(a.MACAddresses)) + for _, mac := range a.MACAddresses { + normalized := strings.ToUpper(strings.TrimSpace(mac)) + if normalized == "" { + continue + } + seen[normalized] = struct{}{} + } + for _, mac := range b.MACAddresses { + normalized := strings.ToUpper(strings.TrimSpace(mac)) + if normalized == "" { + continue + } + if _, ok := seen[normalized]; ok { + return true + } + } + return false +} + // enrichNICMACsFromNetworkDeviceFunctions reads the NetworkDeviceFunctions // collection linked from a NetworkAdapter document and populates the NIC's // MACAddresses from each function's Ethernet.PermanentMACAddress / MACAddress. diff --git a/internal/collector/redfish_test.go b/internal/collector/redfish_test.go index 9adccf8..aa844d8 100644 --- a/internal/collector/redfish_test.go +++ b/internal/collector/redfish_test.go @@ -1366,6 +1366,148 @@ func TestReplayCollectNICs_UsesNetworkDeviceFunctionPCIeFunctionLink(t *testing. if nics[0].BDF != "0000:0f:00.0" { t.Fatalf("expected BDF from linked PCIeFunction, got %q", nics[0].BDF) } + if nics[0].Model != "MT2894 Family [ConnectX-6 Lx]" { + t.Fatalf("expected model resolved from PCI IDs, got %q", nics[0].Model) + } +} + +func TestReplayEnrichNICsFromNetworkInterfaces_DoesNotCreateGhostForLinkedAdapter(t *testing.T) { + tree := map[string]interface{}{ + "/redfish/v1/Chassis/1/NetworkAdapters": map[string]interface{}{ + "Members": []interface{}{ + map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/NetworkAdapters/NIC1"}, + }, + }, + "/redfish/v1/Chassis/1/NetworkAdapters/NIC1": map[string]interface{}{ + "@odata.id": "/redfish/v1/Chassis/1/NetworkAdapters/NIC1", + "Id": "DevType7_NIC1", + "Name": "NetworkAdapter_1", + "Controllers": []interface{}{ + map[string]interface{}{ + "ControllerCapabilities": map[string]interface{}{ + "NetworkPortCount": 1, + }, + "Links": map[string]interface{}{ + "PCIeDevices": []interface{}{ + map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/00_0F_00"}, + }, + }, + }, + map[string]interface{}{ + "ControllerCapabilities": map[string]interface{}{ + "NetworkPortCount": 1, + }, + "Links": map[string]interface{}{ + "PCIeDevices": []interface{}{ + map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/00_0F_00"}, + }, + }, + }, + }, + "NetworkDeviceFunctions": map[string]interface{}{ + "@odata.id": "/redfish/v1/Chassis/1/NetworkAdapters/NIC1/NetworkDeviceFunctions", + }, + }, + "/redfish/v1/Chassis/1/NetworkAdapters/NIC1/NetworkDeviceFunctions": map[string]interface{}{ + "Members": []interface{}{ + map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/NetworkAdapters/NIC1/NetworkDeviceFunctions/Function0"}, + map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/NetworkAdapters/NIC1/NetworkDeviceFunctions/Function1"}, + }, + }, + "/redfish/v1/Chassis/1/NetworkAdapters/NIC1/NetworkDeviceFunctions/Function0": map[string]interface{}{ + "Id": "Function0", + "Ethernet": map[string]interface{}{ + "MACAddress": "CC:40:F3:D6:9E:DE", + "PermanentMACAddress": "CC:40:F3:D6:9E:DE", + }, + "Links": map[string]interface{}{ + "PCIeFunction": map[string]interface{}{ + "@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/00_0F_00/PCIeFunctions/Function0", + }, + }, + }, + "/redfish/v1/Chassis/1/NetworkAdapters/NIC1/NetworkDeviceFunctions/Function1": map[string]interface{}{ + "Id": "Function1", + "Ethernet": map[string]interface{}{ + "MACAddress": "CC:40:F3:D6:9E:DF", + "PermanentMACAddress": "CC:40:F3:D6:9E:DF", + }, + "Links": map[string]interface{}{ + "PCIeFunction": map[string]interface{}{ + "@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/00_0F_00/PCIeFunctions/Function1", + }, + }, + }, + "/redfish/v1/Chassis/1/PCIeDevices/00_0F_00": map[string]interface{}{ + "Id": "00_0F_00", + "Name": "PCIeDevice_00_0F_00", + "Manufacturer": "Mellanox Technologies", + "FirmwareVersion": "26.43.25.66", + "Slot": map[string]interface{}{ + "Location": map[string]interface{}{ + "PartLocation": map[string]interface{}{ + "ServiceLabel": "RISER4", + }, + }, + }, + "PCIeFunctions": map[string]interface{}{ + "@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/00_0F_00/PCIeFunctions", + }, + }, + "/redfish/v1/Chassis/1/PCIeDevices/00_0F_00/PCIeFunctions": map[string]interface{}{ + "Members": []interface{}{ + map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/00_0F_00/PCIeFunctions/Function0"}, + map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/00_0F_00/PCIeFunctions/Function1"}, + }, + }, + "/redfish/v1/Chassis/1/PCIeDevices/00_0F_00/PCIeFunctions/Function0": map[string]interface{}{ + "FunctionId": "0000:0f:00.0", + "VendorId": "0x15b3", + "DeviceId": "0x101f", + "DeviceClass": "NetworkController", + "SerialNumber": "N/A", + }, + "/redfish/v1/Chassis/1/PCIeDevices/00_0F_00/PCIeFunctions/Function1": map[string]interface{}{ + "FunctionId": "0000:0f:00.1", + "VendorId": "0x15b3", + "DeviceId": "0x101f", + "DeviceClass": "NetworkController", + }, + "/redfish/v1/Systems/1/NetworkInterfaces": map[string]interface{}{ + "Members": []interface{}{ + map[string]interface{}{"@odata.id": "/redfish/v1/Systems/1/NetworkInterfaces/DevType7_NIC1"}, + }, + }, + "/redfish/v1/Systems/1/NetworkInterfaces/DevType7_NIC1": map[string]interface{}{ + "Id": "DevType7_NIC1", + "Name": "NetworkAdapter_1", + "Links": map[string]interface{}{ + "NetworkAdapter": map[string]interface{}{ + "@odata.id": "/redfish/v1/Chassis/1/NetworkAdapters/NIC1", + }, + }, + "Status": map[string]interface{}{ + "Health": "OK", + "State": "Disabled", + }, + }, + } + + r := redfishSnapshotReader{tree: tree} + nics := r.collectNICs([]string{"/redfish/v1/Chassis/1"}) + r.enrichNICsFromNetworkInterfaces(&nics, []string{"/redfish/v1/Systems/1"}) + if len(nics) != 1 { + t.Fatalf("expected linked network interface to reuse existing NIC, got %d: %+v", len(nics), nics) + } + if nics[0].Slot != "RISER4" { + t.Fatalf("expected enriched slot to stay canonical, got %q", nics[0].Slot) + } + if nics[0].Model != "MT2894 Family [ConnectX-6 Lx]" { + t.Fatalf("expected resolved Mellanox model, got %q", nics[0].Model) + } + if len(nics[0].MACAddresses) != 2 { + t.Fatalf("expected both MACs to stay on one NIC, got %+v", nics[0].MACAddresses) + } } func TestParseNIC_PortCountFromControllerCapabilities(t *testing.T) { @@ -2469,6 +2611,279 @@ func TestReplayCollectGPUs_DoesNotCollapseOnPlaceholderSerialAndSkipsNIC(t *test } } +func TestReplayCollectPCIeDevices_SkipsMSITopologyNoiseClasses(t *testing.T) { + r := redfishSnapshotReader{tree: map[string]interface{}{ + "/redfish/v1/Chassis/1/PCIeDevices": map[string]interface{}{ + "Members": []interface{}{ + map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/bridge"}, + map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/processor"}, + map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/signal"}, + map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/serial"}, + map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/display"}, + map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/network"}, + map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/storage"}, + }, + }, + "/redfish/v1/Chassis/1/PCIeDevices/bridge": map[string]interface{}{ + "Id": "bridge", + "Name": "Bridge", + "Description": "Bridge Device", + "Manufacturer": "Intel Corporation", + "PCIeFunctions": map[string]interface{}{ + "@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/bridge/PCIeFunctions", + }, + }, + "/redfish/v1/Chassis/1/PCIeDevices/bridge/PCIeFunctions": map[string]interface{}{ + "Members": []interface{}{ + map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/bridge/PCIeFunctions/1"}, + }, + }, + "/redfish/v1/Chassis/1/PCIeDevices/bridge/PCIeFunctions/1": map[string]interface{}{ + "DeviceClass": "Bridge", + "VendorId": "0x8086", + "DeviceId": "0x0db0", + }, + "/redfish/v1/Chassis/1/PCIeDevices/processor": map[string]interface{}{ + "Id": "processor", + "Name": "Processor", + "Description": "Processor Device", + "Manufacturer": "Intel Corporation", + "PCIeFunctions": map[string]interface{}{ + "@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/processor/PCIeFunctions", + }, + }, + "/redfish/v1/Chassis/1/PCIeDevices/processor/PCIeFunctions": map[string]interface{}{ + "Members": []interface{}{ + map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/processor/PCIeFunctions/1"}, + }, + }, + "/redfish/v1/Chassis/1/PCIeDevices/processor/PCIeFunctions/1": map[string]interface{}{ + "DeviceClass": "Processor", + "VendorId": "0x8086", + "DeviceId": "0x4944", + }, + "/redfish/v1/Chassis/1/PCIeDevices/signal": map[string]interface{}{ + "Id": "signal", + "Name": "Signal", + "Manufacturer": "Intel Corporation", + "PCIeFunctions": map[string]interface{}{ + "@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/signal/PCIeFunctions", + }, + }, + "/redfish/v1/Chassis/1/PCIeDevices/signal/PCIeFunctions": map[string]interface{}{ + "Members": []interface{}{ + map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/signal/PCIeFunctions/1"}, + }, + }, + "/redfish/v1/Chassis/1/PCIeDevices/signal/PCIeFunctions/1": map[string]interface{}{ + "DeviceClass": "SignalProcessingController", + "VendorId": "0x8086", + "DeviceId": "0x3254", + }, + "/redfish/v1/Chassis/1/PCIeDevices/serial": map[string]interface{}{ + "Id": "serial", + "Name": "Serial", + "Manufacturer": "Renesas", + "PCIeFunctions": map[string]interface{}{ + "@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/serial/PCIeFunctions", + }, + }, + "/redfish/v1/Chassis/1/PCIeDevices/serial/PCIeFunctions": map[string]interface{}{ + "Members": []interface{}{ + map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/serial/PCIeFunctions/1"}, + }, + }, + "/redfish/v1/Chassis/1/PCIeDevices/serial/PCIeFunctions/1": map[string]interface{}{ + "DeviceClass": "SerialBusController", + "VendorId": "0x1912", + "DeviceId": "0x0014", + }, + "/redfish/v1/Chassis/1/PCIeDevices/display": map[string]interface{}{ + "Id": "display", + "Name": "Display", + "Description": "Display Device", + "Manufacturer": "NVIDIA Corporation", + "PCIeFunctions": map[string]interface{}{ + "@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/display/PCIeFunctions", + }, + }, + "/redfish/v1/Chassis/1/PCIeDevices/display/PCIeFunctions": map[string]interface{}{ + "Members": []interface{}{ + map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/display/PCIeFunctions/1"}, + }, + }, + "/redfish/v1/Chassis/1/PCIeDevices/display/PCIeFunctions/1": map[string]interface{}{ + "DeviceClass": "DisplayController", + "VendorId": "0x10de", + "DeviceId": "0x233b", + }, + "/redfish/v1/Chassis/1/PCIeDevices/network": map[string]interface{}{ + "Id": "network", + "Name": "NIC", + "Description": "Network Device", + "Manufacturer": "Mellanox Technologies", + "PCIeFunctions": map[string]interface{}{ + "@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/network/PCIeFunctions", + }, + }, + "/redfish/v1/Chassis/1/PCIeDevices/network/PCIeFunctions": map[string]interface{}{ + "Members": []interface{}{ + map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/network/PCIeFunctions/1"}, + }, + }, + "/redfish/v1/Chassis/1/PCIeDevices/network/PCIeFunctions/1": map[string]interface{}{ + "DeviceClass": "NetworkController", + "VendorId": "0x15b3", + "DeviceId": "0x101f", + }, + "/redfish/v1/Chassis/1/PCIeDevices/storage": map[string]interface{}{ + "Id": "storage", + "Name": "Storage", + "Description": "Storage Device", + "Manufacturer": "Intel Corporation", + "PCIeFunctions": map[string]interface{}{ + "@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/storage/PCIeFunctions", + }, + }, + "/redfish/v1/Chassis/1/PCIeDevices/storage/PCIeFunctions": map[string]interface{}{ + "Members": []interface{}{ + map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/storage/PCIeFunctions/1"}, + }, + }, + "/redfish/v1/Chassis/1/PCIeDevices/storage/PCIeFunctions/1": map[string]interface{}{ + "DeviceClass": "MassStorageController", + "VendorId": "0x1234", + "DeviceId": "0x5678", + }, + }} + + got := r.collectPCIeDevices(nil, []string{"/redfish/v1/Chassis/1"}) + if len(got) != 2 { + t.Fatalf("expected only endpoint PCIe devices to remain, got %d: %+v", len(got), got) + } + classes := map[string]bool{} + for _, dev := range got { + classes[dev.DeviceClass] = true + } + if !classes["NetworkController"] || !classes["MassStorageController"] { + t.Fatalf("expected network and storage PCIe devices to remain, got %+v", got) + } + if classes["Bridge"] || classes["Processor"] || classes["SignalProcessingController"] || classes["SerialBusController"] || classes["DisplayController"] { + t.Fatalf("expected MSI topology noise classes to be filtered, got %+v", got) + } +} + +func TestReplayCollectPCIeDevices_SkipsNICsAlreadyRepresentedAsNetworkAdapters(t *testing.T) { + r := redfishSnapshotReader{tree: map[string]interface{}{ + "/redfish/v1/Chassis/1/PCIeDevices": map[string]interface{}{ + "Members": []interface{}{ + map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/nic"}, + }, + }, + "/redfish/v1/Chassis/1/PCIeDevices/nic": map[string]interface{}{ + "Id": "nic", + "Name": "PCIeDevice_00_39_00", + "Description": "Network Device", + "Manufacturer": "Mellanox Technologies", + "PCIeFunctions": map[string]interface{}{ + "@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/nic/PCIeFunctions", + }, + }, + "/redfish/v1/Chassis/1/PCIeDevices/nic/PCIeFunctions": map[string]interface{}{ + "Members": []interface{}{ + map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/nic/PCIeFunctions/1"}, + }, + }, + "/redfish/v1/Chassis/1/PCIeDevices/nic/PCIeFunctions/1": map[string]interface{}{ + "DeviceClass": "NetworkController", + "VendorId": "0x15b3", + "DeviceId": "0x101f", + "Links": map[string]interface{}{ + "NetworkDeviceFunctions": []interface{}{ + map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/NetworkAdapters/NIC1/NetworkDeviceFunctions/Function0"}, + }, + "NetworkDeviceFunctions@odata.count": 1, + }, + }, + }} + + got := r.collectPCIeDevices(nil, []string{"/redfish/v1/Chassis/1"}) + if len(got) != 0 { + t.Fatalf("expected network-backed PCIe duplicate to be skipped, got %+v", got) + } +} + +func TestReplayCollectPCIeDevices_SkipsStorageServiceEndpoints(t *testing.T) { + r := redfishSnapshotReader{tree: map[string]interface{}{ + "/redfish/v1/Chassis/1/PCIeDevices": map[string]interface{}{ + "Members": []interface{}{ + map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/vmd"}, + map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/switch-mgmt"}, + map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/hba"}, + }, + }, + "/redfish/v1/Chassis/1/PCIeDevices/vmd": map[string]interface{}{ + "Id": "vmd", + "Description": "Storage Device", + "PCIeFunctions": map[string]interface{}{ + "@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/vmd/PCIeFunctions", + }, + }, + "/redfish/v1/Chassis/1/PCIeDevices/vmd/PCIeFunctions": map[string]interface{}{ + "Members": []interface{}{ + map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/vmd/PCIeFunctions/1"}, + }, + }, + "/redfish/v1/Chassis/1/PCIeDevices/vmd/PCIeFunctions/1": map[string]interface{}{ + "DeviceClass": "MassStorageController", + "VendorId": "0x8086", + "DeviceId": "0x28c0", + }, + "/redfish/v1/Chassis/1/PCIeDevices/switch-mgmt": map[string]interface{}{ + "Id": "switch-mgmt", + "Description": "Storage Device", + "PCIeFunctions": map[string]interface{}{ + "@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/switch-mgmt/PCIeFunctions", + }, + }, + "/redfish/v1/Chassis/1/PCIeDevices/switch-mgmt/PCIeFunctions": map[string]interface{}{ + "Members": []interface{}{ + map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/switch-mgmt/PCIeFunctions/1"}, + }, + }, + "/redfish/v1/Chassis/1/PCIeDevices/switch-mgmt/PCIeFunctions/1": map[string]interface{}{ + "DeviceClass": "MassStorageController", + "VendorId": "0x1000", + "DeviceId": "0x00b2", + }, + "/redfish/v1/Chassis/1/PCIeDevices/hba": map[string]interface{}{ + "Id": "hba", + "Description": "Storage Device", + "PCIeFunctions": map[string]interface{}{ + "@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/hba/PCIeFunctions", + }, + }, + "/redfish/v1/Chassis/1/PCIeDevices/hba/PCIeFunctions": map[string]interface{}{ + "Members": []interface{}{ + map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/hba/PCIeFunctions/1"}, + }, + }, + "/redfish/v1/Chassis/1/PCIeDevices/hba/PCIeFunctions/1": map[string]interface{}{ + "DeviceClass": "MassStorageController", + "VendorId": "0x1234", + "DeviceId": "0x5678", + }, + }} + + got := r.collectPCIeDevices(nil, []string{"/redfish/v1/Chassis/1"}) + if len(got) != 1 { + t.Fatalf("expected only non-service storage controller to remain, got %+v", got) + } + if got[0].VendorID != 0x1234 || got[0].DeviceID != 0x5678 { + t.Fatalf("expected generic HBA to remain, got %+v", got[0]) + } +} + func TestParseBoardInfo_NormalizesNullPlaceholders(t *testing.T) { got := parseBoardInfo(map[string]interface{}{ "Manufacturer": "NULL", diff --git a/internal/exporter/reanimator_converter.go b/internal/exporter/reanimator_converter.go index d3ca41b..4f4def8 100644 --- a/internal/exporter/reanimator_converter.go +++ b/internal/exporter/reanimator_converter.go @@ -2246,10 +2246,8 @@ func normalizePCIeDeviceClass(d models.HardwareDevice) string { func normalizeLegacyPCIeDeviceClass(deviceClass string) string { switch strings.ToLower(strings.TrimSpace(deviceClass)) { - case "", "network", "network controller", "networkcontroller": + case "", "network", "network controller", "networkcontroller", "ethernet", "ethernet controller", "ethernetcontroller": return "NetworkController" - case "ethernet", "ethernet controller", "ethernetcontroller": - return "EthernetController" case "fibre channel", "fibre channel controller", "fibrechannelcontroller", "fc": return "FibreChannelController" case "display", "displaycontroller", "display controller", "vga": @@ -2270,8 +2268,6 @@ func normalizeLegacyPCIeDeviceClass(deviceClass string) string { func normalizeNetworkDeviceClass(portType, model, description string) string { joined := strings.ToLower(strings.TrimSpace(strings.Join([]string{portType, model, description}, " "))) switch { - case strings.Contains(joined, "ethernet"): - return "EthernetController" case strings.Contains(joined, "fibre channel") || strings.Contains(joined, " fibrechannel") || strings.Contains(joined, "fc "): return "FibreChannelController" default: diff --git a/internal/exporter/reanimator_converter_test.go b/internal/exporter/reanimator_converter_test.go index 7587de4..e8096fb 100644 --- a/internal/exporter/reanimator_converter_test.go +++ b/internal/exporter/reanimator_converter_test.go @@ -1733,6 +1733,43 @@ func TestConvertToReanimator_ExportsContractV24Telemetry(t *testing.T) { } } +func TestConvertToReanimator_UnifiesEthernetAndNetworkControllers(t *testing.T) { + input := &models.AnalysisResult{ + Hardware: &models.HardwareConfig{ + BoardInfo: models.BoardInfo{SerialNumber: "BOARD-123"}, + Devices: []models.HardwareDevice{ + { + Kind: models.DeviceKindPCIe, + Slot: "PCIe1", + DeviceClass: "EthernetController", + Present: boolPtr(true), + SerialNumber: "ETH-001", + }, + { + Kind: models.DeviceKindNetwork, + Slot: "NIC1", + Model: "Ethernet Adapter", + Present: boolPtr(true), + SerialNumber: "NIC-001", + }, + }, + }, + } + + out, err := ConvertToReanimator(input) + if err != nil { + t.Fatalf("ConvertToReanimator() failed: %v", err) + } + if len(out.Hardware.PCIeDevices) != 2 { + t.Fatalf("expected two pcie-class exports, got %d", len(out.Hardware.PCIeDevices)) + } + for _, dev := range out.Hardware.PCIeDevices { + if dev.DeviceClass != "NetworkController" { + t.Fatalf("expected unified NetworkController class, got %+v", dev) + } + } +} + func TestConvertToReanimator_PreservesLegacyStorageAndPSUDetails(t *testing.T) { input := &models.AnalysisResult{ Filename: "legacy-details.json", diff --git a/internal/server/collect_handlers_test.go b/internal/server/collect_handlers_test.go index 071b0fc..c7fa9f5 100644 --- a/internal/server/collect_handlers_test.go +++ b/internal/server/collect_handlers_test.go @@ -3,6 +3,8 @@ package server import ( "bytes" "encoding/json" + "fmt" + "net" "net/http" "net/http/httptest" "strings" @@ -29,7 +31,17 @@ func TestCollectProbe(t *testing.T) { _, ts := newCollectTestServer() defer ts.Close() - body := `{"host":"bmc-off.local","protocol":"redfish","port":443,"username":"admin","auth_type":"password","password":"secret","tls_mode":"strict"}` + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen probe target: %v", err) + } + defer ln.Close() + addr, ok := ln.Addr().(*net.TCPAddr) + if !ok { + t.Fatalf("unexpected listener address type: %T", ln.Addr()) + } + + body := fmt.Sprintf(`{"host":"127.0.0.1","protocol":"redfish","port":%d,"username":"admin-off","auth_type":"password","password":"secret","tls_mode":"strict"}`, addr.Port) resp, err := http.Post(ts.URL+"/api/collect/probe", "application/json", bytes.NewBufferString(body)) if err != nil { t.Fatalf("post collect probe failed: %v", err) diff --git a/internal/server/collect_test_helpers_test.go b/internal/server/collect_test_helpers_test.go index 26a5b1e..2baf319 100644 --- a/internal/server/collect_test_helpers_test.go +++ b/internal/server/collect_test_helpers_test.go @@ -21,11 +21,15 @@ func (c *mockConnector) Probe(ctx context.Context, req collector.Request) (*coll if strings.Contains(strings.ToLower(req.Host), "fail") { return nil, context.DeadlineExceeded } + hostPoweredOn := true + if strings.Contains(strings.ToLower(req.Host), "off") || strings.Contains(strings.ToLower(req.Username), "off") { + hostPoweredOn = false + } return &collector.ProbeResult{ Reachable: true, Protocol: c.protocol, - HostPowerState: map[bool]string{true: "On", false: "Off"}[!strings.Contains(strings.ToLower(req.Host), "off")], - HostPoweredOn: !strings.Contains(strings.ToLower(req.Host), "off"), + HostPowerState: map[bool]string{true: "On", false: "Off"}[hostPoweredOn], + HostPoweredOn: hostPoweredOn, PowerControlAvailable: true, SystemPath: "/redfish/v1/Systems/1", }, nil diff --git a/internal/server/device_repository.go b/internal/server/device_repository.go index 89a8c28..72348f8 100644 --- a/internal/server/device_repository.go +++ b/internal/server/device_repository.go @@ -243,6 +243,7 @@ func BuildHardwareDevices(hw *models.HardwareConfig) []models.HardwareDevice { Source: "network_adapters", Slot: nic.Slot, Location: nic.Location, + DeviceClass: "NetworkController", VendorID: nic.VendorID, DeviceID: nic.DeviceID, Model: nic.Model, diff --git a/internal/server/device_repository_test.go b/internal/server/device_repository_test.go index 19303fe..ff0212a 100644 --- a/internal/server/device_repository_test.go +++ b/internal/server/device_repository_test.go @@ -223,6 +223,31 @@ func TestBuildHardwareDevices_SkipsFirmwareOnlyNumericSlots(t *testing.T) { } } +func TestBuildHardwareDevices_NetworkDevicesUseUnifiedControllerClass(t *testing.T) { + hw := &models.HardwareConfig{ + NetworkAdapters: []models.NetworkAdapter{ + { + Slot: "NIC1", + Model: "Ethernet Adapter", + Vendor: "Intel", + Present: true, + }, + }, + } + + devices := BuildHardwareDevices(hw) + for _, d := range devices { + if d.Kind != models.DeviceKindNetwork { + continue + } + if d.DeviceClass != "NetworkController" { + t.Fatalf("expected unified network controller class, got %+v", d) + } + return + } + t.Fatalf("expected one canonical network device") +} + func TestHandleGetConfig_ReturnsCanonicalHardware(t *testing.T) { srv := &Server{} srv.SetResult(&models.AnalysisResult{