Add Ng-UI sidecar charm
[osm/devops.git] / installers / charm / osm-ng-ui / lib / charms / observability_libs / v1 / kubernetes_service_patch.py
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()