diff --git a/ops-sunbeam/advanced_sunbeam_openstack/adapters.py b/ops-sunbeam/advanced_sunbeam_openstack/adapters.py deleted file mode 100644 index 158e54b0..00000000 --- a/ops-sunbeam/advanced_sunbeam_openstack/adapters.py +++ /dev/null @@ -1,178 +0,0 @@ -import logging -import ops_openstack.adapters - -logger = logging.getLogger(__name__) - - -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) - - @property - def ready(self): - return True - - -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' - - @property - def ready(self): - try: - creds = self.relation.credentials() - except AttributeError: - return False - return bool(creds) - - -class AMQPAdapter(ops_openstack.adapters.OpenStackOperRelationAdapter): - - DEFAULT_PORT = "5672" - - @property - def port(self): - """Return the AMQP port - - :returns: AMQP port number - :rtype: string - """ - return self.relation.ssl_port or self.DEFAULT_PORT - - @property - def hosts(self): - """ - Comma separated list of hosts that should be used - to access RabbitMQ. - """ - hosts = self.relation.hostnames - if len(hosts) > 1: - return ','.join(hosts) - else: - return None - - @property - def transport_url(self) -> str: - """ - oslo.messaging formatted transport URL - - :returns: oslo.messaging formatted transport URL - :rtype: string - """ - hosts = self.relation.hostnames - transport_url_hosts = ','.join([ - "{}:{}@{}:{}".format(self.username, - self.password, - host_, # TODO deal with IPv6 - self.port) - for host_ in hosts - ]) - return "rabbit://{}/{}".format(transport_url_hosts, self.vhost) - - @property - def ready(self): - try: - creds = self.transport_url - except AttributeError: - return False - return bool(creds) - - -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/ops-sunbeam/advanced_sunbeam_openstack/charm.py b/ops-sunbeam/advanced_sunbeam_openstack/charm.py new file mode 100644 index 00000000..96172c57 --- /dev/null +++ b/ops-sunbeam/advanced_sunbeam_openstack/charm.py @@ -0,0 +1,264 @@ +# 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. + +"""Base classes for defining a charm using the Operator framework. + +This library provided OSBaseOperatorCharm and OSBaseOperatorAPICharm. The +charm classes use advanced_sunbeam_openstack.relation_handlers.RelationHandler +objects to interact with relations. These objects also provide contexts which +can be used when defining templates. + +In addition to the Relation handlers the charm class can also use +advanced_sunbeam_openstack.config_contexts.ConfigContext objects which +can be used when rendering templates, these are not specific to a relation. + +The charm class interacts with the containers it is managing via +advanced_sunbeam_openstack.container_handlers.PebbleHandler. The +PebbleHandler defines the pebble layers, manages pushing +configuration to the containers and managing the service running +in the container. +""" + +import logging +from typing import List + +import ops.charm +import ops.framework +import ops.model + +import advanced_sunbeam_openstack.config_contexts as sunbeam_config_contexts +import advanced_sunbeam_openstack.container_handlers as sunbeam_chandlers +import advanced_sunbeam_openstack.core as sunbeam_core +import advanced_sunbeam_openstack.relation_handlers as sunbeam_rhandlers + + +logger = logging.getLogger(__name__) + + +class OSBaseOperatorCharm(ops.charm.CharmBase): + """Base charms for OpenStack operators.""" + + _state = ops.framework.StoredState() + + def __init__(self, framework): + super().__init__(framework) + self._state.set_default(bootstrapped=False) + self.relation_handlers = self.get_relation_handlers() + self.pebble_handlers = self.get_pebble_handlers() + self.framework.observe(self.on.config_changed, + self._on_config_changed) + + def get_relation_handlers(self) -> List[sunbeam_rhandlers.RelationHandler]: + """Relation handlers for the operator.""" + return [] + + def get_pebble_handlers(self) -> List[sunbeam_chandlers.PebbleHandler]: + """Pebble handlers for the operator.""" + return [ + sunbeam_chandlers.PebbleHandler( + self, + self.service_name, + self.service_name, + self.container_configs, + self.template_dir, + self.openstack_release, + self.configure_charm)] + + def configure_charm(self, event) -> None: + """Catchall handler to cconfigure charm services.""" + if not self.relation_handlers_ready(): + logging.debug("Aborting charm relations not ready") + return + + for ph in self.pebble_handlers: + if ph.pebble_ready: + ph.init_service(self.contexts()) + + for ph in self.pebble_handlers: + if not ph.service_ready: + logging.debug("Aborting container service not ready") + return + + if not self.bootstrapped(): + self._do_bootstrap() + + self.unit.status = ops.model.ActiveStatus() + self._state.bootstrapped = True + + @property + def container_configs(self) -> List[sunbeam_core.ContainerConfigFile]: + """Container configuration files for the operator.""" + return [] + + @property + def config_contexts(self) -> List[ + sunbeam_config_contexts.CharmConfigContext]: + """Configuration adapters for the operator.""" + return [ + sunbeam_config_contexts.CharmConfigContext(self, 'options')] + + @property + def handler_prefix(self) -> str: + """Prefix for handlers??""" + return self.service_name.replace('-', '_') + + @property + def container_names(self): + """Containers that form part of this service.""" + return [self.service_name] + + @property + def template_dir(self) -> str: + """Directory containing Jinja2 templates.""" + return 'src/templates' + + def _on_config_changed(self, event): + self.configure_charm(None) + + def containers_ready(self) -> bool: + """Determine whether all containers are ready for configuration.""" + for ph in self.pebble_handlers: + if not ph.service_ready: + logger.info(f"Container incomplete: {ph.container_name}") + return False + return True + + def relation_handlers_ready(self) -> bool: + """Determine whether all relations are ready for use.""" + for handler in self.relation_handlers: + if not handler.ready: + logger.info(f"Relation {handler.relation_name} incomplete") + return False + return True + + def contexts(self) -> sunbeam_core.OPSCharmContexts: + """Construct context for rendering templates.""" + ra = sunbeam_core.OPSCharmContexts(self) + for handler in self.relation_handlers: + if handler.relation_name not in self.meta.relations.keys(): + logger.info( + f"Dropping handler for relation {handler.relation_name}, " + "relation not present in charm metadata") + continue + if handler.ready: + ra.add_relation_handler(handler) + ra.add_config_contexts(self.config_contexts) + return ra + + def _do_bootstrap(self) -> None: + """Bootstrap the service ready for operation. + + This method should be overridden as part of a concrete + charm implementation + """ + pass + + def bootstrapped(self) -> bool: + """Determine whether the service has been boostrapped.""" + return self._state.bootstrapped + + +class OSBaseOperatorAPICharm(OSBaseOperatorCharm): + """Base class for OpenStack API operators""" + + def __init__(self, framework): + super().__init__(framework) + self._state.set_default(db_ready=False) + + def get_pebble_handlers(self) -> List[sunbeam_chandlers.PebbleHandler]: + """Pebble handlers for the service""" + return [ + sunbeam_chandlers.WSGIPebbleHandler( + self, + self.service_name, + self.service_name, + self.container_configs, + self.template_dir, + self.openstack_release, + self.configure_charm, + f'wsgi-{self.service_name}')] + + def get_relation_handlers(self) -> List[sunbeam_rhandlers.RelationHandler]: + """Relation handlers for the service.""" + handlers = [] + if 'amqp' in self.meta.relations.keys(): + self.amqp = sunbeam_rhandlers.AMQPHandler( + self, + 'amqp', + self.configure_charm, + self.service_name, + self.service_name) + handlers.append(self.amqp) + if f'{self.service_name}-db' in self.meta.relations.keys(): + self.db = sunbeam_rhandlers.DBHandler( + self, + f'{self.service_name}-db', + self.configure_charm) + handlers.append(self.db) + if 'ingress' in self.meta.relations.keys(): + self.ingress = sunbeam_rhandlers.IngressHandler( + self, + 'ingress', + self.service_name, + self.default_public_ingress_port, + self.configure_charm) + handlers.append(self.ingress) + return handlers + + @property + def container_configs(self) -> List[sunbeam_core.ContainerConfigFile]: + """Container configuration files for the service.""" + _cconfigs = super().container_configs + _cconfigs.extend([ + sunbeam_core.ContainerConfigFile( + [self.wsgi_container_name], + self.service_conf, + self.service_user, + self.service_group)]) + return _cconfigs + + @property + def service_user(self) -> str: + """Service user file and directory ownership.""" + return self.service_name + + @property + def service_group(self) -> str: + """Service group file and directory ownership.""" + return self.service_name + + @property + def service_conf(self) -> str: + """Service default configuration file.""" + return f'/etc/{self.service_name}/{self.service_name}.conf' + + @property + def config_contexts(self) -> List[sunbeam_config_contexts.ConfigContext]: + """Generate list of configuration adapters for the charm.""" + _cadapters = super().config_contexts + _cadapters.extend([ + sunbeam_config_contexts.WSGIWorkerConfigContext( + self, + 'wsgi_config')]) + return _cadapters + + @property + def wsgi_container_name(self) -> str: + """Name of the WSGI application container.""" + return self.service_name + + @property + def default_public_ingress_port(self) -> int: + """Port to use for ingress access to service.""" + raise NotImplementedError diff --git a/ops-sunbeam/advanced_sunbeam_openstack/config_contexts.py b/ops-sunbeam/advanced_sunbeam_openstack/config_contexts.py new file mode 100644 index 00000000..f72c4c56 --- /dev/null +++ b/ops-sunbeam/advanced_sunbeam_openstack/config_contexts.py @@ -0,0 +1,58 @@ +# 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. + +"""Base classes for defining a charm using the Operator framework. + +ConfigContext objects can be used when rendering templates. They idea is to +create reusable contexts which translate charm config, deployment state etc. +These are not specific to a relation. +""" + +import logging + +logger = logging.getLogger(__name__) + + +class ConfigContext(): + + 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) + + @property + def ready(self): + return True + + def context(self): + raise NotImplementedError + + +class CharmConfigContext(ConfigContext): + """A context containing all of the charms config options""" + + def context(self) -> dict: + return self.charm.config + + +class WSGIWorkerConfigContext(ConfigContext): + + def context(self) -> dict: + """A context containing WSGI configuration options""" + return { + 'name': self.charm.service_name, + 'wsgi_admin_script': self.charm.wsgi_admin_script, + 'wsgi_public_script': self.charm.wsgi_public_script} diff --git a/ops-sunbeam/advanced_sunbeam_openstack/container_handlers.py b/ops-sunbeam/advanced_sunbeam_openstack/container_handlers.py new file mode 100644 index 00000000..b505c441 --- /dev/null +++ b/ops-sunbeam/advanced_sunbeam_openstack/container_handlers.py @@ -0,0 +1,213 @@ +# 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. + +"""Base classes for defining Pebble handlers + +The PebbleHandler defines the pebble layers, manages pushing +configuration to the containers and managing the service running +in the container. +""" + +import logging + +import advanced_sunbeam_openstack.core as sunbeam_core +import advanced_sunbeam_openstack.templating as sunbeam_templating +import advanced_sunbeam_openstack.cprocess as sunbeam_cprocess +import ops.charm + +from collections.abc import Callable +from typing import List + +logger = logging.getLogger(__name__) + + +class PebbleHandler(ops.charm.Object): + """Base handler for Pebble based containers.""" + + _state = ops.framework.StoredState() + + def __init__(self, charm: ops.charm.CharmBase, + container_name: str, service_name: str, + container_configs: List[sunbeam_core.ContainerConfigFile], + template_dir: str, openstack_release: str, + callback_f: Callable): + super().__init__(charm, None) + self._state.set_default(pebble_ready=False) + self._state.set_default(config_pushed=False) + self._state.set_default(service_ready=False) + self.charm = charm + self.container_name = container_name + self.service_name = service_name + self.container_configs = container_configs + self.container_configs.extend(self.default_container_configs()) + self.template_dir = template_dir + self.openstack_release = openstack_release + self.callback_f = callback_f + self.setup_pebble_handler() + + def setup_pebble_handler(self) -> None: + """Configure handler for pebble ready event.""" + prefix = self.container_name.replace('-', '_') + pebble_ready_event = getattr( + self.charm.on, + f'{prefix}_pebble_ready') + self.framework.observe(pebble_ready_event, + self._on_service_pebble_ready) + + def _on_service_pebble_ready(self, + event: ops.charm.PebbleReadyEvent) -> None: + """Handle pebble ready event.""" + container = event.workload + container.add_layer( + self.service_name, + self.get_layer(), + combine=True) + logger.debug(f'Plan: {container.get_plan()}') + self.ready = True + self._state.pebble_ready = True + self.charm.configure_charm(event) + + def write_config(self, context) -> None: + """Write configuration files into the container. + + On the pre-condition that all relation adapters are ready + for use, write all configuration files into the container + so that the underlying service may be started. + """ + container = self.charm.unit.get_container( + self.container_name) + if container: + sunbeam_templating.sidecar_config_render( + [container], + self.container_configs, + self.template_dir, + self.openstack_release, + context) + self._state.config_pushed = True + else: + logger.debug( + 'Container not ready') + + def get_layer(self) -> dict: + """Pebble configuration layer for the container""" + return {} + + def init_service(self, context) -> None: + """Initialise service ready for use. + + Write configuration files to the container and record + that service is ready for us. + """ + self.write_config(context) + self._state.service_ready = True + + def default_container_configs(self) -> List[ + sunbeam_core.ContainerConfigFile]: + """Generate default container configurations. + + These should be used by all inheriting classes and are + automatically added to the list or container configurations + provided during object instantiation. + """ + return [] + + @property + def pebble_ready(self) -> bool: + """Determine if pebble is running and ready for use.""" + return self._state.pebble_ready + + @property + def config_pushed(self) -> bool: + """Determine if configuration has been pushed to the container.""" + return self._state.config_pushed + + @property + def service_ready(self) -> bool: + """Determine whether the service the container provides is running.""" + return self._state.service_ready + + +class WSGIPebbleHandler(PebbleHandler): + """WSGI oriented handler for a Pebble managed container.""" + + def __init__(self, charm: ops.charm.CharmBase, + container_name: str, service_name: str, + container_configs: List[sunbeam_core.ContainerConfigFile], + template_dir: str, openstack_release: str, + callback_f: Callable, + wsgi_service_name: str): + super().__init__(charm, container_name, service_name, + container_configs, template_dir, openstack_release, + callback_f) + self.wsgi_service_name = wsgi_service_name + + def start_wsgi(self) -> None: + """Start WSGI service""" + container = self.charm.unit.get_container(self.container_name) + if not container: + logger.debug(f'{self.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 get_layer(self) -> dict: + """Apache WSGI service pebble layer + + :returns: pebble layer configuration for wsgi service + """ + 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 init_service(self, context) -> None: + """Enable and start WSGI service""" + container = self.charm.unit.get_container(self.container_name) + self.write_config(context) + 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() + self._state.service_ready = True + + @property + def wsgi_conf(self) -> str: + return f'/etc/apache2/sites-available/wsgi-{self.service_name}.conf' + + def default_container_configs(self) -> List[ + sunbeam_core.ContainerConfigFile]: + return [ + sunbeam_core.ContainerConfigFile( + [self.container_name], + self.wsgi_conf, + 'root', + 'root')] diff --git a/ops-sunbeam/advanced_sunbeam_openstack/core.py b/ops-sunbeam/advanced_sunbeam_openstack/core.py index 02423a57..dc3dfabf 100644 --- a/ops-sunbeam/advanced_sunbeam_openstack/core.py +++ b/ops-sunbeam/advanced_sunbeam_openstack/core.py @@ -1,563 +1,38 @@ -#!/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 advanced_sunbeam_openstack.adapters as sunbeam_adapters -import advanced_sunbeam_openstack.templating as sunbeam_templating -import advanced_sunbeam_openstack.cprocess as sunbeam_cprocess - -import charms.nginx_ingress_integrator.v0.ingress as ingress -import charms.mysql.v1.mysql as mysql - -import ops.charm -import ops.framework -import ops.model - -from collections.abc import Callable -from typing import List, Tuple -from ops_openstack.adapters import OpenStackOperRelationAdapter - -logger = logging.getLogger(__name__) - ContainerConfigFile = collections.namedtuple( 'ContainerConfigFile', ['container_names', 'path', 'user', 'group']) -class PebbleHandler(ops.charm.Object): - """Base handler for Pebble based containers.""" +class OPSCharmContexts(): - _state = ops.framework.StoredState() - - def __init__(self, charm: ops.charm.CharmBase, - container_name: str, service_name: str, - container_configs: List[ContainerConfigFile], - template_dir: str, openstack_release: str, - adapters: List[OpenStackOperRelationAdapter], - callback_f: Callable): - super().__init__(charm, None) - self._state.set_default(pebble_ready=False) - self._state.set_default(config_pushed=False) - self._state.set_default(service_ready=False) + def __init__(self, charm): self.charm = charm - self.container_name = container_name - self.service_name = service_name - self.container_configs = container_configs - self.container_configs.extend(self.default_container_configs()) - self.template_dir = template_dir - self.openstack_release = openstack_release - self.adapters = adapters - self.callback_f = callback_f - self.setup_pebble_handler() + self.namespaces = [] - def setup_pebble_handler(self) -> None: - """Configure handler for pebble ready event.""" - prefix = self.container_name.replace('-', '_') - pebble_ready_event = getattr( - self.charm.on, - f'{prefix}_pebble_ready') - self.framework.observe(pebble_ready_event, - self._on_service_pebble_ready) + def add_relation_handler(self, handler): + interface, relation_name = handler.get_interface() + _ns = relation_name.replace("-", "_") + self.namespaces.append(_ns) + ctxt = handler.context() + obj_name = ''.join([w.capitalize() for w in relation_name.split('-')]) + obj = collections.namedtuple(obj_name, ctxt.keys())(*ctxt.values()) + setattr(self, _ns, obj) - def _on_service_pebble_ready(self, - event: ops.charm.PebbleReadyEvent) -> None: - """Handle pebble ready event.""" - container = event.workload - container.add_layer( - self.service_name, - self.get_layer(), - combine=True) - logger.debug(f'Plan: {container.get_plan()}') - self.ready = True - self.charm.configure_charm(event) - self._state.pebble_ready = True + def add_config_contexts(self, config_adapters): + for config_adapter in config_adapters: + self.add_config_context( + config_adapter, + config_adapter.namespace) - def write_config(self) -> None: - """Write configuration files into the container. + def add_config_context(self, config_adapter, namespace): + self.namespaces.append(namespace) + setattr(self, namespace, config_adapter) - On the pre-condition that all relation adapters are ready - for use, write all configuration files into the container - so that the underlying service may be started. + def __iter__(self): """ - for adapter in self.adapters: - if not adapter[1].ready: - logger.info("Adapter incomplete") - return - container = self.charm.unit.get_container( - self.container_name) - if container: - sunbeam_templating.sidecar_config_render( - [container], - self.container_configs, - self.template_dir, - self.openstack_release, - self.adapters) - self._state.config_pushed = True - else: - logger.debug( - 'Container not ready') - - def get_layer(self) -> dict: - """Pebble configuration layer for the container""" - return {} - - def init_service(self) -> None: - """Initialise service ready for use. - - Write configuration files to the container and record - that service is ready for us. + Iterate over the relations presented to the charm. """ - self.write_config() - self._state.service_ready = True - - def default_container_configs(self) -> List[ContainerConfigFile]: - """Generate default container configurations. - - These should be used by all inheriting classes and are - automatically added to the list or container configurations - provided during object instantiation. - """ - return [] - - @property - def pebble_ready(self) -> bool: - """Determine if pebble is running and ready for use.""" - return self._state.pebble_ready - - @property - def config_pushed(self) -> bool: - """Determine if configuration has been pushed to the container.""" - return self._state.config_pushed - - @property - def service_ready(self) -> bool: - """Determine whether the service the container provides is running.""" - return self._state.service_ready - - -class WSGIPebbleHandler(PebbleHandler): - """WSGI oriented handler for a Pebble managed container.""" - - def __init__(self, charm: ops.charm.CharmBase, - container_name: str, service_name: str, - container_configs: List[ContainerConfigFile], - template_dir: str, openstack_release: str, - adapters: List[OpenStackOperRelationAdapter], - callback_f: Callable, - wsgi_service_name: str): - super().__init__(charm, container_name, service_name, - container_configs, template_dir, openstack_release, - adapters, callback_f) - self.wsgi_service_name = wsgi_service_name - - def start_wsgi(self) -> None: - """Start WSGI service""" - container = self.charm.unit.get_container(self.container_name) - if not container: - logger.debug(f'{self.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 get_layer(self) -> dict: - """Apache WSGI service pebble layer - - :returns: pebble layer configuration for wsgi service - """ - 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 init_service(self) -> None: - """Enable and start WSGI service""" - container = self.charm.unit.get_container(self.container_name) - 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() - self._state.service_ready = True - - @property - def wsgi_conf(self) -> str: - return f'/etc/apache2/sites-available/wsgi-{self.service_name}.conf' - - def default_container_configs(self) -> List[ContainerConfigFile]: - return [ - ContainerConfigFile( - [self.container_name], - self.wsgi_conf, - 'root', - 'root')] - - -class RelationHandler(ops.charm.Object): - """Base handler class for relations""" - - def __init__(self, charm: ops.charm.CharmBase, - relation_name: str, callback_f: Callable): - super().__init__(charm, None) - self.charm = charm - self.relation_name = relation_name - self.callback_f = callback_f - self.interface = self.setup_event_handler() - - def setup_event_handler(self) -> ops.charm.Object: - """Configure event handlers for the relation. - - This method must be overridden in concrete class - implementations. - """ - raise NotImplementedError - - def get_interface(self) -> Tuple[ops.charm.Object, str]: - """Returns the interface that this handler encapsulates. - - This is a combination of the interface object and the - name of the relation its wired into. - """ - return self.interface, self.relation_name - - @property - def ready(self) -> bool: - """Determine with the relation is ready for use.""" - raise NotImplementedError - - -class IngressHandler(RelationHandler): - """Handler for Ingress relations""" - - def __init__(self, charm: ops.charm.CharmBase, - relation_name: str, - service_name: str, - default_public_ingress_port: int, - callback_f: Callable): - self.default_public_ingress_port = default_public_ingress_port - self.service_name = service_name - super().__init__(charm, relation_name, callback_f) - - def setup_event_handler(self) -> ops.charm.Object: - """Configure event handlers for an Ingress relation.""" - logger.debug('Setting up ingress event handler') - interface = ingress.IngressRequires( - self.charm, - self.ingress_config) - return interface - - @property - def ingress_config(self) -> dict: - """Ingress controller configuration dictionary.""" - # 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.default_public_ingress_port) - svc_hostname = self.model.config.get( - 'os-public-hostname', - self.service_name) - return { - 'service-hostname': svc_hostname, - 'service-name': self.charm.app.name, - 'service-port': port} - - @property - def ready(self) -> bool: - # Nothing to wait for - return True - - -class DBHandler(RelationHandler): - """Handler for DB relations""" - - def setup_event_handler(self) -> ops.charm.Object: - """Configure event handlers for a MySQL relation.""" - logger.debug('Setting up DB event handler') - db = mysql.MySQLConsumer( - self.charm, - self.relation_name, - {"mysql": ">=8"}) - _rname = self.relation_name.replace('-', '_') - db_relation_event = getattr( - self.charm.on, - f'{_rname}_relation_changed') - self.framework.observe(db_relation_event, - self._on_database_changed) - return db - - def _on_database_changed(self, event) -> None: - """Handles database change events.""" - databases = self.interface.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.charm.app.name.replace('-', '_') - self.interface.new_database(name_suffix=name_suffix) - return - credentials = self.interface.credentials() - # XXX Lets not log the credentials - logger.info(f'Received credentials: {credentials}') - self.callback_f(event) - - @property - def ready(self) -> bool: - """Handler ready for use.""" - try: - # Nothing to wait for - return bool(self.interface.databases()) - except AttributeError: - return False - - -class OSBaseOperatorCharm(ops.charm.CharmBase): - """Base charms for OpenStack operators.""" - - _state = ops.framework.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) - # Setup the observers for relationship events and pass the interfaces - # to the adapter classes. - self.relation_handlers = self.get_relation_handlers() - for handler in self.relation_handlers: - interface, relation_name = handler.get_interface() - self.adapters.add_relation_adapter( - interface, - relation_name) - self.pebble_handlers = self.get_pebble_handlers() - self.framework.observe(self.on.config_changed, - self._on_config_changed) - - def get_relation_handlers(self) -> List[RelationHandler]: - """Relation handlers for the operator.""" - return [] - - def get_pebble_handlers(self) -> List[PebbleHandler]: - """Pebble handlers for the operator.""" - return [ - PebbleHandler( - self, - self.service_name, - self.service_name, - self.container_configs, - self.template_dir, - self.openstack_release, - self.adapters, - self.configure_charm)] - - def configure_charm(self, event) -> None: - """Configure containers when all dependencies are met. - - Iterates over all Pebble handlers and writes configuration - files if the handler is ready for use. - """ - for h in self.pebble_handlers: - if h.ready: - h.write_config() - - @property - def container_configs(self) -> List[ContainerConfigFile]: - """Container configuration files for the operator.""" - return [] - - @property - def config_adapters(self) -> List[sunbeam_adapters.CharmConfigAdapter]: - """Configuration adapters for the operator.""" - return [ - sunbeam_adapters.CharmConfigAdapter(self, 'options')] - - @property - def handler_prefix(self) -> str: - """Prefix for handlers??""" - return self.service_name.replace('-', '_') - - @property - def container_names(self): - """Containers that form part of this service.""" - return [self.service_name] - - @property - def template_dir(self) -> str: - """Directory containing Jinja2 templates.""" - return 'src/templates' - - def _on_config_changed(self, event): - self.configure_charm(None) - - def containers_ready(self) -> bool: - """Determine whether all containers are ready for configuration.""" - for ph in self.pebble_handlers: - if not ph.service_ready: - logger.info(f"Container incomplete: {ph.container_name}") - return False - return True - - def relation_handlers_ready(self) -> bool: - """Determine whether all relations are ready for use.""" - for handler in self.relation_handlers: - if not handler.ready: - logger.info(f"Relation {handler.relation_name} incomplete") - return False - return True - - -class OSBaseOperatorAPICharm(OSBaseOperatorCharm): - """Base class for OpenStack API operators""" - - 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) - - def get_pebble_handlers(self) -> List[PebbleHandler]: - """Pebble handlers for the service""" - return [ - WSGIPebbleHandler( - self, - self.service_name, - self.service_name, - self.container_configs, - self.template_dir, - self.openstack_release, - self.adapters, - self.configure_charm, - f'wsgi-{self.service_name}')] - - def get_relation_handlers(self) -> List[RelationHandler]: - """Relation handlers for the service.""" - self.db = DBHandler( - self, - f'{self.service_name}-db', - self.configure_charm) - self.ingress = IngressHandler( - self, - 'ingress', - self.service_name, - self.default_public_ingress_port, - self.configure_charm) - return [self.db, self.ingress] - - @property - def container_configs(self) -> List[ContainerConfigFile]: - """Container configuration files for the service.""" - _cconfigs = super().container_configs - _cconfigs.extend([ - ContainerConfigFile( - [self.wsgi_container_name], - self.service_conf, - self.service_user, - self.service_group)]) - return _cconfigs - - @property - def service_user(self) -> str: - """Service user file and directory ownership.""" - return self.service_name - - @property - def service_group(self) -> str: - """Service group file and directory ownership.""" - return self.service_name - - @property - def service_conf(self) -> str: - """Service default configuration file.""" - return f'/etc/{self.service_name}/{self.service_name}.conf' - - @property - def config_adapters(self) -> List[sunbeam_adapters.ConfigAdapter]: - """Generate list of configuration adapters for the charm.""" - _cadapters = super().config_adapters - _cadapters.extend([ - sunbeam_adapters.WSGIWorkerConfigAdapter(self, 'wsgi_config')]) - return _cadapters - - @property - def wsgi_container_name(self) -> str: - """Name of the WSGI application container.""" - return self.service_name - - @property - def default_public_ingress_port(self) -> int: - """Port to use for ingress access to service.""" - raise NotImplementedError - - def configure_charm(self, event) -> None: - """Catchall handler to cconfigure charm services.""" - if not self.relation_handlers_ready(): - logging.debug("Aborting charm relations not ready") - return - - for ph in self.pebble_handlers: - if ph.pebble_ready: - ph.init_service() - - for ph in self.pebble_handlers: - if not ph.service_ready: - logging.debug("Aborting container service not ready") - return - - if not self.bootstrapped(): - self._do_bootstrap() - - self.unit.status = ops.model.ActiveStatus() - self._state.bootstrapped = True - - def _do_bootstrap(self) -> None: - """Bootstrap the service ready for operation. - - This method should be overridden as part of a concrete - charm implementation - """ - pass - - def bootstrapped(self) -> bool: - """Determine whether the service has been boostrapped.""" - return self._state.bootstrapped + for namespace in self.namespaces: + yield namespace, getattr(self, namespace) diff --git a/ops-sunbeam/advanced_sunbeam_openstack/relation_handlers.py b/ops-sunbeam/advanced_sunbeam_openstack/relation_handlers.py new file mode 100644 index 00000000..55c79f5b --- /dev/null +++ b/ops-sunbeam/advanced_sunbeam_openstack/relation_handlers.py @@ -0,0 +1,254 @@ +# 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. + +"""Base classes for defining a charm using the Operator framework. + +""" + +import logging +from collections.abc import Callable +from typing import Tuple + +import ops.charm + +import charms.nginx_ingress_integrator.v0.ingress as ingress +import charms.mysql.v1.mysql as mysql +import charms.sunbeam_rabbitmq_operator.v0.amqp as sunbeam_amqp + +logger = logging.getLogger(__name__) + + +class RelationHandler(ops.charm.Object): + """Base handler class for relations + + A relation handler is used to manage a charms interaction with a relation + interface. This includes: + + 1) Registering handlers to process events from the interface. The last + step of these handlers is to make a callback to a specified method + within the charm `callback_f` + 2) Expose a `ready` property so the charm can check a relations readyness + 3) A `context` method which returns a dict which pulls together data + recieved and sent on an interface. + """ + + def __init__(self, charm: ops.charm.CharmBase, + relation_name: str, callback_f: Callable): + super().__init__(charm, None) + self.charm = charm + self.relation_name = relation_name + self.callback_f = callback_f + self.interface = self.setup_event_handler() + + def setup_event_handler(self) -> ops.charm.Object: + """Configure event handlers for the relation. + + This method must be overridden in concrete class + implementations. + """ + raise NotImplementedError + + def get_interface(self) -> Tuple[ops.charm.Object, str]: + """Returns the interface that this handler encapsulates. + + This is a combination of the interface object and the + name of the relation its wired into. + """ + return self.interface, self.relation_name + + @property + def ready(self) -> bool: + """Determine with the relation is ready for use.""" + raise NotImplementedError + + def context(self) -> dict: + """Pull together context for rendering templates.""" + return {} + + +class IngressHandler(RelationHandler): + """Handler for Ingress relations""" + + def __init__(self, charm: ops.charm.CharmBase, + relation_name: str, + service_name: str, + default_public_ingress_port: int, + callback_f: Callable): + self.default_public_ingress_port = default_public_ingress_port + self.service_name = service_name + super().__init__(charm, relation_name, callback_f) + + def setup_event_handler(self) -> ops.charm.Object: + """Configure event handlers for an Ingress relation.""" + logger.debug('Setting up ingress event handler') + interface = ingress.IngressRequires( + self.charm, + self.ingress_config) + return interface + + @property + def ingress_config(self) -> dict: + """Ingress controller configuration dictionary.""" + # 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.default_public_ingress_port) + svc_hostname = self.model.config.get( + 'os-public-hostname', + self.service_name) + return { + 'service-hostname': svc_hostname, + 'service-name': self.charm.app.name, + 'service-port': port} + + @property + def ready(self) -> bool: + # Nothing to wait for + return True + + def context(self): + return {} + + +class DBHandler(RelationHandler): + """Handler for DB relations""" + + def setup_event_handler(self) -> ops.charm.Object: + """Configure event handlers for a MySQL relation.""" + logger.debug('Setting up DB event handler') + db = mysql.MySQLConsumer( + self.charm, + self.relation_name, + {"mysql": ">=8"}) + _rname = self.relation_name.replace('-', '_') + db_relation_event = getattr( + self.charm.on, + f'{_rname}_relation_changed') + self.framework.observe(db_relation_event, + self._on_database_changed) + return db + + def _on_database_changed(self, event) -> None: + """Handles database change events.""" + databases = self.interface.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.charm.app.name.replace('-', '_') + self.interface.new_database(name_suffix=name_suffix) + return + credentials = self.interface.credentials() + # XXX Lets not log the credentials + logger.info(f'Received credentials: {credentials}') + self.callback_f(event) + + @property + def ready(self) -> bool: + """Handler ready for use.""" + try: + # Nothing to wait for + return bool(self.interface.databases()) + except AttributeError: + return False + + def context(self): + try: + databases = self.interface.databases() + except AttributeError: + return {} + if not databases: + return {} + ctxt = { + 'database': self.interface.databases()[0], + 'database_host': self.interface.credentials().get('address'), + 'database_password': self.interface.credentials().get('password'), + 'database_user': self.interface.credentials().get('username'), + 'database_type': 'mysql+pymysql'} + return ctxt + + +class AMQPHandler(RelationHandler): + + DEFAULT_PORT = "5672" + + def __init__( + self, + charm: ops.charm.CharmBase, + relation_name: str, + callback_f, + username: str, + vhost: int, + ): + self.username = username + self.vhost = vhost + super().__init__(charm, relation_name, callback_f) + + def setup_event_handler(self): + """Configure event handlers for an AMQP relation.""" + logger.debug("Setting up AMQP event handler") + amqp = sunbeam_amqp.AMQPRequires( + self.charm, self.relation_name, self.username, self.vhost + ) + self.framework.observe(amqp.on.ready, self._on_amqp_ready) + return amqp + + def _on_amqp_ready(self, event) -> None: + """Handles AMQP change events.""" + # Ready is only emitted when the interface considers + # that the relation is complete (indicated by a password) + self.callback_f(event) + + @property + def ready(self) -> bool: + """Handler ready for use.""" + try: + return bool(self.interface.password) + except AttributeError: + return False + + def context(self): + try: + hosts = self.interface.hostnames + except AttributeError: + return {} + if not hosts: + return {} + hosts = ','.join(hosts) + port = self.interface.ssl_port or self.DEFAULT_PORT + username = self.interface.username + password = self.interface.password + transport_url_hosts = ','.join([ + "{}:{}@{}:{}".format(username, + password, + host_, # TODO deal with IPv6 + port) + for host_ in self.interface.hostnames + ]) + transport_url = "rabbit://{}/{}".format( + transport_url_hosts, + self.vhost) + ctxt = { + 'port': port, + 'hosts': hosts, + 'transport_url': transport_url} + return ctxt diff --git a/ops-sunbeam/unit_tests/test_core.py b/ops-sunbeam/unit_tests/test_core.py index 600d0556..50846c62 100644 --- a/ops-sunbeam/unit_tests/test_core.py +++ b/ops-sunbeam/unit_tests/test_core.py @@ -15,20 +15,26 @@ # limitations under the License. +import io import json -from mock import patch +import os +import tempfile +from mock import ANY, patch import unittest import sys sys.path.append('lib') # noqa sys.path.append('src') # noqa -from ops.testing import Harness +from ops import framework, model -import advanced_sunbeam_openstack.core as core +from ops.testing import Harness, _TestingModelBackend, _TestingPebbleClient + +import advanced_sunbeam_openstack.charm as sunbeam_charm CHARM_CONFIG = { 'debug': 'true'} + CHARM_METADATA = ''' name: my-service version: 3 @@ -42,13 +48,45 @@ tags: subordinate: false +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 +''' + +API_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 - + amqp: + interface: rabbitmq peers: peers: @@ -87,12 +125,18 @@ class CharmTestCase(unittest.TestCase): self.addCleanup(_m.stop) return mock + def patch_obj(self, obj, method): + _m = patch.object(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): +class MyCharm(sunbeam_charm.OSBaseOperatorCharm): openstack_release = 'diablo' service_name = 'my-service' @@ -100,11 +144,13 @@ class MyCharm(core.OSBaseOperatorCharm): def __init__(self, framework): super().__init__(framework) self.seen_events = [] + self.render_calls = [] 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): @@ -114,10 +160,6 @@ class MyCharm(core.OSBaseOperatorCharm): super().configure_charm(event) self._log_event(event) - def _configure_charm(self, event): - super()._configure_charm(event) - self._log_event(event) - @property def public_ingress_port(self): return 789 @@ -126,16 +168,14 @@ class MyCharm(core.OSBaseOperatorCharm): class TestOSBaseOperatorCharm(CharmTestCase): PATCHES = [ - 'sunbeam_templating' ] def setUp(self): - super().setUp(core, self.PATCHES) + super().setUp(sunbeam_charm, self.PATCHES) self.harness = Harness( MyCharm, meta=CHARM_METADATA ) - self.harness.update_config(CHARM_CONFIG) self.harness.begin() self.addCleanup(self.harness.cleanup) @@ -145,19 +185,21 @@ class TestOSBaseOperatorCharm(CharmTestCase): # Emit the PebbleReadyEvent self.harness.charm.on.my_service_pebble_ready.emit(container) - def test_pebble_ready_handler(self): + @patch('advanced_sunbeam_openstack.templating.sidecar_config_render') + def test_pebble_ready_handler(self, _renderer): self.assertEqual(self.harness.charm.seen_events, []) self.set_pebble_ready() self.assertEqual(self.harness.charm.seen_events, ['PebbleReadyEvent']) - def test_write_config(self): + @patch('advanced_sunbeam_openstack.templating.sidecar_config_render') + def test_write_config(self, _renderer): self.set_pebble_ready() - self.sunbeam_templating.sidecar_config_render.assert_called_once_with( + _renderer.assert_called_once_with( [self.harness.model.unit.get_container("my-service")], [], 'src/templates', 'diablo', - self.harness.charm.adapters) + ANY) def test_handler_prefix(self): self.assertEqual( @@ -178,8 +220,15 @@ class TestOSBaseOperatorCharm(CharmTestCase): self.assertTrue( self.harness.charm.relation_handlers_ready()) +TEMPLATE_CONTENTS = """ +{{ wsgi_config.wsgi_admin_script }} +{{ my_service_db.database_password }} +{{ options.debug }} +{{ amqp.transport_url }} +""" -class MyAPICharm(core.OSBaseOperatorAPICharm): + +class MyAPICharm(sunbeam_charm.OSBaseOperatorAPICharm): openstack_release = 'diablo' service_name = 'my-service' wsgi_admin_script = '/bin/wsgi_admin' @@ -188,8 +237,19 @@ class MyAPICharm(core.OSBaseOperatorAPICharm): def __init__(self, framework): self.seen_events = [] self.render_calls = [] + self._template_dir = self._setup_templates() super().__init__(framework) + def _setup_templates(self): + tmpdir = tempfile.mkdtemp() + _template_dir = f'{tmpdir}/templates' + os.mkdir(_template_dir) + with open(f'{_template_dir}/my-service.conf.j2', 'w') as f: + f.write(TEMPLATE_CONTENTS) + with open(f'{_template_dir}/wsgi-my-service.conf.j2', 'w') as f: + f.write(TEMPLATE_CONTENTS) + return _template_dir + def _log_event(self, event): self.seen_events.append(type(event).__name__) @@ -204,26 +264,97 @@ class MyAPICharm(core.OSBaseOperatorAPICharm): def default_public_ingress_port(self): return 789 + @property + def template_dir(self): + return self._template_dir + class TestOSBaseOperatorAPICharm(CharmTestCase): PATCHES = [ - 'sunbeam_templating', - 'sunbeam_cprocess', ] def setUp(self): - super().setUp(core, self.PATCHES) - self.sunbeam_cprocess.ContainerProcessError = Exception + container_calls = { + 'push': {}, + 'pull': [], + 'remove_path': []} + + super().setUp(sunbeam_charm, self.PATCHES) + + class _LYTestingPebbleClient(_TestingPebbleClient): + + def push( + self, path, source, *, + encoding='utf-8', make_dirs=False, permissions=None, + user_id=None, user=None, group_id=None, group=None): + container_calls['push'][path] = { + 'source': source, + 'permissions': permissions, + 'user': user, + 'group': group} + + def pull(self, path, *, encoding='utf-8'): + container_calls['pull'].append(path) + reader = io.StringIO("0") + return reader + + def remove_path(self, path, *, recursive=False): + container_calls['remove_path'].append(path) + + class _LYTestingModelBackend(_TestingModelBackend): + + def get_pebble(self, socket_path: str): + client = self._pebble_clients.get(socket_path, None) + if client is None: + client = _LYTestingPebbleClient(self) + self._pebble_clients[socket_path] = client + return client + + self.container_calls = container_calls + # self.sunbeam_cprocess.ContainerProcessError = Exception self.harness = Harness( MyAPICharm, - meta=CHARM_METADATA + meta=API_CHARM_METADATA ) + self.harness._backend = _LYTestingModelBackend( + self.harness._unit_name, self.harness._meta) + self.harness._model = model.Model( + self.harness._meta, + self.harness._backend) + self.harness._framework = framework.Framework( + ":memory:", + self.harness._charm_dir, + self.harness._meta, + self.harness._model) + # END Workaround self.addCleanup(self.harness.cleanup) self.harness.update_config(CHARM_CONFIG) self.harness.begin() + def add_base_amqp_relation(self): + rel_id = self.harness.add_relation('amqp', 'rabbitmq') + self.harness.add_relation_unit( + rel_id, + 'rabbitmq/0') + self.harness.add_relation_unit( + rel_id, + 'rabbitmq/0') + self.harness.update_relation_data( + rel_id, + 'rabbitmq/0', + {'ingress-address': '10.0.0.13'}) + return rel_id + + def add_amqp_relation_credentials(self, rel_id): + self.harness.update_relation_data( + rel_id, + 'rabbitmq', + { + 'hostname': 'rabbithost1.local', + 'password': 'rabbit.pass'}) + def add_base_db_relation(self): rel_id = self.harness.add_relation('my-service-db', 'mysql') self.harness.add_relation_unit( @@ -256,27 +387,34 @@ class TestOSBaseOperatorAPICharm(CharmTestCase): def test_write_config(self): self.harness.set_leader() self.set_pebble_ready() - rel_id = self.add_base_db_relation() - self.add_db_relation_credentials(rel_id) - self.sunbeam_templating.sidecar_config_render.assert_called_once_with( - [self.harness.model.unit.get_container("my-service")], - [ - core.ContainerConfigFile( - container_names=['my-service'], - path=('/etc/my-service/my-service.conf'), - user='my-service', - group='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) + db_rel_id = self.add_base_db_relation() + self.add_db_relation_credentials(db_rel_id) + amqp_rel_id = self.add_base_amqp_relation() + self.add_amqp_relation_credentials(amqp_rel_id) + expect_entries = [ + '/bin/wsgi_admin', + 'hardpassword', + 'true', + 'rabbit://my-service:rabbit.pass@10.0.0.13:5672/my-service'] + expect_string = '\n' + '\n'.join(expect_entries) + self.assertEqual( + self.container_calls['push']['/etc/my-service/my-service.conf'], + { + 'group': 'my-service', + 'permissions': None, + 'source': expect_string, + 'user': 'my-service'}) + self.assertEqual( + self.container_calls['push'][ + '/etc/apache2/sites-available/wsgi-my-service.conf'], + { + 'group': 'root', + 'permissions': None, + 'source': expect_string, + 'user': 'root'}) - def test__on_database_changed(self): + @patch('advanced_sunbeam_openstack.templating.sidecar_config_render') + def test__on_database_changed(self, _renderer): self.harness.set_leader() self.set_pebble_ready() rel_id = self.add_base_db_relation() @@ -287,17 +425,18 @@ class TestOSBaseOperatorAPICharm(CharmTestCase): requested_db = json.loads(rel_data['databases'])[0] self.assertRegex(requested_db, r'^db_.*my_service$') - def test_DBAdapter(self): + def test_contexts(self): self.harness.set_leader() self.set_pebble_ready() rel_id = self.add_base_db_relation() self.add_db_relation_credentials(rel_id) + contexts = self.harness.charm.contexts() self.assertEqual( - self.harness.charm.adapters.wsgi_config.wsgi_admin_script, + contexts.wsgi_config.wsgi_admin_script, '/bin/wsgi_admin') self.assertEqual( - self.harness.charm.adapters.my_service_db.database_password, + contexts.my_service_db.database_password, 'hardpassword') self.assertEqual( - self.harness.charm.adapters.options.debug, + contexts.options.debug, 'true')