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