506dbf035ada3a2e83b14ec17e357e1548133685
[osm/devops.git] /
1 # Copyright 2021 Canonical Ltd.
2 # See LICENSE file for licensing details.
3 #   http://www.apache.org/licenses/LICENSE-2.0
4
5 """# KubernetesServicePatch Library.
6
7 This library is designed to enable developers to more simply patch the Kubernetes Service created
8 by Juju during the deployment of a sidecar charm. When sidecar charms are deployed, Juju creates a
9 service named after the application in the namespace (named after the Juju model). This service by
10 default contains a "placeholder" port, which is 65536/TCP.
11
12 When modifying the default set of resources managed by Juju, one must consider the lifecycle of the
13 charm. In this case, any modifications to the default service (created during deployment), will be
14 overwritten during a charm upgrade.
15
16 When initialised, this library binds a handler to the parent charm's `install` and `upgrade_charm`
17 events which applies the patch to the cluster. This should ensure that the service ports are
18 correct throughout the charm's life.
19
20 The constructor simply takes a reference to the parent charm, and a list of
21 [`lightkube`](https://github.com/gtsystem/lightkube) ServicePorts that each define a port for the
22 service. For information regarding the `lightkube` `ServicePort` model, please visit the
23 `lightkube` [docs](https://gtsystem.github.io/lightkube-models/1.23/models/core_v1/#serviceport).
24
25 Optionally, a name of the service (in case service name needs to be patched as well), labels,
26 selectors, and annotations can be provided as keyword arguments.
27
28 ## Getting Started
29
30 To get started using the library, you just need to fetch the library using `charmcraft`. **Note
31 that you also need to add `lightkube` and `lightkube-models` to your charm's `requirements.txt`.**
32
33 ```shell
34 cd some-charm
35 charmcraft fetch-lib charms.observability_libs.v0.kubernetes_service_patch
36 echo <<-EOF >> requirements.txt
37 lightkube
38 lightkube-models
39 EOF
40 ```
41
42 Then, to initialise the library:
43
44 For `ClusterIP` services:
45
46 ```python
47 # ...
48 from charms.observability_libs.v0.kubernetes_service_patch import KubernetesServicePatch
49 from lightkube.models.core_v1 import ServicePort
50
51 class SomeCharm(CharmBase):
52   def __init__(self, *args):
53     # ...
54     port = ServicePort(443, name=f"{self.app.name}")
55     self.service_patcher = KubernetesServicePatch(self, [port])
56     # ...
57 ```
58
59 For `LoadBalancer`/`NodePort` services:
60
61 ```python
62 # ...
63 from charms.observability_libs.v0.kubernetes_service_patch import KubernetesServicePatch
64 from lightkube.models.core_v1 import ServicePort
65
66 class SomeCharm(CharmBase):
67   def __init__(self, *args):
68     # ...
69     port = ServicePort(443, name=f"{self.app.name}", targetPort=443, nodePort=30666)
70     self.service_patcher = KubernetesServicePatch(
71         self, [port], "LoadBalancer"
72     )
73     # ...
74 ```
75
76 Port protocols can also be specified. Valid protocols are `"TCP"`, `"UDP"`, and `"SCTP"`
77
78 ```python
79 # ...
80 from charms.observability_libs.v0.kubernetes_service_patch import KubernetesServicePatch
81 from lightkube.models.core_v1 import ServicePort
82
83 class SomeCharm(CharmBase):
84   def __init__(self, *args):
85     # ...
86     tcp = ServicePort(443, name=f"{self.app.name}-tcp", protocol="TCP")
87     udp = ServicePort(443, name=f"{self.app.name}-udp", protocol="UDP")
88     sctp = ServicePort(443, name=f"{self.app.name}-sctp", protocol="SCTP")
89     self.service_patcher = KubernetesServicePatch(self, [tcp, udp, sctp])
90     # ...
91 ```
92
93 Additionally, you may wish to use mocks in your charm's unit testing to ensure that the library
94 does not try to make any API calls, or open any files during testing that are unlikely to be
95 present, and could break your tests. The easiest way to do this is during your test `setUp`:
96
97 ```python
98 # ...
99
100 @patch("charm.KubernetesServicePatch", lambda x, y: None)
101 def setUp(self, *unused):
102     self.harness = Harness(SomeCharm)
103     # ...
104 ```
105 """
106
107 import logging
108 from types import MethodType
109 from typing import List, Literal
110
111 from lightkube import ApiError, Client
112 from lightkube.models.core_v1 import ServicePort, ServiceSpec
113 from lightkube.models.meta_v1 import ObjectMeta
114 from lightkube.resources.core_v1 import Service
115 from lightkube.types import PatchType
116 from ops.charm import CharmBase
117 from ops.framework import Object
118
119 logger = logging.getLogger(__name__)
120
121 # The unique Charmhub library identifier, never change it
122 LIBID = "0042f86d0a874435adef581806cddbbb"
123
124 # Increment this major API version when introducing breaking changes
125 LIBAPI = 1
126
127 # Increment this PATCH version before using `charmcraft publish-lib` or reset
128 # to 0 if you are raising the major API version
129 LIBPATCH = 1
130
131 ServiceType = Literal["ClusterIP", "LoadBalancer"]
132
133
134 class KubernetesServicePatch(Object):
135     """A utility for patching the Kubernetes service set up by Juju."""
136
137     def __init__(
138         self,
139         charm: CharmBase,
140         ports: List[ServicePort],
141         service_name: str = None,
142         service_type: ServiceType = "ClusterIP",
143         additional_labels: dict = None,
144         additional_selectors: dict = None,
145         additional_annotations: dict = None,
146     ):
147         """Constructor for KubernetesServicePatch.
148
149         Args:
150             charm: the charm that is instantiating the library.
151             ports: a list of ServicePorts
152             service_name: allows setting custom name to the patched service. If none given,
153                 application name will be used.
154             service_type: desired type of K8s service. Default value is in line with ServiceSpec's
155                 default value.
156             additional_labels: Labels to be added to the kubernetes service (by default only
157                 "app.kubernetes.io/name" is set to the service name)
158             additional_selectors: Selectors to be added to the kubernetes service (by default only
159                 "app.kubernetes.io/name" is set to the service name)
160             additional_annotations: Annotations to be added to the kubernetes service.
161         """
162         super().__init__(charm, "kubernetes-service-patch")
163         self.charm = charm
164         self.service_name = service_name if service_name else self._app
165         self.service = self._service_object(
166             ports,
167             service_name,
168             service_type,
169             additional_labels,
170             additional_selectors,
171             additional_annotations,
172         )
173
174         # Make mypy type checking happy that self._patch is a method
175         assert isinstance(self._patch, MethodType)
176         # Ensure this patch is applied during the 'install' and 'upgrade-charm' events
177         self.framework.observe(charm.on.install, self._patch)
178         self.framework.observe(charm.on.upgrade_charm, self._patch)
179
180     def _service_object(
181         self,
182         ports: List[ServicePort],
183         service_name: str = None,
184         service_type: ServiceType = "ClusterIP",
185         additional_labels: dict = None,
186         additional_selectors: dict = None,
187         additional_annotations: dict = None,
188     ) -> Service:
189         """Creates a valid Service representation.
190
191         Args:
192             ports: a list of ServicePorts
193             service_name: allows setting custom name to the patched service. If none given,
194                 application name will be used.
195             service_type: desired type of K8s service. Default value is in line with ServiceSpec's
196                 default value.
197             additional_labels: Labels to be added to the kubernetes service (by default only
198                 "app.kubernetes.io/name" is set to the service name)
199             additional_selectors: Selectors to be added to the kubernetes service (by default only
200                 "app.kubernetes.io/name" is set to the service name)
201             additional_annotations: Annotations to be added to the kubernetes service.
202
203         Returns:
204             Service: A valid representation of a Kubernetes Service with the correct ports.
205         """
206         if not service_name:
207             service_name = self._app
208         labels = {"app.kubernetes.io/name": self._app}
209         if additional_labels:
210             labels.update(additional_labels)
211         selector = {"app.kubernetes.io/name": self._app}
212         if additional_selectors:
213             selector.update(additional_selectors)
214         return Service(
215             apiVersion="v1",
216             kind="Service",
217             metadata=ObjectMeta(
218                 namespace=self._namespace,
219                 name=service_name,
220                 labels=labels,
221                 annotations=additional_annotations,  # type: ignore[arg-type]
222             ),
223             spec=ServiceSpec(
224                 selector=selector,
225                 ports=ports,
226                 type=service_type,
227             ),
228         )
229
230     def _patch(self, _) -> None:
231         """Patch the Kubernetes service created by Juju to map the correct port.
232
233         Raises:
234             PatchFailed: if patching fails due to lack of permissions, or otherwise.
235         """
236         if not self.charm.unit.is_leader():
237             return
238
239         client = Client()
240         try:
241             if self.service_name != self._app:
242                 self._delete_and_create_service(client)
243             client.patch(Service, self.service_name, self.service, patch_type=PatchType.MERGE)
244         except ApiError as e:
245             if e.status.code == 403:
246                 logger.error("Kubernetes service patch failed: `juju trust` this application.")
247             else:
248                 logger.error("Kubernetes service patch failed: %s", str(e))
249         else:
250             logger.info("Kubernetes service '%s' patched successfully", self._app)
251
252     def _delete_and_create_service(self, client: Client):
253         service = client.get(Service, self._app, namespace=self._namespace)
254         service.metadata.name = self.service_name  # type: ignore[attr-defined]
255         service.metadata.resourceVersion = service.metadata.uid = None  # type: ignore[attr-defined]   # noqa: E501
256         client.delete(Service, self._app, namespace=self._namespace)
257         client.create(service)
258
259     def is_patched(self) -> bool:
260         """Reports if the service patch has been applied.
261
262         Returns:
263             bool: A boolean indicating if the service patch has been applied.
264         """
265         client = Client()
266         # Get the relevant service from the cluster
267         service = client.get(Service, name=self.service_name, namespace=self._namespace)
268         # Construct a list of expected ports, should the patch be applied
269         expected_ports = [(p.port, p.targetPort) for p in self.service.spec.ports]
270         # Construct a list in the same manner, using the fetched service
271         fetched_ports = [(p.port, p.targetPort) for p in service.spec.ports]  # type: ignore[attr-defined]  # noqa: E501
272         return expected_ports == fetched_ports
273
274     @property
275     def _app(self) -> str:
276         """Name of the current Juju application.
277
278         Returns:
279             str: A string containing the name of the current Juju application.
280         """
281         return self.charm.app.name
282
283     @property
284     def _namespace(self) -> str:
285         """The Kubernetes namespace we're running in.
286
287         Returns:
288             str: A string containing the name of the current Kubernetes namespace.
289         """
290         with open("/var/run/secrets/kubernetes.io/serviceaccount/namespace", "r") as f:
291             return f.read().strip()