diff --git a/src/kerf/dtc/overlay.py b/src/kerf/dtc/overlay.py index d9dbc3f..c856207 100644 --- a/src/kerf/dtc/overlay.py +++ b/src/kerf/dtc/overlay.py @@ -296,10 +296,6 @@ def _create_overlay_dtb( fdt_sw.property_u64("memory-bytes", instance.resources.memory_bytes) - if instance.resources.devices: - stringlist_data = b'\0'.join(d.encode('utf-8') for d in instance.resources.devices) + b'\0' - fdt_sw.property("device-names", stringlist_data) - if instance.resources.numa_nodes: numa_data = struct.pack( ">" + "I" * len(instance.resources.numa_nodes), *instance.resources.numa_nodes @@ -341,6 +337,19 @@ def _create_overlay_dtb( fdt_sw.end_node() # End fragment fragment_id += 1 + if instance.resources.devices: + fdt_sw.begin_node(f"fragment@{fragment_id:x}") + fdt_sw.property_string( + "target-path", f"{self.INSTANCES_PATH}/{name}" + ) + fdt_sw.begin_node("__overlay__") + self._device_op( + fdt_sw, "device-add", sorted(instance.resources.devices) + ) + fdt_sw.end_node() # End __overlay__ + fdt_sw.end_node() # End fragment + fragment_id += 1 + for name in instances_to_remove: fdt_sw.begin_node(f"fragment@{fragment_id:x}") fdt_sw.property_string("target-path", self.INSTANCES_PATH) diff --git a/src/kerf/dtc/parser.py b/src/kerf/dtc/parser.py index 0c95131..9676887 100644 --- a/src/kerf/dtc/parser.py +++ b/src/kerf/dtc/parser.py @@ -18,13 +18,11 @@ import re import struct -from typing import Dict, List, Optional, Tuple +from typing import Dict, List, Optional, TYPE_CHECKING, Tuple import libfdt from ..exceptions import ParseError -from ..pool_diff import ANY_NODE -from .cells import unpack_cpu_ids from ..models import ( CPUAllocation, DeviceInfo, @@ -38,6 +36,11 @@ PoolMemoryRegion, TopologySection, ) +from ..pool_diff import ANY_NODE +from .cells import unpack_cpu_ids + +if TYPE_CHECKING: + from ..models import CPUTopology _LEGACY_MEMORY_ERROR = ( @@ -106,6 +109,21 @@ def parse_dtb_from_bytes(self, dtb_data: bytes) -> GlobalDeviceTree: except Exception as e: raise ParseError(f"Failed to parse DTB from bytes: {e}") from e + def parse_devices_from_bytes(self, dtb_data: bytes) -> Dict[str, DeviceInfo]: + """Parse only a DTB's device inventory. + + Instance trees describe their assigned memory differently from a live + pool, but use the same hierarchical PCI representation. Callers that + only need to verify device membership can therefore avoid parsing the + unrelated pool metadata. + """ + try: + self.fdt = libfdt.Fdt(dtb_data) + resources = self.fdt.path_offset('/resources') + return self._parse_devices(resources) + except libfdt.FdtException as e: + raise ParseError(f"Failed to parse devices from DTB: {e}") from e + def _build_global_tree(self) -> GlobalDeviceTree: """Build GlobalDeviceTree from parsed FDT.""" try: @@ -312,32 +330,90 @@ def _parse_memory_allocation(self, resources_node: int) -> MemoryAllocation: ) def _parse_devices(self, resources_node: int) -> Dict[str, DeviceInfo]: - """Parse device information from resources node.""" + """Parse flat platform devices and the kernel's PCI hierarchy.""" devices = {} try: devices_node = self.fdt.subnode_offset(resources_node, 'devices') except libfdt.FdtException: - return devices + devices_node = -1 - # Iterate through device nodes - offset = self.fdt.first_subnode(devices_node) - while offset >= 0: - name = self.fdt.get_name(offset) - try: - device_info = self._parse_device_info(offset, name) - devices[name] = device_info - except ParseError: - # Skip nodes that don't have required properties (not valid devices) - pass + if devices_node >= 0: + # Platform devices and legacy PCI inventories are flat here. + offset = self.fdt.first_subnode(devices_node) + while offset >= 0: + name = self.fdt.get_name(offset) + try: + device_info = self._parse_device_info(offset, name) + devices[name] = device_info + except ParseError: + # Skip nodes that don't have required properties. + pass + try: + offset = self.fdt.next_subnode(offset) + except libfdt.FdtException: + break + + known_pci_ids = { + device.pci_id for device in devices.values() if device.pci_id + } + root = self.fdt.path_offset('/') + for node in self._subnodes(root): + compatible = self._optional_prop(node, 'compatible') + if compatible is None: + continue try: - offset = self.fdt.next_subnode(offset) - except libfdt.FdtException: - # No more subnodes - break + is_pci_root = compatible.as_str() == 'multikernel,pci-host-bridge' + except (AttributeError, ValueError): + is_pci_root = False + if not is_pci_root: + continue + domain = self._optional_u32(node, 'linux,pci-domain', 0) + self._parse_pci_bus(node, domain, devices, known_pci_ids) return devices + def _parse_pci_bus( + self, + bus_node: int, + domain: int, + devices: Dict[str, DeviceInfo], + known_pci_ids: set[str], + ) -> None: + """Recover canonical BDFs from hierarchical PCI ``reg`` cells.""" + for node in self._subnodes(bus_node): + reg = self._optional_prop(node, 'reg') + if reg is None or len(reg) < 4: + continue + + phys_hi = struct.unpack('>I', bytes(reg)[:4])[0] + vendor = self._optional_prop(node, 'vendor-id') + if vendor is None: + self._parse_pci_bus(node, domain, devices, known_pci_ids) + continue + + device = self._optional_prop(node, 'device-id') + if device is None: + continue + + bus = (phys_hi >> 16) & 0xff + devfn = (phys_hi >> 8) & 0xff + slot = (devfn >> 3) & 0x1f + function = devfn & 0x7 + pci_id = f'{domain:04x}:{bus:02x}:{slot:02x}.{function:x}' + if pci_id in known_pci_ids: + continue + + devices[pci_id] = DeviceInfo( + name=pci_id, + compatible='pci', + device_type='pci', + pci_id=pci_id, + vendor_id=vendor.as_uint32(), + device_id=device.as_uint32(), + ) + known_pci_ids.add(pci_id) + def _parse_device_info(self, node_offset: int, name: str) -> DeviceInfo: """Parse individual device information.""" compatible = "" @@ -783,14 +859,14 @@ def _parse_hardware_from_dts(self, dts_content: str) -> HardwareInventory: devices=devices ) - def _extract_resources_section(self, dts_content: str) -> Optional[str]: - """Extract the resources section content with proper brace matching.""" + def _extract_named_section(self, dts_content: str, section_name: str) -> Optional[str]: + """Extract a named DTS section body with brace matching.""" - resources_start = re.search(r'resources\s*\{', dts_content) - if not resources_start: + section_start = re.search(rf'{re.escape(section_name)}\s*\{{', dts_content) + if not section_start: return None - start_pos = resources_start.end() - 1 + start_pos = section_start.end() - 1 brace_count = 0 end_pos = start_pos @@ -804,9 +880,36 @@ def _extract_resources_section(self, dts_content: str) -> Optional[str]: break if brace_count == 0: - return dts_content[start_pos+1:end_pos] + return dts_content[start_pos + 1:end_pos] return None + def _extract_resources_section(self, dts_content: str) -> Optional[str]: + """Extract the resources section content with proper brace matching.""" + return self._extract_named_section(dts_content, 'resources') + + @staticmethod + def _parse_cell_values(values: str) -> List[int]: + """Parse integer cells after removing DTS comments.""" + + values = re.sub(r'/\*.*?\*/', ' ', values, flags=re.DOTALL) + values = re.sub(r'//[^\n]*', ' ', values) + return [int(value, 0) for value in values.split()] + + def _parse_cpu_cell_values( + self, property_name: str, dts_content: str + ) -> Optional[List[int]]: + """Parse a CPU-list property accepting legacy and explicit-width cells.""" + + match = re.search( + rf'{re.escape(property_name)}\s*=\s*' + rf'(?:/bits/\s+(?:32|64)\s*)?<([^>]+)>', + dts_content, + ) + if not match: + return None + + return self._parse_cell_values(match.group(1)) + def _parse_cpus_from_dts(self, dts_content: str) -> CPUAllocation: """Parse CPU allocation from DTS content.""" @@ -814,17 +917,10 @@ def _parse_cpus_from_dts(self, dts_content: str) -> CPUAllocation: if not resources_text: raise ParseError("Missing /resources section in DTS") - cpus_match = re.search(r'cpus\s*=\s*<([^>]+)>', resources_text) - if not cpus_match: + available = self._parse_cpu_cell_values('cpus', resources_text) + if available is None: raise ParseError("Missing 'cpus' property in /resources") - - available = [int(x.strip()) for x in cpus_match.group(1).split()] - - free_match = re.search(r'cpus-available\s*=\s*<([^>]+)>', resources_text) - available_free = None - if free_match: - available_free = [int(x.strip()) for x in free_match.group(1).split()] - + available_free = self._parse_cpu_cell_values('cpus-available', resources_text) if available: total = max(available) + 1 else: @@ -1177,10 +1273,9 @@ def _parse_instance_resources_from_dts(self, content: str) -> InstanceResources: resources_text = resources_section.group(1) # Parse CPUs - cpus_match = re.search(r'cpus\s*=\s*<([^>]+)>', resources_text) - if not cpus_match: + cpus = self._parse_cpu_cell_values('cpus', resources_text) + if cpus is None: raise ParseError("Missing 'cpus' in resources") - cpus = [int(x.strip()) for x in cpus_match.group(1).split()] # Parse memory base memory_base_match = re.search(r'memory-base\s*=\s*<([^>]+)>', resources_text) @@ -1202,10 +1297,7 @@ def _parse_instance_resources_from_dts(self, content: str) -> InstanceResources: devices = [x.strip().lstrip('&') for x in devices_match.group(1).split(',')] # Parse NUMA nodes (optional) - numa_nodes = None - numa_nodes_match = re.search(r'numa-nodes\s*=\s*<([^>]+)>', resources_text) - if numa_nodes_match: - numa_nodes = [int(x.strip()) for x in numa_nodes_match.group(1).split()] + numa_nodes = self._parse_cpu_cell_values('numa-nodes', resources_text) # Parse CPU affinity (optional) cpu_affinity = None @@ -1307,12 +1399,10 @@ def _parse_topology_from_dts(self, dts_content: str) -> Optional[TopologySection """Parse topology section from DTS content.""" # Look for topology section - topology_section = re.search(r'topology\s*\{([^}]+)\}', dts_content, re.DOTALL) - if not topology_section: + topology_text = self._extract_named_section(dts_content, 'topology') + if not topology_text: return None - topology_text = topology_section.group(1) - # Parse NUMA nodes from topology section numa_nodes = self._parse_numa_nodes_from_dts(topology_text) @@ -1324,12 +1414,10 @@ def _parse_numa_nodes_from_dts(self, topology_text: str) -> Optional[Dict[int, N numa_nodes = {} # Look for numa-nodes subsection - numa_section = re.search(r'numa-nodes\s*\{([^}]+)\}', topology_text, re.DOTALL) - if not numa_section: + numa_text = self._extract_named_section(topology_text, 'numa-nodes') + if not numa_text: return None - numa_text = numa_section.group(1) - # Find all NUMA node definitions node_pattern = r'node@(\d+)\s*\{([^}]+)\}' node_matches = re.finditer(node_pattern, numa_text, re.DOTALL) @@ -1356,14 +1444,14 @@ def _parse_numa_nodes_from_dts(self, topology_text: str) -> Optional[Dict[int, N memory_size = self._parse_hex_value(memory_size_match.group(1)) # Parse CPUs - cpus_match = re.search(r'cpus\s*=\s*<([^>]+)>', node_content) - if cpus_match: - cpus = [int(x.strip()) for x in cpus_match.group(1).split()] + parsed_cpus = self._parse_cpu_cell_values('cpus', node_content) + if parsed_cpus is not None: + cpus = parsed_cpus # Parse distance matrix (optional) distance_match = re.search(r'distance-matrix\s*=\s*<([^>]+)>', node_content) if distance_match: - distances = [int(x.strip()) for x in distance_match.group(1).split()] + distances = self._parse_cell_values(distance_match.group(1)) # Simple distance matrix parsing - would need more sophisticated logic for full matrix _ = distances # Mark as intentionally unused for now @@ -1383,27 +1471,28 @@ def _parse_numa_nodes_from_dts(self, topology_text: str) -> Optional[Dict[int, N return numa_nodes if numa_nodes else None - def _parse_cpu_topology_from_dts(self, dts_content: str) -> Optional[Dict[int, 'CPUTopology']]: + def _parse_cpu_topology_from_dts( + self, dts_content: str + ) -> Optional[Dict[int, 'CPUTopology']]: """Parse CPU topology from DTS content.""" from ..models import CPUTopology topology = {} # Look for cores section - cores_section = re.search(r'cores\s*\{([^}]+)\}', dts_content, re.DOTALL) - if not cores_section: + cores_text = self._extract_named_section(dts_content, 'cores') + if not cores_text: return None - cores_text = cores_section.group(1) - # Find all core definitions - core_pattern = r'core@(\d+)\s*\{\s*cpus\s*=\s*<([^>]+)>\s*;\s*\}' + core_pattern = r'core@(\d+)\s*\{([^}]+)\}' core_matches = re.finditer(core_pattern, cores_text, re.DOTALL) for match in core_matches: core_id = int(match.group(1)) - cpus_str = match.group(2) - cpus = [int(x.strip()) for x in cpus_str.split()] + cpus = self._parse_cpu_cell_values('cpus', match.group(2)) + if cpus is None: + continue # Create topology entries for each CPU in this core for i, cpu_id in enumerate(cpus): diff --git a/tests/test_parser.py b/tests/test_parser.py index 24ffba4..24e66d8 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -16,6 +16,9 @@ Tests for kerf device tree parser. """ +import struct + +import libfdt import pytest from kerf.dtc.parser import DeviceTreeParser from kerf.dtc.extractor import InstanceExtractor @@ -107,6 +110,142 @@ def test_parse_dtb_with_devices(self, sample_tree): assert device.compatible == "intel,i40e" assert device.sriov_vfs == 8 + def test_parse_kernel_pci_hierarchy_by_bdf(self): + """Parse PCI leaves from the live pool tree emitted by the kernel.""" + fdt_sw = libfdt.FdtSw() + fdt_sw.finish_reservemap() + fdt_sw.begin_node("") + fdt_sw.property_string("compatible", "multikernel-v1") + + fdt_sw.begin_node("resources") + fdt_sw.property("cpus", struct.pack(">QQQQ", 0, 1, 2, 3)) + fdt_sw.property("cpus-available", struct.pack(">QQ", 2, 3)) + fdt_sw.begin_node("memory@0") + fdt_sw.property("reg", struct.pack(">QQ", 0x200000000, 0x40000000)) + fdt_sw.property_u32("numa-node-id", 0) + fdt_sw.end_node() + fdt_sw.end_node() + + fdt_sw.begin_node("pci@0") + fdt_sw.property_string("compatible", "multikernel,pci-host-bridge") + fdt_sw.property_u32("linux,pci-domain", 0) + fdt_sw.begin_node("pci@12,0") + fdt_sw.property("reg", struct.pack(">IIIII", 0x9000, 0, 0, 0, 0)) + fdt_sw.property_u32("vendor-id", 0x8086) + fdt_sw.property_u32("device-id", 0x10CA) + fdt_sw.end_node() + fdt_sw.begin_node("pci@3,0") + fdt_sw.property("reg", struct.pack(">IIIII", 0x1800, 0, 0, 0, 0)) + fdt_sw.begin_node("pci@10,2") + fdt_sw.property("reg", struct.pack(">IIIII", 0x18200, 0, 0, 0, 0)) + fdt_sw.property_u32("vendor-id", 0x8086) + fdt_sw.property_u32("device-id", 0x10CA) + fdt_sw.end_node() + fdt_sw.end_node() + fdt_sw.end_node() + fdt_sw.end_node() + + dtb = fdt_sw.as_fdt() + dtb.pack() + devices = DeviceTreeParser().parse_dtb_from_bytes( + dtb.as_bytearray() + ).hardware.devices + + assert set(devices) == {"0000:00:12.0", "0000:01:10.2"} + assert devices["0000:00:12.0"].pci_id == "0000:00:12.0" + assert devices["0000:01:10.2"].vendor_id == 0x8086 + assert set(DeviceTreeParser().parse_devices_from_bytes(dtb.as_bytearray())) == set( + devices + ) + + @pytest.mark.parametrize( + ("declaration", "expected"), + [ + ("<2 3>", [2, 3]), + ("<0x2 3>", [2, 3]), + ("/bits/ 32 <0x2 0x3>", [2, 3]), + ("/bits/ 64 <0x2 0x3>", [2, 3]), + ], + ) + def test_parse_cpu_ids_from_dts(self, declaration, expected): + """Test legacy and explicit-width CPU cells in DTS sources.""" + dts = f"/dts-v1/; / {{ resources {{ cpus = {declaration}; }}; }};" + cpus = DeviceTreeParser()._parse_cpus_from_dts(dts) # pylint: disable=protected-access + + assert cpus.available == expected + + @pytest.mark.parametrize( + ("declaration", "expected"), + [ + ("<4 5>", [4, 5]), + ("/bits/ 32 <0x4 0x5>", [4, 5]), + ("/bits/ 64 <0x4 0x5>", [4, 5]), + ], + ) + def test_parse_instance_resource_cpu_ids_from_dts(self, declaration, expected): + """Test instance resource CPU cells in DTS sources.""" + dts = f""" + resources {{ + cpus = {declaration}; + memory-base = <0x100000000>; + memory-bytes = <0x40000000>; + }}; + """ + resources = DeviceTreeParser()._parse_instance_resources_from_dts(dts) # pylint: disable=protected-access + + assert resources.cpus == expected + + @pytest.mark.parametrize( + ("declaration", "expected"), + [ + ("<6 7>", [6, 7]), + ("/bits/ 32 <0x6 0x7>", [6, 7]), + ("/bits/ 64 <0x6 0x7>", [6, 7]), + ], + ) + def test_parse_numa_membership_cpu_ids_from_dts(self, declaration, expected): + """Test NUMA node CPU membership cells in DTS sources.""" + topology = f""" + topology {{ + numa-nodes {{ + node@0 {{ + memory-base = <0x0>; + memory-size = <0x40000000>; + cpus = {declaration}; + }}; + }}; + }}; + """ + parsed = DeviceTreeParser()._parse_topology_from_dts(topology) # pylint: disable=protected-access + + assert parsed is not None + assert parsed.numa_nodes is not None + assert parsed.numa_nodes[0].cpus == expected + + @pytest.mark.parametrize( + ("declaration", "expected"), + [ + ("<8 9>", [8, 9]), + ("/bits/ 32 <0x8 0x9>", [8, 9]), + ("/bits/ 64 <0x8 0x9>", [8, 9]), + ], + ) + def test_parse_core_topology_cpu_ids_from_dts(self, declaration, expected): + """Test core topology CPU cells in DTS sources.""" + dts = f""" + /dts-v1/; + / {{ + cores {{ + core@4 {{ cpus = {declaration}; }}; + }}; + }}; + """ + topology = DeviceTreeParser()._parse_cpu_topology_from_dts(dts) # pylint: disable=protected-access + + assert topology is not None + assert sorted(topology) == expected + assert [topology[cpu_id].core_id for cpu_id in expected] == [4, 4] # pylint: disable=unsubscriptable-object + class TestInstanceExtractor: """Test instance extraction.""" @@ -120,8 +259,6 @@ def test_generate_global_dtb(self, sample_tree): assert len(dtb_data) > 0 # Should be valid FDT with magic number - import struct - magic = struct.unpack(">I", dtb_data[:4])[0] assert magic == 0xD00DFEED # FDT magic number diff --git a/tests/test_pool_overlay.py b/tests/test_pool_overlay.py index d0ceb2b..39d8ebe 100644 --- a/tests/test_pool_overlay.py +++ b/tests/test_pool_overlay.py @@ -131,9 +131,11 @@ def test_update_overlay_shrink_names_memory_items(sample_instances): def test_create_overlay_targets_the_instance_namespace(sample_hardware, sample_instances): current = GlobalDeviceTree(hardware=sample_hardware, instances={}, device_references={}) + instance = copy.deepcopy(sample_instances["database"]) + instance.resources.devices = ["0000:01:00.0"] modified = GlobalDeviceTree( hardware=sample_hardware, - instances={"database": sample_instances["database"]}, + instances={"database": instance}, device_references={}, ) fdt, ov = _ov(OverlayGenerator().generate_overlay(current, modified)) @@ -145,7 +147,14 @@ def test_create_overlay_targets_the_instance_namespace(sample_hardware, sample_i resources = fdt.subnode_offset(create, "resources") assert fdt.getprop(resources, "memory-base", quiet=[libfdt.FDT_ERR_NOTFOUND]) == MISSING assert fdt.getprop(resources, "memory-bytes").as_uint64() == \ - sample_instances["database"].resources.memory_bytes + instance.resources.memory_bytes + assert fdt.getprop(resources, "device-names", quiet=[libfdt.FDT_ERR_NOTFOUND]) == MISSING + + device_fragment = fdt.path_offset("/fragment@1") + assert fdt.getprop(device_fragment, "target-path").as_str() == \ + "/instances/database" + device_add = fdt.path_offset("/fragment@1/__overlay__/device-add/pci@0") + assert fdt.getprop(device_add, "pci-id").as_str() == "0000:01:00.0" for offset in _walk(fdt): assert fdt.getprop(offset, "mk,instance", quiet=[libfdt.FDT_ERR_NOTFOUND]) == MISSING