02d46dbc98812e2c623543975ef34a0073d3079d
[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 from dataclasses import dataclass
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 = 5
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 @dataclass
236 class SubModule:
237 """Represent RO Submodules."""
238
239 sub_module_path: str
240 container_path: str
241
242
243 class HostPath:
244 """Represents a hostpath."""
245
246 def __init__(self, config: str, container_path: str, submodules: dict = None) -> None:
247 mount_path_items = config.split("-")
248 mount_path_items.reverse()
249 self.mount_path = "/" + "/".join(mount_path_items)
250 self.config = config
251 self.sub_module_dict = {}
252 if submodules:
253 for submodule in submodules.keys():
254 self.sub_module_dict[submodule] = SubModule(
255 sub_module_path=self.mount_path
256 + "/"
257 + submodule
258 + "/"
259 + submodules[submodule].split("/")[-1],
260 container_path=submodules[submodule],
261 )
262 else:
263 self.container_path = container_path
264 self.module_name = container_path.split("/")[-1]
265
266
267 class DebugMode(Object):
268 """Class to handle the debug-mode."""
269
270 def __init__(
271 self,
272 charm: CharmBase,
273 stored: StoredState,
274 container: Container,
275 hostpaths: List[HostPath] = [],
276 vscode_workspace_path: str = "files/vscode-workspace.json",
277 ) -> None:
278 super().__init__(charm, "debug-mode")
279
280 self.charm = charm
281 self._stored = stored
282 self.hostpaths = hostpaths
283 self.vscode_workspace = Path(vscode_workspace_path).read_text()
284 self.container = container
285
286 self._stored.set_default(
287 debug_mode_started=False,
288 debug_mode_vscode_command=None,
289 debug_mode_password=None,
290 )
291
292 self.framework.observe(self.charm.on.config_changed, self._on_config_changed)
293 self.framework.observe(self.charm.on[container.name].pebble_ready, self._on_config_changed)
294 self.framework.observe(self.charm.on.update_status, self._on_update_status)
295
296 def _on_config_changed(self, _) -> None:
297 """Handler for the config-changed event."""
298 if not self.charm.unit.is_leader():
299 return
300
301 debug_mode_enabled = self.charm.config.get("debug-mode", False)
302 action = self.enable if debug_mode_enabled else self.disable
303 action()
304
305 def _on_update_status(self, _) -> None:
306 """Handler for the update-status event."""
307 if not self.charm.unit.is_leader() or not self.started:
308 return
309
310 self.charm.unit.status = ActiveStatus("debug-mode: ready")
311
312 @property
313 def started(self) -> bool:
314 """Indicates whether the debug-mode has started or not."""
315 return self._stored.debug_mode_started
316
317 @property
318 def command(self) -> str:
319 """Command to launch vscode."""
320 return self._stored.debug_mode_vscode_command
321
322 @property
323 def password(self) -> str:
324 """SSH password."""
325 return self._stored.debug_mode_password
326
327 def enable(self, service_name: str = None) -> None:
328 """Enable debug-mode.
329
330 This function mounts hostpaths of the OSM modules (if set), and
331 configures the container so it can be easily debugged. The setup
332 includes the configuration of SSH, environment variables, and
333 VSCode workspace and plugins.
334
335 Args:
336 service_name (str, optional): Pebble service name which has the desired environment
337 variables. Mandatory if there is more than one Pebble service configured.
338 """
339 hostpaths_to_reconfigure = self._hostpaths_to_reconfigure()
340 if self.started and not hostpaths_to_reconfigure:
341 self.charm.unit.status = ActiveStatus("debug-mode: ready")
342 return
343
344 logger.debug("enabling debug-mode")
345
346 # Mount hostpaths if set.
347 # If hostpaths are mounted, the statefulset will be restarted,
348 # and for that reason we return immediately. On restart, the hostpaths
349 # won't be mounted and then we can continue and setup the debug-mode.
350 if hostpaths_to_reconfigure:
351 self.charm.unit.status = MaintenanceStatus("debug-mode: configuring hostpaths")
352 self._configure_hostpaths(hostpaths_to_reconfigure)
353 return
354
355 self.charm.unit.status = MaintenanceStatus("debug-mode: starting")
356 password = secrets.token_hex(8)
357 self._setup_debug_mode(
358 password,
359 service_name,
360 mounted_hostpaths=[hp for hp in self.hostpaths if self.charm.config.get(hp.config)],
361 )
362
363 self._stored.debug_mode_vscode_command = self._get_vscode_command(get_pod_ip())
364 self._stored.debug_mode_password = password
365 self._stored.debug_mode_started = True
366 logger.info("debug-mode is ready")
367 self.charm.unit.status = ActiveStatus("debug-mode: ready")
368
369 def disable(self) -> None:
370 """Disable debug-mode."""
371 logger.debug("disabling debug-mode")
372 current_status = self.charm.unit.status
373 hostpaths_unmounted = self._unmount_hostpaths()
374
375 if not self._stored.debug_mode_started:
376 return
377 self._stored.debug_mode_started = False
378 self._stored.debug_mode_vscode_command = None
379 self._stored.debug_mode_password = None
380
381 if not hostpaths_unmounted:
382 self.charm.unit.status = current_status
383 self._restart()
384
385 def _hostpaths_to_reconfigure(self) -> List[HostPath]:
386 hostpaths_to_reconfigure: List[HostPath] = []
387 client = Client()
388 statefulset = client.get(StatefulSet, self.charm.app.name, namespace=self.charm.model.name)
389 volumes = statefulset.spec.template.spec.volumes
390
391 for hostpath in self.hostpaths:
392 hostpath_is_set = True if self.charm.config.get(hostpath.config) else False
393 hostpath_already_configured = next(
394 (True for volume in volumes if volume.name == hostpath.config), False
395 )
396 if hostpath_is_set != hostpath_already_configured:
397 hostpaths_to_reconfigure.append(hostpath)
398
399 return hostpaths_to_reconfigure
400
401 def _setup_debug_mode(
402 self,
403 password: str,
404 service_name: str = None,
405 mounted_hostpaths: List[HostPath] = [],
406 ) -> None:
407 services = self.container.get_plan().services
408 if not service_name and len(services) != 1:
409 raise Exception("Cannot start debug-mode: please set the service_name")
410
411 service = None
412 if not service_name:
413 service_name, service = services.popitem()
414 if not service:
415 service = services.get(service_name)
416
417 logger.debug(f"getting environment variables from service {service_name}")
418 environment = service.environment
419 environment_file_content = "\n".join(
420 [f'export {key}="{value}"' for key, value in environment.items()]
421 )
422 logger.debug(f"pushing environment file to {self.container.name} container")
423 self.container.push("/debug.envs", environment_file_content)
424
425 # Push VSCode workspace
426 logger.debug(f"pushing vscode workspace to {self.container.name} container")
427 self.container.push("/debug.code-workspace", self.vscode_workspace)
428
429 # Execute debugging script
430 logger.debug(f"pushing debug-mode setup script to {self.container.name} container")
431 self.container.push("/debug.sh", _DEBUG_SCRIPT.format(password), permissions=0o777)
432 logger.debug(f"executing debug-mode setup script in {self.container.name} container")
433 self.container.exec(["/debug.sh"]).wait_output()
434 logger.debug(f"stopping service {service_name} in {self.container.name} container")
435 self.container.stop(service_name)
436
437 # Add symlinks to mounted hostpaths
438 for hostpath in mounted_hostpaths:
439 logger.debug(f"adding symlink for {hostpath.config}")
440 if len(hostpath.sub_module_dict) > 0:
441 for sub_module in hostpath.sub_module_dict.keys():
442 self.container.exec(
443 ["rm", "-rf", hostpath.sub_module_dict[sub_module].container_path]
444 ).wait_output()
445 self.container.exec(
446 [
447 "ln",
448 "-s",
449 hostpath.sub_module_dict[sub_module].sub_module_path,
450 hostpath.sub_module_dict[sub_module].container_path,
451 ]
452 )
453
454 else:
455 self.container.exec(["rm", "-rf", hostpath.container_path]).wait_output()
456 self.container.exec(
457 [
458 "ln",
459 "-s",
460 f"{hostpath.mount_path}/{hostpath.module_name}",
461 hostpath.container_path,
462 ]
463 )
464
465 def _configure_hostpaths(self, hostpaths: List[HostPath]):
466 client = Client()
467 statefulset = client.get(StatefulSet, self.charm.app.name, namespace=self.charm.model.name)
468
469 for hostpath in hostpaths:
470 if self.charm.config.get(hostpath.config):
471 self._add_hostpath_to_statefulset(hostpath, statefulset)
472 else:
473 self._delete_hostpath_from_statefulset(hostpath, statefulset)
474
475 client.replace(statefulset)
476
477 def _unmount_hostpaths(self) -> bool:
478 client = Client()
479 hostpath_unmounted = False
480 statefulset = client.get(StatefulSet, self.charm.app.name, namespace=self.charm.model.name)
481
482 for hostpath in self.hostpaths:
483 if self._delete_hostpath_from_statefulset(hostpath, statefulset):
484 hostpath_unmounted = True
485
486 if hostpath_unmounted:
487 client.replace(statefulset)
488
489 return hostpath_unmounted
490
491 def _add_hostpath_to_statefulset(self, hostpath: HostPath, statefulset: StatefulSet):
492 # Add volume
493 logger.debug(f"adding volume {hostpath.config} to {self.charm.app.name} statefulset")
494 volume = Volume(
495 hostpath.config,
496 hostPath=HostPathVolumeSource(
497 path=self.charm.config[hostpath.config],
498 type="Directory",
499 ),
500 )
501 statefulset.spec.template.spec.volumes.append(volume)
502
503 # Add volumeMount
504 for statefulset_container in statefulset.spec.template.spec.containers:
505 if statefulset_container.name != self.container.name:
506 continue
507
508 logger.debug(
509 f"adding volumeMount {hostpath.config} to {self.container.name} container"
510 )
511 statefulset_container.volumeMounts.append(
512 VolumeMount(mountPath=hostpath.mount_path, name=hostpath.config)
513 )
514
515 def _delete_hostpath_from_statefulset(self, hostpath: HostPath, statefulset: StatefulSet):
516 hostpath_unmounted = False
517 for volume in statefulset.spec.template.spec.volumes:
518 if hostpath.config != volume.name:
519 continue
520
521 # Remove volumeMount
522 for statefulset_container in statefulset.spec.template.spec.containers:
523 if statefulset_container.name != self.container.name:
524 continue
525 for volume_mount in statefulset_container.volumeMounts:
526 if volume_mount.name != hostpath.config:
527 continue
528
529 logger.debug(
530 f"removing volumeMount {hostpath.config} from {self.container.name} container"
531 )
532 statefulset_container.volumeMounts.remove(volume_mount)
533
534 # Remove volume
535 logger.debug(
536 f"removing volume {hostpath.config} from {self.charm.app.name} statefulset"
537 )
538 statefulset.spec.template.spec.volumes.remove(volume)
539
540 hostpath_unmounted = True
541 return hostpath_unmounted
542
543 def _get_vscode_command(
544 self,
545 pod_ip: str,
546 user: str = "root",
547 workspace_path: str = "/debug.code-workspace",
548 ) -> str:
549 return f"code --remote ssh-remote+{user}@{pod_ip} {workspace_path}"
550
551 def _restart(self):
552 self.container.exec(["kill", "-HUP", "1"])