Skip to content

Commit

Permalink
Test service patch lib update (#624)
Browse files Browse the repository at this point in the history
  • Loading branch information
dragomirp authored Aug 14, 2024
1 parent de20498 commit 1a4057f
Showing 1 changed file with 99 additions and 7 deletions.
106 changes: 99 additions & 7 deletions lib/charms/observability_libs/v1/kubernetes_service_patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`:
Expand All @@ -125,14 +145,15 @@ 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
from lightkube.models.core_v1 import ServicePort, ServiceSpec
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

Expand All @@ -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"]

Expand Down Expand Up @@ -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,
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand Down

0 comments on commit 1a4057f

Please sign in to comment.