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