From f3b74f1a57b4670b1629100b3e7d0e080384b341 Mon Sep 17 00:00:00 2001 From: ahmad-can Date: Tue, 24 Mar 2026 18:59:01 +0500 Subject: [PATCH 1/3] Add CPU topology profile split for Nova dedicated/shared sets --- README.md | 11 ++++++ openstack_hypervisor/cli/common.py | 4 +- openstack_hypervisor/cli/schemas.py | 11 ++++-- openstack_hypervisor/hooks.py | 61 +++++++++++++++++++++++++++++ templates/nova.conf.j2 | 6 ++- tests/unit/test_hooks.py | 25 ++++++------ tests/unit/test_templates.py | 22 +++++++++++ 7 files changed, 123 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 1b4ba20..aa75711 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,17 @@ 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` Dedicated/shared split percentage + +When set to an integer `0-100`, the snap treats EPA orchestrator's +`allocated_cores` as the source set and splits it into Nova CPU sets: + +- first `N%` of cores become `cpu_dedicated_set` +- remaining cores become `cpu_shared_set` + +When unset (default), the snap uses EPA-returned `allocated_cores` and +`shared_cpus` directly. + * `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..4a6633b 100644 --- a/openstack_hypervisor/cli/common.py +++ b/openstack_hypervisor/cli/common.py @@ -83,7 +83,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).encode()) data = s.recv(4096) response_dict = json.loads(data.decode()) if "error" in response_dict: @@ -121,7 +121,7 @@ def get_cpu_pinning_from_socket( request = AllocateCoresRequest( service_name=service_name, action=ActionType.ALLOCATE_CORES, - num_of_cores=cores_requested, + cores_requested=cores_requested, ) try: response = _communicate_with_socket(request, AllocateCoresResponse, socket_path) diff --git a/openstack_hypervisor/cli/schemas.py b/openstack_hypervisor/cli/schemas.py index 48a381c..40f7d0a 100644 --- a/openstack_hypervisor/cli/schemas.py +++ b/openstack_hypervisor/cli/schemas.py @@ -5,7 +5,7 @@ from enum import Enum from typing import Annotated, Dict, List, Literal, Optional, Union -from pydantic import BaseModel, Field, field_validator +from pydantic import AliasChoices, BaseModel, Field, field_validator API_VERSION: Literal["1.0"] = "1.0" @@ -26,8 +26,10 @@ class AllocateCoresRequest(BaseModel): version: Literal["1.0"] = Field(default=API_VERSION) action: Literal[ActionType.ALLOCATE_CORES] service_name: str = Field(description="Name of the requesting service") - num_of_cores: int = Field( + cores_requested: int = Field( default=0, + validation_alias=AliasChoices("cores_requested", "num_of_cores"), + serialization_alias="cores_requested", description=("Number of dedicated cores requested. 0 keeps default policy."), ) numa_node: Optional[int] = Field( @@ -111,7 +113,10 @@ class AllocateCoresResponse(BaseModel): version: Literal["1.0"] = Field(default=API_VERSION) service_name: str = Field(description="Name of the service that was allocated cores") - num_of_cores: int = Field(description="Number of cores that were requested") + cores_requested: int = Field( + validation_alias=AliasChoices("cores_requested", "num_of_cores"), + description="Number of cores that were requested", + ) cores_allocated: int = Field(description="Number of cores that were actually allocated") allocated_cores: str = Field(description="Comma-separated list of allocated CPU ranges") shared_cpus: str = Field(description="Comma-separated list of shared CPU ranges") diff --git a/openstack_hypervisor/hooks.py b/openstack_hypervisor/hooks.py index 13a3407..b0885f2 100644 --- a/openstack_hypervisor/hooks.py +++ b/openstack_hypervisor/hooks.py @@ -354,6 +354,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 +494,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 = int(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", @@ -3062,6 +3109,20 @@ def _get_configure_context(snap: Snap) -> dict: } ) context = _context_compat(context) + + cpu_pinning_profile = context["compute"].get("cpu_pinning_profile") + if cpu_pinning_profile not in ("", None): + try: + dedicated_percentage = int(cpu_pinning_profile) + except (TypeError, ValueError): + dedicated_percentage = -1 + if 0 <= dedicated_percentage <= 100: + cpu_shared_set, allocated_cores = _split_dedicated_cores_by_profile( + allocated_cores, dedicated_percentage + ) + 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..3cc05a1 100644 --- a/templates/nova.conf.j2 +++ b/templates/nova.conf.j2 @@ -74,10 +74,14 @@ 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 -%} diff --git a/tests/unit/test_hooks.py b/tests/unit/test_hooks.py index 3744f27..703f117 100644 --- a/tests/unit/test_hooks.py +++ b/tests/unit/test_hooks.py @@ -692,10 +692,11 @@ 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,cpu_pinning_profile,expected_shared,expected_dedicated", [ - ("0-3", "4-7", True), - ("", "", False), + ("0-3", "4-7", "", "0-3", "4-7"), + ("0-3", "4-7", "50", "6-7", "4-5"), + ("", "", "", "", ""), ], ) def test_nova_conf_cpu_pinning_injection( @@ -703,7 +704,9 @@ def test_nova_conf_cpu_pinning_injection( snap, cpu_shared_set, allocated_cores, - should_include, + cpu_pinning_profile, + expected_shared, + expected_dedicated, check_call, check_output, shutil_chown, @@ -756,19 +759,19 @@ def as_dict(self): ] } mocker.patch.object(snap.config, "get_options", return_value=ConfigOptionsDict(config_dict)) - mocker.patch.object(snap.config, "get", return_value="dummy") + mocker.patch.object( + snap.config, + "get", + side_effect=lambda key: cpu_pinning_profile if key == "compute.cpu-pinning-profile" else "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"] == expected_dedicated + assert context["compute"]["cpu_shared_set"] == expected_shared @mock.patch("openstack_hypervisor.netplan.get_netplan_config") diff --git a/tests/unit/test_templates.py b/tests/unit/test_templates.py index 98ca6bf..1af5b0b 100644 --- a/tests/unit/test_templates.py +++ b/tests/unit/test_templates.py @@ -77,6 +77,28 @@ def test_nova_clients_use_internal_interface(): ) +def test_nova_cpu_pinning_renders_dedicated_only(): + output = _render( + "nova.conf.j2", + compute={**BASE_CONTEXT["compute"], "allocated_cores": "4-7", "cpu_shared_set": ""}, + ) + + assert "[compute]" in output + assert "cpu_dedicated_set = 4-7" in output + assert "cpu_shared_set =" not in output + + +def test_nova_cpu_pinning_renders_shared_only(): + output = _render( + "nova.conf.j2", + compute={**BASE_CONTEXT["compute"], "allocated_cores": "", "cpu_shared_set": "0-3"}, + ) + + assert "[compute]" in output + assert "cpu_shared_set = 0-3" in output + assert "cpu_dedicated_set =" not in output + + def test_neutron_clients_use_internal_interface(): output = _render("neutron.conf.j2") From 3f8fc4a96d7080b291897a5b0aafdca08afee6d9 Mon Sep 17 00:00:00 2001 From: ahmad-can Date: Wed, 25 Mar 2026 19:56:47 +0500 Subject: [PATCH 2/3] Use new dedicated cores percentage allocation action request when profile set --- README.md | 17 +++++---- openstack_hypervisor/cli/common.py | 28 +++++++++++++-- openstack_hypervisor/cli/schemas.py | 49 ++++++++++++++++++++------ openstack_hypervisor/hooks.py | 54 +++++++++++++++++------------ templates/nova.conf.j2 | 6 +--- tests/unit/test_hooks.py | 21 ++++------- tests/unit/test_templates.py | 22 ------------ 7 files changed, 112 insertions(+), 85 deletions(-) diff --git a/README.md b/README.md index aa75711..899041a 100644 --- a/README.md +++ b/README.md @@ -66,17 +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` Dedicated/shared split percentage - -When set to an integer `0-100`, the snap treats EPA orchestrator's -`allocated_cores` as the source set and splits it into Nova CPU sets: - -- first `N%` of cores become `cpu_dedicated_set` -- remaining cores become `cpu_shared_set` +* `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 4a6633b..0255ac6 100644 --- a/openstack_hypervisor/cli/common.py +++ b/openstack_hypervisor/cli/common.py @@ -14,6 +14,8 @@ ActionType, AllocateCoresRequest, AllocateCoresResponse, + AllocateCoresPercentRequest, + AllocateCoresPercentResponse, AllocateHugepagesRequest, AllocateNumaCoresRequest, GetMemoryInfoRequest, @@ -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.model_dump_json(by_alias=True).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: @@ -121,7 +124,7 @@ def get_cpu_pinning_from_socket( request = AllocateCoresRequest( service_name=service_name, action=ActionType.ALLOCATE_CORES, - cores_requested=cores_requested, + num_of_cores=cores_requested, ) try: response = _communicate_with_socket(request, AllocateCoresResponse, socket_path) @@ -129,3 +132,24 @@ 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 40f7d0a..3a82c87 100644 --- a/openstack_hypervisor/cli/schemas.py +++ b/openstack_hypervisor/cli/schemas.py @@ -5,7 +5,7 @@ from enum import Enum from typing import Annotated, Dict, List, Literal, Optional, Union -from pydantic import AliasChoices, BaseModel, Field, field_validator +from pydantic import BaseModel, Field, field_validator API_VERSION: Literal["1.0"] = "1.0" @@ -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" @@ -26,23 +27,37 @@ class AllocateCoresRequest(BaseModel): version: Literal["1.0"] = Field(default=API_VERSION) action: Literal[ActionType.ALLOCATE_CORES] service_name: str = Field(description="Name of the requesting service") - cores_requested: int = Field( + num_of_cores: int = Field( default=0, - validation_alias=AliasChoices("cores_requested", "num_of_cores"), - serialization_alias="cores_requested", - description=("Number of dedicated cores requested. 0 keeps default policy."), + 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.", + ) + + class ListAllocationsRequest(BaseModel): """Request model for listing allocations.""" 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): @@ -66,7 +81,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): @@ -99,6 +117,7 @@ def validate_hugepages_requested(cls, v: int) -> int: EpaRequest = Annotated[ Union[ AllocateCoresRequest, + AllocateCoresPercentRequest, AllocateNumaCoresRequest, ListAllocationsRequest, GetMemoryInfoRequest, @@ -113,10 +132,7 @@ class AllocateCoresResponse(BaseModel): version: Literal["1.0"] = Field(default=API_VERSION) service_name: str = Field(description="Name of the service that was allocated cores") - cores_requested: int = Field( - validation_alias=AliasChoices("cores_requested", "num_of_cores"), - description="Number of cores that were requested", - ) + num_of_cores: int = Field(description="Number of cores that were requested") cores_allocated: int = Field(description="Number of cores that were actually allocated") allocated_cores: str = Field(description="Comma-separated list of allocated CPU ranges") shared_cpus: str = Field(description="Comma-separated list of shared CPU ranges") @@ -126,6 +142,17 @@ 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 b0885f2..b04778c 100644 --- a/openstack_hypervisor/hooks.py +++ b/openstack_hypervisor/hooks.py @@ -10,6 +10,7 @@ import hashlib import ipaddress import json +import math import logging import os import platform @@ -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 @@ -534,7 +536,7 @@ def _split_dedicated_cores_by_profile( if not cores: return "", "" - dedicated_count = int(len(cores) * dedicated_percentage / 100) + 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) @@ -3068,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", @@ -3094,9 +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() ) @@ -3111,17 +3099,37 @@ def _get_configure_context(snap: Snap) -> dict: context = _context_compat(context) cpu_pinning_profile = context["compute"].get("cpu_pinning_profile") - if cpu_pinning_profile not in ("", None): - try: - dedicated_percentage = int(cpu_pinning_profile) - except (TypeError, ValueError): - dedicated_percentage = -1 - if 0 <= dedicated_percentage <= 100: + try: + if cpu_pinning_profile: + # Expected JSON: + # {"dedicated_percentage": <1-99>, "requested_cores_percentage": <0-100>} + 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 ) - context["compute"]["allocated_cores"] = allocated_cores - context["compute"]["cpu_shared_set"] = cpu_shared_set + 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) diff --git a/templates/nova.conf.j2 b/templates/nova.conf.j2 index 3cc05a1..0337c4f 100644 --- a/templates/nova.conf.j2 +++ b/templates/nova.conf.j2 @@ -74,14 +74,10 @@ 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 or compute.cpu_shared_set -%} +{% if compute.allocated_cores and 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 -%} diff --git a/tests/unit/test_hooks.py b/tests/unit/test_hooks.py index 703f117..52013fa 100644 --- a/tests/unit/test_hooks.py +++ b/tests/unit/test_hooks.py @@ -692,11 +692,10 @@ def test_set_sriov_context(self, mock_get_nics, snap): @pytest.mark.parametrize( - "cpu_shared_set,allocated_cores,cpu_pinning_profile,expected_shared,expected_dedicated", + "cpu_shared_set,allocated_cores", [ - ("0-3", "4-7", "", "0-3", "4-7"), - ("0-3", "4-7", "50", "6-7", "4-5"), - ("", "", "", "", ""), + ("0-3", "4-7"), + ("", ""), ], ) def test_nova_conf_cpu_pinning_injection( @@ -704,9 +703,6 @@ def test_nova_conf_cpu_pinning_injection( snap, cpu_shared_set, allocated_cores, - cpu_pinning_profile, - expected_shared, - expected_dedicated, check_call, check_output, shutil_chown, @@ -758,21 +754,16 @@ 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", - side_effect=lambda key: cpu_pinning_profile if key == "compute.cpu-pinning-profile" else "dummy", - ) import openstack_hypervisor.hooks as hooks hooks.configure(snap) context = mock_template.render.call_args_list[0][0][0] - assert context["compute"]["allocated_cores"] == expected_dedicated - assert context["compute"]["cpu_shared_set"] == expected_shared - + assert context["compute"]["allocated_cores"] == allocated_cores + assert context["compute"]["cpu_shared_set"] == cpu_shared_set @mock.patch("openstack_hypervisor.netplan.get_netplan_config") def test_process_dpdk_netplan_config(mock_get_netplan_config, get_pci_address): diff --git a/tests/unit/test_templates.py b/tests/unit/test_templates.py index 1af5b0b..98ca6bf 100644 --- a/tests/unit/test_templates.py +++ b/tests/unit/test_templates.py @@ -77,28 +77,6 @@ def test_nova_clients_use_internal_interface(): ) -def test_nova_cpu_pinning_renders_dedicated_only(): - output = _render( - "nova.conf.j2", - compute={**BASE_CONTEXT["compute"], "allocated_cores": "4-7", "cpu_shared_set": ""}, - ) - - assert "[compute]" in output - assert "cpu_dedicated_set = 4-7" in output - assert "cpu_shared_set =" not in output - - -def test_nova_cpu_pinning_renders_shared_only(): - output = _render( - "nova.conf.j2", - compute={**BASE_CONTEXT["compute"], "allocated_cores": "", "cpu_shared_set": "0-3"}, - ) - - assert "[compute]" in output - assert "cpu_shared_set = 0-3" in output - assert "cpu_dedicated_set =" not in output - - def test_neutron_clients_use_internal_interface(): output = _render("neutron.conf.j2") From 3995f6a835f160cea8b9718eee823e9259861b0f Mon Sep 17 00:00:00 2001 From: ahmad-can Date: Thu, 26 Mar 2026 16:40:42 +0500 Subject: [PATCH 3/3] Align CPU pinning profile flow and nova CPU set rendering with EPA schema updates --- openstack_hypervisor/cli/common.py | 8 ++-- openstack_hypervisor/cli/schemas.py | 9 ++-- openstack_hypervisor/hooks.py | 18 ++++---- templates/nova.conf.j2 | 6 ++- tests/unit/test_hooks.py | 64 +++++++++++++++++++++++++++++ 5 files changed, 84 insertions(+), 21 deletions(-) diff --git a/openstack_hypervisor/cli/common.py b/openstack_hypervisor/cli/common.py index 0255ac6..362d3e0 100644 --- a/openstack_hypervisor/cli/common.py +++ b/openstack_hypervisor/cli/common.py @@ -12,10 +12,10 @@ from .schemas import ( ActionType, - AllocateCoresRequest, - AllocateCoresResponse, AllocateCoresPercentRequest, AllocateCoresPercentResponse, + AllocateCoresRequest, + AllocateCoresResponse, AllocateHugepagesRequest, AllocateNumaCoresRequest, GetMemoryInfoRequest, @@ -149,7 +149,5 @@ def get_cpu_pinning_percent_from_socket( action=ActionType.ALLOCATE_CORES_PERCENT, percent=requested_cores_percentage, ) - response = _communicate_with_socket( - request, AllocateCoresPercentResponse, socket_path - ) + 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 3a82c87..b1e21c0 100644 --- a/openstack_hypervisor/cli/schemas.py +++ b/openstack_hypervisor/cli/schemas.py @@ -29,10 +29,7 @@ class AllocateCoresRequest(BaseModel): service_name: str = Field(description="Name of the requesting service") num_of_cores: int = Field( 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)" + description=("Number of dedicated cores requested. 0 keeps default policy."), ) @@ -150,7 +147,9 @@ class AllocateCoresPercentResponse(BaseModel): 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") + remaining_available_cpus: int = Field( + description="Number of CPUs still available for allocation" + ) class AllocateNumaCoresResponse(BaseModel): diff --git a/openstack_hypervisor/hooks.py b/openstack_hypervisor/hooks.py index b04778c..e018b54 100644 --- a/openstack_hypervisor/hooks.py +++ b/openstack_hypervisor/hooks.py @@ -10,8 +10,8 @@ import hashlib import ipaddress import json -import math import logging +import math import os import platform import re @@ -3085,10 +3085,6 @@ def _get_configure_context(snap: Snap) -> dict: "sev", "internal", ).as_dict() - context["compute"]["multipath_enabled"] = ( - context["compute"].get("multipath_forced", False) or _is_multipathd_available() - ) - context.update( { "snap_common": str(snap.paths.common), @@ -3097,12 +3093,16 @@ 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: - # Expected JSON: - # {"dedicated_percentage": <1-99>, "requested_cores_percentage": <0-100>} 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( @@ -3123,9 +3123,7 @@ def _get_configure_context(snap: Snap) -> dict: 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}" - ) + logging.warning(f"Failed to get CPU pinning info from EPA orchestrator: {e}") cpu_shared_set, allocated_cores = "", "" context["compute"]["allocated_cores"] = allocated_cores 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 52013fa..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") @@ -722,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", @@ -765,6 +767,68 @@ def as_dict(self): 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") def test_process_dpdk_netplan_config(mock_get_netplan_config, get_pci_address): mock_get_netplan_config.return_value = yaml.safe_load(