Patch service definition for internal cluster access.

This commit is contained in:
James Page 2021-10-14 17:00:00 +01:00
parent d61e6bec61
commit 3f184a42da
3 changed files with 197 additions and 0 deletions

View File

@ -0,0 +1,185 @@
# Copyright 2021 Canonical Ltd.
# See LICENSE file for licensing details.
"""# KubernetesServicePatch Library.
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.
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 overwritten during a charm upgrade.
When intialised, this library binds a handler to the parent charm's `install` and `upgrade_charm`
events which applies the patch to the cluster. This should ensure that the service ports are
correct throughout the charm's life.
The constructor simply takes a reference to the parent charm, and a list of tuples that each define
a port for the service, where each tuple contains:
- a name for the port
- port for the service to listen on
- optionally: a targetPort for the service (the port in the container!)
## Getting Started
To get started using the library, you just need to fetch the library using `charmcraft`. **Note
that you also need to add `lightkube` to your charm's `requirements.txt`.**
```shell
cd some-charm
charmcraft fetch-lib charms.observability_libs.v0.kubernetes_service_patch
echo "lightkube" >> requirements.txt
```
Then, to initialise the library:
```python
# ...
from charms.observability_libs.v0.kubernetes_service_patch import KubernetesServicePatch
class SomeCharm(CharmBase):
def __init__(self, *args):
# ...
self.service_patcher = KubernetesServicePatch(self, [(f"{self.app.name}", 8080)])
# ...
```
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`:
```python
# ...
@patch("charm.KubernetesServicePatch", lambda x, y: None)
def setUp(self, *unused):
self.harness = Harness(SomeCharm)
# ...
```
"""
import logging
from typing import List, Optional, Tuple
from lightkube import ApiError, Client
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.charm import CharmBase
from ops.framework import Object
logger = logging.getLogger(__name__)
# The unique Charmhub library identifier, never change it
LIBID = "0042f86d0a874435adef581806cddbbb"
# 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 = 2
class KubernetesServicePatch(Object):
"""A utility for patching the Kubernetes service set up by Juju."""
def __init__(self, charm: CharmBase, ports: List[Tuple[str, int, Optional[int]]]):
"""Constructor for KubernetesServicePatch.
Args:
charm: the charm that is instantiating the library.
ports: a list of tuples (name, port, targetPort) for every service port.
"""
super().__init__(charm, "kubernetes-service-patch")
self.charm = charm
self.service = self._service_object(ports)
# 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)
def _service_object(self, ports: List[Tuple[str, int, Optional[int]]]) -> Service:
"""Creates a valid Service representation for Alertmanager.
Args:
ports: a list of tuples of the form (name, port) or (name, port, targetPort) for every
service port. If the 'targetPort' is omitted, it is assumed to be equal to 'port'.
Returns:
Service: A valid representation of a Kubernetes Service with the correct ports.
"""
return Service(
apiVersion="v1",
kind="Service",
metadata=ObjectMeta(
namespace=self._namespace,
name=self._app,
labels={"app.kubernetes.io/name": self._app},
),
spec=ServiceSpec(
selector={"app.kubernetes.io/name": self._app},
ports=[
ServicePort(name=p[0], port=p[1], targetPort=p[2] if len(p) > 2 else p[1])
for p in ports
],
),
)
def _patch(self, _) -> None:
"""Patch the Kubernetes service created by Juju to map the correct port.
Raises:
PatchFailed: if patching fails due to lack of permissions, or otherwise.
"""
if not self.charm.unit.is_leader():
return
client = Client()
try:
client.patch(Service, self._app, self.service, patch_type=PatchType.MERGE)
except ApiError as e:
if e.status.code == 403:
logger.error("Kubernetes service patch failed: `juju trust` this application.")
else:
logger.error("Kubernetes service patch failed: %s", str(e))
else:
logger.info("Kubernetes service '%s' patched successfully", self._app)
def is_patched(self) -> bool:
"""Reports if the service patch has been applied.
Returns:
bool: A boolean indicating if the service patch has been applied.
"""
client = Client()
# Get the relevant service from the cluster
service = client.get(Service, name=self._app, namespace=self._namespace)
# Construct a list of expected ports, should the patch be applied
expected_ports = [(p.port, p.targetPort) for p in self.service.spec.ports]
# Construct a list in the same manner, using the fetched service
fetched_ports = [(p.port, p.targetPort) for p in service.spec.ports]
return expected_ports == fetched_ports
@property
def _app(self) -> str:
"""Name of the current Juju application.
Returns:
str: A string containing the name of the current Juju application.
"""
return self.charm.app.name
@property
def _namespace(self) -> str:
"""The Kubernetes namespace we're running in.
Returns:
str: A string containing the name of the current Kubernetes namespace.
"""
with open("/var/run/secrets/kubernetes.io/serviceaccount/namespace", "r") as f:
return f.read().strip()

View File

@ -5,3 +5,4 @@ git+https://github.com/canonical/operator@2875e73e#egg=ops
python-keystoneclient
git+https://opendev.org/openstack/charm-ops-openstack#egg=ops_openstack
git+https://github.com/openstack-charmers/advanced-sunbeam-openstack#egg=advanced_sunbeam_openstack
lightkube

View File

@ -28,6 +28,9 @@ import advanced_sunbeam_openstack.config_contexts as sunbeam_contexts
import advanced_sunbeam_openstack.relation_handlers as sunbeam_rhandlers
import charms.sunbeam_keystone_operator.v0.identity_service as sunbeam_id_svc
from charms.observability_libs.v0.kubernetes_service_patch import KubernetesServicePatch
logger = logging.getLogger(__name__)
KEYSTONE_CONTAINER = "keystone"
@ -135,6 +138,14 @@ class KeystoneOperatorCharm(sunbeam_charm.OSBaseOperatorAPICharm):
self._state.set_default(default_domain_id=None)
self._state.set_default(service_project_id=None)
self.service_patcher = KubernetesServicePatch(
self,
[
('public', 5000),
('admin', 35357)
]
)
def get_relation_handlers(self, handlers=None) -> List[
sunbeam_rhandlers.RelationHandler]:
"""Relation handlers for the service."""