X-Git-Url: https://osm.etsi.org/gitweb/?a=blobdiff_plain;f=installers%2Fcharm%2Fosm-nglcm%2Flib%2Fcharms%2Fosm_libs%2Fv0%2Futils.py;fp=installers%2Fcharm%2Fosm-nglcm%2Flib%2Fcharms%2Fosm_libs%2Fv0%2Futils.py;h=df3da94e8ec3083a273ee76b69ea8e873b8db30a;hb=7cb878a01863db170ee1d6c238d13708295b9748;hp=0000000000000000000000000000000000000000;hpb=e1005c45b33ba19c6a63a46b374813a34911b27a;p=osm%2Fdevops.git diff --git a/installers/charm/osm-nglcm/lib/charms/osm_libs/v0/utils.py b/installers/charm/osm-nglcm/lib/charms/osm_libs/v0/utils.py new file mode 100644 index 00000000..df3da94e --- /dev/null +++ b/installers/charm/osm-nglcm/lib/charms/osm_libs/v0/utils.py @@ -0,0 +1,544 @@ +#!/usr/bin/env python3 +# Copyright 2022 Canonical Ltd. +# See LICENSE file for licensing details. +# http://www.apache.org/licenses/LICENSE-2.0 +"""OSM Utils Library. + +This library offers some utilities made for but not limited to Charmed OSM. + +# Getting started + +Execute the following command inside your Charmed Operator folder to fetch the library. + +```shell +charmcraft fetch-lib charms.osm_libs.v0.utils +``` + +# CharmError Exception + +An exception that takes to arguments, the message and the StatusBase class, which are useful +to set the status of the charm when the exception raises. + +Example: +```shell +from charms.osm_libs.v0.utils import CharmError + +class MyCharm(CharmBase): + def _on_config_changed(self, _): + try: + if not self.config.get("some-option"): + raise CharmError("need some-option", BlockedStatus) + + if not self.mysql_ready: + raise CharmError("waiting for mysql", WaitingStatus) + + # Do stuff... + + exception CharmError as e: + self.unit.status = e.status +``` + +# Pebble validations + +The `check_container_ready` function checks that a container is ready, +and therefore Pebble is ready. + +The `check_service_active` function checks that a service in a container is running. + +Both functions raise a CharmError if the validations fail. + +Example: +```shell +from charms.osm_libs.v0.utils import check_container_ready, check_service_active + +class MyCharm(CharmBase): + def _on_config_changed(self, _): + try: + container: Container = self.unit.get_container("my-container") + check_container_ready(container) + check_service_active(container, "my-service") + # Do stuff... + + exception CharmError as e: + self.unit.status = e.status +``` + +# Debug-mode + +The debug-mode allows OSM developers to easily debug OSM modules. + +Example: +```shell +from charms.osm_libs.v0.utils import DebugMode + +class MyCharm(CharmBase): + _stored = StoredState() + + def __init__(self, _): + # ... + container: Container = self.unit.get_container("my-container") + hostpaths = [ + HostPath( + config="module-hostpath", + container_path="/usr/lib/python3/dist-packages/module" + ), + ] + vscode_workspace_path = "files/vscode-workspace.json" + self.debug_mode = DebugMode( + self, + self._stored, + container, + hostpaths, + vscode_workspace_path, + ) + + def _on_update_status(self, _): + if self.debug_mode.started: + return + # ... + + def _get_debug_mode_information(self): + command = self.debug_mode.command + password = self.debug_mode.password + return command, password +``` + +# More + +- Get pod IP with `get_pod_ip()` +""" +from dataclasses import dataclass +import logging +import secrets +import socket +from pathlib import Path +from typing import List + +from lightkube import Client +from lightkube.models.core_v1 import HostPathVolumeSource, Volume, VolumeMount +from lightkube.resources.apps_v1 import StatefulSet +from ops.charm import CharmBase +from ops.framework import Object, StoredState +from ops.model import ( + ActiveStatus, + BlockedStatus, + Container, + MaintenanceStatus, + StatusBase, + WaitingStatus, +) +from ops.pebble import ServiceStatus + +# The unique Charmhub library identifier, never change it +LIBID = "e915908eebee4cdd972d484728adf984" + +# Increment this major API version when introducing breaking changes +LIBAPI = 0 + +# Increment this PATCH version before using `charmcraft publish-lib` or reset +# to 0 if you are raising the major API version +LIBPATCH = 3 + +logger = logging.getLogger(__name__) + + +class CharmError(Exception): + """Charm Error Exception.""" + + def __init__(self, message: str, status_class: StatusBase = BlockedStatus) -> None: + self.message = message + self.status_class = status_class + self.status = status_class(message) + + +def check_container_ready(container: Container) -> None: + """Check Pebble has started in the container. + + Args: + container (Container): Container to be checked. + + Raises: + CharmError: if container is not ready. + """ + if not container.can_connect(): + raise CharmError("waiting for pebble to start", MaintenanceStatus) + + +def check_service_active(container: Container, service_name: str) -> None: + """Check if the service is running. + + Args: + container (Container): Container to be checked. + service_name (str): Name of the service to check. + + Raises: + CharmError: if the service is not running. + """ + if service_name not in container.get_plan().services: + raise CharmError(f"{service_name} service not configured yet", WaitingStatus) + + if container.get_service(service_name).current != ServiceStatus.ACTIVE: + raise CharmError(f"{service_name} service is not running") + + +def get_pod_ip() -> str: + """Get Kubernetes Pod IP. + + Returns: + str: The IP of the Pod. + """ + return socket.gethostbyname(socket.gethostname()) + + +_DEBUG_SCRIPT = r"""#!/bin/bash +# Install SSH + +function download_code(){{ + wget https://go.microsoft.com/fwlink/?LinkID=760868 -O code.deb +}} + +function setup_envs(){{ + grep "source /debug.envs" /root/.bashrc || echo "source /debug.envs" | tee -a /root/.bashrc +}} +function setup_ssh(){{ + apt install ssh -y + cat /etc/ssh/sshd_config | + grep -E '^PermitRootLogin yes$$' || ( + echo PermitRootLogin yes | + tee -a /etc/ssh/sshd_config + ) + service ssh stop + sleep 3 + service ssh start + usermod --password $(echo {} | openssl passwd -1 -stdin) root +}} + +function setup_code(){{ + apt install libasound2 -y + (dpkg -i code.deb || apt-get install -f -y || apt-get install -f -y) && echo Code installed successfully + code --install-extension ms-python.python --user-data-dir /root + mkdir -p /root/.vscode-server + cp -R /root/.vscode/extensions /root/.vscode-server/extensions +}} + +export DEBIAN_FRONTEND=noninteractive +apt update && apt install wget -y +download_code & +setup_ssh & +setup_envs +wait +setup_code & +wait +""" + + +@dataclass +class SubModule: + """Represent RO Submodules.""" + sub_module_path: str + container_path: str + + +class HostPath: + """Represents a hostpath.""" + def __init__(self, config: str, container_path: str, submodules: dict = None) -> None: + mount_path_items = config.split("-") + mount_path_items.reverse() + self.mount_path = "/" + "/".join(mount_path_items) + self.config = config + self.sub_module_dict = {} + if submodules: + for submodule in submodules.keys(): + self.sub_module_dict[submodule] = SubModule( + sub_module_path=self.mount_path + "/" + submodule, + container_path=submodules[submodule], + ) + else: + self.container_path = container_path + self.module_name = container_path.split("/")[-1] + +class DebugMode(Object): + """Class to handle the debug-mode.""" + + def __init__( + self, + charm: CharmBase, + stored: StoredState, + container: Container, + hostpaths: List[HostPath] = [], + vscode_workspace_path: str = "files/vscode-workspace.json", + ) -> None: + super().__init__(charm, "debug-mode") + + self.charm = charm + self._stored = stored + self.hostpaths = hostpaths + self.vscode_workspace = Path(vscode_workspace_path).read_text() + self.container = container + + self._stored.set_default( + debug_mode_started=False, + debug_mode_vscode_command=None, + debug_mode_password=None, + ) + + self.framework.observe(self.charm.on.config_changed, self._on_config_changed) + self.framework.observe(self.charm.on[container.name].pebble_ready, self._on_config_changed) + self.framework.observe(self.charm.on.update_status, self._on_update_status) + + def _on_config_changed(self, _) -> None: + """Handler for the config-changed event.""" + if not self.charm.unit.is_leader(): + return + + debug_mode_enabled = self.charm.config.get("debug-mode", False) + action = self.enable if debug_mode_enabled else self.disable + action() + + def _on_update_status(self, _) -> None: + """Handler for the update-status event.""" + if not self.charm.unit.is_leader() or not self.started: + return + + self.charm.unit.status = ActiveStatus("debug-mode: ready") + + @property + def started(self) -> bool: + """Indicates whether the debug-mode has started or not.""" + return self._stored.debug_mode_started + + @property + def command(self) -> str: + """Command to launch vscode.""" + return self._stored.debug_mode_vscode_command + + @property + def password(self) -> str: + """SSH password.""" + return self._stored.debug_mode_password + + def enable(self, service_name: str = None) -> None: + """Enable debug-mode. + + This function mounts hostpaths of the OSM modules (if set), and + configures the container so it can be easily debugged. The setup + includes the configuration of SSH, environment variables, and + VSCode workspace and plugins. + + Args: + service_name (str, optional): Pebble service name which has the desired environment + variables. Mandatory if there is more than one Pebble service configured. + """ + hostpaths_to_reconfigure = self._hostpaths_to_reconfigure() + if self.started and not hostpaths_to_reconfigure: + self.charm.unit.status = ActiveStatus("debug-mode: ready") + return + + logger.debug("enabling debug-mode") + + # Mount hostpaths if set. + # If hostpaths are mounted, the statefulset will be restarted, + # and for that reason we return immediately. On restart, the hostpaths + # won't be mounted and then we can continue and setup the debug-mode. + if hostpaths_to_reconfigure: + self.charm.unit.status = MaintenanceStatus("debug-mode: configuring hostpaths") + self._configure_hostpaths(hostpaths_to_reconfigure) + return + + self.charm.unit.status = MaintenanceStatus("debug-mode: starting") + password = secrets.token_hex(8) + self._setup_debug_mode( + password, + service_name, + mounted_hostpaths=[hp for hp in self.hostpaths if self.charm.config.get(hp.config)], + ) + + self._stored.debug_mode_vscode_command = self._get_vscode_command(get_pod_ip()) + self._stored.debug_mode_password = password + self._stored.debug_mode_started = True + logger.info("debug-mode is ready") + self.charm.unit.status = ActiveStatus("debug-mode: ready") + + def disable(self) -> None: + """Disable debug-mode.""" + logger.debug("disabling debug-mode") + current_status = self.charm.unit.status + hostpaths_unmounted = self._unmount_hostpaths() + + if not self._stored.debug_mode_started: + return + self._stored.debug_mode_started = False + self._stored.debug_mode_vscode_command = None + self._stored.debug_mode_password = None + + if not hostpaths_unmounted: + self.charm.unit.status = current_status + self._restart() + + def _hostpaths_to_reconfigure(self) -> List[HostPath]: + hostpaths_to_reconfigure: List[HostPath] = [] + client = Client() + statefulset = client.get(StatefulSet, self.charm.app.name, namespace=self.charm.model.name) + volumes = statefulset.spec.template.spec.volumes + + for hostpath in self.hostpaths: + hostpath_is_set = True if self.charm.config.get(hostpath.config) else False + hostpath_already_configured = next( + (True for volume in volumes if volume.name == hostpath.config), False + ) + if hostpath_is_set != hostpath_already_configured: + hostpaths_to_reconfigure.append(hostpath) + + return hostpaths_to_reconfigure + + def _setup_debug_mode( + self, + password: str, + service_name: str = None, + mounted_hostpaths: List[HostPath] = [], + ) -> None: + services = self.container.get_plan().services + if not service_name and len(services) != 1: + raise Exception("Cannot start debug-mode: please set the service_name") + + service = None + if not service_name: + service_name, service = services.popitem() + if not service: + service = services.get(service_name) + + logger.debug(f"getting environment variables from service {service_name}") + environment = service.environment + environment_file_content = "\n".join( + [f'export {key}="{value}"' for key, value in environment.items()] + ) + logger.debug(f"pushing environment file to {self.container.name} container") + self.container.push("/debug.envs", environment_file_content) + + # Push VSCode workspace + logger.debug(f"pushing vscode workspace to {self.container.name} container") + self.container.push("/debug.code-workspace", self.vscode_workspace) + + # Execute debugging script + logger.debug(f"pushing debug-mode setup script to {self.container.name} container") + self.container.push("/debug.sh", _DEBUG_SCRIPT.format(password), permissions=0o777) + logger.debug(f"executing debug-mode setup script in {self.container.name} container") + self.container.exec(["/debug.sh"]).wait_output() + logger.debug(f"stopping service {service_name} in {self.container.name} container") + self.container.stop(service_name) + + # Add symlinks to mounted hostpaths + for hostpath in mounted_hostpaths: + logger.debug(f"adding symlink for {hostpath.config}") + if len(hostpath.sub_module_dict) > 0: + for sub_module in hostpath.sub_module_dict.keys(): + self.container.exec(["rm", "-rf", hostpath.sub_module_dict[sub_module].container_path]).wait_output() + self.container.exec( + [ + "ln", + "-s", + hostpath.sub_module_dict[sub_module].sub_module_path, + hostpath.sub_module_dict[sub_module].container_path, + ] + ) + + else: + self.container.exec(["rm", "-rf", hostpath.container_path]).wait_output() + self.container.exec( + [ + "ln", + "-s", + f"{hostpath.mount_path}/{hostpath.module_name}", + hostpath.container_path, + ] + ) + + def _configure_hostpaths(self, hostpaths: List[HostPath]): + client = Client() + statefulset = client.get(StatefulSet, self.charm.app.name, namespace=self.charm.model.name) + + for hostpath in hostpaths: + if self.charm.config.get(hostpath.config): + self._add_hostpath_to_statefulset(hostpath, statefulset) + else: + self._delete_hostpath_from_statefulset(hostpath, statefulset) + + client.replace(statefulset) + + def _unmount_hostpaths(self) -> bool: + client = Client() + hostpath_unmounted = False + statefulset = client.get(StatefulSet, self.charm.app.name, namespace=self.charm.model.name) + + for hostpath in self.hostpaths: + if self._delete_hostpath_from_statefulset(hostpath, statefulset): + hostpath_unmounted = True + + if hostpath_unmounted: + client.replace(statefulset) + + return hostpath_unmounted + + def _add_hostpath_to_statefulset(self, hostpath: HostPath, statefulset: StatefulSet): + # Add volume + logger.debug(f"adding volume {hostpath.config} to {self.charm.app.name} statefulset") + volume = Volume( + hostpath.config, + hostPath=HostPathVolumeSource( + path=self.charm.config[hostpath.config], + type="Directory", + ), + ) + statefulset.spec.template.spec.volumes.append(volume) + + # Add volumeMount + for statefulset_container in statefulset.spec.template.spec.containers: + if statefulset_container.name != self.container.name: + continue + + logger.debug( + f"adding volumeMount {hostpath.config} to {self.container.name} container" + ) + statefulset_container.volumeMounts.append( + VolumeMount(mountPath=hostpath.mount_path, name=hostpath.config) + ) + + def _delete_hostpath_from_statefulset(self, hostpath: HostPath, statefulset: StatefulSet): + hostpath_unmounted = False + for volume in statefulset.spec.template.spec.volumes: + + if hostpath.config != volume.name: + continue + + # Remove volumeMount + for statefulset_container in statefulset.spec.template.spec.containers: + if statefulset_container.name != self.container.name: + continue + for volume_mount in statefulset_container.volumeMounts: + if volume_mount.name != hostpath.config: + continue + + logger.debug( + f"removing volumeMount {hostpath.config} from {self.container.name} container" + ) + statefulset_container.volumeMounts.remove(volume_mount) + + # Remove volume + logger.debug( + f"removing volume {hostpath.config} from {self.charm.app.name} statefulset" + ) + statefulset.spec.template.spec.volumes.remove(volume) + + hostpath_unmounted = True + return hostpath_unmounted + + def _get_vscode_command( + self, + pod_ip: str, + user: str = "root", + workspace_path: str = "/debug.code-workspace", + ) -> str: + return f"code --remote ssh-remote+{user}@{pod_ip} {workspace_path}" + + def _restart(self): + self.container.exec(["kill", "-HUP", "1"])