commit 376a29181a6e6e5cb3d867f5e8c0daf05dedc6be Author: Liam Young Date: Mon Sep 27 11:17:05 2021 +0100 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..90aca4c --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +venv/ +build/ +.idea/ +*.charm +.tox +venv +.coverage +__pycache__/ +*.py[cod] +**.swp +.stestr/ +lib/charms/nginx* diff --git a/.stestr.conf b/.stestr.conf new file mode 100644 index 0000000..5fcccac --- /dev/null +++ b/.stestr.conf @@ -0,0 +1,3 @@ +[DEFAULT] +test_path=./unit_tests +top_dir=./ diff --git a/advanced_sunbeam_openstack/__init__.py b/advanced_sunbeam_openstack/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/advanced_sunbeam_openstack/adapters.py b/advanced_sunbeam_openstack/adapters.py new file mode 100644 index 0000000..9829846 --- /dev/null +++ b/advanced_sunbeam_openstack/adapters.py @@ -0,0 +1,112 @@ +import ops_openstack.adapters + +class ConfigAdapter(): + + def __init__(self, charm, namespace): + self.charm = charm + self.namespace = namespace + for k, v in self.context().items(): + k = k.replace('-', '_') + setattr(self, k, v) + + +class CharmConfigAdapter(ConfigAdapter): + + def context(self): + return self.charm.config + + +class WSGIWorkerConfigAdapter(ConfigAdapter): + + def context(self): + return { + 'name': self.charm.service_name, + 'wsgi_admin_script': self.charm.wsgi_admin_script, + 'wsgi_public_script': self.charm.wsgi_public_script} + + +class DBAdapter(ops_openstack.adapters.OpenStackOperRelationAdapter): + + @property + def database(self): + return self.relation.databases()[0] + + @property + def database_host(self): + return self.relation.credentials().get('address') + + @property + def database_password(self): + return self.relation.credentials().get('password') + + @property + def database_user(self): + return self.relation.credentials().get('username') + + @property + def database_type(self): + return 'mysql+pymysql' + + + + +class OPSRelationAdapters(): + + def __init__(self, charm): + self.charm = charm + self.namespaces = [] + + def _get_adapter(self, relation_name): + # Matching relation first + # Then interface name + if self.relation_map.get(relation_name): + return self.relation_map.get(relation_name) + interface_name = self.charm.meta.relations[ + relation_name].interface_name + if self.interface_map.get(interface_name): + return self.interface_map.get(interface_name) + + def add_relation_adapter(self, interface, relation_name): + adapter = self._get_adapter(relation_name) + if adapter: + adapter_ns = relation_name.replace("-", "_") + self.namespaces.append(adapter_ns) + setattr(self, adapter_ns, adapter(interface)) + else: + logging.debug(f"No adapter found for {relation_name}") + + def add_config_adapters(self, config_adapters): + for config_adapter in config_adapters: + self.add_config_adapter( + config_adapter, + config_adapter.namespace) + + def add_config_adapter(self, config_adapter, namespace): + self.namespaces.append(namespace) + setattr(self, namespace, config_adapter) + + @property + def interface_map(self): + return {} + + @property + def relation_map(self): + return {} + + def __iter__(self): + """ + Iterate over the relations presented to the charm. + """ + for namespace in self.namespaces: + yield namespace, getattr(self, namespace) + + +class APICharmAdapters(OPSRelationAdapters): + """Collection of relation adapters.""" + + @property + def interface_map(self): + _map = super().interface_map + _map.update({ + 'mysql_datastore': DBAdapter}) + return _map diff --git a/advanced_sunbeam_openstack/core.py b/advanced_sunbeam_openstack/core.py new file mode 100644 index 0000000..d03be23 --- /dev/null +++ b/advanced_sunbeam_openstack/core.py @@ -0,0 +1,335 @@ +#!/usr/bin/env python3 +# Copyright 2021 Billy Olsen +# See LICENSE file for licensing details. +# +# Learn more at: https://juju.is/docs/sdk + +"""Charm the service. + +Refer to the following post for a quick-start guide that will help you +develop a new k8s charm using the Operator Framework: + + https://discourse.charmhub.io/t/4208 +""" + +import collections +import logging +import ops_openstack +import ops_openstack.adapters + +from charms.nginx_ingress_integrator.v0.ingress import IngressRequires +from charms.mysql.v1.mysql import MySQLConsumer + +from ops.charm import CharmBase +from ops.charm import PebbleReadyEvent +from ops import model + +from ops.framework import StoredState + +import advanced_sunbeam_openstack.adapters as sunbeam_adapters +from advanced_sunbeam_openstack.templating import sidecar_config_render +import advanced_sunbeam_openstack.cprocess as sunbeam_cprocess + +logger = logging.getLogger(__name__) + + +ContainerConfigFile = collections.namedtuple( + 'ContainerConfigFile', + ['container_names', 'path', 'user', 'group']) + + +class OSBaseOperatorCharm(CharmBase): + _state = StoredState() + + def __init__(self, framework, adapters=None): + if adapters: + self.adapters = adapters + else: + self.adapters = sunbeam_adapters.OPSRelationAdapters(self) + super().__init__(framework) + self.adapters.add_config_adapters(self.config_adapters) + self.framework.observe(self.on.config_changed, + self._on_config_changed) + self.container_configs = [] + self.handlers = self.setup_event_handlers() + + @property + def config_adapters(self): + return [ + sunbeam_adapters.CharmConfigAdapter(self, 'options')] + + @property + def handler_prefix(self): + return self.service_name.replace('-', '_') + + @property + def container_names(self): + return [self.service_name] + + @property + def template_dir(self): + return 'src/templates' + + def renderer(self, containers, container_configs, template_dir, + openstack_release, adapters): + sidecar_config_render( + containers, + self.container_configs, + self.template_dir, + self.openstack_release, + self.adapters) + + def write_config(self): + containers = [self.unit.get_container(c_name) + for c_name in self.container_names] + if all(containers): + self.renderer( + containers, + self.container_configs, + self.template_dir, + self.openstack_release, + self.adapters) + else: + logger.debug( + 'One or more containers are not ready') + + def setup_event_handlers(self): + self.setup_pebble_handler() + return [] + + def setup_pebble_handler(self): + pebble_ready_event = getattr( + self.on, + f'{self.handler_prefix}_pebble_ready') + self.framework.observe(pebble_ready_event, + self._on_service_pebble_ready) + + def _on_service_pebble_ready(self, event: PebbleReadyEvent) -> None: + raise NotImplementedError + + def _on_config_changed(self, event): + raise NotImplementedError + + +class OSBaseOperatorAPICharm(OSBaseOperatorCharm): + _state = StoredState() + + def __init__(self, framework, adapters=None): + if not adapters: + adapters = sunbeam_adapters.APICharmAdapters(self) + super().__init__(framework, adapters) + self._state.set_default(db_ready=False) + self._state.set_default(bootstrapped=False) + self.container_configs = [ + ContainerConfigFile( + [self.wsgi_container_name], + self.wsgi_conf, + 'root', + 'root')] + self.write_config() + + @property + def config_adapters(self): + _cadapters = super().config_adapters + _cadapters.extend([ + sunbeam_adapters.WSGIWorkerConfigAdapter(self, 'wsgi_config')]) + return _cadapters + + @property + def wsgi_admin_script(self): + raise NotImplementedError + + @property + def wsgi_public_script(self): + raise NotImplementedError + + @property + def wsgi_container_name(self): + return self.service_name + + @property + def wsgi_service_name(self): + return f'{self.service_name}-wsgi' + + @property + def wsgi_conf(self): + return f'/etc/apache2/sites-available/{self.wsgi_service_name}.conf' + + @property + def wsgi_service_name(self): + return f'wsgi-{self.service_name}' + + @property + def public_ingress_port(self): + raise NotImplementedError + + @property + def ingress_config(self): + # Most charms probably won't (or shouldn't) expose service-port + # but use it if its there. + port = self.model.config.get('service-port', self.public_ingress_port) + svc_hostname = self.model.config.get( + 'os-public-hostname', + self.service_name) + return { + 'service-hostname': svc_hostname, + 'service-name': self.app.name, + 'service-port': port} + + def setup_event_handlers(self): + handlers = super().setup_event_handlers() + handlers.extend([ + self.setup_db_event_handler(), + self.setup_ingress_event_handler()]) + return handlers + + def setup_db_event_handler(self): + logger.debug('Setting up DB event handler') + relation_name = f'{self.service_name}-db' + self.db = MySQLConsumer( + self, + f'{self.service_name}-db', + {"mysql": ">=8"}) + self.adapters.add_relation_adapter( + self.db, + relation_name) + db_relation_event = getattr( + self.on, + f'{self.handler_prefix}_db_relation_changed') + self.framework.observe(db_relation_event, + self._on_database_changed) + return self.db + + def _on_database_changed(self, event) -> None: + """Handles database change events.""" + # self.unit.status = model.MaintenanceStatus('Updating database ' + # 'configuration') + databases = self.db.databases() + logger.info(f'Received databases: {databases}') + + if not databases: + logger.info('Requesting a new database...') + # The mysql-k8s operator creates a database using the relation + # information in the form of: + # db_{relation_id}_{partial_uuid}_{name_suffix} + # where name_suffix defaults to "". Specify it to the name of the + # current app to make it somewhat understandable as to what this + # database actually is for. + # NOTE(wolsen): database name cannot contain a '-' + name_suffix = self.app.name.replace('-', '_') + self.db.new_database(name_suffix=name_suffix) + return + credentials = self.db.credentials() + logger.info(f'Received credentials: {credentials}') + self._state.db_ready = True + self.configure_charm() + + @property + def db_ready(self): + """Returns True if the remote database has been configured and is + ready for access from the local service. + + :returns: True if the database is ready to be accessed, False otherwise + :rtype: bool + """ + return self._state.db_ready + + def setup_ingress_event_handler(self): + logger.debug('Setting up ingress event handler') + self.ingress_public = IngressRequires( + self, + self.ingress_config) + return self.ingress_public + + def _on_service_pebble_ready(self, event: PebbleReadyEvent) -> None: + container = event.workload + container.add_layer( + self.service_name, + self.get_apache_layer(), + combine=True) + logger.debug(f'Plan: {container.get_plan()}') + + def get_apache_layer(self): + """Apache WSGI service + + :returns: pebble layer configuration for wsgi services + :rtype: dict + """ + return { + 'summary': f'{self.service_name} layer', + 'description': 'pebble config layer for apache wsgi', + 'services': { + f'{self.wsgi_service_name}': { + 'override': 'replace', + 'summary': f'{self.service_name} wsgi', + 'command': '/usr/sbin/apache2ctl -DFOREGROUND', + 'startup': 'disabled', + }, + }, + } + + def start_wsgi(self): + container = self.unit.get_container(self.wsgi_container_name) + if not container: + logger.debug(f'{self.wsgi_container_name} container is not ready. ' + 'Cannot start wgi service.') + return + service = container.get_service(self.wsgi_service_name) + if service.is_running(): + container.stop(self.wsgi_service_name) + + container.start(self.wsgi_service_name) + + + def configure_charm(self): + self._do_bootstrap() + self.unit.status = model.ActiveStatus() + self._state.bootstrapped = True + + + def _do_bootstrap(self): + """Checks the services to see which services need to run depending + on the current state.""" + + if self.is_bootstrapped(): + logger.debug(f'{self.service_name} is already bootstrapped') + return + + if not self.db_ready: + logger.debug('Database not ready, not bootstrapping') + self.unit.status = model.BlockedStatus('Waiting for database') + return + + if not self.unit.is_leader(): + logger.debug('Deferring bootstrap to leader unit') + self.unit.status = model.BlockedStatus('Waiting for leader to ' + 'bootstrap keystone') + return + + container = self.unit.get_container(self.wsgi_container_name) + if not container: + logger.debug(f'{self.wsgi_container_name} container is not ready. Deferring bootstrap') + return + + # Write the config files to the container + self.write_config() + + try: + sunbeam_cprocess.check_output( + container, + f'a2ensite {self.wsgi_service_name} && sleep 1') + except sunbeam_cprocess.ContainerProcessError: + logger.exception(f'Failed to enable {self.wsgi_service_name} site in apache') + # ignore for now - pebble is raising an exited too quickly, but it + # appears to work properly. + self.start_wsgi() + + + def is_bootstrapped(self): + """Returns True if the instance is bootstrapped. + + :returns: True if the keystone service has been bootstrapped, + False otherwise + :rtype: bool + """ + return self._state.bootstrapped diff --git a/advanced_sunbeam_openstack/cprocess.py b/advanced_sunbeam_openstack/cprocess.py new file mode 100644 index 0000000..6e3f405 --- /dev/null +++ b/advanced_sunbeam_openstack/cprocess.py @@ -0,0 +1,365 @@ +# Copyright 2021, Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import subprocess +import textwrap +import time +import typing +import weakref + +from ops import model +from ops import pebble +import uuid + +import logging + + +logger = logging.getLogger(__name__) + +# Unknown return code is a large negative number outside the usual range of a +# process exit code +RETURN_CODE_UNKNOWN = -1000 + + +class ContainerProcess(object): + """A process that has finished running. + + This is returned by an invocation to run() + + :param container: the container the process was running in + :type container: model.Container + :param process_name: the name of the process the container is running as + :type process_name: str + :param tmp_dir: the dir containing the location of process files + :type tmp_dir: str + """ + def __init__(self, container: model.Container, process_name: str, + tmp_dir: str): + self.container = weakref.proxy(container) + self.process_name = process_name + self._returncode = RETURN_CODE_UNKNOWN + self.tmp_dir = tmp_dir + self.stdout_file = f'{tmp_dir}/{process_name}.stdout' + self.stderr_file = f'{tmp_dir}/{process_name}.stderr' + self._env = dict() + self.env_file = f'{tmp_dir}/{process_name}.env' + self.rc_file = f'{tmp_dir}/{process_name}.rc' + self._cleaned = False + + @property + def stdout(self) -> typing.Union[typing.BinaryIO, typing.TextIO]: + return self.container.pull(f'{self.stdout_file}') + + @property + def stderr(self) -> typing.Union[typing.BinaryIO, typing.TextIO]: + return self.container.pull(f'{self.stderr_file}') + + @property + def env(self) -> typing.Dict[str, str]: + if self._env: + return self._env + + with self.container.pull(f'{self.env_file}') as f: + for env_vars in f.read().split(b'\n'): + key_values = env_vars.split(b'=', 1) + self._env[key_values[0]] = key_values[1] + + return self._env + + @property + def returncode(self) -> int: + if self._returncode == RETURN_CODE_UNKNOWN: + self._returncode = self._get_returncode() + return self._returncode + + def _get_returncode(self): + """Reads the contents of the returncode file""" + try: + with self.container.pull(f'{self.rc_file}') as text: + return int(text.read()) + except pebble.PathError: + # If the rc file doesn't exist within the container, then the + # process is either running or failed without capturing output + return RETURN_CODE_UNKNOWN + + @property + def completed(self) -> bool: + return self._returncode != RETURN_CODE_UNKNOWN + + def check_returncode(self): + """Raise CalledProcessError if the exit code is non-zero.""" + if self.returncode: + stdout = None + stderr = None + try: + stdout = self.stdout.read() + except pebble.PathError: + pass + try: + stderr = self.stderr.read() + except pebble.PathError: + pass + raise CalledProcessError(self.returncode, self.process_name, + stdout, stderr) + + def wait(self, timeout: int = 30) -> None: + """Waits for the process to complete. + + Waits for the process to complete. If the process has not completed + within the timeout specified, this method will raise a TimeoutExpired + exception. + + :param timeout: the number of seconds to wait before timing out + :type timeout: int + """ + timeout_at = time.time() + timeout + while not self.completed and time.time() < timeout_at: + try: + self._returncode = self._get_returncode() + if self.completed: + return + else: + time.sleep(0.2) + except pebble.PathError: + # This happens while the process is still running + # Sleep a moment and try again + time.sleep(0.2) + + raise TimeoutExpired(self.process_name, timeout) + + def cleanup(self) -> None: + """Clean up process files left on the container. + + Attempts to cleanup the process artifacts left on the container. This + will remove the directory containing the stdout, stderr, rc and env + files generated. + + :raises pebble.PathError: when the path has already been cleand up. + """ + if self._cleaned: + return + + self.container.remove_path(f'{self.tmp_dir}', recursive=True) + + def __del__(self): + """On destruction of this process, we'll attempt to clean up left over + process files. + """ + try: + self.cleanup() + except pebble.PathError: + pass + + +class ContainerProcessError(Exception): + """Base class for exceptions raised within this module.""" + pass + + +class CalledProcessError(ContainerProcessError): + """Raised when an error occurs running a process in a container and + the check=True has been passed to raise an error on failure. + + :param returncode: the exit code from the program + :type returncode: int + :param cmd: the command that was run + :type cmd: str or list + :param stdout: the output of the command on standard out + :type stdout: str + :param stderr: the output of the command on standard err + :type stderr: str + """ + def __init__(self, returncode: int, cmd: typing.Union[str, list], + stdout: str = None, stderr: str = None): + self.returncode = returncode + self.cmd = cmd + self.stdout = stdout + self.stderr = stderr + + +class TimeoutExpired(ContainerProcessError): + """This exception is raised when the timeout expires while waiting for a + container process. + + :param cmd: the command that was run + :type cmd: list + :param timeout: the configured timeout for the command + :type timeout: int + """ + def __init__(self, cmd: typing.Union[str, list], timeout: int): + self.cmd = cmd + self.timeout = timeout + + def __str__(self): + return f"Command '{self.cmd}' timed out after {self.timeout} seconds" + + +def run(container: model.Container, args: typing.List[str], + timeout: int = 30, check: bool = False, + env: dict = None, service_name: str = None) -> ContainerProcess: + """Run command with arguments in the specified container. + + Run a command in the specified container and returns a + subprocess.CompletedProcess instance containing the command which + was run (args), returncode, and stdout and stderr. When the check + option is True and the process exits with a non-zero exit code, a + CalledProcessError will be raised containing the cmd, returncode, + stdout and stderr. + + :param container: the container to run the command in + :type container: model.Container + :param args: the command to run in the container + :type args: str or list + :param timeout: the timeout of the process in seconds + :type timeout: int + :param check: when True, raise an exception on a non-zero exit code + :type check: bool + :param env: environment variables to pass to the process + :type env: dict + :param service_name: name of the service + :type service_name: str + :returns: CompletedProcess the completed process + :rtype: ContainerProcess + """ + if not container: + raise ValueError('container cannot be None') + if not isinstance(container, model.Container): + raise ValueError('container must be of type ops.model.Container, ' + f'not of type {type(container)}') + + if isinstance(args, str): + if service_name is None: + service_name = args.split(' ')[0] + service_name = service_name.split('/')[-1] + cmdline = args + elif isinstance(args, list): + if service_name is None: + service_name = args[0].split('/')[-1] + cmdline = subprocess.list2cmdline(args) + else: + raise ValueError('args are expected to be a str or a list of str.' + f' Provided {type(args)}') + + tmp_dir = f'/tmp/{service_name}-{str(uuid.uuid4()).split("-")[0]}' + process = ContainerProcess(container, service_name, tmp_dir) + + command = f"""\ + #!/bin/bash + mkdir -p {tmp_dir} + echo $(env) > {process.env_file} + {cmdline} 2> {process.stderr_file} 1> {process.stdout_file} + rc=$? + echo $rc > {process.rc_file} + exit $rc + """ + command = textwrap.dedent(command) + + container.push(path=f'/tmp/{service_name}.sh', source=command, + encoding='utf-8', permissions=0o755) + + logger.debug(f'Adding layer for {service_name} to run command ' + f'{cmdline}') + container.add_layer('process_layer', { + 'summary': 'container process runner', + 'description': 'layer for running single-shot commands (kinda)', + 'services': { + service_name: { + 'override': 'replace', + 'summary': cmdline, + 'command': f'/tmp/{service_name}.sh', + 'startup': 'disabled', + 'environment': env or {}, + } + } + }, combine=True) + + timeout_at = time.time() + timeout + try: + # Start the service which will run the command. + logger.debug(f'Starting {service_name} via pebble') + + # TODO(wolsen) this is quite naughty, but the container object + # doesn't provide us access to the pebble layer to specify + # timeouts and such. Some commands may need a longer time to + # start, and as such I'm using the private internal reference + # in order to be able to specify the timeout itself. + container._pebble.start_services([service_name], # noqa + timeout=float(timeout)) + except pebble.ChangeError: + # Check to see if the command has timed out and if so, raise + # the TimeoutExpired. + if time.time() >= timeout_at: + logger.error(f'Command {cmdline} could not start out after ' + f'{timeout} seconds in container ' + f'{container.name}') + raise TimeoutExpired(args, timeout) + + # Note, this could be expected. + logger.exception(f'Error running {service_name}') + + logger.debug('Waiting for process completion...') + process.wait(timeout) + + # It appears that pebble services are still active after the command + # has finished. Feels like a bug, but let's stop it. + try: + service = container.get_service(service_name) + if service.is_running(): + container.stop(service_name) + except pebble.ChangeError as e: + # Eat the change error that might occur. This was a best effort + # attempt to ensure the process is stopped + logger.exception(f'Failed to stop service {service_name}', e) + + if check: + process.check_returncode() + return process + + +def call(container: model.Container, args: typing.Union[str, list], + env: dict = None, timeout: int = 30) -> int: + """Runs a command in the container. + + The command will run until the process completes (either normally or + abnormally) or until the timeout expires. + + :param container: the container to run the command in + :type container: model.Container + :param args: arguments to pass to the commandline + :type args: str or list of strings + :param env: environment variables for the process + :type env: dictionary of environment variables + :param timeout: number of seconds the command should complete in before + timing out + :type timeout: int + :returns: the exit code of the process + :rtype: int + """ + return run(container, args, env=env, timeout=timeout).returncode + + +def check_call(container: model.Container, args: typing.Union[str, list], + env: dict = None, timeout: int = 30, + service_name: str = None) -> None: + run(container, args, env=env, check=True, timeout=timeout, + service_name=service_name) + + +def check_output(container: model.Container, args: typing.Union[str, list], + env: dict = None, timeout: int = 30, + service_name: str = None) -> str: + process = run(container, args, env=env, check=True, timeout=timeout, + service_name=service_name) + with process.stdout as stdout: + return stdout.read() diff --git a/advanced_sunbeam_openstack/guard.py b/advanced_sunbeam_openstack/guard.py new file mode 100644 index 0000000..259cb82 --- /dev/null +++ b/advanced_sunbeam_openstack/guard.py @@ -0,0 +1,73 @@ +# Copyright 2021, Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging +from contextlib import contextmanager + +from ops.model import BlockedStatus + +logger = logging.getLogger(__name__) + + +class GuardException(Exception): + pass + + +class BlockedException(Exception): + pass + + +@contextmanager +def guard(charm: 'CharmBase', + section: str, + handle_exception: bool = True, + log_traceback: bool = True, + **__): + """Context manager to handle errors and bailing out of an event/hook. + The nature of Juju is that all the information may not be available to run + a set of actions. This context manager allows a section of code to be + 'guarded' so that it can be bailed at any time. + + It also handles errors which can be interpreted as a Block rather than the + charm going into error. + + :param charm: the charm class (so that unit status can be set) + :param section: the name of the section (for debugging/info purposes) + :handle_exception: whether to handle the exception to a BlockedStatus() + :log_traceback: whether to log the traceback for debugging purposes. + :raises: Exception if handle_exception is False + """ + logger.info("Entering guarded section: '%s'", section) + try: + yield + logging.info("Completed guarded section fully: '%s'", section) + except GuardException as e: + logger.info("Guarded Section: Early exit from '%s' due to '%s'.", + section, str(e)) + except BlockedException as e: + logger.warning( + "Charm is blocked in section '%s' due to '%s'", section, str(e)) + charm.unit.status = BlockedStatus(e.msg) + except Exception as e: + # something else went wrong + if handle_exception: + logging.error("Exception raised in secion '%s': %s", + section, str(e)) + if log_traceback: + import traceback + logging.error(traceback.format_exc()) + charm.unit.status = BlockedStatus( + "Error in charm (see logs): {}".format(str(e))) + return + raise diff --git a/advanced_sunbeam_openstack/templating.py b/advanced_sunbeam_openstack/templating.py new file mode 100644 index 0000000..f7961b9 --- /dev/null +++ b/advanced_sunbeam_openstack/templating.py @@ -0,0 +1,143 @@ +# Copyright 2021, Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging +import os + +from collections import defaultdict + +from charmhelpers.contrib.openstack.templating import ( + OSConfigException, + OSConfigRenderer, + get_loader) +import jinja2 +# from jinja2 import FileSystemLoader, ChoiceLoader, Environment, exceptions + +log = logging.getLogger(__name__) + +def get_container(containers, name): + container = None + for c in containers: + if c.name == name: + container = c + return c + +def sidecar_config_render(containers, container_configs, template_dir, + openstack_release, adapters): + loader = get_loader(template_dir, openstack_release) + _tmpl_env = jinja2.Environment(loader=loader) + for config in container_configs: + for container_name in config.container_names: + try: + template = _tmpl_env.get_template( + os.path.basename(config.path)) + except jinja2.exceptions.TemplateNotFound: + template = _tmpl_env.get_template( + os.path.basename(config.path) + '.j2') + container = get_container(containers, container_name) + contents = template.render(adapters) + kwargs = { + 'user': config.user, + 'group': config.group} + container.push(config.path, contents, **kwargs) + log.debug(f'Wrote template {config.path} in container ' + f'{container.name}.') + +class SidecarConfigRenderer(OSConfigRenderer): + + """ + This class provides a common templating system to be used by OpenStack + sidecar charms. + """ + def __init__(self, templates_dir, openstack_release): + super(SidecarConfigRenderer, self).__init__(templates_dir, + openstack_release) + +class _SidecarConfigRenderer(OSConfigRenderer): + + """ + This class provides a common templating system to be used by OpenStack + sidecar charms. + """ + def __init__(self, templates_dir, openstack_release): + super(SidecarConfigRenderer, self).__init__(templates_dir, + openstack_release) + self.config_to_containers = defaultdict(set) + self.owner_info = defaultdict(set) + + def _get_template(self, template): + """ + + """ + self._get_tmpl_env() + if not template.endswith('.j2'): + template += '.j2' + template = self._tmpl_env.get_template(template) + log.debug(f'Loaded template from {template.filename}') + return template + + def register(self, config_file, contexts, config_template=None, + containers=None, user=None, group=None): + """ + + """ + # NOTE(wolsen): Intentionally overriding base class to raise an error + # if this is accidentally used instead. + if containers is None: + raise ValueError('One or more containers must be provided') + + super().register(config_file, contexts, config_template) + + # Register user/group info. There's a better way to do this for sure + if user or group: + self.owner_info[config_file] = (user, group) + + for container in containers: + self.config_to_containers[config_file].add(container) + log.debug(f'Registered config file "{config_file}" for container ' + f'{container}') + + def write(self, config_file, container): + """ + + """ + containers = self.config_to_containers.get(config_file) + if not containers or container.name not in containers: + log.error(f'Config file {config_file} not registered for ' + f'container {container.name}') + raise OSConfigException + + contents = self.render(config_file) + owner_info = self.owner_info.get(config_file) + kwargs = {} + log.debug(f'Got owner_info of {owner_info}') + if owner_info: + user, group = owner_info + kwargs['user'] = user + kwargs['group'] = group + container.push(config_file, contents, **kwargs) + + log.debug(f'Wrote template {config_file} in container ' + f'{container.name}.') + + def write_all(self, container=None): + for config_file, containers in self.config_to_containers.items(): + if container: + if container.name not in containers: + continue + + self.write(config_file, container) + else: + for c in containers: + self.write(config_file, c) diff --git a/fetch-libs.sh b/fetch-libs.sh new file mode 100755 index 0000000..1e0cc0a --- /dev/null +++ b/fetch-libs.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +charmcraft fetch-lib charms.nginx_ingress_integrator.v0.ingress +charmcraft fetch-lib charms.mysql.v1.mysql + diff --git a/lib/charms/NOTE.txt b/lib/charms/NOTE.txt new file mode 100644 index 0000000..f758539 --- /dev/null +++ b/lib/charms/NOTE.txt @@ -0,0 +1,2 @@ +mysql lib does not seem to be published +so as a tectical solution keep it in tree diff --git a/lib/charms/mysql/v1/mysql.py b/lib/charms/mysql/v1/mysql.py new file mode 100644 index 0000000..6502bd2 --- /dev/null +++ b/lib/charms/mysql/v1/mysql.py @@ -0,0 +1,105 @@ +""" +## Overview + +This document explains how to integrate with the MySQL charm for the purposes of consuming a mysql database. It also explains how alternative implementations of the MySQL charm may maintain the same interface and be backward compatible with all currently integrated charms. Finally this document is the authoritative reference on the structure of relation data that is shared between MySQL charms and any other charm that intends to use the database. + + +## Consumer Library Usage + +The MySQL charm library uses the [Provider and Consumer](https://ops.readthedocs.io/en/latest/#module-ops.relation) objects from the Operator Framework. Charms that would like to use a MySQL database must use the `MySQLConsumer` object from the charm library. Using the `MySQLConsumer` object requires instantiating it, typically in the constructor of your charm. The `MySQLConsumer` constructor requires the name of the relation over which a database will be used. This relation must use the `mysql_datastore` interface. In addition the constructor also requires a `consumes` specification, which is a dictionary with key `mysql` (also see Provider Library Usage below) and a value that represents the minimum acceptable version of MySQL. This version string can be in any format that is compatible with the Python [Semantic Version module](https://pypi.org/project/semantic-version/). For example, assuming your charm consumes a database over a rlation named "monitoring", you may instantiate `MySQLConsumer` as follows: + + from charms.mysql_k8s.v0.mysql import MySQLConsumer + def __init__(self, *args): + super().__init__(*args) + ... + self.mysql_consumer = MySQLConsumer( + self, "monitoring", {"mysql": ">=8"} + ) + ... + +This example hard codes the consumes dictionary argument containing the minimal MySQL version required, however you may want to consider generating this dictionary by some other means, such as a `self.consumes` property in your charm. This is because the minimum required MySQL version may change when you upgrade your charm. Of course it is expected that you will keep this version string updated as you develop newer releases of your charm. If the version string can be determined at run time by inspecting the actual deployed version of your charmed application, this would be ideal. +An instantiated `MySQLConsumer` object may be used to request new databases using the `new_database()` method. This method requires no arguments unless you require multiple databases. If multiple databases are requested, you must provide a unique `name_suffix` argument. For example: + + def _on_database_relation_joined(self, event): + self.mysql_consumer.new_database(name_suffix="db1") + self.mysql_consumer.new_database(name_suffix="db2") + +The `address`, `port`, `databases`, and `credentials` methods can all be called +to get the relevant information from the relation data. +""" + +# !/usr/bin/env python3 +# Copyright 2021 Canonical Ltd. +# See LICENSE file for licensing details. + +import json +import uuid +import logging + +from ops.relation import ConsumerBase + +LIBID = "abcdef1234" # Will change when uploding the charm to charmhub +LIBAPI = 1 +LIBPATCH = 0 +logger = logging.getLogger(__name__) + + +class MySQLConsumer(ConsumerBase): + """ + MySQLConsumer lib class + """ + + def __init__(self, charm, name, consumes, multi=False): + super().__init__(charm, name, consumes, multi) + self.charm = charm + self.relation_name = name + + def databases(self, rel_id=None) -> list: + """ + List of currently available databases + Returns: + list: list of database names + """ + + rel = self.framework.model.get_relation(self.relation_name, rel_id) + relation_data = rel.data[rel.app] + dbs = relation_data.get("databases") + databases = json.loads(dbs) if dbs else [] + + return databases + + def credentials(self, rel_id=None) -> dict: + """ + Dictionary of credential information to access databases + Returns: + dict: dictionary of credential information including username, + password and address + """ + rel = self.framework.model.get_relation(self.relation_name, rel_id) + relation_data = rel.data[rel.app] + data = relation_data.get("data") + data = json.loads(data) if data else {} + credentials = data.get("credentials") + + return credentials + + def new_database(self, rel_id=None, name_suffix=""): + """ + Request creation of an additional database + """ + if not self.charm.unit.is_leader(): + return + + rel = self.framework.model.get_relation(self.relation_name, rel_id) + + if name_suffix: + name_suffix = "_{}".format(name_suffix) + + rid = str(uuid.uuid4()).split("-")[-1] + db_name = "db_{}_{}_{}".format(rel.id, rid, name_suffix) + logger.debug("CLIENT REQUEST %s", db_name) + rel_data = rel.data[self.charm.app] + dbs = rel_data.get("databases") + dbs = json.loads(dbs) if dbs else [] + dbs.append(db_name) + rel.data[self.charm.app]["databases"] = json.dumps(dbs) diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..4f2a3f5 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +-r requirements.txt +coverage +flake8 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..cf59671 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +charmhelpers +jinja2 +kubernetes +git+https://github.com/canonical/operator@2875e73e#egg=ops +python-keystoneclient +git+https://opendev.org/openstack/charm-ops-openstack#egg=ops_openstack diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..37b8ba4 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,18 @@ +[metadata] +name = advanced_sunbeam_openstack +summary = Charm lib for OpenStack Charms using operator framework +version = 0.0.1.dev1 +description-file = + README.rst +author = OpenStack Charmers +author-email = openstack-charmers@lists.ubuntu.com +classifier = + Development Status :: 2 - Pre-Alpha + Intended Audience :: Developers + Topic :: System + Topic :: System :: Installation/Setup + opic :: System :: Software Distribution + Programming Language :: Python :: 3 + Programming Language :: Python :: 3.5 + License :: OSI Approved :: Apache Software License + diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..d037b01 --- /dev/null +++ b/setup.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- + +# Copyright 2021 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Module used to setup the advanced_sunbeam_openstack framework.""" + +from __future__ import print_function + +from setuptools import setup, find_packages + +version = "0.0.1.dev1" +install_require = [ + 'charmhelpers', + 'ops', +] + +tests_require = [ + 'tox >= 2.3.1', +] + +setup( + license='Apache-2.0: http://www.apache.org/licenses/LICENSE-2.0', + packages=find_packages(exclude=["unit_tests"]), + zip_safe=False, + install_requires=install_require, +) + diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 0000000..8057d2c --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1,17 @@ +# This file is managed centrally. If you find the need to modify this as a +# one-off, please don't. Intead, consult #openstack-charms and ask about +# requirements management in charms via bot-control. Thank you. +charm-tools>=2.4.4 +coverage>=3.6 +mock>=1.2 +flake8>=2.2.4,<=2.4.1 +pyflakes==2.1.1 +stestr>=2.2.0 +requests>=2.18.4 +psutil +# oslo.i18n dropped py35 support +oslo.i18n<4.0.0 +git+https://github.com/openstack-charmers/zaza.git#egg=zaza +git+https://github.com/openstack-charmers/zaza-openstack-tests.git#egg=zaza.openstack +pytz # workaround for 14.04 pip/tox +pyudev # for ceph-* charm unit tests (not mocked?) diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..b0468f9 --- /dev/null +++ b/tox.ini @@ -0,0 +1,141 @@ +# Operator charm (with zaza): tox.ini + +[tox] +envlist = pep8,fetch,py3 +skipsdist = True +# NOTE: Avoid build/test env pollution by not enabling sitepackages. +sitepackages = False +# NOTE: Avoid false positives by not skipping missing interpreters. +skip_missing_interpreters = False +# NOTES: +# * We avoid the new dependency resolver by pinning pip < 20.3, see +# https://github.com/pypa/pip/issues/9187 +# * Pinning dependencies requires tox >= 3.2.0, see +# https://tox.readthedocs.io/en/latest/config.html#conf-requires +# * It is also necessary to pin virtualenv as a newer virtualenv would still +# lead to fetching the latest pip in the func* tox targets, see +# https://stackoverflow.com/a/38133283 +requires = pip < 20.3 + virtualenv < 20.0 +# NOTE: https://wiki.canonical.com/engineering/OpenStack/InstallLatestToxOnOsci +minversion = 3.2.0 + +[testenv] +setenv = VIRTUAL_ENV={envdir} + PYTHONHASHSEED=0 + CHARM_DIR={envdir} +install_command = + pip install {opts} {packages} +commands = stestr run --slowest {posargs} +whitelist_externals = + git + add-to-archive.py + fetch-libs.sh + bash + charmcraft +passenv = HOME TERM CS_* OS_* TEST_* +deps = -r{toxinidir}/test-requirements.txt + +[testenv:fetch] +basepython = python3 +deps = +commands = + ./fetch-libs.sh + +[testenv:py35] +basepython = python3.5 +# python3.5 is irrelevant on a focal+ charm. +commands = /bin/true + +[testenv:py36] +basepython = python3.6 +deps = -r{toxinidir}/requirements.txt + -r{toxinidir}/test-requirements.txt + +[testenv:py37] +basepython = python3.7 +deps = -r{toxinidir}/requirements.txt + -r{toxinidir}/test-requirements.txt + +[testenv:py38] +basepython = python3.8 +deps = -r{toxinidir}/requirements.txt + -r{toxinidir}/test-requirements.txt + +[testenv:py3] +basepython = python3 +deps = -r{toxinidir}/requirements.txt + -r{toxinidir}/test-requirements.txt + +[testenv:pep8] +basepython = python3 +deps = -r{toxinidir}/requirements.txt + -r{toxinidir}/test-requirements.txt +commands = flake8 {posargs} src unit_tests tests + +[testenv:cover] +# Technique based heavily upon +# https://github.com/openstack/nova/blob/master/tox.ini +basepython = python3 +deps = -r{toxinidir}/requirements.txt + -r{toxinidir}/test-requirements.txt +setenv = + {[testenv]setenv} + PYTHON=coverage run +commands = + coverage erase + stestr run --slowest {posargs} + coverage combine + coverage html -d cover + coverage xml -o cover/coverage.xml + coverage report + +[coverage:run] +branch = True +concurrency = multiprocessing +parallel = True +source = + . +omit = + .tox/* + */charmhelpers/* + unit_tests/* + +[testenv:venv] +basepython = python3 +commands = {posargs} + +[testenv:build] +basepython = python3 +deps = -r{toxinidir}/build-requirements.txt +commands = + charmcraft build + +[testenv:func-noop] +basepython = python3 +commands = + functest-run-suite --help + +[testenv:func] +basepython = python3 +commands = + functest-run-suite --keep-model + +[testenv:func-smoke] +basepython = python3 +commands = + functest-run-suite --keep-model --smoke + +[testenv:func-dev] +basepython = python3 +commands = + functest-run-suite --keep-model --dev + +[testenv:func-target] +basepython = python3 +commands = + functest-run-suite --keep-model --bundle {posargs} + +[flake8] +# Ignore E902 because the unit_tests directory is missing in the built charm. +ignore = E402,E226,E902 diff --git a/unit_tests/__init__.py b/unit_tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/unit_tests/test_core.py b/unit_tests/test_core.py new file mode 100644 index 0000000..15024c5 --- /dev/null +++ b/unit_tests/test_core.py @@ -0,0 +1,301 @@ +#!/usr/bin/env python3 + +# Copyright 2020 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json +from mock import patch +import unittest +import sys + +sys.path.append('lib') # noqa +sys.path.append('src') # noqa + +from ops.testing import Harness + +import advanced_sunbeam_openstack.core as core + +CHARM_CONFIG = { + 'debug': 'true'} +CHARM_METADATA = ''' +name: my-service +version: 3 +bases: + - name: ubuntu + channel: 20.04/stable +tags: + - openstack + - identity + - misc + +subordinate: false + +requires: + my-service-db: + interface: mysql_datastore + limit: 1 + ingress: + interface: ingress + + +peers: + peers: + interface: mysvc-peer + +containers: + my-service: + resource: mysvc-image + mounts: + - storage: db + location: /var/lib/mysvc + +storage: + logs: + type: filesystem + db: + type: filesystem + +resources: + mysvc-image: + type: oci-image +''' + + +class CharmTestCase(unittest.TestCase): + + def setUp(self, obj, patches): + super().setUp() + self.patches = patches + self.obj = obj + self.patch_all() + + def patch(self, method): + _m = patch.object(self.obj, method) + mock = _m.start() + self.addCleanup(_m.stop) + return mock + + def patch_all(self): + for method in self.patches: + setattr(self, method, self.patch(method)) + + +class MyCharm(core.OSBaseOperatorCharm): + + openstack_release = 'diablo' + service_name = 'my-service' + + def __init__(self, framework): + super().__init__(framework) + self.seen_events = [] + self.render_calls = [] + + def renderer(self, containers, container_configs, template_dir, + openstack_release, adapters): + self.render_calls.append( + ( + containers, + container_configs, + template_dir, + openstack_release, + adapters)) + + def _log_event(self, event): + self.seen_events.append(type(event).__name__) + + def _on_service_pebble_ready(self, event): + self._log_event(event) + + def _on_config_changed(self, event): + self._log_event(event) + + @property + def public_ingress_port(self): + return 789 + + +class TestOSBaseOperatorCharm(unittest.TestCase): + def setUp(self): + self.harness = Harness( + MyCharm, + meta=CHARM_METADATA + ) + + self.addCleanup(self.harness.cleanup) + self.harness.update_config(CHARM_CONFIG) + self.harness.begin() + + def set_pebble_ready(self): + container = self.harness.model.unit.get_container("my-service") + # Emit the PebbleReadyEvent + self.harness.charm.on.my_service_pebble_ready.emit(container) + + def test_pebble_ready_handler(self): + self.assertEqual(self.harness.charm.seen_events, []) + self.set_pebble_ready() + self.assertEqual(self.harness.charm.seen_events, ['PebbleReadyEvent']) + + def test_write_config(self): + self.set_pebble_ready() + self.harness.charm.write_config() + self.assertEqual( + self.harness.charm.render_calls[0], + ( + [self.harness.model.unit.get_container("my-service")], + [], + 'src/templates', + 'diablo', + self.harness.charm.adapters)) + + def test_handler_prefix(self): + self.assertEqual( + self.harness.charm.handler_prefix, + 'my_service') + + def test_container_names(self): + self.assertEqual( + self.harness.charm.container_names, + ['my-service']) + + def test_template_dir(self): + self.assertEqual( + self.harness.charm.template_dir, + 'src/templates') + + +class MyAPICharm(core.OSBaseOperatorAPICharm): + openstack_release = 'diablo' + service_name = 'my-service' + wsgi_admin_script = '/bin/wsgi_admin' + wsgi_public_script = '/bin/wsgi_public' + + def __init__(self, framework): + self.seen_events = [] + self.render_calls = [] + super().__init__(framework) + + def renderer(self, containers, container_configs, template_dir, + openstack_release, adapters): + self.render_calls.append( + ( + containers, + container_configs, + template_dir, + openstack_release, + adapters)) + + def _log_event(self, event): + self.seen_events.append(type(event).__name__) + + def _on_service_pebble_ready(self, event): + super()._on_service_pebble_ready(event) + self._log_event(event) + + def _on_config_changed(self, event): + self._log_event(event) + + @property + def public_ingress_port(self): + return 789 + + +class TestOSBaseOperatorAPICharm(CharmTestCase): + + PATCHES = [ + 'sunbeam_cprocess', + ] + + def setUp(self): + super().setUp(core, self.PATCHES) + self.sunbeam_cprocess.ContainerProcessError = Exception + self.harness = Harness( + MyAPICharm, + meta=CHARM_METADATA + ) + + self.addCleanup(self.harness.cleanup) + self.harness.update_config(CHARM_CONFIG) + self.harness.begin() + + def add_base_db_relation(self): + rel_id = self.harness.add_relation('my-service-db', 'mysql') + self.harness.add_relation_unit( + rel_id, + 'mysql/0') + self.harness.add_relation_unit( + rel_id, + 'mysql/0') + self.harness.update_relation_data( + rel_id, + 'mysql/0', + {'ingress-address': '10.0.0.3'}) + return rel_id + + def add_db_relation_credentials(self, rel_id): + self.harness.update_relation_data( + rel_id, + 'mysql', + { + 'databases': json.dumps(['db1']), + 'data': json.dumps({ + 'credentials': { + 'username': 'foo', + 'password': 'hardpassword', + 'address': '10.0.0.10'}})}) + + def set_pebble_ready(self): + self.harness.container_pebble_ready('my-service') + + def test_write_config(self): + self.set_pebble_ready() + self.harness.charm.write_config() + self.assertEqual( + self.harness.charm.render_calls[0], + ( + [self.harness.model.unit.get_container("my-service")], + [ + core.ContainerConfigFile( + container_names=['my-service'], + path=('/etc/apache2/sites-available/' + 'wsgi-my-service.conf'), + user='root', + group='root')], + 'src/templates', + 'diablo', + self.harness.charm.adapters)) + + def test__on_database_changed(self): + self.harness.set_leader() + self.set_pebble_ready() + rel_id = self.add_base_db_relation() + rel_data = self.harness.get_relation_data( + rel_id, + 'my-service') + requested_db = json.loads(rel_data['databases'])[0] + self.assertRegex(requested_db, r'^db_.*my_service$') + + def test_DBAdapter(self): + self.harness.set_leader() + self.set_pebble_ready() + rel_id = self.add_base_db_relation() + self.add_db_relation_credentials(rel_id) + self.assertEqual( + self.harness.charm.adapters.wsgi_config.wsgi_admin_script, + '/bin/wsgi_admin') + self.assertEqual( + self.harness.charm.adapters.my_service_db.database_password, + 'hardpassword') + self.assertEqual( + self.harness.charm.adapters.options.debug, + 'true')