diff --git a/config.yaml b/config.yaml index 42b23968..1136e7e2 100644 --- a/config.yaml +++ b/config.yaml @@ -46,3 +46,7 @@ options: If not provided, it will default to the LoadBalancer Service hostname. If that is not available, it will default to the internal Kubernetes FQDN of the service. + enable-hugepages: + type: boolean + default: false + description: When enabled, HugePages of 1Gi will be used for a total of 2Gi HugePages. diff --git a/lib/charms/kubernetes_charm_libraries/v0/hugepages_volumes_patch.py b/lib/charms/kubernetes_charm_libraries/v0/hugepages_volumes_patch.py new file mode 100644 index 00000000..f81a0307 --- /dev/null +++ b/lib/charms/kubernetes_charm_libraries/v0/hugepages_volumes_patch.py @@ -0,0 +1,682 @@ +# Copyright 2023 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Charm library used to manage HugePages volumes in Kubernetes charms. + +- On a bound event (e.g., self.on.hugepages_volumes_config_changed), it will: + - Replace the volumes in the StatefulSet with the new requested ones + - Replace the volume mounts in the container in the Pod with the new requested ones. + - Replace the resource requirements in the container in the Pod with the new requested ones. + +## Usage + +```python + +from charms.kubernetes_charm_libraries.v0.kubernetes_hugepages_volumes_patch import ( + KubernetesHugePagesPatchCharmLib, + HugePagesVolume, +) + + +class K8sHugePagesVolumePatchChangedEvent(EventBase): + + +class K8sHugePagesVolumePatchChangedCharmEvents(CharmEvents): + hugepages_volumes_config_changed = EventSource(K8sHugePagesVolumePatchChangedEvent) + + +class YourCharm(CharmBase): + + on = K8sHugePagesVolumePatchChangedCharmEvents() + + def __init__(self, *args): + super().__init__(*args) + self._kubernetes_volumes_patch = KubernetesHugePagesPatchCharmLib( + charm=self, + container_name=self._container_name, + hugepages_volumes_func=self._hugepages_volumes_func_from_config, + refresh_event=self.on.hugepages_volumes_config_changed, + ) + + def _hugepages_volumes_func_from_config(self) -> list[HugePagesVolume]: + return [ + HugePagesVolume( + mount_path="/dev/hugepages", + size="1Gi", + limit="4Gi", + ) + ] + +""" +import logging +from dataclasses import dataclass +from typing import Callable, Iterable + +from lightkube import Client +from lightkube.core.exceptions import ApiError +from lightkube.models.apps_v1 import StatefulSetSpec +from lightkube.models.core_v1 import ( + Container, + EmptyDirVolumeSource, + ResourceRequirements, + Volume, + VolumeMount, +) +from lightkube.resources.apps_v1 import StatefulSet +from lightkube.resources.core_v1 import Pod +from ops.charm import CharmBase +from ops.framework import BoundEvent, Object + +# The unique Charmhub library identifier, never change it +LIBID = "b4cf8e58c9f64b73b22083d3e8d0de8e" + +# Increment this major API version when introducing breaking changes +LIBAPI = 0 + +# Increment this PATCH version before using `charmcraft publish-lib` or reset +# to 0 if you are raising the major API version +LIBPATCH = 1 + +logger = logging.getLogger(__name__) + + +@dataclass +class HugePagesVolume: + """HugePagesVolume.""" + + mount_path: str + size: str = "1Gi" + limit: str = "2Gi" + + +class KubernetesHugePagesVolumesPatchError(Exception): + """KubernetesHugePagesVolumesPatchError.""" + + def __init__(self, message: str): + self.message = message + super().__init__(self.message) + + +class KubernetesClient: + """Class containing all the Kubernetes specific calls.""" + + def __init__(self, namespace: str): + self.client = Client() + self.namespace = namespace + + @classmethod + def _get_container(cls, container_name: str, containers: Iterable[Container]) -> Container: + """Find the container from the container list, assuming list is unique by name. + + Args: + containers: Iterable of containers + container_name: Container name + + Raises: + KubernetesHugePagesVolumesPatchError: If the user-provided container name does + not exist in the list. + + Returns: + Container: An instance of :class:`Container` whose name matches the given name. + """ + try: + return next(iter(filter(lambda ctr: ctr.name == container_name, containers))) + except StopIteration: + raise KubernetesHugePagesVolumesPatchError(f"Container `{container_name}` not found") + + def pod_is_patched( + self, + pod_name: str, + requested_volumemounts: Iterable[VolumeMount], + requested_resources: ResourceRequirements, + container_name: str, + ) -> bool: + """Returns whether pod contains the given volumes mounts and resources. + + Args: + pod_name: Pod name + requested_volumemounts: Iterable of volume mounts + requested_resources: requested resources + container_name: Container name + + Raises: + KubernetesHugePagesVolumesPatchError: If the user-provided pod name does + not exist. + + Returns: + bool: Whether pod contains the given volumes mounts and resources. + """ + try: + pod = self.client.get(Pod, name=pod_name, namespace=self.namespace) + except ApiError as e: + if e.status.reason == "Unauthorized": + logger.debug("kube-apiserver not ready yet") + else: + raise KubernetesHugePagesVolumesPatchError(f"Pod `{pod_name}` not found") + return False + pod_has_volumemounts = self._pod_contains_requested_volumemounts( + requested_volumemounts=requested_volumemounts, + containers=pod.spec.containers, # type: ignore[attr-defined] + container_name=container_name, + ) + pod_has_resources = self._pod_resources_are_set( + containers=pod.spec.containers, # type: ignore[attr-defined] + container_name=container_name, + requested_resources=requested_resources, + ) + return pod_has_volumemounts and pod_has_resources + + def statefulset_is_patched( + self, + statefulset_name: str, + requested_volumes: Iterable[Volume], + ) -> bool: + """Returns whether the statefulset contains the given volumes. + + Args: + statefulset_name: Statefulset name + requested_volumes: Iterable of volumes + + Raises: + KubernetesHugePagesVolumesPatchError: If the user-provided statefulset name does + not exist. + + Returns: + bool: Whether the statefulset contains the given volumes. + """ + try: + statefulset = self.client.get( + res=StatefulSet, name=statefulset_name, namespace=self.namespace + ) + except ApiError as e: + if e.status.reason == "Unauthorized": + logger.debug("kube-apiserver not ready yet") + else: + raise KubernetesHugePagesVolumesPatchError( + f"Could not get statefulset `{statefulset_name}`" + ) + return False + return self._statefulset_contains_requested_volumes( + statefulset_spec=statefulset.spec, # type: ignore[attr-defined] + requested_volumes=requested_volumes, + ) + + @staticmethod + def _statefulset_contains_requested_volumes( + statefulset_spec: StatefulSetSpec, + requested_volumes: Iterable[Volume], + ) -> bool: + """Returns whether the StatefulSet contains the given volumes. + + Args: + statefulset_spec: StatefulSet spec + requested_volumes: Iterable of volumes + + Returns: + bool: Whether the StatefulSet contains the given volumes. + """ + if not statefulset_spec.template.spec.volumes: + return False + return all( + [ + requested_volume in statefulset_spec.template.spec.volumes + for requested_volume in requested_volumes + ] + ) + + def _pod_contains_requested_volumemounts( + self, + containers: Iterable[Container], + container_name: str, + requested_volumemounts: Iterable[VolumeMount], + ) -> bool: + """Returns whether container spec contains the given volumemounts. + + Args: + containers: Iterable of Containers + container_name: Container name + requested_volumemounts: Iterable of volume mounts that the container shall contain + + Returns: + bool: Whether container spec contains the given volumemounts. + """ + container = self._get_container(container_name=container_name, containers=containers) + return all( + [ + requested_volumemount in container.volumeMounts + for requested_volumemount in requested_volumemounts + ] + ) + + def _pod_resources_are_set( + self, + containers: Iterable[Container], + container_name: str, + requested_resources: ResourceRequirements, + ) -> bool: + """Returns whether container spec contains the expected resources requests and limits. + + Args: + containers: Iterable of Containers + container_name: Container name + requested_resources: resource requirements + + Returns: + bool: whether container spec contains the expected resources requests and limits. + """ + container = self._get_container(container_name=container_name, containers=containers) + if requested_resources.limits: + for limit, value in requested_resources.limits.items(): + if not container.resources.limits: + return False + if container.resources.limits.get(limit) != value: + return False + if requested_resources.requests: + for request, value in requested_resources.requests.items(): + if not container.resources.requests: + return False + if container.resources.requests.get(request) != value: + return False + return True + + def replace_statefulset( + self, + statefulset_name: str, + requested_volumes: Iterable[Volume], + requested_volumemounts: Iterable[VolumeMount], + requested_resources: ResourceRequirements, + container_name: str, + ) -> None: + """Updates a StatefulSet and a container in its spec. + + Raises: + KubernetesHugePagesVolumesPatchError: If the user-provided statefulset name does + not exist, or replacing statefulset failed. + + Args: + statefulset_name: Statefulset name + requested_volumes: Iterable of new volumes to be set in the StatefulSet + requested_volumemounts: Iterable of new volume mounts to be set in the given container + requested_resources: new resource requirements to be set in the given container + container_name: Container name + """ + if not requested_volumes: + logger.warning("No requested volumes were provided") + if not requested_volumemounts: + logger.warning("No requested volume mounts were provided") + try: + statefulset = self.client.get( + res=StatefulSet, name=statefulset_name, namespace=self.namespace + ) + except ApiError: + raise KubernetesHugePagesVolumesPatchError( + f"Could not get statefulset `{statefulset_name}`" + ) + containers: Iterable[Container] = statefulset.spec.template.spec.containers # type: ignore[attr-defined] # noqa: E501 + container = self._get_container(container_name=container_name, containers=containers) + container.volumeMounts = requested_volumemounts # type: ignore[assignment] + container.resources = requested_resources + statefulset.spec.template.spec.volumes = requested_volumes # type: ignore[attr-defined] + try: + self.client.replace(obj=statefulset) + except ApiError: + raise KubernetesHugePagesVolumesPatchError( + f"Could not replace statefulset `{statefulset_name}`" + ) + logger.info("Replaced `%s` statefulset", statefulset_name) + + def list_volumes(self, statefulset_name: str) -> list[Volume]: + """Lists current volumes in the given StatefulSet. + + Args: + statefulset_name: Statefulset name + + Raises: + KubernetesHugePagesVolumesPatchError: If the user-provided statefulset name does + not exist. + + Returns: + list[Volume]: List of current volumes in the given StatefulSet + """ + try: + statefulset = self.client.get( + res=StatefulSet, name=statefulset_name, namespace=self.namespace + ) + except ApiError: + raise KubernetesHugePagesVolumesPatchError( + f"Could not get statefulset `{statefulset_name}`" + ) + return statefulset.spec.template.spec.volumes # type: ignore[attr-defined] + + def list_volumemounts(self, statefulset_name: str, container_name: str) -> list[VolumeMount]: + """Lists current volume mounts in the given container. + + Args: + statefulset_name: Statefulset name + container_name: Container name + + Raises: + KubernetesHugePagesVolumesPatchError: If the user-provided statefulset name does + not exist. + + Returns: + list[VolumeMount]: List of current volume mounts in the given container + """ + try: + statefulset = self.client.get( + res=StatefulSet, name=statefulset_name, namespace=self.namespace + ) + except ApiError: + raise KubernetesHugePagesVolumesPatchError( + f"Could not get statefulset `{statefulset_name}`" + ) + containers: Iterable[Container] = statefulset.spec.template.spec.containers # type: ignore[attr-defined] # noqa: E501 + container = self._get_container(container_name=container_name, containers=containers) + return container.volumeMounts + + def list_container_resources( + self, statefulset_name: str, container_name: str + ) -> ResourceRequirements: + """Returns resource requirements in the given container. + + Args: + statefulset_name: Statefulset name + container_name: Container name + + Raises: + KubernetesHugePagesVolumesPatchError: If the user-provided statefulset name does + not exist. + + Returns: + ResourceRequirements: resource requirements in the given container + """ + try: + statefulset = self.client.get( + res=StatefulSet, name=statefulset_name, namespace=self.namespace + ) + except ApiError: + raise KubernetesHugePagesVolumesPatchError( + f"Could not get statefulset `{statefulset_name}`" + ) + containers: Iterable[ + Container + ] = statefulset.spec.template.spec.containers # type: ignore[attr-defined] # noqa: E501 + container = self._get_container(container_name=container_name, containers=containers) + return container.resources + + +class KubernetesHugePagesPatchCharmLib(Object): + """Class to be instantiated by charms requiring changes in HugePages volumes.""" + + def __init__( + self, + charm: CharmBase, + hugepages_volumes_func: Callable[[], Iterable[HugePagesVolume]], + container_name: str, + refresh_event: BoundEvent, + ): + """Constructor for the KubernetesHugePagesPatchCharmLib. + + Args: + charm: Charm object + hugepages_volumes_func: A callable to a function returning a list of + `HugePagesVolume` to be created. + container_name: Container name + refresh_event: a bound event which will be observed to re-apply the patch. + """ + super().__init__(charm, "kubernetes-requested-volumes") + self.kubernetes = KubernetesClient(namespace=self.model.name) + self.hugepages_volumes_func = hugepages_volumes_func + self.container_name = container_name + self.framework.observe(refresh_event, self._configure_requested_volumes) + + def _configure_requested_volumes(self, _): + """Configures HugePages in the StatefulSet and container.""" + if not self.is_patched(): + self.kubernetes.replace_statefulset( + statefulset_name=self.model.app.name, + container_name=self.container_name, + requested_volumes=self._generate_volumes_to_be_replaced(), + requested_volumemounts=self._generate_volumemounts_to_be_replaced(), + requested_resources=self._generate_resource_requirements_to_be_replaced(), + ) + + def _pod_is_patched( + self, + requested_volumemounts: Iterable[VolumeMount], + requested_resources: ResourceRequirements, + ) -> bool: + """Returns whether pod contains given volume mounts and resource limits. + + If no HugePages volumeMount is requested, it returns whether other HugePages + volumeMounts are present in the pod. + + Args: + requested_volumemounts: Iterable of volume mounts to be set in the pod. + requested_resources: resource requirements to be set in the pod. + + Returns: + bool: Whether pod contains given volume mounts and resource limits. + """ + if not requested_volumemounts: + return not any( + [ + self._volumemount_is_hugepages(x) + for x in self.kubernetes.list_volumemounts( + statefulset_name=self.model.app.name, container_name=self.container_name + ) + ] + ) + return self.kubernetes.pod_is_patched( + pod_name=self._pod, + requested_volumemounts=requested_volumemounts, + requested_resources=requested_resources, + container_name=self.container_name, + ) + + @property + def _pod(self) -> str: + """Name of the unit's pod. + + Returns: + str: A string containing the name of the current unit's pod. + """ + return "-".join(self.model.unit.name.rsplit("/", 1)) + + def _statefulset_is_patched(self, requested_volumes: Iterable[Volume]) -> bool: + """Returns whether statefulset contains requested volumes. + + If no HugePages volume is requested, it returns whether other HugePages + volumes are present in the statefulset. + + Args: + requested_volumes: Iterable of volumes to be set in the statefulset + + Returns: + bool: Whether statefulset contains requested volumes. + """ + if not requested_volumes: + return not any( + [ + self._volume_is_hugepages(volume) + for volume in self.kubernetes.list_volumes( + statefulset_name=self.model.app.name + ) + ] + ) + return self.kubernetes.statefulset_is_patched( + statefulset_name=self.model.app.name, + requested_volumes=requested_volumes, + ) + + def is_patched(self) -> bool: + """Returns whether statefulset and pod are patched. + + Validates that the statefulset contains the appropriate volumes + and that the pod also contains the appropriate volume mounts and + resource requirements. + + Returns: + bool: Whether statefulset and pod are patched. + """ + volumes = self._generate_volumes_from_requested_hugepage() + statefulset_is_patched = self._statefulset_is_patched(volumes) + volumemounts = self._generate_volumemounts_from_requested_hugepage() + resource_requirements = self._generate_resource_requirements_from_requested_hugepage() + pod_is_patched = self._pod_is_patched( + requested_volumemounts=volumemounts, + requested_resources=resource_requirements, + ) + return statefulset_is_patched and pod_is_patched + + def _generate_volumes_from_requested_hugepage(self) -> list[Volume]: + """Generates the list of required HugePages volumes. + + Returns: + list[Volume]: list of volumes to be set in the StatefulSet. + """ + return [ + Volume( + name=f"hugepages-{requested_hugepages.size.lower()}", + emptyDir=EmptyDirVolumeSource(medium=f"HugePages-{requested_hugepages.size}"), + ) + for requested_hugepages in self.hugepages_volumes_func() + ] + + def _generate_volumemounts_from_requested_hugepage(self) -> list[VolumeMount]: + """Generates the list of required HugePages volume mounts. + + Returns: + list[VolumeMount]: list of volume mounts to be set in the container. + """ + return [ + VolumeMount( + name=f"hugepages-{requested_hugepages.size.lower()}", + mountPath=requested_hugepages.mount_path, + ) + for requested_hugepages in self.hugepages_volumes_func() + ] + + def _generate_resource_requirements_from_requested_hugepage(self) -> ResourceRequirements: + """Generates the required resource requirements for HugePages. + + Returns: + ResourceRequirements: required resource requirements to be set in the container. + """ + limits = {} + requests = {} + for hugepage in self.hugepages_volumes_func(): + limits.update({f"hugepages-{hugepage.size}": hugepage.limit}) + limits.update({"cpu": "2"}) + limits.update({"memory": "512Mi"}) + requests.update({f"hugepages-{hugepage.size}": hugepage.limit}) + requests.update({"cpu": "2"}) + requests.update({"memory": "512Mi"}) + return ResourceRequirements( + limits=limits, + requests=requests, + ) + + @staticmethod + def _volumemount_is_hugepages(volume_mount: VolumeMount) -> bool: + """Returns whether the specified volumeMount is HugePages.""" + return volume_mount.name.startswith("hugepages") + + @staticmethod + def _volume_is_hugepages(volume: Volume) -> bool: + """Returns whether the specified volume is HugePages.""" + return volume.name.startswith("hugepages") + + @staticmethod + def _limit_or_request_is_hugepages(key: str) -> bool: + """Returns whether the specified limit or request regards HugePages.""" + return key.startswith("hugepages") + + def _generate_volumes_to_be_replaced(self) -> list[Volume]: + """Generate the list of new volumes to be replaced in the StatefulSet. + + 1. Generates the list of new HugePages volumes to be added + 2. Goes through the list of current volumes for the specified StatefulSet + - If a current volume is HugePages, discard it. + - Else keep it. + + Returns: + list[Volume]: list of new volumes to be replaced in the StatefulSet. + """ + new_volumes = self._generate_volumes_from_requested_hugepage() + current_volumes = self.kubernetes.list_volumes( + statefulset_name=self.model.app.name, + ) + for current_volume in current_volumes: + if not self._volume_is_hugepages(current_volume): + new_volumes.append(current_volume) + return new_volumes + + def _generate_volumemounts_to_be_replaced(self) -> list[VolumeMount]: + """Generate the list of new volume mounts to be replaced in the container. + + 1. Generates the list of new HugePages volume mounts to be added + 2. Goes through the list of current volume mounts for the specified container + - If a current volume mount is HugePages, discard it. + - Else keep it. + + Returns: + list[VolumeMount]: list of new volume mounts to be replaced in the container. + """ + new_volumemounts = self._generate_volumemounts_from_requested_hugepage() + current_volumemounts = self.kubernetes.list_volumemounts( + statefulset_name=self.model.app.name, container_name=self.container_name + ) + for current_volumemount in current_volumemounts: + if not self._volumemount_is_hugepages(current_volumemount): + new_volumemounts.append(current_volumemount) + return new_volumemounts + + def _remove_hugepages_from_resource_requirements(self, resource_attribute: dict) -> dict: + """Removes HugePages-related keys from the given dictionary. + + Args: + resource_attribute: dictionary of resource requirements attribute (limits or requests) + + Returns: + dict: the input dictionary without HugePages-related keys. + """ + return { + key: value + for key, value in resource_attribute.items() + if not self._limit_or_request_is_hugepages(key) + } + + def _generate_resource_requirements_to_be_replaced(self) -> ResourceRequirements: + """Generate the new resource requirements to be replaced in the container. + + 1. Generates the new HugePages resource requirements (limits and requests) to be added + 2. Goes through the current resource requirements for the specified container + - If a current limit (or request) is HugePages, discard it. + - Else keep it. + 3. Merge old resource requirements (without HugePages) and new HugePages requirements. + + Returns: + ResourceRequirements: new resource requirements to be replaced in the container. + """ + additional_resources = self._generate_resource_requirements_from_requested_hugepage() + current_resources = self.kubernetes.list_container_resources( + statefulset_name=self.model.app.name, container_name=self.container_name + ) + + new_limits = ( + self._remove_hugepages_from_resource_requirements(current_resources.limits) + if current_resources.limits + else {} + ) + new_requests = ( + self._remove_hugepages_from_resource_requirements(current_resources.requests) + if current_resources.requests + else {} + ) + new_limits = dict(new_limits.items() | additional_resources.limits.items()) + new_requests = dict(new_requests.items() | additional_resources.requests.items()) + new_resources = ResourceRequirements( + limits=new_limits, requests=new_requests, claims=current_resources.claims + ) + return new_resources diff --git a/src/charm.py b/src/charm.py index 94f449c2..602a08f1 100755 --- a/src/charm.py +++ b/src/charm.py @@ -11,6 +11,10 @@ from subprocess import check_output from typing import Any, Dict, Optional +from charms.kubernetes_charm_libraries.v0.hugepages_volumes_patch import ( # type: ignore[import] + HugePagesVolume, + KubernetesHugePagesPatchCharmLib, +) from charms.kubernetes_charm_libraries.v0.multus import ( # type: ignore[import] KubernetesMultusCharmLib, NetworkAnnotation, @@ -66,16 +70,21 @@ class NadConfigChangedEvent(EventBase): """Event triggered when an existing network attachment definition is changed.""" -class KubernetesMultusCharmEvents(CharmEvents): - """Kubernetes Multus charm events.""" +class K8sHugePagesVolumePatchChangedEvent(EventBase): + """Event triggered when a HugePages volume is changed.""" + + +class UpfOperatorCharmEvents(CharmEvents): + """Kubernetes UPF operator charm events.""" nad_config_changed = EventSource(NadConfigChangedEvent) + hugepages_volumes_config_changed = EventSource(K8sHugePagesVolumePatchChangedEvent) class UPFOperatorCharm(CharmBase): """Main class to describe juju event handling for the 5G UPF operator.""" - on = KubernetesMultusCharmEvents() + on = UpfOperatorCharmEvents() def __init__(self, *args): super().__init__(*args) @@ -117,6 +126,12 @@ def __init__(self, *args): network_attachment_definitions_func=self._network_attachment_definitions_from_config, refresh_event=self.on.nad_config_changed, ) + self._kubernetes_volumes_patch = KubernetesHugePagesPatchCharmLib( + charm=self, + container_name=self._bessd_container_name, + hugepages_volumes_func=self._volumes_request_func_from_config, + refresh_event=self.on.hugepages_volumes_config_changed, + ) self.framework.observe(self.on.config_changed, self._on_config_changed) self.framework.observe(self.on.bessd_pebble_ready, self._on_bessd_pebble_ready) self.framework.observe(self.on.pfcp_agent_pebble_ready, self._on_pfcp_agent_pebble_ready) @@ -252,6 +267,16 @@ def _get_n4_upf_hostname(self) -> str: return lb_hostname return self._upf_hostname + def _volumes_request_func_from_config(self) -> list[HugePagesVolume]: + """Returns list of HugePages to be set based on the application config. + + Returns: + list[HugePagesVolume]: list of HugePages to be set based on the application config. + """ + if self.hugepages_is_enabled(): + return [HugePagesVolume(mount_path="/dev/hugepages", size="1Gi", limit="2Gi")] + return [] + def _network_attachment_definitions_from_config( self, ) -> list[NetworkAttachmentDefinition]: @@ -329,6 +354,7 @@ def _on_config_changed(self, event: EventBase): ) return self.on.nad_config_changed.emit() + self.on.hugepages_volumes_config_changed.emit() if not self._bessd_container.can_connect(): self.unit.status = WaitingStatus("Waiting for bessd container to be ready") event.defer() @@ -566,7 +592,7 @@ def _bessd_pebble_layer(self) -> Layer: self._bessd_service_name: { "override": "replace", "startup": "enabled", - "command": f"/bin/bessd -f -grpc-url=0.0.0.0:{BESSD_PORT} -m 0", # "-m 0" means that we are not using hugepages # noqa: E501 + "command": f"/bin/bessd -f -grpc-url=0.0.0.0:{BESSD_PORT} {'-m 0' if not self.hugepages_is_enabled() else ''}", # "-m 0" means that we are not using hugepages # noqa: E501 "environment": self._bessd_environment_variables, }, }, @@ -845,6 +871,14 @@ def _core_interface_mtu_size_is_valid(self) -> bool: except ValueError: return False + def hugepages_is_enabled(self) -> bool: + """Returns whether HugePages are enabled. + + Returns: + bool: Whether HugePages are enabled + """ + return bool(self.model.config.get("enable-hugepages", False)) + def render_bessd_config_file( upf_hostname: str, diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index 1b90c02f..6e1ad1a8 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -19,6 +19,7 @@ from charm import IncompatibleCPUError, UPFOperatorCharm MULTUS_LIBRARY_PATH = "charms.kubernetes_charm_libraries.v0.multus" +HUGEPAGES_LIBRARY_PATH = "charms.kubernetes_charm_libraries.v0.hugepages_volumes_patch" TOO_BIG_MTU_SIZE = 65536 # Out of range TOO_SMALL_MTU_SIZE = 1199 # Out of range ZERO_MTU_SIZE = 0 # Out of range @@ -429,10 +430,14 @@ def test_given_cant_connect_to_bessd_container_when_pfcp_agent_pebble_ready_then self.harness.model.unit.status, WaitingStatus("Waiting for bessd service to run") ) + @patch(f"{HUGEPAGES_LIBRARY_PATH}.KubernetesHugePagesPatchCharmLib.is_patched") @patch("charms.sdcore_upf.v0.fiveg_n3.N3Provides.publish_upf_information") def test_given_fiveg_n3_relation_created_when_fiveg_n3_request_then_upf_ip_address_is_published( # noqa: E501 - self, patched_publish_upf_information + self, + patched_publish_upf_information, + patch_hugepages_is_patched, ): + patch_hugepages_is_patched.return_value = True self.harness.set_leader(is_leader=True) test_upf_access_ip_cidr = "1.2.3.4/21" self.harness.update_config(key_values={"access-ip": test_upf_access_ip_cidr}) @@ -459,15 +464,21 @@ def test_given_unit_is_not_leader_when_fiveg_n3_request_then_upf_ip_address_is_n @patch(f"{MULTUS_LIBRARY_PATH}.KubernetesClient", new=Mock) @patch("charms.sdcore_upf.v0.fiveg_n3.N3Provides.publish_upf_information") @patch(f"{MULTUS_LIBRARY_PATH}.KubernetesMultusCharmLib.is_ready") + @patch(f"{HUGEPAGES_LIBRARY_PATH}.KubernetesHugePagesPatchCharmLib.is_patched") @patch("ops.model.Container.push", new=Mock) @patch("ops.model.Container.exec", new=MagicMock) @patch("ops.model.Container.pull", new=Mock) @patch("ops.model.Container.exists") def test_given_fiveg_n3_relation_exists_when_access_ip_config_changed_then_new_upf_ip_address_is_published( # noqa: E501 - self, patch_exists, patch_multus_is_ready, patched_publish_upf_information + self, + patch_exists, + patch_multus_is_ready, + patch_hugepages_is_patched, + patched_publish_upf_information, ): patch_exists.return_value = True patch_multus_is_ready.return_value = True + patch_hugepages_is_patched.return_value = True self.harness.set_can_connect(container="bessd", val=True) self.harness.set_can_connect(container="pfcp-agent", val=True) self.harness.set_leader(is_leader=True) @@ -521,11 +532,15 @@ def test_given_unit_is_not_leader_when_fiveg_n4_request_then_upf_hostname_is_not patched_publish_upf_n4_information.assert_not_called() + @patch(f"{HUGEPAGES_LIBRARY_PATH}.KubernetesHugePagesPatchCharmLib.is_patched") @patch("charms.sdcore_upf.v0.fiveg_n4.N4Provides.publish_upf_n4_information") @patch("charm.PFCP_PORT", TEST_PFCP_PORT) def test_given_external_upf_hostname_config_set_and_fiveg_n4_relation_created_when_fiveg_n4_request_then_upf_hostname_and_n4_port_is_published( # noqa: E501 - self, patched_publish_upf_n4_information + self, + patched_publish_upf_n4_information, + patch_hugepages_is_patched, ): + patch_hugepages_is_patched.return_value = True self.harness.set_leader(is_leader=True) test_external_upf_hostname = "test-upf.external.hostname.com" self.harness.update_config( @@ -590,18 +605,24 @@ def test_given_external_upf_hostname_config_not_set_and_external_upf_service_hos ) @patch("charms.sdcore_upf.v0.fiveg_n4.N4Provides.publish_upf_n4_information") - @patch("charms.kubernetes_charm_libraries.v0.multus.KubernetesMultusCharmLib.is_ready") + @patch(f"{MULTUS_LIBRARY_PATH}.KubernetesMultusCharmLib.is_ready") + @patch(f"{HUGEPAGES_LIBRARY_PATH}.KubernetesHugePagesPatchCharmLib.is_patched") @patch("ops.model.Container.push", new=Mock) @patch("ops.model.Container.exec", new=MagicMock) @patch("ops.model.Container.pull", new=Mock) @patch("ops.model.Container.exists") @patch("charm.PFCP_PORT", TEST_PFCP_PORT) def test_given_fiveg_n4_relation_exists_when_external_upf_hostname_config_changed_then_new_upf_hostname_is_published( # noqa: E501 - self, patch_exists, patch_multus_is_ready, patched_publish_upf_n4_information + self, + patch_exists, + patch_multus_is_ready, + patch_hugepages_is_ready, + patched_publish_upf_n4_information, ): test_external_upf_hostname = "test-upf.external.hostname.com" patch_exists.return_value = True patch_multus_is_ready.return_value = True + patch_hugepages_is_ready.return_value = True self.harness.set_can_connect(container="bessd", val=True) self.harness.set_can_connect(container="pfcp-agent", val=True) self.harness.set_leader(is_leader=True) @@ -625,9 +646,12 @@ def test_given_fiveg_n4_relation_exists_when_external_upf_hostname_config_change patched_publish_upf_n4_information.assert_has_calls(expected_calls) + @patch(f"{HUGEPAGES_LIBRARY_PATH}.KubernetesHugePagesPatchCharmLib.is_patched") def test_given_default_config_when_network_attachment_definitions_from_config_is_called_then_no_interface_specified_in_nad( # noqa: E501 self, + patch_hugepages_is_patched, ): + patch_hugepages_is_patched.return_value = True self.harness.set_leader(is_leader=True) self.harness.update_config( key_values={ @@ -724,9 +748,12 @@ def test_when_remove_then_external_service_is_deleted(self, patch_client): namespace=self.namespace, ) + @patch(f"{HUGEPAGES_LIBRARY_PATH}.KubernetesHugePagesPatchCharmLib.is_patched") def test_given_default_config_when_network_attachment_definitions_from_config_is_called_then_no_interface_mtu_specified_in_nad( # noqa: E501 self, + patch_hugepages_is_patched, ): + patch_hugepages_is_patched.return_value = True self.harness.set_leader(is_leader=True) self.harness.update_config( key_values={ @@ -742,9 +769,12 @@ def test_given_default_config_when_network_attachment_definitions_from_config_is config = json.loads(nad.spec["config"]) self.assertNotIn("mtu", config) + @patch(f"{HUGEPAGES_LIBRARY_PATH}.KubernetesHugePagesPatchCharmLib.is_patched") def test_given_default_config_with_interfaces_mtu_sizes_when_network_attachment_definitions_from_config_is_called_then_mtu_sizes_specified_in_nad( # noqa: E501 self, + patch_hugepages_is_patched, ): + patch_hugepages_is_patched.return_value = True self.harness.set_leader(is_leader=True) self.harness.update_config( key_values={ @@ -800,6 +830,7 @@ def test_given_default_config_with_interfaces_zero_mtu_sizes_when_network_attach @patch(f"{MULTUS_LIBRARY_PATH}.KubernetesClient.list_network_attachment_definitions") @patch(f"{MULTUS_LIBRARY_PATH}.KubernetesMultusCharmLib.is_ready") @patch(f"{MULTUS_LIBRARY_PATH}.KubernetesMultusCharmLib.delete_pod") + @patch(f"{HUGEPAGES_LIBRARY_PATH}.KubernetesHugePagesPatchCharmLib.is_patched") @patch("ops.model.Container.push", new=Mock) @patch("ops.model.Container.exec", new=MagicMock) @patch("ops.model.Container.pull", new=Mock) @@ -807,11 +838,13 @@ def test_given_default_config_with_interfaces_zero_mtu_sizes_when_network_attach def test_given_container_can_connect_bessd_pebble_ready_when_core_net_mtu_config_changed_to_a_different_valid_value_then_delete_pod_is_called( # noqa: E501 self, patch_exists, + patch_hugepages_is_patched, patch_delete_pod, patch_multus_is_ready, patch_list_na_definitions, ): patch_exists.return_value = True + patch_hugepages_is_patched.return_value = True patch_multus_is_ready.return_value = True self.harness.set_can_connect(container="bessd", val=True) self.harness.set_can_connect(container="pfcp-agent", val=True) @@ -826,6 +859,7 @@ def test_given_container_can_connect_bessd_pebble_ready_when_core_net_mtu_config @patch(f"{MULTUS_LIBRARY_PATH}.KubernetesClient.list_network_attachment_definitions") @patch(f"{MULTUS_LIBRARY_PATH}.KubernetesMultusCharmLib.is_ready") @patch(f"{MULTUS_LIBRARY_PATH}.KubernetesMultusCharmLib.delete_pod") + @patch(f"{HUGEPAGES_LIBRARY_PATH}.KubernetesHugePagesPatchCharmLib.is_patched") @patch("ops.model.Container.push", new=Mock) @patch("ops.model.Container.exec", new=MagicMock) @patch("ops.model.Container.pull", new=Mock) @@ -833,11 +867,13 @@ def test_given_container_can_connect_bessd_pebble_ready_when_core_net_mtu_config def test_given_container_can_connect_bessd_pebble_ready_when_core_net_mtu_config_changed_to_different_valid_values_then_delete_pod_called_twice( # noqa: E501 self, patch_exists, + patch_hugepages_is_patched, patch_delete_pod, patch_multus_is_ready, patch_list_na_definitions, ): patch_exists.return_value = True + patch_hugepages_is_patched.return_value = True patch_multus_is_ready.return_value = True self.harness.set_can_connect(container="bessd", val=True) self.harness.set_can_connect(container="pfcp-agent", val=True) @@ -856,6 +892,7 @@ def test_given_container_can_connect_bessd_pebble_ready_when_core_net_mtu_config @patch(f"{MULTUS_LIBRARY_PATH}.KubernetesClient.list_network_attachment_definitions") @patch(f"{MULTUS_LIBRARY_PATH}.KubernetesMultusCharmLib.is_ready") @patch(f"{MULTUS_LIBRARY_PATH}.KubernetesClient.delete_pod") + @patch(f"{HUGEPAGES_LIBRARY_PATH}.KubernetesHugePagesPatchCharmLib.is_patched") @patch("ops.model.Container.push", new=Mock) @patch("ops.model.Container.exec", new=MagicMock) @patch("ops.model.Container.pull", new=Mock) @@ -863,12 +900,14 @@ def test_given_container_can_connect_bessd_pebble_ready_when_core_net_mtu_config def test_given_container_can_connect_bessd_pebble_ready_when_core_net_mtu_config_changed_to_same_valid_value_multiple_times_then_delete_pod_called_once( # noqa: E501 self, patch_exists, + patch_hugepages_is_patched, patch_delete_pod, patch_multus_is_ready, patch_list_na_definitions, ): """Delete pod is called for the first config change, setting the same config value does not trigger pod restarts.""" # noqa: E501, W505 patch_exists.return_value = True + patch_hugepages_is_patched.return_value = True patch_multus_is_ready.return_value = True self.harness.set_can_connect(container="bessd", val=True) self.harness.set_can_connect(container="pfcp-agent", val=True)