e686f82431dd066fd16db70c948cee9e69f2336f
[osm/devops.git] / installers / charm / osm-lcm / lib / charms / osm_libs / v0 / utils.py
1 #!/usr/bin/env python3
2 # Copyright 2022 Canonical Ltd.
3 # See LICENSE file for licensing details.
4 # http://www.apache.org/licenses/LICENSE-2.0
5 """OSM Utils Library.
6
7 This library offers some utilities made for but not limited to Charmed OSM.
8
9 # Getting started
10
11 Execute the following command inside your Charmed Operator folder to fetch the library.
12
13 ```shell
14 charmcraft fetch-lib charms.osm_libs.v0.utils
15 ```
16
17 # CharmError Exception
18
19 An exception that takes to arguments, the message and the StatusBase class, which are useful
20 to set the status of the charm when the exception raises.
21
22 Example:
23 ```shell
24 from charms.osm_libs.v0.utils import CharmError
25
26 class MyCharm(CharmBase):
27 def _on_config_changed(self, _):
28 try:
29 if not self.config.get("some-option"):
30 raise CharmError("need some-option", BlockedStatus)
31
32 if not self.mysql_ready:
33 raise CharmError("waiting for mysql", WaitingStatus)
34
35 # Do stuff...
36
37 exception CharmError as e:
38 self.unit.status = e.status
39 ```
40
41 # Pebble validations
42
43 The `check_container_ready` function checks that a container is ready,
44 and therefore Pebble is ready.
45
46 The `check_service_active` function checks that a service in a container is running.
47
48 Both functions raise a CharmError if the validations fail.
49
50 Example:
51 ```shell
52 from charms.osm_libs.v0.utils import check_container_ready, check_service_active
53
54 class MyCharm(CharmBase):
55 def _on_config_changed(self, _):
56 try:
57 container: Container = self.unit.get_container("my-container")
58 check_container_ready(container)
59 check_service_active(container, "my-service")
60 # Do stuff...
61
62 exception CharmError as e:
63 self.unit.status = e.status
64 ```
65
66 # Debug-mode
67
68 The debug-mode allows OSM developers to easily debug OSM modules.
69
70 Example:
71 ```shell
72 from charms.osm_libs.v0.utils import DebugMode
73
74 class MyCharm(CharmBase):
75 _stored = StoredState()
76
77 def __init__(self, _):
78 # ...
79 container: Container = self.unit.get_container("my-container")
80 hostpaths = [
81 HostPath(
82 config="module-hostpath",
83 container_path="/usr/lib/python3/dist-packages/module"
84 ),
85 ]
86 vscode_workspace_path = "files/vscode-workspace.json"
87 self.debug_mode = DebugMode(
88 self,
89 self._stored,
90 container,
91 hostpaths,
92 vscode_workspace_path,
93 )
94
95 def _on_update_status(self, _):
96 if self.debug_mode.started:
97 return
98 # ...
99
100 def _get_debug_mode_information(self):
101 command = self.debug_mode.command
102 password = self.debug_mode.password
103 return command, password
104 ```
105
106 # More
107
108 - Get pod IP with `get_pod_ip()`
109 """
110
111 import logging
112 import secrets
113 import socket
114 from pathlib import Path
115 from typing import List
116
117 from lightkube import Client
118 from lightkube.models.core_v1 import HostPathVolumeSource, Volume, VolumeMount
119 from lightkube.resources.apps_v1 import StatefulSet
120 from ops.charm import CharmBase
121 from ops.framework import Object, StoredState
122 from ops.model import (
123 ActiveStatus,
124 BlockedStatus,
125 Container,
126 MaintenanceStatus,
127 StatusBase,
128 WaitingStatus,
129 )
130 from ops.pebble import ServiceStatus
131
132 # The unique Charmhub library identifier, never change it
133 LIBID = "e915908eebee4cdd972d484728adf984"
134
135 # Increment this major API version when introducing breaking changes
136 LIBAPI = 0
137
138 # Increment this PATCH version before using `charmcraft publish-lib` or reset
139 # to 0 if you are raising the major API version
140 LIBPATCH = 2
141
142 logger = logging.getLogger(__name__)
143
144
145 class CharmError(Exception):
146 """Charm Error Exception."""
147
148 def __init__(self, message: str, status_class: StatusBase = BlockedStatus) -> None:
149 self.message = message
150 self.status_class = status_class
151 self.status = status_class(message)
152
153
154 def check_container_ready(container: Container) -> None:
155 """Check Pebble has started in the container.
156
157 Args:
158 container (Container): Container to be checked.
159
160 Raises:
161 CharmError: if container is not ready.
162 """
163 if not container.can_connect():
164 raise CharmError("waiting for pebble to start", MaintenanceStatus)
165
166
167 def check_service_active(container: Container, service_name: str) -> None:
168 """Check if the service is running.
169
170 Args:
171 container (Container): Container to be checked.
172 service_name (str): Name of the service to check.
173
174 Raises:
175 CharmError: if the service is not running.
176 """
177 if service_name not in container.get_plan().services:
178 raise CharmError(f"{service_name} service not configured yet", WaitingStatus)
179
180 if container.get_service(service_name).current != ServiceStatus.ACTIVE:
181 raise CharmError(f"{service_name} service is not running")
182
183
184 def get_pod_ip() -> str:
185 """Get Kubernetes Pod IP.
186
187 Returns:
188 str: The IP of the Pod.
189 """
190 return socket.gethostbyname(socket.gethostname())
191
192
193 _DEBUG_SCRIPT = r"""#!/bin/bash
194 # Install SSH
195
196 function download_code(){{
197 wget https://go.microsoft.com/fwlink/?LinkID=760868 -O code.deb
198 }}
199
200 function setup_envs(){{
201 grep "source /debug.envs" /root/.bashrc || echo "source /debug.envs" | tee -a /root/.bashrc
202 }}
203 function setup_ssh(){{
204 apt install ssh -y
205 cat /etc/ssh/sshd_config |
206 grep -E '^PermitRootLogin yes$$' || (
207 echo PermitRootLogin yes |
208 tee -a /etc/ssh/sshd_config
209 )
210 service ssh stop
211 sleep 3
212 service ssh start
213 usermod --password $(echo {} | openssl passwd -1 -stdin) root
214 }}
215
216 function setup_code(){{
217 apt install libasound2 -y
218 (dpkg -i code.deb || apt-get install -f -y || apt-get install -f -y) && echo Code installed successfully
219 code --install-extension ms-python.python --user-data-dir /root
220 mkdir -p /root/.vscode-server
221 cp -R /root/.vscode/extensions /root/.vscode-server/extensions
222 }}
223
224 export DEBIAN_FRONTEND=noninteractive
225 apt update && apt install wget -y
226 download_code &
227 setup_ssh &
228 setup_envs
229 wait
230 setup_code &
231 wait
232 """
233
234
235 class HostPath:
236 """Represents a hostpath."""
237
238 def __init__(self, config: str, container_path: str) -> None:
239 mount_path_items = config.split("-")
240 mount_path_items.reverse()
241 self.mount_path = "/" + "/".join(mount_path_items)
242 self.config = config
243 self.container_path = container_path
244 self.module_name = container_path.split("/")[-1]
245
246
247 class DebugMode(Object):
248 """Class to handle the debug-mode."""
249
250 def __init__(
251 self,
252 charm: CharmBase,
253 stored: StoredState,
254 container: Container,
255 hostpaths: List[HostPath] = [],
256 vscode_workspace_path: str = "files/vscode-workspace.json",
257 ) -> None:
258 super().__init__(charm, "debug-mode")
259
260 self.charm = charm
261 self._stored = stored
262 self.hostpaths = hostpaths
263 self.vscode_workspace = Path(vscode_workspace_path).read_text()
264 self.container = container
265
266 self._stored.set_default(
267 debug_mode_started=False,
268 debug_mode_vscode_command=None,
269 debug_mode_password=None,
270 )
271
272 self.framework.observe(self.charm.on.config_changed, self._on_config_changed)
273 self.framework.observe(self.charm.on[container.name].pebble_ready, self._on_config_changed)
274 self.framework.observe(self.charm.on.update_status, self._on_update_status)
275
276 def _on_config_changed(self, _) -> None:
277 """Handler for the config-changed event."""
278 if not self.charm.unit.is_leader():
279 return
280
281 debug_mode_enabled = self.charm.config.get("debug-mode", False)
282 action = self.enable if debug_mode_enabled else self.disable
283 action()
284
285 def _on_update_status(self, _) -> None:
286 """Handler for the update-status event."""
287 if not self.charm.unit.is_leader() or not self.started:
288 return
289
290 self.charm.unit.status = ActiveStatus("debug-mode: ready")
291
292 @property
293 def started(self) -> bool:
294 """Indicates whether the debug-mode has started or not."""
295 return self._stored.debug_mode_started
296
297 @property
298 def command(self) -> str:
299 """Command to launch vscode."""
300 return self._stored.debug_mode_vscode_command
301
302 @property
303 def password(self) -> str:
304 """SSH password."""
305 return self._stored.debug_mode_password
306
307 def enable(self, service_name: str = None) -> None:
308 """Enable debug-mode.
309
310 This function mounts hostpaths of the OSM modules (if set), and
311 configures the container so it can be easily debugged. The setup
312 includes the configuration of SSH, environment variables, and
313 VSCode workspace and plugins.
314
315 Args:
316 service_name (str, optional): Pebble service name which has the desired environment
317 variables. Mandatory if there is more than one Pebble service configured.
318 """
319 hostpaths_to_reconfigure = self._hostpaths_to_reconfigure()
320 if self.started and not hostpaths_to_reconfigure:
321 self.charm.unit.status = ActiveStatus("debug-mode: ready")
322 return
323
324 logger.debug("enabling debug-mode")
325
326 # Mount hostpaths if set.
327 # If hostpaths are mounted, the statefulset will be restarted,
328 # and for that reason we return immediately. On restart, the hostpaths
329 # won't be mounted and then we can continue and setup the debug-mode.
330 if hostpaths_to_reconfigure:
331 self.charm.unit.status = MaintenanceStatus("debug-mode: configuring hostpaths")
332 self._configure_hostpaths(hostpaths_to_reconfigure)
333 return
334
335 self.charm.unit.status = MaintenanceStatus("debug-mode: starting")
336 password = secrets.token_hex(8)
337 self._setup_debug_mode(
338 password,
339 service_name,
340 mounted_hostpaths=[hp for hp in self.hostpaths if self.charm.config.get(hp.config)],
341 )
342
343 self._stored.debug_mode_vscode_command = self._get_vscode_command(get_pod_ip())
344 self._stored.debug_mode_password = password
345 self._stored.debug_mode_started = True
346 logger.info("debug-mode is ready")
347 self.charm.unit.status = ActiveStatus("debug-mode: ready")
348
349 def disable(self) -> None:
350 """Disable debug-mode."""
351 logger.debug("disabling debug-mode")
352 current_status = self.charm.unit.status
353 hostpaths_unmounted = self._unmount_hostpaths()
354
355 if not self._stored.debug_mode_started:
356 return
357 self._stored.debug_mode_started = False
358 self._stored.debug_mode_vscode_command = None
359 self._stored.debug_mode_password = None
360
361 if not hostpaths_unmounted:
362 self.charm.unit.status = current_status
363 self._restart()
364
365 def _hostpaths_to_reconfigure(self) -> List[HostPath]:
366 hostpaths_to_reconfigure: List[HostPath] = []
367 client = Client()
368 statefulset = client.get(StatefulSet, self.charm.app.name, namespace=self.charm.model.name)
369 volumes = statefulset.spec.template.spec.volumes
370
371 for hostpath in self.hostpaths:
372 hostpath_is_set = True if self.charm.config.get(hostpath.config) else False
373 hostpath_already_configured = next(
374 (True for volume in volumes if volume.name == hostpath.config), False
375 )
376 if hostpath_is_set != hostpath_already_configured:
377 hostpaths_to_reconfigure.append(hostpath)
378
379 return hostpaths_to_reconfigure
380
381 def _setup_debug_mode(
382 self,
383 password: str,
384 service_name: str = None,
385 mounted_hostpaths: List[HostPath] = [],
386 ) -> None:
387 services = self.container.get_plan().services
388 if not service_name and len(services) != 1:
389 raise Exception("Cannot start debug-mode: please set the service_name")
390
391 service = None
392 if not service_name:
393 service_name, service = services.popitem()
394 if not service:
395 service = services.get(service_name)
396
397 logger.debug(f"getting environment variables from service {service_name}")
398 environment = service.environment
399 environment_file_content = "\n".join(
400 [f'export {key}="{value}"' for key, value in environment.items()]
401 )
402 logger.debug(f"pushing environment file to {self.container.name} container")
403 self.container.push("/debug.envs", environment_file_content)
404
405 # Push VSCode workspace
406 logger.debug(f"pushing vscode workspace to {self.container.name} container")
407 self.container.push("/debug.code-workspace", self.vscode_workspace)
408
409 # Execute debugging script
410 logger.debug(f"pushing debug-mode setup script to {self.container.name} container")
411 self.container.push("/debug.sh", _DEBUG_SCRIPT.format(password), permissions=0o777)
412 logger.debug(f"executing debug-mode setup script in {self.container.name} container")
413 self.container.exec(["/debug.sh"]).wait_output()
414 logger.debug(f"stopping service {service_name} in {self.container.name} container")
415 self.container.stop(service_name)
416
417 # Add symlinks to mounted hostpaths
418 for hostpath in mounted_hostpaths:
419 logger.debug(f"adding symlink for {hostpath.config}")
420 self.container.exec(["rm", "-rf", hostpath.container_path]).wait_output()
421 self.container.exec(
422 [
423 "ln",
424 "-s",
425 f"{hostpath.mount_path}/{hostpath.module_name}",
426 hostpath.container_path,
427 ]
428 )
429
430 def _configure_hostpaths(self, hostpaths: List[HostPath]):
431 client = Client()
432 statefulset = client.get(StatefulSet, self.charm.app.name, namespace=self.charm.model.name)
433
434 for hostpath in hostpaths:
435 if self.charm.config.get(hostpath.config):
436 self._add_hostpath_to_statefulset(hostpath, statefulset)
437 else:
438 self._delete_hostpath_from_statefulset(hostpath, statefulset)
439
440 client.replace(statefulset)
441
442 def _unmount_hostpaths(self) -> bool:
443 client = Client()
444 hostpath_unmounted = False
445 statefulset = client.get(StatefulSet, self.charm.app.name, namespace=self.charm.model.name)
446
447 for hostpath in self.hostpaths:
448 if self._delete_hostpath_from_statefulset(hostpath, statefulset):
449 hostpath_unmounted = True
450
451 if hostpath_unmounted:
452 client.replace(statefulset)
453
454 return hostpath_unmounted
455
456 def _add_hostpath_to_statefulset(self, hostpath: HostPath, statefulset: StatefulSet):
457 # Add volume
458 logger.debug(f"adding volume {hostpath.config} to {self.charm.app.name} statefulset")
459 volume = Volume(
460 hostpath.config,
461 hostPath=HostPathVolumeSource(
462 path=self.charm.config[hostpath.config],
463 type="Directory",
464 ),
465 )
466 statefulset.spec.template.spec.volumes.append(volume)
467
468 # Add volumeMount
469 for statefulset_container in statefulset.spec.template.spec.containers:
470 if statefulset_container.name != self.container.name:
471 continue
472
473 logger.debug(
474 f"adding volumeMount {hostpath.config} to {self.container.name} container"
475 )
476 statefulset_container.volumeMounts.append(
477 VolumeMount(mountPath=hostpath.mount_path, name=hostpath.config)
478 )
479
480 def _delete_hostpath_from_statefulset(self, hostpath: HostPath, statefulset: StatefulSet):
481 hostpath_unmounted = False
482 for volume in statefulset.spec.template.spec.volumes:
483
484 if hostpath.config != volume.name:
485 continue
486
487 # Remove volumeMount
488 for statefulset_container in statefulset.spec.template.spec.containers:
489 if statefulset_container.name != self.container.name:
490 continue
491 for volume_mount in statefulset_container.volumeMounts:
492 if volume_mount.name != hostpath.config:
493 continue
494
495 logger.debug(
496 f"removing volumeMount {hostpath.config} from {self.container.name} container"
497 )
498 statefulset_container.volumeMounts.remove(volume_mount)
499
500 # Remove volume
501 logger.debug(
502 f"removing volume {hostpath.config} from {self.charm.app.name} statefulset"
503 )
504 statefulset.spec.template.spec.volumes.remove(volume)
505
506 hostpath_unmounted = True
507 return hostpath_unmounted
508
509 def _get_vscode_command(
510 self,
511 pod_ip: str,
512 user: str = "root",
513 workspace_path: str = "/debug.code-workspace",
514 ) -> str:
515 return f"code --remote ssh-remote+{user}@{pod_ip} {workspace_path}"
516
517 def _restart(self):
518 self.container.exec(["kill", "-HUP", "1"])