diff --git a/lib/charms/observability_libs/v1/kubernetes_service_patch.py b/lib/charms/observability_libs/v1/kubernetes_service_patch.py index 2cce729eeb..e85834be31 100644 --- a/lib/charms/observability_libs/v1/kubernetes_service_patch.py +++ b/lib/charms/observability_libs/v1/kubernetes_service_patch.py @@ -6,7 +6,7 @@ This library is designed to enable developers to more simply patch the Kubernetes Service created by Juju during the deployment of a sidecar charm. When sidecar charms are deployed, Juju creates a service named after the application in the namespace (named after the Juju model). This service by -default contains a "placeholder" port, which is 65536/TCP. +default contains a "placeholder" port, which is 65535/TCP. When modifying the default set of resources managed by Juju, one must consider the lifecycle of the charm. In this case, any modifications to the default service (created during deployment), will be @@ -109,6 +109,26 @@ def __init__(self, *args): # ... ``` +Creating a new k8s lb service instead of patching the one created by juju +Service name is optional. If not provided, it defaults to {app_name}-lb. +If provided and equal to app_name, it also defaults to {app_name}-lb to prevent conflicts with the Juju default service. +```python +from charms.observability_libs.v1.kubernetes_service_patch import KubernetesServicePatch +from lightkube.models.core_v1 import ServicePort + +class SomeCharm(CharmBase): + def __init__(self, *args): + # ... + port = ServicePort(int(self.config["charm-config-port"]), name=f"{self.app.name}") + self.service_patcher = KubernetesServicePatch( + self, + [port], + service_type="LoadBalancer", + service_name="application-lb" + ) + # ... +``` + Additionally, you may wish to use mocks in your charm's unit testing to ensure that the library does not try to make any API calls, or open any files during testing that are unlikely to be present, and could break your tests. The easiest way to do this is during your test `setUp`: @@ -125,7 +145,7 @@ def setUp(self, *unused): import logging from types import MethodType -from typing import List, Literal, Optional, Union +from typing import Any, List, Literal, Optional, Union from lightkube import ApiError, Client # pyright: ignore from lightkube.core import exceptions @@ -133,6 +153,7 @@ def setUp(self, *unused): from lightkube.models.meta_v1 import ObjectMeta from lightkube.resources.core_v1 import Service from lightkube.types import PatchType +from ops import UpgradeCharmEvent from ops.charm import CharmBase from ops.framework import BoundEvent, Object @@ -146,7 +167,7 @@ def setUp(self, *unused): # Increment this PATCH version before using `charmcraft publish-lib` or reset # to 0 if you are raising the major API version -LIBPATCH = 9 +LIBPATCH = 12 ServiceType = Literal["ClusterIP", "LoadBalancer"] @@ -186,10 +207,15 @@ def __init__( """ super().__init__(charm, "kubernetes-service-patch") self.charm = charm - self.service_name = service_name if service_name else self._app + self.service_name = service_name or self._app + # To avoid conflicts with the default Juju service, append "-lb" to the service name. + # The Juju application name is retained for the default service created by Juju. + if self.service_name == self._app and service_type == "LoadBalancer": + self.service_name = f"{self._app}-lb" + self.service_type = service_type self.service = self._service_object( ports, - service_name, + self.service_name, service_type, additional_labels, additional_selectors, @@ -200,8 +226,11 @@ def __init__( assert isinstance(self._patch, MethodType) # Ensure this patch is applied during the 'install' and 'upgrade-charm' events self.framework.observe(charm.on.install, self._patch) - self.framework.observe(charm.on.upgrade_charm, self._patch) + self.framework.observe(charm.on.upgrade_charm, self._on_upgrade_charm) self.framework.observe(charm.on.update_status, self._patch) + # Sometimes Juju doesn't clean-up a manually created LB service, + # so we clean it up ourselves just in case. + self.framework.observe(charm.on.remove, self._remove_service) # apply user defined events if refresh_event: @@ -277,7 +306,10 @@ def _patch(self, _) -> None: if self._is_patched(client): return if self.service_name != self._app: - self._delete_and_create_service(client) + if not self.service_type == "LoadBalancer": + self._delete_and_create_service(client) + else: + self._create_lb_service(client) client.patch(Service, self.service_name, self.service, patch_type=PatchType.MERGE) except ApiError as e: if e.status.code == 403: @@ -294,6 +326,12 @@ def _delete_and_create_service(self, client: Client): client.delete(Service, self._app, namespace=self._namespace) client.create(service) + def _create_lb_service(self, client: Client): + try: + client.get(Service, self.service_name, namespace=self._namespace) + except ApiError: + client.create(self.service) + def is_patched(self) -> bool: """Reports if the service patch has been applied. @@ -321,6 +359,60 @@ def _is_patched(self, client: Client) -> bool: ] # noqa: E501 return expected_ports == fetched_ports + def _on_upgrade_charm(self, event: UpgradeCharmEvent): + """Handle the upgrade charm event.""" + # If a charm author changed the service type from LB to ClusterIP across an upgrade, we need to delete the previous LB. + if self.service_type == "ClusterIP": + + client = Client() # pyright: ignore + + # Define a label selector to find services related to the app + selector: dict[str, Any] = {"app.kubernetes.io/name": self._app} + + # Check if any service of type LoadBalancer exists + services = client.list(Service, namespace=self._namespace, labels=selector) + for service in services: + if ( + not service.metadata + or not service.metadata.name + or not service.spec + or not service.spec.type + ): + logger.warning( + "Service patch: skipping resource with incomplete metadata: %s.", service + ) + continue + if service.spec.type == "LoadBalancer": + client.delete(Service, service.metadata.name, namespace=self._namespace) + logger.info(f"LoadBalancer service {service.metadata.name} deleted.") + + # Continue the upgrade flow normally + self._patch(event) + + def _remove_service(self, _): + """Remove a Kubernetes service associated with this charm. + + Specifically designed to delete the load balancer service created by the charm, since Juju only deletes the + default ClusterIP service and not custom services. + + Returns: + None + + Raises: + ApiError: for deletion errors, excluding when the service is not found (404 Not Found). + """ + client = Client() # pyright: ignore + + try: + client.delete(Service, self.service_name, namespace=self._namespace) + logger.info("The patched k8s service '%s' was deleted.", self.service_name) + except ApiError as e: + if e.status.code == 404: + # Service not found, so no action needed + return + # Re-raise for other statuses + raise + @property def _app(self) -> str: """Name of the current Juju application.