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