From 92d3f14919f707d596d1ffb0f21f3514e2e44813 Mon Sep 17 00:00:00 2001 From: Alex Kavanagh Date: Wed, 31 Aug 2016 19:59:17 +0000 Subject: [PATCH] Add 'declarative helpers' Declarative helpers takes (most) of the boiler plate of writing an OpenStack charm and places it into this module. This simplified the writing, and testing, of an OpenStack charm when using reactive and the layered approach. This change makes a BREAKING change to existing charms written using charms.openstack. This is that the 'assess-status' hook that was originally in layer-openstack has been removed and made optional via the declarative helpers. This was to maintain consistency with the declarative helpers and also to allow charm authors to write their own 'assess-status' state handler as required. Thus there is also a change to (charm-)layer-openstack which removes the wired in handler under the same topic. Change-Id: I3c74f60bb4ed7901828902118697f310622c4061 --- .gitignore | 1 + charms_openstack/adapters.py | 238 +++++++++++-- charms_openstack/charm.py | 356 ++++++++++++++++++- unit_tests/__init__.py | 1 + unit_tests/test_charms_openstack_adapters.py | 212 +++++++++++ unit_tests/test_charms_openstack_charm.py | 332 ++++++++++++++++- 6 files changed, 1101 insertions(+), 39 deletions(-) diff --git a/.gitignore b/.gitignore index e1bf1a9..ff1de76 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ .testrepository *.pyc charm.openstack.egg-info +.ropeproject .eggs diff --git a/charms_openstack/adapters.py b/charms_openstack/adapters.py index ee207f7..29f350f 100644 --- a/charms_openstack/adapters.py +++ b/charms_openstack/adapters.py @@ -15,6 +15,11 @@ """Adapter classes and utilities for use with Reactive interfaces""" from __future__ import absolute_import +import re +import weakref + +import six + import charms.reactive.bus import charmhelpers.contrib.hahelpers.cluster as ch_cluster import charmhelpers.contrib.network.ip as ch_ip @@ -24,6 +29,57 @@ import charms_openstack.ip as os_ip ADDRESS_TYPES = os_ip.ADDRESS_MAP.keys() +# handle declarative adapter properties using a decorator and simple functions + +# Hold the custom adapter properties somewhere! +_custom_adapter_properties = {} + + +def adapter_property(interface_name): + """Decorator to take the interface name and add a custom property. + These are used to generate custom Adapter classes automatically for the + charm author which are then plugged into the class. The adapter class is + built using a different function. + + :param interface_name: the name of the interface to add the property to + """ + def wrapper(f): + property_name = f.__name__ + if interface_name not in _custom_adapter_properties: + _custom_adapter_properties[interface_name] = {} + if property_name in _custom_adapter_properties[interface_name]: + raise RuntimeError( + "Property name '{}' used more than once for '{} interface?" + .format(property_name, interface_name)) + _custom_adapter_properties[interface_name][property_name] = f + return f + return wrapper + + +# declaring custom configuration properties: + +# Hold the custom configuration adapter properties somewhere! +_custom_config_properties = {} + + +def config_property(f): + """Decorator to add a custom configuration property. + + These are used to generate a custom ConfigurationAdapter for use when + automatically creating a Charm class + + :param f: the function passed as part of the @decorator syntax + """ + property_name = f.__name__ + if property_name in _custom_config_properties: + raise RuntimeError( + "Property name '{}' used more than once for configuration?" + .format(property_name)) + _custom_config_properties[property_name] = f + return f + +## + class OpenStackRelationAdapter(object): """ @@ -346,25 +402,74 @@ class DatabaseRelationAdapter(OpenStackRelationAdapter): return self.get_uri() +def make_default_configuration_adapter_class(base_cls=None, + custom_properties=None): + """Create a default configuration adapter, using the base type specified + and any customer configuration properties. + + This is called by the charm creation metaclass when 'bringing' up the class + if no configuration adapter has been specified in the adapters_class + + :param base_cls: a ConfigurationAdapter derived class; or None + :param custom_properties: the name:function for the properties to set. + """ + base_cls = base_cls or ConfigurationAdapter + # if there are no custom properties, just return the base_cls + if not custom_properties: + return base_cls + # turns the functions into properties on the class + properties = {n: property(f) for n, f in six.iteritems(custom_properties)} + # build a custom class with the custom properties + return type('DefaultConfigurationAdapter', (base_cls, ), properties) + + class ConfigurationAdapter(object): """ Configuration Adapter which provides python based access to all configuration options for the current charm. + + It also holds a weakref to the instance of the OpenStackCharm derived class + that it is associated with. This is so that methods on the configuration + adapter can query the charm class for global config (e.g. service_name). + + + The configuration items from Juju are copied over and the '-' are replaced + with '_'. This allows them to be used directly on the instance. """ - def __init__(self): + def __init__(self, charm_instance=None): + """Create a ConfigurationAdapter (or derived) class. + + :param charm_instance: the instance of the OpenStackCharm derived + class. + """ + self._charm_instance_weakref = None + if charm_instance is not None: + self._charm_instance_weakref = weakref.ref(charm_instance) + # copy over (statically) the items of the charms Juju configuration _config = hookenv.config() - for k, v in _config.items(): + for k, v in six.iteritems(_config): k = k.replace('-', '_') setattr(self, k, v) + @property + def charm_instance(self): + """Return the reference to the charm_instance or return None""" + if self._charm_instance_weakref: + return self._charm_instance_weakref() + return None + class APIConfigurationAdapter(ConfigurationAdapter): """This configuration adapter extends the base class and adds properties - common accross most OpenstackAPI services""" + common accross most OpenstackAPI services + """ - def __init__(self, port_map=None, service_name=None): + def __init__(self, port_map=None, service_name=None, charm_instance=None): """ + Note passing port_map and service_name is deprecated, but supporte for + backwards compatibility. The port_map and service_name can be got from + the self.charm_instance weak reference. :param port_map: Map containing service names and the ports used e.g. port_map = { 'svc1': { @@ -379,10 +484,29 @@ class APIConfigurationAdapter(ConfigurationAdapter): }, } :param service_name: Name of service being deployed + :param charm_instance: a charm instance that will be passed to the base + constructor """ - super(APIConfigurationAdapter, self).__init__() - self.port_map = port_map - self.service_name = service_name + super(APIConfigurationAdapter, self).__init__( + charm_instance=charm_instance) + if port_map is not None: + hookenv.log( + "DEPRECATION: should not use port_map parameter in " + "APIConfigurationAdapter.__init__()", level=hookenv.WARNING) + self.port_map = port_map + elif self.charm_instance is not None: + self.port_map = self.charm_instance.api_ports + else: + self.port_map = None + if service_name is not None: + hookenv.log( + "DEPRECATION: should not use service_name parameter in " + "APIConfigurationAdapter.__init__()", level=hookenv.WARNING) + self.service_name = service_name + elif self.charm_instance is not None: + self.service_name = self.charm_instance.name + else: + self.service_name = None self.network_addresses = self.get_network_addresses() @property @@ -643,6 +767,36 @@ class APIConfigurationAdapter(ConfigurationAdapter): return sorted(list(set(eps))) +def make_default_relation_adapter(base_cls, relation, properties): + """Create a default relation adapter using a base class, and custom + properties for various relations that may have been defined as custom + properties. + + This mixes the declarative 'custom' properties + with the default classes + to provide a class that manages the relation for the charm. + + This mixes the associated RelationAdapter class with the custom relations. + + :param base_cls: the class to use as the base for the properties + :param relation: the relation we want the properties for + :param properties: {key: function} functions to make custom properties + """ + # Just return the base_cls if there's nothing to modify + if not properties: + return base_cls + # convert the functions into properties + props = {n: property(f) for n, f in six.iteritems(properties)} + # turn 'my-Something_interface' into 'MySomethingInterface' + # future proof incase other chars come in which can't be in an Python Class + # name. + relation = re.sub(r'[^a-zA-Z_-]', '', relation) + parts = relation.replace('-', '_').lower().split('_') + header = ''.join([s.capitalize() for s in parts]) + name = "{}RelationAdapterModified".format(header) + # and make the class + return type(name, (base_cls,), props) + + class OpenStackRelationAdapters(object): """ Base adapters class for OpenStack Charms, used to aggregate @@ -661,29 +815,63 @@ class OpenStackRelationAdapters(object): } By default, relations will be wrapped in an OpenStackRelationAdapter. + + Each derived class can define their OWN relation_adapters and they will + overlay on the class further back in the class hierarchy, according to the + mro() for the class. """ - _adapters = {} - """ - Default adapter mappings; may be overridden by relation adapters - in subclasses. - """ - - def __init__(self, relations, options=None, options_instance=None): + def __init__(self, relations, options=None, options_instance=None, + charm_instance=None): """ :param relations: List of instances of relation classes :param options: Configuration class to use (DEPRECATED) :param options_instance: Instance of Configuration class to use + :param charm_instance: optional charm_instance that is captured as a + weakref for use on the adapter. """ + self._charm_instance_weakref = None + if charm_instance is not None: + self._charm_instance_weakref = weakref.ref(charm_instance) self._relations = [] - if options: + if options is not None: hookenv.log("The 'options' argument is deprecated please use " "options_instance instead.", level=hookenv.WARNING) self.options = options() + elif options_instance is not None: + self.options = options_instance else: - self.options = options_instance or ConfigurationAdapter() + # create a default, customised ConfigurationAdapter if the + # APIConfigurationAdapter is needed as a base, then it must be + # passed as an instance on the options_instance First pull the + # configuration class from the charm instance (if it's available). + base_cls = ConfigurationAdapter + if self.charm_instance: + base_cls = getattr(self.charm_instance, 'configuration_class', + base_cls) + self.options = make_default_configuration_adapter_class( + base_cls=base_cls, + custom_properties=_custom_config_properties)( + charm_instance=self.charm_instance) self._relations.append('options') - self._adapters.update(self.relation_adapters) + # walk the mro() from object to this class to build up the _adapters + # ensure that all of the relations' have their '-' turned into a '_' to + # ensure that everything is consistent in the class. + self._adapters = {} + for cls in reversed(self.__class__.mro()): + self._adapters.update( + {k.replace('-', '_'): v + for k, v in six.iteritems( + getattr(cls, 'relation_adapters', {}))}) + # now we have to add in any customisations to those adapters + for relation, properties in six.iteritems(_custom_adapter_properties): + relation = relation.replace('-', '_') + try: + cls = self._adapters[relation] + except KeyError: + cls = OpenStackRelationAdapter + self._adapters[relation] = make_default_relation_adapter( + cls, relation, properties) for relation in relations: relation_name = relation.relation_name.replace('-', '_') try: @@ -693,6 +881,13 @@ class OpenStackRelationAdapters(object): setattr(self, relation_name, relation_value) self._relations.append(relation_name) + @property + def charm_instance(self): + """Return the reference to the charm_instance or return None""" + if self._charm_instance_weakref: + return self._charm_instance_weakref() + return None + def __iter__(self): """ Iterate over the relations presented to the charm. @@ -703,22 +898,25 @@ class OpenStackRelationAdapters(object): class OpenStackAPIRelationAdapters(OpenStackRelationAdapters): - _adapters = { + relation_adapters = { 'amqp': RabbitMQRelationAdapter, 'shared_db': DatabaseRelationAdapter, 'cluster': PeerHARelationAdapter, } - def __init__(self, relations, options=None, options_instance=None): + def __init__(self, relations, options=None, options_instance=None, + charm_instance=None): """ :param relations: List of instances of relation classes :param options: Configuration class to use (DEPRECATED) :param options_instance: Instance of Configuration class to use + :param charm_instance: an instance of the charm class """ super(OpenStackAPIRelationAdapters, self).__init__( relations, options=options, - options_instance=options_instance) + options_instance=options_instance, + charm_instance=charm_instance) # LY: The cluster interface only gets initialised if there are more # than one unit in a cluster, however, a cluster of one unit is valid # for the Openstack API charms. So, create and populate the 'cluster' diff --git a/charms_openstack/charm.py b/charms_openstack/charm.py index 8c7df09..d5d89f1 100644 --- a/charms_openstack/charm.py +++ b/charms_openstack/charm.py @@ -21,6 +21,7 @@ from __future__ import absolute_import import base64 import collections import contextlib +import functools import itertools import os import random @@ -37,10 +38,12 @@ import charmhelpers.contrib.openstack.utils as os_utils import charmhelpers.core.hookenv as hookenv import charmhelpers.core.host as ch_host import charmhelpers.core.templating +import charmhelpers.core.unitdata as unitdata import charmhelpers.fetch import charms.reactive as reactive import charms_openstack.ip as os_ip +import charms_openstack.adapters as os_adapters # _releases{} is a dictionary of release -> class that is instantiated @@ -80,6 +83,261 @@ CIDR_KEY = "vip_cidr" IFACE_KEY = "vip_iface" APACHE_SSL_VHOST = '/etc/apache2/sites-available/openstack_https_frontend.conf' +OPENSTACK_RELEASE_KEY = 'charmers.openstack-release-version' + +# handler support for default handlers + +# The default handlers that charms.openstack provides. +ALLOWED_DEFAULT_HANDLERS = [ + 'charm.installed', + 'amqp.connected', + 'shared-db.connected', + 'identity-service.connected', + 'identity-service.available', + 'config.changed', + 'charm.default-select-release', + 'update-status', +] + +# Where to store the default handler functions for each default state +_default_handler_map = {} + + +def use_defaults(*defaults): + """Activate the default functionality for various handlers + + This is to provide default functionality for common operations for + openstack charms. + """ + for state in defaults: + if state in ALLOWED_DEFAULT_HANDLERS: + if state in _default_handler_map: + # Initialise the default handler for this state + _default_handler_map[state]() + else: + raise RuntimeError( + "State '{}' is allowed, but has no handler???" + .format(state)) + else: + raise RuntimeError("Default handler for '{}' doesn't exist" + .format(state)) + + +def _map_default_handler(state): + """Decorator to map a default handler to a state -- just makes adding + handlers a bit easier. + + :param state: the state that the handler is for. + :raises RuntimeError: if the state doesn't exist in + ALLOWED_DEFAULT_HANDLERS + """ + def wrapper(f): + if state in _default_handler_map: + raise RuntimeError( + "State '{}' can't have more than one default handler" + .format(state)) + if state not in ALLOWED_DEFAULT_HANDLERS: + raise RuntimeError( + "State '{} doesn't have a default handler????".format(state)) + _default_handler_map[state] = f + return f + return wrapper + + +@_map_default_handler('charm.installed') +def make_default_install_handler(): + + @reactive.when_not('charm.installed') + def default_install(): + """Provide a default install handler + + The instance automagically becomes the derived OpenStackCharm instance. + The kv() key charmers.openstack-release-version' is used to cache the + release being used for this charm. It is determined by the + default_select_release() function below, unless this is overriden by + the charm author + """ + unitdata.kv().unset(OPENSTACK_RELEASE_KEY) + OpenStackCharm.singleton.install() + reactive.set_state('charm.installed') + + +@_map_default_handler('charm.default-select-release') +def make_default_select_release_handler(): + """This handler is a bit more unusual, as it just sets the release selector + using the @register_os_release_selector decorator + """ + + @register_os_release_selector + def default_select_release(): + """Determine the release based on the python-keystonemiddleware that is + installed. + + Note that this function caches the release after the first install so + that it doesn't need to keep going and getting it from the package + information. + """ + release_version = unitdata.kv().get(OPENSTACK_RELEASE_KEY, None) + if release_version is None: + release_version = os_utils.os_release('python-keystonemiddleware') + unitdata.kv().set(OPENSTACK_RELEASE_KEY, release_version) + return release_version + + +@_map_default_handler('amqp.connected') +def make_default_amqp_connection_handler(): + + @reactive.when('amqp.connected') + def default_amqp_connection(amqp): + """Handle the default amqp connection. + + This requires that the charm implements get_amqp_credentials() to + provide a tuple of the (user, vhost) for the amqp server + """ + instance = OpenStackCharm.singleton + user, vhost = instance.get_amqp_credentials() + amqp.request_access(username=user, vhost=vhost) + instance.assess_status() + + +@_map_default_handler('shared-db.connected') +def make_default_setup_database_handler(): + + @reactive.when('shared-db.connected') + def default_setup_database(database): + """Handle the default database connection setup + + This requires that the charm implements get_database_setup() to provide + a list of dictionaries; + [{'database': ..., 'username': ..., 'hostname': ..., 'prefix': ...}] + + The prefix can be missing: it defaults to None. + """ + instance = OpenStackCharm.singleton + for db in instance.get_database_setup(): + database.configure(**db) + instance.assess_status() + + +@_map_default_handler('identity-service.connected') +def make_default_setup_endpoint_connection(): + + @reactive.when('identity-service.connected') + def default_setup_endpoint_connection(keystone): + """When the keystone interface connects, register this unit into the + catalog. This is the default handler, and calls on the charm class to + provide the endpoint information. If multiple endpoints are needed, + then a custom endpoint handler will be needed. + """ + instance = OpenStackCharm.singleton + keystone.register_endpoints(instance.service_type, + instance.region, + instance.public_url, + instance.internal_url, + instance.admin_url) + instance.assess_status() + + +@_map_default_handler('identity-service.available') +def make_setup_endpoint_available_handler(): + + @reactive.when('identity-service.available') + def default_setup_endpoint_available(keystone): + """When the identity-service interface is available, this default + handler switches on the SSL support. + """ + instance = OpenStackCharm.singleton + instance.configure_ssl(keystone) + instance.assess_status() + + +@_map_default_handler('config.changed') +def make_default_config_changed_handler(): + + @reactive.when('config.changed') + def default_config_changed(): + """Default handler for config.changed state from reactive. Just see if + our status has changed. This is just to clear any errors that may have + got stuck due to missing async handlers, etc. + """ + OpenStackCharm.singleton.assess_status() + + +def default_render_configs(*interfaces): + """Default renderer for configurations. Really just a proxy for + OpenstackCharm.singleton.render_configs(..) with a call to update the + workload status afterwards. + + :params *interfaces: the list of interfaces to provide to the + render_configs() function + """ + instance = OpenStackCharm.singleton + instance.render_configs(interfaces) + instance.assess_status() + + +@_map_default_handler('update-status') +def make_default_update_status_handler(): + + @reactive.when('update-status') + def default_update_status(): + """Default handler for update-status state. + Just call update status. + """ + OpenStackCharm.singleton.assess_status() + + +# End of default handlers + +def optional_interfaces(args, *interfaces): + """Return a tuple with possible optional interfaces + + :param args: a list of reactive interfaces + :param *interfaces: list of strings representing possible reactive + interfaces. + :returns: [list of reactive interfaces] + """ + return args + tuple(ri for ri in (reactive.RelationBase.from_state(i) + for i in interfaces) + if ri is not None) + + +# Note that we are breaking the camalcase rule as this is acting as a +# decoarator and a context manager, neither of which are expecting a 'class' +class provide_charm_instance(object): + """Be a decoarator and a context manager at the same time to be able to + easily provide the charm instance to some code that needs it. + + Allows the charm author to either write: + + @provide_charm_instance + def some_handler(charm_instance, *args): + charm_instance.method_call(*args) + + or: + + with provide_charm_instance() as charm_instance: + charm_instance.some_method() + """ + + def __init__(self, f=None): + self.f = f + if f: + functools.update_wrapper(self, f) + + def __call__(self, *args, **kwargs): + return self.f(OpenStackCharm.singleton, *args, **kwargs) + + def __enter__(self): + """with statement as gets the charm instance""" + return OpenStackCharm.singleton + + def __exit__(self, *_): + # Never bother with the exception + return False + + +# Start of charm definitions def get_charm_instance(release=None, *args, **kwargs): """Get an instance of the charm based on the release (or use the @@ -102,20 +360,22 @@ def get_charm_instance(release=None, *args, **kwargs): if release is None: # take the latest version of the charm if no release is passed. cls = _releases[known_releases[-1]] - elif release < known_releases[0]: - raise RuntimeError( - "Release {} is not supported by this charm. Earliest support is " - "{} release".format(release, known_releases[0])) else: # check that the release is a valid release if release not in KNOWN_RELEASES: raise RuntimeError( "Release {} is not a known OpenStack release?".format(release)) - # try to find the release that is supported. - for known_release in reversed(known_releases): - if release >= known_release: - cls = _releases[known_release] - break + release_index = KNOWN_RELEASES.index(release) + if release_index < KNOWN_RELEASES.index(known_releases[0]): + raise RuntimeError( + "Release {} is not supported by this charm. Earliest support " + "is {} release".format(release, known_releases[0])) + else: + # try to find the release that is supported. + for known_release in reversed(known_releases): + if release_index >= KNOWN_RELEASES.index(known_release): + cls = _releases[known_release] + break if cls is None: raise RuntimeError("Release {} is not supported".format(release)) return cls(release=release, *args, **kwargs) @@ -270,7 +530,12 @@ class OpenStackCharm(object): services = [] # The adapters class that this charm uses to adapt interfaces. - adapters_class = None + # If None, then it defaults to OpenstackRelationsAdapter + adapters_class = os_adapters.OpenStackRelationAdapters + + # The configuration base class to use for the charm + # If None, then the default ConfigurationAdapter is used. + configuration_class = os_adapters.ConfigurationAdapter ha_resources = [] adapters_class = None @@ -291,13 +556,14 @@ class OpenStackCharm(object): :param interfaces: list of interface instances for the charm. :param config: the config for the charm (optionally None for - automatically using config()) + automatically using config()) """ self.config = config or hookenv.config() self.release = release self.adapters_instance = None if interfaces and self.adapters_class: - self.adapters_instance = self.adapters_class(interfaces) + self.adapters_instance = self.adapters_class(interfaces, + charm_instance=self) @property def all_packages(self): @@ -467,7 +733,8 @@ class OpenStackCharm(object): configs = self.full_restart_map.keys() self.render_configs( configs, - adapters_instance=self.adapters_class(interfaces)) + adapters_instance=self.adapters_class(interfaces, + charm_instance=self)) def restart_all(self): """Restart all the services configured in the self.services[] @@ -581,7 +848,7 @@ class OpenStackCharm(object): available_states = reactive.bus.get_states().keys() status = None messages = [] - for relation, states in states_to_check.items(): + for relation, states in six.iteritems(states_to_check): for state, err_status, err_msg in states: if state not in available_states: messages.append(err_msg) @@ -715,7 +982,7 @@ class OpenStackCharm(object): return None vers_map = os_utils.OPENSTACK_CODENAMES - for version, cname in vers_map.items(): + for version, cname in six.iteritems(vers_map): if cname == codename: return version @@ -790,7 +1057,60 @@ class OpenStackCharm(object): hookenv.log("Deferring DB sync to leader", level=hookenv.INFO) -class HAOpenStackCharm(OpenStackCharm): +class OpenStackAPICharm(OpenStackCharm): + """The base class for API OS charms -- this just bakes in the default + configuration and adapter classes. + """ + abstract_class = True + + # The adapters class that this charm uses to adapt interfaces. + # If None, then it defaults to OpenstackRelationAdapters + adapters_class = os_adapters.OpenStackAPIRelationAdapters + + # The configuration base class to use for the charm + # If None, then the default ConfigurationAdapter is used. + configuration_class = os_adapters.APIConfigurationAdapter + + def get_amqp_credentials(self): + """Provide the default amqp username and vhost as a tuple. + + This needs to be overriden in a derived class to provide the username + and vhost to the amqp interface IF the default amqp handlers are being + used. + :returns (username, host): two strings to send to the amqp provider. + """ + raise RuntimeError( + "get_amqp_credentials() needs to be overriden in the derived " + "class") + + def get_database_setup(self): + """Provide the default database credentials as a list of 3-tuples + + This is used when using the default handlers for the shared-db service + and provides the (db, db_user, ip) for each database as a list. + + returns a structure of: + [ + {'database': , + 'username': , + 'hostname': + 'prefix': , }, + ] + + This allows multiple databases to be setup. + + If more complex database setup is required, then the default + setup_database() will need to be ignored, and a custom function + written. + + :returns [{'database': ...}, ...]: credentials for multiple databases + """ + raise RuntimeError( + "get_database_setup() needs to be overriden in the derived " + "class") + + +class HAOpenStackCharm(OpenStackAPICharm): abstract_class = True @@ -1064,8 +1384,8 @@ class HAOpenStackCharm(OpenStackCharm): def configure_ca(self, ca_cert, update_certs=True): """Write Certificate Authority certificate""" - cert_file = \ - '/usr/local/share/ca-certificates/keystone_juju_ca_cert.crt' + cert_file = ( + '/usr/local/share/ca-certificates/keystone_juju_ca_cert.crt') if ca_cert: with self.update_central_cacerts([cert_file], update_certs): with open(cert_file, 'w') as crt: diff --git a/unit_tests/__init__.py b/unit_tests/__init__.py index 8bba87c..a20c038 100644 --- a/unit_tests/__init__.py +++ b/unit_tests/__init__.py @@ -24,6 +24,7 @@ sys.modules['charmhelpers.core'] = charmhelpers.core sys.modules['charmhelpers.core.hookenv'] = charmhelpers.core.hookenv sys.modules['charmhelpers.core.host'] = charmhelpers.core.host sys.modules['charmhelpers.core.templating'] = charmhelpers.core.templating +sys.modules['charmhelpers.core.unitdata'] = charmhelpers.core.unitdata sys.modules['charmhelpers.contrib'] = charmhelpers.contrib sys.modules['charmhelpers.contrib.openstack'] = charmhelpers.contrib.openstack sys.modules['charmhelpers.contrib.openstack.utils'] = ( diff --git a/unit_tests/test_charms_openstack_adapters.py b/unit_tests/test_charms_openstack_adapters.py index a6fe5a3..ec43071 100644 --- a/unit_tests/test_charms_openstack_adapters.py +++ b/unit_tests/test_charms_openstack_adapters.py @@ -27,6 +27,29 @@ import mock import charms_openstack.adapters as adapters +class TestCustomProperties(unittest.TestCase): + + def test_adapter_property(self): + with mock.patch.object(adapters, '_custom_adapter_properties', new={}): + + @adapters.adapter_property('my-int') + def test_func(): + pass + + self.assertTrue(adapters._custom_adapter_properties['my-int'], + test_func) + + def test_config_property(self): + with mock.patch.object(adapters, '_custom_config_properties', new={}): + + @adapters.config_property + def test_func(): + pass + + self.assertTrue(adapters._custom_config_properties['test_func'], + test_func) + + class MyRelation(object): auto_accessors = ['this', 'that'] @@ -57,6 +80,27 @@ class TestOpenStackRelationAdapter(unittest.TestCase): ad = adapters.OpenStackRelationAdapter(relation_name='cluster') self.assertEqual(ad.relation_name, 'cluster') + def test_make_default_relation_adapter(self): + # test that no properties just gets the standard one. + self.assertEqual( + adapters.make_default_relation_adapter('fake', None, {}), + 'fake') + + # now create a fake class with some properties to work with + class FakeRelation(object): + a = 4 + + def b(int): + return int.a # e.g. in test, return the 4 for the property 'b' + + kls = adapters.make_default_relation_adapter( + FakeRelation, 'my./?-int', {'b': b}) + self.assertEqual(kls.__name__, 'MyIntRelationAdapterModified') + + i = kls() + self.assertIsInstance(i, FakeRelation) + self.assertEqual(i.b, 4) + class FakeRabbitMQRelation(): @@ -304,6 +348,50 @@ class TestConfigurationAdapter(unittest.TestCase): self.assertEqual(c.three, 3) self.assertEqual(c.that_one, 4) + def test_make_default_configuration_adapter_class(self): + # test that emply class just gives us a normal ConfigurationAdapter + self.assertEqual( + adapters.make_default_configuration_adapter_class(None, {}), + adapters.ConfigurationAdapter) + # now test with a custom class, but no properties + self.assertEqual( + adapters.make_default_configuration_adapter_class( + adapters.APIConfigurationAdapter, {}), + adapters.APIConfigurationAdapter) + # finally give it a custom property + + def custom_property(config): + return 'custom-thing' + + kls = adapters.make_default_configuration_adapter_class( + None, {'custom_property': custom_property}) + self.assertEqual(kls.__name__, 'DefaultConfigurationAdapter') + self.assertTrue( + 'ConfigurationAdapter' in [c.__name__ for c in kls.mro()]) + # instantiate the kls and check for the property + test_config = { + 'my-value': True, + } + with mock.patch.object(adapters.hookenv, + 'config', + new=lambda: test_config): + c = kls() + self.assertTrue(c.my_value) + self.assertEqual(c.custom_property, 'custom-thing') + + def test_charm_instance(self): + with mock.patch.object(adapters.hookenv, 'config', new=lambda: {}): + c = adapters.ConfigurationAdapter() + self.assertEqual(c.charm_instance, None) + + class MockCharm(object): + pass + + instance = MockCharm() + c = adapters.ConfigurationAdapter(charm_instance=instance) + self.assertEqual(c.charm_instance, instance) + self.assertTrue(c._charm_instance_weakref is not None) + class TestAPIConfigurationAdapter(unittest.TestCase): api_ports = { @@ -336,6 +424,20 @@ class TestAPIConfigurationAdapter(unittest.TestCase): self.assertEqual(c.service_listen_info, {}) self.assertEqual(c.external_endpoints, {}) + def test_class_init_using_charm_instance(self): + + class TestCharm(object): + + api_ports = TestAPIConfigurationAdapter.api_ports + name = 'test-charm' + + with mock.patch.object(adapters.hookenv, 'config', new=lambda: {}), \ + mock.patch.object(adapters.APIConfigurationAdapter, + 'get_network_addresses'): + c = adapters.APIConfigurationAdapter(charm_instance=TestCharm()) + self.assertEqual(c.port_map, TestCharm.api_ports) + self.assertEqual(c.service_name, 'test-charm') + def test_ipv4_mode(self): test_config = { 'prefer-ipv6': False, @@ -608,6 +710,116 @@ class TestOpenStackRelationAdapters(unittest.TestCase): self.assertEqual(items[2][0], 'shared_db') self.assertEqual(items[3][0], 'my_name') + def test_set_charm_instance(self): + + # a fake charm instance to play with + class FakeCharm(object): + name = 'fake-charm' + + shared_db = FakeDatabaseRelation() + charm = FakeCharm() + a = adapters.OpenStackRelationAdapters([shared_db], + charm_instance=charm) + self.assertEqual(a.charm_instance, charm) + + def test_custom_configurations_creation(self): + # Test we can bring in a custom configurations + + class FakeConfigurationAdapter(adapters.ConfigurationAdapter): + + def __init__(self, charm_instance): + self.test = 'hello' + + class FakeCharm(object): + name = 'fake-charm' + configuration_class = FakeConfigurationAdapter + + with mock.patch.object(adapters, '_custom_config_properties', new={}): + + @adapters.config_property + def custom_prop(config): + return config.test + + a = adapters.OpenStackRelationAdapters( + [], charm_instance=FakeCharm()) + + self.assertEqual(a.options.custom_prop, 'hello') + self.assertIsInstance(a.options, FakeConfigurationAdapter) + + def test_hoists_custom_relation_properties(self): + + class FakeConfigurationAdapter(adapters.ConfigurationAdapter): + + def __init__(self, charm_instance): + pass + + class FakeSharedDBAdapter(adapters.OpenStackRelationAdapter): + interface_name = 'shared-db' + + class FakeThingAdapter(adapters.OpenStackRelationAdapter): + interface_name = 'some-interface' + + class FakeAdapters(adapters.OpenStackRelationAdapters): + # override the relation_adapters to our shared_db adapter + relation_adapters = { + 'shared-db': FakeSharedDBAdapter, + 'some-interface': FakeThingAdapter, + } + + class FakeThing(object): + relation_name = 'some-interface' + auto_accessors = [] + + class FakeSharedDB(object): + relation_name = 'shared-db' + auto_accessors = ('thing',) + + def thing(self): + return 'kenobi' + + class FakeCharm(object): + name = 'fake-charm' + adapters_class = FakeAdapters + configuration_class = FakeConfigurationAdapter + + with mock.patch.object(adapters, '_custom_adapter_properties', {}): + + @adapters.adapter_property('some-interface') + def custom_property(interface): + return 'goodbye' + + @adapters.adapter_property('shared-db') + def custom_thing(shared_db): + return 'obe wan {}'.format(shared_db.thing) + + shared_db = FakeSharedDB() + fake_thing = FakeThing() + a = FakeAdapters([shared_db, fake_thing], + charm_instance=FakeCharm()) + + # Verify that the custom properties got set. + # This also checks that all the classes were instantiated + self.assertEqual(a.some_interface.custom_property, 'goodbye') + self.assertEqual(a.shared_db.custom_thing, 'obe wan kenobi') + + # verify that the right relations clases were instantiated. + # Note that this checks that the adapters' inheritence is correct; + # they are actually modified classes. + self.assertEqual(len(a._adapters), 2) + self.assertIsInstance(a.some_interface, FakeThingAdapter) + self.assertNotEqual(a.some_interface.__class__.__name__, + 'FakeThingAdapter') + self.assertIsInstance(a.shared_db, FakeSharedDBAdapter) + self.assertNotEqual(a.shared_db.__class__.__name__, + 'FakeSharedDBAdapter') + + # verify that the iteration of the adapters yields the interfaces + ctxt = dict(a) + self.assertIsInstance(ctxt['options'], FakeConfigurationAdapter) + self.assertIsInstance(ctxt['shared_db'], FakeSharedDBAdapter) + self.assertIsInstance(ctxt['some_interface'], FakeThingAdapter) + self.assertEqual(len(ctxt.keys()), 3) + class MyRelationAdapter(adapters.OpenStackRelationAdapter): diff --git a/unit_tests/test_charms_openstack_charm.py b/unit_tests/test_charms_openstack_charm.py index 5e2baa9..445e9eb 100644 --- a/unit_tests/test_charms_openstack_charm.py +++ b/unit_tests/test_charms_openstack_charm.py @@ -168,6 +168,311 @@ class TestRegisterOSReleaseSelector(unittest.TestCase): chm._release_selector_function = save_rsf +class TestDefaults(BaseOpenStackCharmTest): + + def setUp(self): + super(TestDefaults, self).setUp(chm.OpenStackCharm, TEST_CONFIG) + + def test_use_defaults(self): + self.patch_object(chm, 'ALLOWED_DEFAULT_HANDLERS', new=['handler']) + self.patch_object(chm, '_default_handler_map', new={}) + # first check for a missing handler. + with self.assertRaises(RuntimeError): + chm.use_defaults('does not exist') + # now check for an allowed handler, but no function. + with self.assertRaises(RuntimeError): + chm.use_defaults('handler') + + class TestException(Exception): + pass + + # finally, have an actual handler. + @chm._map_default_handler('handler') + def do_handler(): + raise TestException() + + with self.assertRaises(TestException): + chm.use_defaults('handler') + + def test_map_default_handler(self): + self.patch_object(chm, 'ALLOWED_DEFAULT_HANDLERS', new=['handler']) + self.patch_object(chm, '_default_handler_map', new={}) + # test that we can only map allowed handlers. + with self.assertRaises(RuntimeError): + @chm._map_default_handler('does-not-exist') + def test_func1(): + pass + + # test we can only map a handler once + @chm._map_default_handler('handler') + def test_func2(): + pass + + with self.assertRaises(RuntimeError): + @chm._map_default_handler('handler') + def test_func3(): + pass + + @staticmethod + def mock_decorator_gen(): + _map = {} + + def mock_generator(state): + def wrapper(f): + _map[state] = f + + def wrapped(*args, **kwargs): + return f(*args, **kwargs) + return wrapped + return wrapper + + Handler = collections.namedtuple('Handler', ['map', 'decorator']) + return Handler(_map, mock_generator) + + @staticmethod + def mock_decorator_gen_simple(): + _func = {} + + def wrapper(f): + _func['function'] = f + + def wrapped(*args, **kwargs): + return f(*args, **kwargs) + return wrapped + + Handler = collections.namedtuple('Handler', ['map', 'decorator']) + return Handler(_func, wrapper) + + def test_default_install_handler(self): + self.assertIn('charm.installed', chm._default_handler_map) + self.patch_object(chm.reactive, 'when_not') + h = self.mock_decorator_gen() + self.when_not.side_effect = h.decorator + # call the default handler installer function, and check its map. + f = chm._default_handler_map['charm.installed'] + f() + self.assertIn('charm.installed', h.map) + # verify that the installed function calls the charm installer + self.patch_object(chm, 'OpenStackCharm', name='charm') + kv = mock.MagicMock() + self.patch_object(chm.unitdata, 'kv', new=lambda: kv) + self.patch_object(chm.reactive, 'set_state') + h.map['charm.installed']() + kv.unset.assert_called_once_with(chm.OPENSTACK_RELEASE_KEY) + self.charm.singleton.install.assert_called_once_with() + self.set_state.assert_called_once_with('charm.installed') + + def test_default_select_release_handler(self): + self.assertIn('charm.default-select-release', chm._default_handler_map) + self.patch_object(chm, 'register_os_release_selector') + h = self.mock_decorator_gen_simple() + self.register_os_release_selector.side_effect = h.decorator + # call the default handler installer function, and check its map. + f = chm._default_handler_map['charm.default-select-release'] + f() + self.assertIsNotNone(h.map['function']) + # verify that the installed function works + kv = mock.MagicMock() + self.patch_object(chm.unitdata, 'kv', new=lambda: kv) + self.patch_object(chm.os_utils, 'os_release') + # set a release + kv.get.return_value = 'one' + release = h.map['function']() + self.assertEqual(release, 'one') + kv.set.assert_not_called() + kv.get.assert_called_once_with(chm.OPENSTACK_RELEASE_KEY, None) + # No release set, ensure it calls os_release + kv.reset_mock() + kv.get.return_value = None + self.os_release.return_value = 'two' + release = h.map['function']() + self.assertEqual(release, 'two') + kv.set.assert_called_once_with(chm.OPENSTACK_RELEASE_KEY, 'two') + self.os_release.assert_called_once_with('python-keystonemiddleware') + + def test_default_amqp_connection_handler(self): + self.assertIn('amqp.connected', chm._default_handler_map) + self.patch_object(chm.reactive, 'when') + h = self.mock_decorator_gen() + self.when.side_effect = h.decorator + # call the default handler installer function, and check its map. + f = chm._default_handler_map['amqp.connected'] + f() + self.assertIn('amqp.connected', h.map) + # verify that the installed function works + self.patch_object(chm, 'OpenStackCharm', name='charm') + self.charm.singleton.get_amqp_credentials.return_value = \ + ('user', 'vhost') + amqp = mock.MagicMock() + h.map['amqp.connected'](amqp) + self.charm.singleton.get_amqp_credentials.assert_called_once_with() + amqp.request_access.assert_called_once_with(username='user', + vhost='vhost') + self.charm.singleton.assess_status.assert_called_once_with() + + def test_default_setup_datatbase_handler(self): + self.assertIn('shared-db.connected', chm._default_handler_map) + self.patch_object(chm.reactive, 'when') + h = self.mock_decorator_gen() + self.when.side_effect = h.decorator + # call the default handler installer function, and check its map. + f = chm._default_handler_map['shared-db.connected'] + f() + self.assertIn('shared-db.connected', h.map) + # verify that the installed function works + self.patch_object(chm, 'OpenStackCharm', name='charm') + self.charm.singleton.get_database_setup.return_value = [ + {'database': 'configuration'}] + database = mock.MagicMock() + h.map['shared-db.connected'](database) + self.charm.singleton.get_database_setup.assert_called_once_with() + database.configure.assert_called_once_with(database='configuration') + self.charm.singleton.assess_status.assert_called_once_with() + + def test_default_setup_endpoint_handler(self): + self.assertIn('identity-service.connected', chm._default_handler_map) + self.patch_object(chm.reactive, 'when') + h = self.mock_decorator_gen() + self.when.side_effect = h.decorator + # call the default handler installer function, and check its map. + f = chm._default_handler_map['identity-service.connected'] + f() + self.assertIn('identity-service.connected', h.map) + # verify that the installed function works + + OpenStackCharm = mock.MagicMock() + + class Instance(object): + service_type = 'type1' + region = 'region1' + public_url = 'public_url' + internal_url = 'internal_url' + admin_url = 'admin_url' + assess_status = mock.MagicMock() + + OpenStackCharm.singleton = Instance + with mock.patch.object(chm, 'OpenStackCharm', new=OpenStackCharm): + keystone = mock.MagicMock() + h.map['identity-service.connected'](keystone) + keystone.register_endpoints.assert_called_once_with( + 'type1', 'region1', 'public_url', 'internal_url', 'admin_url') + Instance.assess_status.assert_called_once_with() + + def test_default_setup_endpoint_available_handler(self): + self.assertIn('identity-service.available', chm._default_handler_map) + self.patch_object(chm.reactive, 'when') + h = self.mock_decorator_gen() + self.when.side_effect = h.decorator + # call the default handler installer function, and check its map. + f = chm._default_handler_map['identity-service.available'] + f() + self.assertIn('identity-service.available', h.map) + # verify that the installed function works + self.patch_object(chm, 'OpenStackCharm', name='charm') + h.map['identity-service.available']('keystone') + self.charm.singleton.configure_ssl.assert_called_once_with('keystone') + self.charm.singleton.assess_status.assert_called_once_with() + + def test_default_config_changed_handler(self): + self.assertIn('config.changed', chm._default_handler_map) + self.patch_object(chm.reactive, 'when') + h = self.mock_decorator_gen() + self.when.side_effect = h.decorator + # call the default handler installer function, and check its map. + f = chm._default_handler_map['config.changed'] + f() + self.assertIn('config.changed', h.map) + # verify that the installed function works + self.patch_object(chm, 'OpenStackCharm', name='charm') + h.map['config.changed']() + self.charm.singleton.assess_status.assert_called_once_with() + + def test_default_update_status_handler(self): + self.assertIn('update-status', chm._default_handler_map) + self.patch_object(chm.reactive, 'when') + h = self.mock_decorator_gen() + self.when.side_effect = h.decorator + # call the default handler installer function, and check its map. + f = chm._default_handler_map['update-status'] + f() + self.assertIn('update-status', h.map) + # verify that the installed function works + self.patch_object(chm, 'OpenStackCharm', name='charm') + h.map['update-status']() + self.charm.singleton.assess_status.assert_called_once_with() + + def test_default_render_configs(self): + self.patch_object(chm, 'OpenStackCharm', name='charm') + interfaces = ['a', 'b', 'c'] + chm.default_render_configs(*interfaces) + self.charm.singleton.render_configs.assert_called_once_with( + tuple(interfaces)) + self.charm.singleton.assess_status.assert_called_once_with() + + def test_optional_interfaces(self): + self.patch_object(chm.reactive, 'RelationBase', name='relation_base') + self.relation_base.from_state.side_effect = ['x', None, 'z'] + r = chm.optional_interfaces(('a', 'b', 'c'), 'any', 'old', 'thing') + self.assertEqual(r, ('a', 'b', 'c', 'x', 'z')) + self.relation_base.from_state.assert_has_calls( + [mock.call('any'), mock.call('old'), mock.call('thing')]) + + +class TestProvideCharmInstance(utils.BaseTestCase): + + def test_provide_charm_instance_as_decorator(self): + self.patch_object(chm, 'OpenStackCharm', name='charm') + self.charm.singleton = 'the-charm' + + @chm.provide_charm_instance + def the_handler(charm_instance, *args): + self.assertEqual(charm_instance, 'the-charm') + self.assertEqual(args, (1, 2, 3)) + + the_handler(1, 2, 3) + + def test_provide_charm_instance_as_context_manager(self): + self.patch_object(chm, 'OpenStackCharm', name='charm') + self.charm.singleton = 'the-charm' + + with chm.provide_charm_instance() as charm: + self.assertEqual(charm, 'the-charm') + + +class TestOpenStackCharm__init__(BaseOpenStackCharmTest): + # Just test the __init__() function, as it takes some params which do some + # initalisation. + + def setUp(self): + + class NoOp(object): + pass + + # bypass setting p the charm directly, as we want control over that. + super(TestOpenStackCharm__init__, self).setUp(NoOp, TEST_CONFIG) + + def test_empty_init_args(self): + target = chm.OpenStackCharm() + self.assertIsNone(target.release) + self.assertIsNone(target.adapters_instance) + # from mocked hookenv.config() + self.assertEqual(target.config, TEST_CONFIG) + + def test_filled_init_args(self): + self.patch_object(chm, '_releases', new={}) + + class TestCharm(chm.OpenStackCharm): + release = 'mitaka' + adapters_class = mock.MagicMock() + + target = TestCharm('interfaces', 'config', 'release') + self.assertEqual(target.release, 'release') + self.assertEqual(target.config, 'config') + self.assertIsInstance(target.adapters_instance, mock.MagicMock) + TestCharm.adapters_class.assert_called_once_with( + 'interfaces', charm_instance=target) + + class TestOpenStackCharm(BaseOpenStackCharmTest): # Note that this only tests the OpenStackCharm() class, which has not very # useful defaults for testing. In order to test all the code without too @@ -322,6 +627,23 @@ class TestOpenStackCharm(BaseOpenStackCharmTest): self.leader_set.assert_not_called() +class TestOpenStackAPICharm(BaseOpenStackCharmTest): + + def setUp(self): + super(TestOpenStackAPICharm, self).setUp(chm.OpenStackAPICharm, + TEST_CONFIG) + + def test_get_amqp_credentials(self): + # verify that the instance throws an error if not overriden + with self.assertRaises(RuntimeError): + self.target.get_amqp_credentials() + + def test_get_database_setup(self): + # verify that the instance throws an error if not overriden + with self.assertRaises(RuntimeError): + self.target.get_database_setup() + + class TestHAOpenStackCharm(BaseOpenStackCharmTest): # Note that this only tests the OpenStackCharm() class, which has not very # useful defaults for testing. In order to test all the code without too @@ -659,7 +981,7 @@ class TestHAOpenStackCharm(BaseOpenStackCharmTest): class MyAdapter(object): - def __init__(self, interfaces): + def __init__(self, interfaces, charm_instance=None): self.interfaces = interfaces @@ -838,9 +1160,17 @@ class TestMyOpenStackCharm(BaseOpenStackCharmTest): self.patch_object(chm.os_templating, 'get_loader', return_value='my-loader') + # also patch the cls.adapters_class to ensure that it is called with + # the target. + self.patch_object(self.target.singleton, 'adapters_class', + return_value='the-context') self.target.singleton.render_with_interfaces( ['interface1', 'interface2']) + + self.adapters_class.assert_called_once_with( + ['interface1', 'interface2'], charm_instance=self.target.singleton) + calls = [ mock.call( source='path1',