From 8794e0adeca52c50957e9b9e61548f04c6008b5d Mon Sep 17 00:00:00 2001 From: Mikhail Chusavitin Date: Mon, 14 Sep 2026 10:48:47 +0300 Subject: [PATCH] fix: parse Inspur PCIe CPU affinity --- bible-local/10-decisions.md | 25 ++++++++ internal/parser/vendors/inspur/component.go | 45 +++++++------- .../parser/vendors/inspur/component_test.go | 21 +++++++ internal/parser/vendors/inspur/parser.go | 2 +- internal/parser/vendors/inspur/pcie.go | 60 +++++++++---------- internal/parser/vendors/inspur/pcie_test.go | 41 ++++++++++++- 6 files changed, 140 insertions(+), 54 deletions(-) diff --git a/bible-local/10-decisions.md b/bible-local/10-decisions.md index 45bcb12..d64d303 100644 --- a/bible-local/10-decisions.md +++ b/bible-local/10-decisions.md @@ -2020,3 +2020,28 @@ couple a standard inventory document to one viewer. `reanimator.json`. - Fan RPM animation, PSU live load, and other Bee live-only presentation are intentionally out of scope. + +--- + +## ADL-069 — Inspur physical CPU locator as PCIe affinity + +**Date:** 2026-09-14 +**Context:** Some Inspur/Kaytus `onekeylog` archives do not contain host sysfs +NUMA data, but their RESTful PCIe inventory identifies the physical processor +in `location` or `DeviceLocator` values such as `#CPU0_PE2_P7_R5_SL0`. + +**Decision:** Parse the bounded `CPU` token from those locator fields into +the nullable `numa_node`/CPU-affinity field for PCIe devices and network +adapters. Combined `component.log` is also a valid PCIe inventory source when +`devicefrusdr.log` is absent. RESTful PCIe entries use their domain-qualified +BDF as canonical `slot`, retaining the physical locator as description, so +card/function records merge with the NIC inventory. Locator values without an +isolated `CPU` token remain unknown (`nil`); no affinity is inferred from +BDF numbering. + +**Consequences:** +- Inspur physical PCIe-to-socket wiring can drive the standalone topology even + for BMC-only dumps. +- `CPU0` remains distinct from missing affinity. +- On systems with Sub-NUMA Clustering, the value represents physical CPU + affinity rather than an operating-system NUMA-domain ID. diff --git a/internal/parser/vendors/inspur/component.go b/internal/parser/vendors/inspur/component.go index 68d1277..fb10653 100644 --- a/internal/parser/vendors/inspur/component.go +++ b/internal/parser/vendors/inspur/component.go @@ -40,6 +40,9 @@ func ParseComponentLog(content []byte, hw *models.HardwareConfig) { // Parse RESTful Network Adapter info parseNetworkAdapterInfo(text, hw) + // Combined component.log dumps may be the only PCIe inventory source. + hw.PCIeDevices = MergePCIeDevices(hw.PCIeDevices, ParsePCIeDevices(content)) + // Extract firmware from all components extractComponentFirmware(text, hw) } @@ -517,25 +520,26 @@ type NetworkAdapterRESTInfo struct { } type sysAdapter struct { - ID int `json:"id"` - Name string `json:"name"` - Location string `json:"Location"` - Present int `json:"present"` - Slot int `json:"slot"` - PcieBus int `json:"pcie_bus"` - PcieDev int `json:"pcie_dev"` - PcieFunc int `json:"pcie_func"` - VendorID int `json:"vendor_id"` - DeviceID int `json:"device_id"` - Vendor string `json:"vendor"` - Model string `json:"model"` - FwVer string `json:"fw_ver"` - Status string `json:"status"` - SN string `json:"sn"` - PN string `json:"pn"` - PortNum int `json:"port_num"` - PortType string `json:"port_type"` - Ports []struct { + ID int `json:"id"` + Name string `json:"name"` + Location string `json:"Location"` + DeviceLocator string `json:"location"` + Present int `json:"present"` + Slot int `json:"slot"` + PcieBus int `json:"pcie_bus"` + PcieDev int `json:"pcie_dev"` + PcieFunc int `json:"pcie_func"` + VendorID int `json:"vendor_id"` + DeviceID int `json:"device_id"` + Vendor string `json:"vendor"` + Model string `json:"model"` + FwVer string `json:"fw_ver"` + Status string `json:"status"` + SN string `json:"sn"` + PN string `json:"pn"` + PortNum int `json:"port_num"` + PortType string `json:"port_type"` + Ports []struct { ID int `json:"id"` MacAddr string `json:"mac_addr"` } `json:"ports"` @@ -579,7 +583,7 @@ func parseNetworkAdapterInfo(text string, hw *models.HardwareConfig) { } base := models.NetworkAdapter{ - Location: adapter.Location, + Location: inspurFirstNonEmpty(adapter.Location, adapter.DeviceLocator), Present: adapter.Present == 1, Model: model, Vendor: vendor, @@ -591,6 +595,7 @@ func parseNetworkAdapterInfo(text string, hw *models.HardwareConfig) { PortCount: adapter.PortNum, PortType: adapter.PortType, Status: adapter.Status, + NUMANode: parseInspurCPUAffinity(adapter.Location, adapter.DeviceLocator), } // If another source (asset.json PcieInfo) already recorded this card's PCI diff --git a/internal/parser/vendors/inspur/component_test.go b/internal/parser/vendors/inspur/component_test.go index b0571cc..4f51558 100644 --- a/internal/parser/vendors/inspur/component_test.go +++ b/internal/parser/vendors/inspur/component_test.go @@ -49,6 +49,27 @@ RESTful fan` if got.Vendor == "" { t.Fatalf("expected NIC vendor resolved from pci.ids") } + if got.NUMANode == nil || *got.NUMANode != 0 { + t.Fatalf("expected CPU affinity 0, got %v", got.NUMANode) + } +} + +func TestParseComponentLog_AddsPCIeInventoryAndAffinity(t *testing.T) { + text := `RESTful PCIE Device info: +[{"id":1,"present":1,"vendor_id":6987,"vendor_name":"Marvell","device_id":37424,"device_name":"Marvell 9230 Raid","bus_num":101,"dev_num":0,"func_num":0,"max_link_width":2,"max_link_speed":2,"current_link_width":2,"current_link_speed":2,"location":"#CPU0_PE3_P4_R4_SL1","DeviceLocator":"CPU0_PE3_PCIE4","dev_type":1,"dev_subtype":6}] +RESTful Network Adapter info: +{"sys_adapters":[]} +RESTful fan info: +{"fans":[]}` + + hw := &models.HardwareConfig{} + ParseComponentLog([]byte(text), hw) + if len(hw.PCIeDevices) != 1 { + t.Fatalf("expected 1 PCIe device, got %d", len(hw.PCIeDevices)) + } + if got := hw.PCIeDevices[0].NUMANode; got == nil || *got != 0 { + t.Fatalf("expected CPU affinity 0, got %v", got) + } } func TestParseNetworkAdapterInfo_MergesIntoExistingInventory(t *testing.T) { diff --git a/internal/parser/vendors/inspur/parser.go b/internal/parser/vendors/inspur/parser.go index dfe17f4..5255b6a 100644 --- a/internal/parser/vendors/inspur/parser.go +++ b/internal/parser/vendors/inspur/parser.go @@ -16,7 +16,7 @@ import ( // parserVersion - version of this parser module // IMPORTANT: Increment this version when making changes to parser logic! -const parserVersion = "2.6" +const parserVersion = "3.0" func init() { parser.Register(&Parser{}) diff --git a/internal/parser/vendors/inspur/pcie.go b/internal/parser/vendors/inspur/pcie.go index a2f3df1..a915d85 100644 --- a/internal/parser/vendors/inspur/pcie.go +++ b/internal/parser/vendors/inspur/pcie.go @@ -65,20 +65,15 @@ func ParsePCIeSlotDeviceNames(content []byte) map[int]string { func parsePCIeRESTJSON(content []byte) (PCIeRESTInfo, bool) { text := string(content) startMarker := "RESTful PCIE Device info:" - endMarker := "BMC sdr Info:" startIdx := strings.Index(text, startMarker) if startIdx == -1 { return nil, false } - endIdx := strings.Index(text[startIdx:], endMarker) - if endIdx == -1 { - endIdx = len(text) - startIdx - } - jsonText := strings.TrimSpace(text[startIdx+len(startMarker) : startIdx+endIdx]) + jsonText := strings.TrimSpace(text[startIdx+len(startMarker):]) var info PCIeRESTInfo - if err := json.Unmarshal([]byte(jsonText), &info); err != nil { + if err := json.NewDecoder(strings.NewReader(jsonText)).Decode(&info); err != nil { return nil, false } return info, true @@ -118,27 +113,8 @@ func ParsePCIeNVMeLocToSlot(content []byte) map[int]int { // ParsePCIeDevices parses RESTful PCIE Device info from devicefrusdr.log func ParsePCIeDevices(content []byte) []models.PCIeDevice { - text := string(content) - - // Find RESTful PCIE Device info section - startMarker := "RESTful PCIE Device info:" - endMarker := "BMC sdr Info:" - - startIdx := strings.Index(text, startMarker) - if startIdx == -1 { - return nil - } - - endIdx := strings.Index(text[startIdx:], endMarker) - if endIdx == -1 { - endIdx = len(text) - startIdx - } - - jsonText := text[startIdx+len(startMarker) : startIdx+endIdx] - jsonText = strings.TrimSpace(jsonText) - - var pcieInfo PCIeRESTInfo - if err := json.Unmarshal([]byte(jsonText), &pcieInfo); err != nil { + pcieInfo, ok := parsePCIeRESTJSON(content) + if !ok { return nil } @@ -156,8 +132,9 @@ func ParsePCIeDevices(content []byte) []models.PCIeDevice { deviceClass := determineDeviceClass(pcie.DevType, pcie.DevSubtype, pcie.DeviceName) _, pciDeviceName := pciids.DeviceInfo(pcie.VendorID, pcie.DeviceID) - // Build BDF string in canonical form (bb:dd.f) - bdf := formatBDF(pcie.BusNum, pcie.DevNum, pcie.FuncNum) + // Use the domain-qualified BDF as the canonical slot identity. Keep the + // physical locator as descriptive evidence for affinity parsing. + bdf := fullBDF(pcie.BusNum, pcie.DevNum, pcie.FuncNum) partNumber := strings.TrimSpace(pcie.PartNum) if partNumber == "" { @@ -177,7 +154,8 @@ func ParsePCIeDevices(content []byte) []models.PCIeDevice { } device := models.PCIeDevice{ - Slot: pcie.Location, + Slot: bdf, + Description: strings.TrimSpace(pcie.Location), VendorID: pcie.VendorID, DeviceID: pcie.DeviceID, BDF: bdf, @@ -190,6 +168,7 @@ func ParsePCIeDevices(content []byte) []models.PCIeDevice { PartNumber: partNumber, SerialNumber: strings.TrimSpace(pcie.SerialNum), Status: pcieRESTStatus(pcie.Status), + NUMANode: parseInspurCPUAffinity(pcie.Location, pcie.DeviceLocator), } devices = append(devices, device) @@ -198,6 +177,22 @@ func ParsePCIeDevices(content []byte) []models.PCIeDevice { return devices } +var inspurCPUAffinityRegex = regexp.MustCompile(`(?i)(?:^|[^a-z0-9])CPU(\d+)(?:[^0-9]|$)`) + +func parseInspurCPUAffinity(locations ...string) *int { + for _, location := range locations { + match := inspurCPUAffinityRegex.FindStringSubmatch(strings.TrimSpace(location)) + if match == nil { + continue + } + value, err := strconv.Atoi(match[1]) + if err == nil { + return &value + } + } + return nil +} + // pcieRESTStatus maps the RESTful PCIE Device info "status" flag (1 = OK) to // the shared status vocabulary. Only the observed OK case is mapped — the // meaning of other values isn't confirmed in the source, so it's left @@ -329,6 +324,9 @@ func enrichPCIeDevice(dst *models.PCIeDevice, src models.PCIeDevice) { if dst.Present == nil { dst.Present = src.Present } + if dst.NUMANode == nil { + dst.NUMANode = src.NUMANode + } if strings.TrimSpace(dst.Status) == "" { dst.Status = src.Status } diff --git a/internal/parser/vendors/inspur/pcie_test.go b/internal/parser/vendors/inspur/pcie_test.go index 15ebcb0..4ba52af 100644 --- a/internal/parser/vendors/inspur/pcie_test.go +++ b/internal/parser/vendors/inspur/pcie_test.go @@ -19,8 +19,34 @@ BMC sdr Info:`) if devices[0].PartNumber != "I350T4V2" { t.Fatalf("expected part/model I350T4V2, got %q", devices[0].PartNumber) } - if devices[0].BDF != "45:00.0" { - t.Fatalf("expected BDF 45:00.0, got %q", devices[0].BDF) + if devices[0].BDF != "0000:45:00.0" || devices[0].Slot != devices[0].BDF { + t.Fatalf("expected canonical BDF slot 0000:45:00.0, got slot=%q bdf=%q", devices[0].Slot, devices[0].BDF) + } + if devices[0].NUMANode == nil || *devices[0].NUMANode != 0 { + t.Fatalf("expected CPU affinity 0, got %v", devices[0].NUMANode) + } +} + +func TestParsePCIeDevices_ReadsAffinityFromCombinedComponentLog(t *testing.T) { + content := []byte(`RESTful PCIE Device info: +[{"id":3,"present":1,"vendor_id":5555,"vendor_name":"NVIDIA","device_id":4125,"device_name":"MCX623106AN-CDAT","bus_num":177,"dev_num":0,"func_num":0,"max_link_width":16,"max_link_speed":4,"current_link_width":16,"current_link_speed":4,"location":"Riser 3 Slot 1","DeviceLocator":"CPU1_PE1_PCIE1","dev_type":2,"dev_subtype":0}] +RESTful Network Adapter info: +{"sys_adapters":[]}`) + + devices := ParsePCIeDevices(content) + if len(devices) != 1 { + t.Fatalf("expected 1 device, got %d", len(devices)) + } + if devices[0].NUMANode == nil || *devices[0].NUMANode != 1 { + t.Fatalf("expected CPU affinity 1 from DeviceLocator, got %v", devices[0].NUMANode) + } +} + +func TestParseInspurCPUAffinity_UnknownRemainsNil(t *testing.T) { + for _, location := range []string{"", "N/A", "Riser 2 Slot 1", "SCPU1_SLOT"} { + if got := parseInspurCPUAffinity(location); got != nil { + t.Fatalf("parseInspurCPUAffinity(%q) = %v, want nil", location, got) + } } } @@ -56,6 +82,17 @@ func TestMergePCIeDevices_EnrichesGenericAssetEntry(t *testing.T) { } } +func TestMergePCIeDevices_EnrichesNUMAAffinity(t *testing.T) { + node := 1 + got := MergePCIeDevices( + []models.PCIeDevice{{BDF: "98:00.0"}}, + []models.PCIeDevice{{BDF: "98:00.0", NUMANode: &node}}, + ) + if len(got) != 1 || got[0].NUMANode == nil || *got[0].NUMANode != 1 { + t.Fatalf("expected merged NUMA affinity 1, got %#v", got) + } +} + func TestParsePCIeDevices_ResolvesModelFromPCIIDsWhenDeviceNameIsRawHex(t *testing.T) { content := []byte(`RESTful PCIE Device info: [{"id":5,"present":1,"vendor_id":36869,"vendor_name":"","device_id":655,"device_name":"0x028F","bus_num":152,"dev_num":0,"func_num":0,"max_link_width":8,"max_link_speed":3,"current_link_width":8,"current_link_speed":3,"location":"#CPU1_PCIE9","dev_type":1,"dev_subtype":7,"part_num":"","serial_num":"","fw_ver":""}]