OSM Libs
- OSM Charmers
Channel | Revision | Published | Runs on |
---|---|---|---|
latest/edge | 1 | 12 May 2022 |
juju deploy osm-libs --channel edge
Deploy universal operators easily with Juju, the Universal Operator Lifecycle Manager.
Platform:
20.04
charms.osm_libs.v0.utils
-
- Last updated 18 Jan 2023
- Revision Library version 0.5
#!/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 = 5
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 + "/" + submodules[submodule].split("/")[-1],
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"])