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
This commit is contained in:
Alex Kavanagh 2016-08-31 19:59:17 +00:00
parent 6068c5a586
commit 92d3f14919
6 changed files with 1101 additions and 39 deletions

1
.gitignore vendored
View File

@ -2,4 +2,5 @@
.testrepository
*.pyc
charm.openstack.egg-info
.ropeproject
.eggs

View File

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

View File

@ -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': <database>,
'username': <username>,
'hostname': <hostname of this unit>
'prefix': <the optional prefix for the database>, },
]
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:

View File

@ -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'] = (

View File

@ -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):

View File

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