Skip to content

Commit

Permalink
KubernetesServicePatch Add support for creating a new K8s service (#90)
Browse files Browse the repository at this point in the history
* add support for creating a new service

* fmt

* addressing comments

* pre-set service name if lb

* fmt

* linter reqs

* update docs

* refactor _remove_service
  • Loading branch information
IbraAoad authored May 28, 2024
1 parent 9bf167c commit e4eb655
Show file tree
Hide file tree
Showing 2 changed files with 68 additions and 9 deletions.
67 changes: 63 additions & 4 deletions lib/charms/observability_libs/v1/kubernetes_service_patch.py
Original file line number Diff line number Diff line change
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 Down Expand Up @@ -146,7 +166,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 = 10

ServiceType = Literal["ClusterIP", "LoadBalancer"]

Expand Down Expand Up @@ -186,10 +206,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 @@ -202,6 +227,7 @@ def __init__(
self.framework.observe(charm.on.install, self._patch)
self.framework.observe(charm.on.upgrade_charm, self._patch)
self.framework.observe(charm.on.update_status, self._patch)
self.framework.observe(charm.on.stop, self._remove_service)

# apply user defined events
if refresh_event:
Expand Down Expand Up @@ -277,7 +303,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 +323,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 +356,30 @@ def _is_patched(self, client: Client) -> bool:
] # noqa: E501
return expected_ports == fetched_ports

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)
except ApiError as e:
if e.status.code == 404:
# Service not found, so no action needed
pass
else:
# Re-raise for other statuses
raise

@property
def _app(self) -> str:
"""Name of the current Juju application.
Expand Down
10 changes: 5 additions & 5 deletions tests/unit/test_kubernetes_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@ def test_k8s_load_balancer_service(self):
kind="Service",
metadata=ObjectMeta(
namespace="test",
name="lb-test-charm",
name="lb-test-charm-lb",
labels={"app.kubernetes.io/name": "lb-test-charm"},
),
spec=ServiceSpec(
Expand Down Expand Up @@ -405,14 +405,14 @@ def test_patch_k8s_service(self, client_patch):
with self.assertLogs(MOD_PATH) as logs:
self.harness.charm.service_patch._patch(None)
msg = "Kubernetes service patch failed: `juju trust` this application."
self.assertIn(msg, ";".join(logs.output))
self.assertTrue(msg in ";".join(logs.output))

client_patch.reset()
client_patch.side_effect = _FakeApiError(500)

with self.assertLogs(MOD_PATH) as logs:
self.harness.charm.service_patch._patch(None)
self.assertIn("Kubernetes service patch failed: broken", ";".join(logs.output))
self.assertTrue("Kubernetes service patch failed: broken" in ";".join(logs.output))

@patch(f"{MOD_PATH}.Client.get")
@patch(f"{CL_PATH}._namespace", "test")
Expand Down Expand Up @@ -485,7 +485,7 @@ def test_is_patched_k8s_service_api_error_with_default_name(self, client):
with self.assertLogs(MOD_PATH) as logs:
with self.assertRaises(_FakeApiError):
self.harness.charm.service_patch.is_patched()
self.assertIn("Kubernetes service get failed: broken", ";".join(logs.output))
self.assertTrue("Kubernetes service get failed: broken" in ";".join(logs.output))

@patch(f"{MOD_PATH}.ApiError", _FakeApiError)
@patch(f"{CL_PATH}._namespace", "test")
Expand All @@ -499,4 +499,4 @@ def test_is_patched_k8s_service_api_error_not_404(self, client):
with self.assertLogs(MOD_PATH) as logs:
with self.assertRaises(_FakeApiError):
charm.custom_service_name_service_patch.is_patched()
self.assertIn("Kubernetes service get failed: broken", ";".join(logs.output))
self.assertTrue("Kubernetes service get failed: broken" in ";".join(logs.output))

0 comments on commit e4eb655

Please sign in to comment.