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',