diff --git a/README.md b/README.md index 1b4ba20..899041a 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,20 @@ Only used with `compute.cpu-mode` is set to `custom`. For more details please refer to the Nova [configuration reference](https://docs.openstack.org/nova/latest/admin/cpu-models.html) for cpu models. +* `compute.cpu-pinning-profile` CPU topology profile for Nova pinning + +When unset (default), the snap uses EPA-returned `allocated_cores` and +`shared_cpus` directly. + +When set to JSON (as sent by the charm) like: + +`{"dedicated_percentage": 40, "requested_cores_percentage": 90}` + +the snap requests EPA `allocated_cores` sized by `requested_cores_percentage` +using the `allocate_cores_percent` socket action, then applies the +`dedicated_percentage` split strategy to produce Nova's +`cpu_dedicated_set`/`cpu_shared_set`. + * `compute.spice-proxy-address` (`localhost`) IP address for SPICE consoles IP address to use for configuration of SPICE consoles in instances. diff --git a/openstack_hypervisor/cli/common.py b/openstack_hypervisor/cli/common.py index 5fe7e9d..362d3e0 100644 --- a/openstack_hypervisor/cli/common.py +++ b/openstack_hypervisor/cli/common.py @@ -12,6 +12,8 @@ from .schemas import ( ActionType, + AllocateCoresPercentRequest, + AllocateCoresPercentResponse, AllocateCoresRequest, AllocateCoresResponse, AllocateHugepagesRequest, @@ -57,6 +59,7 @@ def socket_path(snap: Snap) -> str: def _communicate_with_socket( request: Union[ AllocateCoresRequest, + AllocateCoresPercentRequest, AllocateNumaCoresRequest, AllocateHugepagesRequest, GetMemoryInfoRequest, @@ -83,7 +86,7 @@ def _communicate_with_socket( try: with pysocket.socket(pysocket.AF_UNIX, pysocket.SOCK_STREAM) as s: s.connect(socket_path) - s.sendall(request.json().encode()) + s.sendall(request.model_dump_json(by_alias=True, exclude_none=True).encode()) data = s.recv(4096) response_dict = json.loads(data.decode()) if "error" in response_dict: @@ -129,3 +132,22 @@ def get_cpu_pinning_from_socket( except (SocketCommunicationError, EPAOrchestratorError) as e: logging.error("Failed to get CPU pinning info from socket: {}".format(e)) raise + + +def get_cpu_pinning_percent_from_socket( + service_name: str, + socket_path: str, + requested_cores_percentage: int, +) -> str: + """Get allocated cores from EPA based on requested percentage. + + Returns EPA `allocated_cores` only; callers can derive any shared/dedicated + Nova sets they need. + """ + request = AllocateCoresPercentRequest( + service_name=service_name, + action=ActionType.ALLOCATE_CORES_PERCENT, + percent=requested_cores_percentage, + ) + response = _communicate_with_socket(request, AllocateCoresPercentResponse, socket_path) + return response.allocated_cores diff --git a/openstack_hypervisor/cli/schemas.py b/openstack_hypervisor/cli/schemas.py index 48a381c..b1e21c0 100644 --- a/openstack_hypervisor/cli/schemas.py +++ b/openstack_hypervisor/cli/schemas.py @@ -14,6 +14,7 @@ class ActionType(str, Enum): """Enum for different action types.""" ALLOCATE_CORES = "allocate_cores" + ALLOCATE_CORES_PERCENT = "allocate_cores_percent" LIST_ALLOCATIONS = "list_allocations" ALLOCATE_NUMA_CORES = "allocate_numa_cores" GET_MEMORY_INFO = "get_memory_info" @@ -30,8 +31,18 @@ class AllocateCoresRequest(BaseModel): default=0, description=("Number of dedicated cores requested. 0 keeps default policy."), ) - numa_node: Optional[int] = Field( - default=None, ge=0, description="NUMA node (must be omitted for allocate_cores)" + + +class AllocateCoresPercentRequest(BaseModel): + """Request model for allocating a percentage of isolated cores.""" + + version: Literal["1.0"] = Field(default=API_VERSION) + action: Literal[ActionType.ALLOCATE_CORES_PERCENT] + service_name: str = Field(description="Name of the requesting service") + percent: int = Field( + ge=-1, + le=100, + description="Percentage of isolated cores to allocate (0-100). -1 or 0 to deallocate.", ) @@ -40,7 +51,10 @@ class ListAllocationsRequest(BaseModel): version: Literal["1.0"] = Field(default=API_VERSION) action: Literal[ActionType.LIST_ALLOCATIONS] - service_name: str = Field(description="Name of the requesting service") + service_name: Optional[str] = Field( + default=None, + description="Name of the requesting service (optional)", + ) class AllocateNumaCoresRequest(BaseModel): @@ -64,7 +78,10 @@ class GetMemoryInfoRequest(BaseModel): version: Literal["1.0"] = Field(default=API_VERSION) action: Literal[ActionType.GET_MEMORY_INFO] - service_name: str = Field(description="Name of the requesting service") + service_name: Optional[str] = Field( + default=None, + description="Name of the requesting service (optional)", + ) class AllocateHugepagesRequest(BaseModel): @@ -97,6 +114,7 @@ def validate_hugepages_requested(cls, v: int) -> int: EpaRequest = Annotated[ Union[ AllocateCoresRequest, + AllocateCoresPercentRequest, AllocateNumaCoresRequest, ListAllocationsRequest, GetMemoryInfoRequest, @@ -121,6 +139,19 @@ class AllocateCoresResponse(BaseModel): ) +class AllocateCoresPercentResponse(BaseModel): + """Pydantic model for allocate cores percent response.""" + + version: Literal["1.0"] = Field(default=API_VERSION) + service_name: str = Field(description="Name of the service that was allocated cores") + cores_allocated_count: int = Field(description="Number of cores that were actually allocated") + allocated_cores: str = Field(description="Comma-separated list of allocated CPU ranges") + total_available_cpus: int = Field(description="Total number of CPUs available in the system") + remaining_available_cpus: int = Field( + description="Number of CPUs still available for allocation" + ) + + class AllocateNumaCoresResponse(BaseModel): """Pydantic model for NUMA allocate cores response.""" diff --git a/openstack_hypervisor/hooks.py b/openstack_hypervisor/hooks.py index 13a3407..e018b54 100644 --- a/openstack_hypervisor/hooks.py +++ b/openstack_hypervisor/hooks.py @@ -11,6 +11,7 @@ import ipaddress import json import logging +import math import os import platform import re @@ -54,6 +55,7 @@ EPAOrchestratorError, SocketCommunicationError, get_cpu_pinning_from_socket, + get_cpu_pinning_percent_from_socket, socket_path, ) from openstack_hypervisor.log import setup_logging @@ -354,6 +356,7 @@ def _get_local_ip_by_default_route() -> str: "compute.key": UNSET, "compute.migration-address": UNSET, "compute.resume-on-boot": True, + "compute.cpu-pinning-profile": UNSET, "compute.flavors": UNSET, "compute.pci-device-specs": [], "compute.pci-excluded-devices": [], @@ -493,6 +496,52 @@ def _context_compat(context: Dict[str, Any]) -> Dict[str, Any]: return clean_context +def _expand_cpu_ranges(cpu_ranges: str) -> List[int]: + """Expand comma-separated CPU ranges into an ordered list of CPU ids.""" + cpus: List[int] = [] + for token in cpu_ranges.split(","): + token = token.strip() + if not token: + continue + if "-" in token: + start, end = token.split("-", 1) + cpus.extend(list(range(int(start), int(end) + 1))) + else: + cpus.append(int(token)) + return cpus + + +def _compress_cpu_ranges(cpu_list: List[int]) -> str: + """Compress an ordered CPU list into Nova-compatible range notation.""" + if not cpu_list: + return "" + + ranges: List[str] = [] + start = prev = cpu_list[0] + for cpu in cpu_list[1:]: + if cpu == prev + 1: + prev = cpu + continue + ranges.append(f"{start}-{prev}" if start != prev else str(start)) + start = prev = cpu + ranges.append(f"{start}-{prev}" if start != prev else str(start)) + return ",".join(ranges) + + +def _split_dedicated_cores_by_profile( + dedicated_cores: str, dedicated_percentage: int +) -> tuple[str, str]: + """Split dedicated cores into (shared_set, dedicated_set) by percentage.""" + cores = _expand_cpu_ranges(dedicated_cores) + if not cores: + return "", "" + + dedicated_count = math.ceil(len(cores) * dedicated_percentage / 100) + profile_dedicated = cores[:dedicated_count] + profile_shared = cores[dedicated_count:] + return _compress_cpu_ranges(profile_shared), _compress_cpu_ranges(profile_dedicated) + + TEMPLATES = { Path("etc/nova/nova.conf"): { "template": "nova.conf.j2", @@ -3021,17 +3070,6 @@ def configure(snap: Snap) -> None: def _get_configure_context(snap: Snap) -> dict: - try: - cpu_shared_set, allocated_cores = get_cpu_pinning_from_socket( - service_name=snap.name, socket_path=socket_path(snap), cores_requested=0 - ) - except (SocketCommunicationError, EPAOrchestratorError) as e: - if "No Isolated CPUs configured" in str(e): - logging.info("No Isolated CPUs configured, continuing without CPU pinning.") - cpu_shared_set, allocated_cores = "", "" - else: - logging.warning(f"Failed to get CPU pinning info from EPA orchestrator: {e}") - cpu_shared_set, allocated_cores = "", "" context = snap.config.get_options( "compute", "network", @@ -3047,13 +3085,6 @@ def _get_configure_context(snap: Snap) -> dict: "sev", "internal", ).as_dict() - context["compute"]["allocated_cores"] = allocated_cores - context["compute"]["cpu_shared_set"] = cpu_shared_set - - context["compute"]["multipath_enabled"] = ( - context["compute"].get("multipath_forced", False) or _is_multipathd_available() - ) - context.update( { "snap_common": str(snap.paths.common), @@ -3062,6 +3093,42 @@ def _get_configure_context(snap: Snap) -> dict: } ) context = _context_compat(context) + context.setdefault("compute", {}) + context.setdefault("network", {}) + context.setdefault("identity", {}) + context["compute"]["multipath_enabled"] = ( + context["compute"].get("multipath_forced", False) or _is_multipathd_available() + ) + + cpu_pinning_profile = context["compute"].get("cpu_pinning_profile") + try: + if cpu_pinning_profile: + dedicated_percentage = int(cpu_pinning_profile["dedicated_percentage"]) + requested_cores_percentage = int(cpu_pinning_profile["requested_cores_percentage"]) + allocated_cores = get_cpu_pinning_percent_from_socket( + service_name=snap.name, + socket_path=socket_path(snap), + requested_cores_percentage=requested_cores_percentage, + ) + cpu_shared_set, allocated_cores = _split_dedicated_cores_by_profile( + allocated_cores, dedicated_percentage + ) + else: + cpu_shared_set, allocated_cores = get_cpu_pinning_from_socket( + service_name=snap.name, + socket_path=socket_path(snap), + cores_requested=0, + ) + except (SocketCommunicationError, EPAOrchestratorError) as e: + if "No Isolated CPUs configured" in str(e): + logging.info("No Isolated CPUs configured, continuing without CPU pinning.") + else: + logging.warning(f"Failed to get CPU pinning info from EPA orchestrator: {e}") + cpu_shared_set, allocated_cores = "", "" + + context["compute"]["allocated_cores"] = allocated_cores + context["compute"]["cpu_shared_set"] = cpu_shared_set + logging.info(context) if not context.get("identity"): diff --git a/templates/nova.conf.j2 b/templates/nova.conf.j2 index 0337c4f..77800af 100644 --- a/templates/nova.conf.j2 +++ b/templates/nova.conf.j2 @@ -74,11 +74,15 @@ swtpm_group = snap_daemon # are placed on the shared set, but this can be customized in Nova. For more details on # emulator thread pinning policies and their impact, see: # https://docs.openstack.org/nova/latest/admin/cpu-topologies.html#customizing-instance-emulator-thread-pinning-policy -{% if compute.allocated_cores and compute.cpu_shared_set -%} +{% if compute.allocated_cores or compute.cpu_shared_set -%} [compute] +{% if compute.allocated_cores -%} cpu_dedicated_set = {{ compute.allocated_cores }} +{% endif -%} +{% if compute.cpu_shared_set -%} cpu_shared_set = {{ compute.cpu_shared_set }} {% endif %} +{% endif -%} {% if compute.rbd_secret_uuid -%} rbd_user = {{ compute.rbd_user }} diff --git a/tests/unit/test_hooks.py b/tests/unit/test_hooks.py index 3744f27..822a015 100644 --- a/tests/unit/test_hooks.py +++ b/tests/unit/test_hooks.py @@ -184,6 +184,7 @@ def test_configure_hook( mocker.patch.object(hooks, "_secure_copy") mocker.patch.object(hooks, "_configure_webdav_apache") mocker.patch.object(hooks, "_process_dpdk_ports") + mocker.patch.object(hooks, "_is_multipathd_available", return_value=False) mocker.patch.object(hooks, "_get_template", return_value=mock_template) mocker.patch.object(hooks, "OVSCli", spec=hooks.OVSCli) mock_write_text = mocker.patch.object(hooks.Path, "write_text") @@ -692,10 +693,10 @@ def test_set_sriov_context(self, mock_get_nics, snap): @pytest.mark.parametrize( - "cpu_shared_set,allocated_cores,should_include", + "cpu_shared_set,allocated_cores", [ - ("0-3", "4-7", True), - ("", "", False), + ("0-3", "4-7"), + ("", ""), ], ) def test_nova_conf_cpu_pinning_injection( @@ -703,7 +704,6 @@ def test_nova_conf_cpu_pinning_injection( snap, cpu_shared_set, allocated_cores, - should_include, check_call, check_output, shutil_chown, @@ -723,6 +723,7 @@ def test_nova_conf_cpu_pinning_injection( "_configure_ovn_base", "_configure_ovn_external_networking", "_configure_ovn_base_external_ovs", + "_configure_webdav_apache", "_configure_kvm", "_configure_monitoring_services", "_configure_ceph", @@ -755,20 +756,77 @@ def as_dict(self): "sev", ] } + config_dict["compute"]["cpu-pinning-profile"] = "" mocker.patch.object(snap.config, "get_options", return_value=ConfigOptionsDict(config_dict)) - mocker.patch.object(snap.config, "get", return_value="dummy") import openstack_hypervisor.hooks as hooks hooks.configure(snap) context = mock_template.render.call_args_list[0][0][0] - if should_include: - assert context["compute"]["allocated_cores"] == allocated_cores - assert context["compute"]["cpu_shared_set"] == cpu_shared_set - else: - assert context["compute"]["allocated_cores"] == "" - assert context["compute"]["cpu_shared_set"] == "" + assert context["compute"]["allocated_cores"] == allocated_cores + assert context["compute"]["cpu_shared_set"] == cpu_shared_set + + +def test_get_configure_context_cpu_pinning_profile_percent_path(mocker, snap): + profile = {"dedicated_percentage": 40, "requested_cores_percentage": 50} + epa_allocated_cores = "2-9" + split_shared_set = "2-4" + split_allocated_cores = "5-9" + + mock_get_percent = mocker.patch( + "openstack_hypervisor.hooks.get_cpu_pinning_percent_from_socket", + return_value=epa_allocated_cores, + ) + mock_split = mocker.patch( + "openstack_hypervisor.hooks._split_dedicated_cores_by_profile", + return_value=(split_shared_set, split_allocated_cores), + ) + mock_legacy_get = mocker.patch("openstack_hypervisor.hooks.get_cpu_pinning_from_socket") + + class ConfigOptionsDict(dict): + def as_dict(self): + return dict(self) + + config_dict = { + k: {} + for k in [ + "compute", + "network", + "identity", + "logging", + "node", + "rabbitmq", + "credentials", + "telemetry", + "monitoring", + "ca", + "masakari", + "sev", + "internal", + ] + } + config_dict["compute"]["cpu-pinning-profile"] = profile + mocker.patch.object(snap.config, "get_options", return_value=ConfigOptionsDict(config_dict)) + mocker.patch("openstack_hypervisor.hooks._is_multipathd_available", return_value=False) + mocker.patch( + "openstack_hypervisor.hooks.ovs_switch_socket", + return_value="unix:/var/snap/openstack-hypervisor/common/run/openvswitch/db.sock", + ) + + import openstack_hypervisor.hooks as hooks + + context = hooks._get_configure_context(snap) + + mock_get_percent.assert_called_once_with( + service_name=snap.name, + socket_path=hooks.socket_path(snap), + requested_cores_percentage=profile["requested_cores_percentage"], + ) + mock_split.assert_called_once_with(epa_allocated_cores, profile["dedicated_percentage"]) + mock_legacy_get.assert_not_called() + assert context["compute"]["allocated_cores"] == split_allocated_cores + assert context["compute"]["cpu_shared_set"] == split_shared_set @mock.patch("openstack_hypervisor.netplan.get_netplan_config")