Permalink
Cannot retrieve contributors at this time
Fetching contributors…
| # Copyright 2014-2015 Canonical Limited. | |
| # | |
| # This file is part of charm-helpers. | |
| # | |
| # charm-helpers is free software: you can redistribute it and/or modify | |
| # it under the terms of the GNU Lesser General Public License version 3 as | |
| # published by the Free Software Foundation. | |
| # | |
| # charm-helpers is distributed in the hope that it will be useful, | |
| # but WITHOUT ANY WARRANTY; without even the implied warranty of | |
| # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
| # GNU Lesser General Public License for more details. | |
| # | |
| # You should have received a copy of the GNU Lesser General Public License | |
| # along with charm-helpers. If not, see <http://www.gnu.org/licenses/>. | |
| import os | |
| from inspect import isclass | |
| from subprocess import CalledProcessError | |
| from six import with_metaclass | |
| from charmhelpers.core import hookenv | |
| from charmhelpers.core import unitdata | |
| from charmhelpers.cli import cmdline | |
| from charms.reactive.bus import get_states | |
| from charms.reactive.bus import get_state | |
| from charms.reactive.bus import set_state | |
| from charms.reactive.bus import remove_state | |
| from charms.reactive.bus import _load_module | |
| from charms.reactive.bus import StateList | |
| # arbitrary obj instances to use as defaults instead of None | |
| ALL = object() | |
| TOGGLE = object() | |
| class scopes(object): | |
| """ | |
| These are the recommended scope values for relation implementations. | |
| To use, simply set the ``scope`` class variable to one of these:: | |
| class MyRelationClient(RelationBase): | |
| scope = scopes.SERVICE | |
| """ | |
| GLOBAL = 'global' | |
| """ | |
| All connected services and units for this relation will share a single | |
| conversation. The same data will be broadcast to every remote unit, and | |
| retrieved data will be aggregated across all remote units and is expected | |
| to either eventually agree or be set by a single leader. | |
| """ | |
| SERVICE = 'service' | |
| """ | |
| Each connected service for this relation will have its own conversation. | |
| The same data will be broadcast to every unit of each service's conversation, | |
| and data from all units of each service will be aggregated and is expected | |
| to either eventually agree or be set by a single leader. | |
| """ | |
| UNIT = 'unit' | |
| """ | |
| Each connected unit for this relation will have its own conversation. This | |
| is the default scope. Each unit's data will be retrieved individually, but | |
| note that due to how Juju works, the same data is still broadcast to all | |
| units of a single service. | |
| """ | |
| class AutoAccessors(type): | |
| """ | |
| Metaclass that converts fields referenced by ``auto_accessors`` into | |
| accessor methods with very basic doc strings. | |
| """ | |
| def __new__(cls, name, parents, dct): | |
| for field in dct.get('auto_accessors', []): | |
| meth_name = field.replace('-', '_') | |
| meth = cls._accessor(field) | |
| meth.__name__ = meth_name | |
| meth.__module__ = dct.get('__module__') | |
| meth.__doc__ = 'Get the %s, if available, or None.' % field | |
| dct[meth_name] = meth | |
| return super(AutoAccessors, cls).__new__(cls, name, parents, dct) | |
| @staticmethod | |
| def _accessor(field): | |
| def __accessor(self): | |
| return self.get_remote(field) | |
| return __accessor | |
| class RelationBase(with_metaclass(AutoAccessors, object)): | |
| """ | |
| The base class for all relation implementations. | |
| """ | |
| _cache = {} | |
| scope = scopes.UNIT | |
| """ | |
| Conversation scope for this relation. | |
| The conversation scope controls how communication with connected units | |
| is aggregated into related :class:`Conversations <Conversation>`, and | |
| can be any of the predefined :class:`scopes`, or any arbitrary string. | |
| Connected units which share the same scope will be considered part of | |
| the same conversation. Data sent to a conversation is sent to all units | |
| that are a part of that conversation, and units that are part of a | |
| conversation are expected to agree on the data that they send, whether | |
| via eventual consistency or by having a single leader set the data. | |
| The default scope is :attr:`scopes.UNIT`. | |
| """ | |
| class states(StateList): | |
| """ | |
| This is the set of :class:`States <charms.reactive.bus.State>` that this | |
| relation could set. | |
| This should be defined by the relation subclass to ensure that | |
| states are consistent and documented, as well as being discoverable | |
| and introspectable by linting and composition tools. | |
| For example:: | |
| class MyRelationClient(RelationBase): | |
| scope = scopes.GLOBAL | |
| auto_accessors = ['host', 'port'] | |
| class states(StateList): | |
| connected = State('{relation_name}.connected') | |
| available = State('{relation_name}.available') | |
| @hook('{requires:my-interface}-relation-{joined,changed}') | |
| def changed(self): | |
| self.set_state(self.states.connected) | |
| if self.host() and self.port(): | |
| self.set_state(self.states.available) | |
| """ | |
| pass | |
| auto_accessors = [] | |
| """ | |
| Remote field names to be automatically converted into accessors with | |
| basic documentation. | |
| These accessors will just call :meth:`get_remote` using the | |
| :meth:`default conversation <conversation>`. Note that it is highly | |
| recommended that this be used only with :attr:`scopes.GLOBAL` scope. | |
| """ | |
| def __init__(self, relation_name, conversations=None): | |
| self._relation_name = relation_name | |
| self._conversations = conversations or [Conversation.join(self.scope)] | |
| @property | |
| def relation_name(self): | |
| """ | |
| Name of the relation this instance is handling. | |
| """ | |
| return self._relation_name | |
| @classmethod | |
| def from_state(cls, state): | |
| """ | |
| Find relation implementation in the current charm, based on the | |
| name of an active state. | |
| """ | |
| value = get_state(state) | |
| if value is None: | |
| return None | |
| relation_name = value['relation'] | |
| conversations = Conversation.load(value['conversations']) | |
| return cls.from_name(relation_name, conversations) | |
| @classmethod | |
| def from_name(cls, relation_name, conversations=None): | |
| """ | |
| Find relation implementation in the current charm, based on the | |
| name of the relation. | |
| :return: A Relation instance, or None | |
| """ | |
| if relation_name is None: | |
| return None | |
| relation_class = cls._cache.get(relation_name) | |
| if relation_class: | |
| return relation_class(relation_name, conversations) | |
| role, interface = hookenv.relation_to_role_and_interface(relation_name) | |
| if role and interface: | |
| relation_class = cls._find_impl(role, interface) | |
| if relation_class: | |
| cls._cache[relation_name] = relation_class | |
| return relation_class(relation_name, conversations) | |
| return None | |
| @classmethod | |
| def _find_impl(cls, role, interface): | |
| """ | |
| Find relation implementation based on its role and interface. | |
| Looks for the first file matching: | |
| ``$CHARM_DIR/hooks/relations/{interface}/{provides,requires,peer}.py`` | |
| """ | |
| hooks_dir = os.path.join(hookenv.charm_dir(), 'hooks') | |
| try: | |
| filepath = os.path.join(hooks_dir, 'relations', interface, role + '.py') | |
| module = _load_module(filepath) | |
| return cls._find_subclass(module) | |
| except ImportError: | |
| return None | |
| @classmethod | |
| def _find_subclass(cls, module): | |
| """ | |
| Attempt to find subclass of :class:`RelationBase` in the given module. | |
| Note: This means strictly subclasses and not :class:`RelationBase` itself. | |
| This is to prevent picking up :class:`RelationBase` being imported to be | |
| used as the base class. | |
| """ | |
| for attr in dir(module): | |
| candidate = getattr(module, attr) | |
| if isclass(candidate) and issubclass(candidate, cls) and candidate is not cls: | |
| return candidate | |
| return None | |
| def conversations(self): | |
| """ | |
| Return a list of the conversations that this relation is currently handling. | |
| Note that "currently handling" means for the current state or hook context, | |
| and not all conversations that might be active for this relation for other | |
| states. | |
| """ | |
| return list(self._conversations) | |
| def conversation(self, scope=None): | |
| """ | |
| Get a single conversation, by scope, that this relation is currently handling. | |
| If the scope is not given, the correct scope is inferred by the current | |
| hook execution context. If there is no current hook execution context, it | |
| is assume that there is only a single global conversation scope for this | |
| relation. If this relation's scope is not global and there is no current | |
| hook execution context, then an error is raised. | |
| """ | |
| if scope is None: | |
| if self.scope is scopes.UNIT: | |
| scope = hookenv.remote_unit() | |
| elif self.scope is scopes.SERVICE: | |
| scope = hookenv.remote_service_name() | |
| else: | |
| scope = self.scope | |
| if scope is None: | |
| raise ValueError('Unable to determine default scope: no current hook or global scope') | |
| for conversation in self._conversations: | |
| if conversation.scope == scope: | |
| return conversation | |
| else: | |
| raise ValueError("Conversation with scope '%s' not found" % scope) | |
| def set_state(self, state, scope=None): | |
| """ | |
| Set the state for the :class:`Conversation` with the given scope. | |
| In Python, this is equivalent to:: | |
| relation.conversation(scope).set_state(state) | |
| See :meth:`conversation` and :meth:`Conversation.set_state`. | |
| """ | |
| self.conversation(scope).set_state(state) | |
| def remove_state(self, state, scope=None): | |
| """ | |
| Remove the state for the :class:`Conversation` with the given scope. | |
| In Python, this is equivalent to:: | |
| relation.conversation(scope).remove_state(state) | |
| See :meth:`conversation` and :meth:`Conversation.remove_state`. | |
| """ | |
| self.conversation(scope).remove_state(state) | |
| def is_state(self, state, scope=None): | |
| """ | |
| Test the state for the :class:`Conversation` with the given scope. | |
| In Python, this is equivalent to:: | |
| relation.conversation(scope).is_state(state) | |
| See :meth:`conversation` and :meth:`Conversation.is_state`. | |
| """ | |
| return self.conversation(scope).is_state(state) | |
| def toggle_state(self, state, active=TOGGLE, scope=None): | |
| """ | |
| Toggle the state for the :class:`Conversation` with the given scope. | |
| In Python, this is equivalent to:: | |
| relation.conversation(scope).toggle_state(state, active) | |
| See :meth:`conversation` and :meth:`Conversation.toggle_state`. | |
| """ | |
| self.conversation(scope).toggle_state(state, active) | |
| def set_remote(self, key=None, value=None, data=None, scope=None, **kwdata): | |
| """ | |
| Set data for the remote end(s) of the :class:`Conversation` with the given scope. | |
| In Python, this is equivalent to:: | |
| relation.conversation(scope).set_remote(key, value, data, scope, **kwdata) | |
| See :meth:`conversation` and :meth:`Conversation.set_remote`. | |
| """ | |
| self.conversation(scope).set_remote(key, value, data, **kwdata) | |
| def get_remote(self, key, default=None, scope=None): | |
| """ | |
| Get data from the remote end(s) of the :class:`Conversation` with the given scope. | |
| In Python, this is equivalent to:: | |
| relation.conversation(scope).get_remote(key, default) | |
| See :meth:`conversation` and :meth:`Conversation.get_remote`. | |
| """ | |
| return self.conversation(scope).get_remote(key, default) | |
| def set_local(self, key=None, value=None, data=None, scope=None, **kwdata): | |
| """ | |
| Locally store some data, namespaced by the current or given :class:`Conversation` scope. | |
| In Python, this is equivalent to:: | |
| relation.conversation(scope).set_local(data, scope, **kwdata) | |
| See :meth:`conversation` and :meth:`Conversation.set_local`. | |
| """ | |
| self.conversation(scope).set_local(key, value, data, **kwdata) | |
| def get_local(self, key, default=None, scope=None): | |
| """ | |
| Retrieve some data previously set via :meth:`set_local`. | |
| In Python, this is equivalent to:: | |
| relation.conversation(scope).get_local(key, default) | |
| See :meth:`conversation` and :meth:`Conversation.get_local`. | |
| """ | |
| return self.conversation(scope).get_local(key, default) | |
| class Conversation(object): | |
| """ | |
| Converations are the persistent, evolving, two-way communication between | |
| this service and one or more remote services. | |
| Conversations are not limited to a single Juju hook context. They represent | |
| the entire set of interactions between the end-points from the time the | |
| relation is joined until it is departed. | |
| Conversations evolve over time, moving from one semantic state to the next | |
| as the communication progresses. | |
| Conversations may encompass multiple remote services or units. While a | |
| database client would connect to only a single database, that database will | |
| likely serve several other services. On the other hand, while the database | |
| is only concerned about providing a database to each service as a whole, a | |
| load-balancing proxy must consider each unit of each service individually. | |
| Conversations use the idea of :class:`scope` to determine how units and | |
| services are grouped together. | |
| """ | |
| def __init__(self, namespace, units, scope): | |
| self.namespace = namespace | |
| self.units = set(units) | |
| self.scope = scope | |
| @classmethod | |
| def _key(cls, namespace, scope): | |
| return 'reactive.conversations.%s.%s' % (namespace, scope) | |
| @property | |
| def key(self): | |
| """ | |
| The key under which this conversation will be stored. | |
| """ | |
| return self._key(self.namespace, self.scope) | |
| @property | |
| def relation_name(self): | |
| return self.namespace.split(':')[0] | |
| @property | |
| def relation_ids(self): | |
| """ | |
| The set of IDs of the specific relation instances that this conversation | |
| is communicating with. | |
| """ | |
| if self.scope == scopes.GLOBAL: | |
| # the namespace is the relation name and this conv speaks for all | |
| # connected instances of that relation | |
| return hookenv.relation_ids(self.namespace) | |
| else: | |
| # the namespace is the relation ID | |
| return [self.namespace] | |
| @classmethod | |
| def join(cls, scope): | |
| """ | |
| Get or create a conversation for the given scope and active hook context. | |
| The current remote unit for the active hook context will be added to | |
| the conversation. | |
| Note: This uses :mod:`charmhelpers.core.unitdata` and requires that | |
| :meth:`~charmhelpers.core.unitdata.Storage.flush` be called. | |
| """ | |
| relation_name = hookenv.relation_type() | |
| relation_id = hookenv.relation_id() | |
| unit = hookenv.remote_unit() | |
| service = hookenv.remote_service_name() | |
| if scope is scopes.UNIT: | |
| scope = unit | |
| namespace = relation_id | |
| elif scope is scopes.SERVICE: | |
| scope = service | |
| namespace = relation_id | |
| else: | |
| namespace = relation_name | |
| key = cls._key(namespace, scope) | |
| data = unitdata.kv().get(key, {'namespace': namespace, 'scope': scope, 'units': []}) | |
| conversation = cls.deserialize(data) | |
| conversation.units.add(unit) | |
| unitdata.kv().set(key, cls.serialize(conversation)) | |
| return conversation | |
| def depart(self): | |
| """ | |
| Remove the current remote unit, for the active hook context, from | |
| this conversation. This should be called from a `-departed` hook. | |
| """ | |
| unit = hookenv.remote_unit() | |
| self.units.remove(unit) | |
| if self.units: | |
| unitdata.kv().set(self.key, self.serialize(self)) | |
| else: | |
| unitdata.kv().unset(self.key) | |
| @classmethod | |
| def deserialize(cls, conversation): | |
| """ | |
| Deserialize a :meth:`serialized <serialize>` conversation. | |
| """ | |
| return cls(**conversation) | |
| @classmethod | |
| def serialize(cls, conversation): | |
| """ | |
| Serialize a conversation instance for storage. | |
| """ | |
| return { | |
| 'namespace': conversation.namespace, | |
| 'units': sorted(conversation.units), | |
| 'scope': conversation.scope, | |
| } | |
| @classmethod | |
| def load(cls, keys): | |
| """ | |
| Load a set of conversations by their keys. | |
| """ | |
| conversations = [] | |
| for key in keys: | |
| conversation = unitdata.kv().get(key) | |
| if conversation: | |
| conversations.append(cls.deserialize(conversation)) | |
| return conversations | |
| def set_state(self, state): | |
| """ | |
| Activate and put this conversation into the given state. | |
| The relation name will be interpolated in the state name, and it is | |
| recommended that it be included to avoid conflicts with states from | |
| other relations. For example:: | |
| conversation.set_state('{relation_name}.state') | |
| If called from a converation handling the relation "foo", this will | |
| activate the "foo.state" state, and will add this conversation to | |
| that state. | |
| Note: This uses :mod:`charmhelpers.core.unitdata` and requires that | |
| :meth:`~charmhelpers.core.unitdata.Storage.flush` be called. | |
| """ | |
| state = state.format(relation_name=self.relation_name) | |
| value = get_state(state, { | |
| 'relation': self.relation_name, | |
| 'conversations': [], | |
| }) | |
| if self.key not in value['conversations']: | |
| value['conversations'].append(self.key) | |
| set_state(state, value) | |
| def remove_state(self, state): | |
| """ | |
| Remove this conversation from the given state, and potentially | |
| deactivate the state if no more conversations are in it. | |
| The relation name will be interpolated in the state name, and it is | |
| recommended that it be included to avoid conflicts with states from | |
| other relations. For example:: | |
| conversation.remove_state('{relation_name}.state') | |
| If called from a converation handling the relation "foo", this will | |
| remove the conversation from the "foo.state" state, and, if no more | |
| conversations are in this the state, will deactivate it. | |
| """ | |
| state = state.format(relation_name=self.relation_name) | |
| value = get_state(state) | |
| if not value: | |
| return | |
| if self.key in value['conversations']: | |
| value['conversations'].remove(self.key) | |
| if value['conversations']: | |
| set_state(state, value) | |
| else: | |
| remove_state(state) | |
| def is_state(self, state): | |
| """ | |
| Test if this conversation is in the given state. | |
| """ | |
| state = state.format(relation_name=self.relation_name) | |
| value = get_state(state) | |
| if not value: | |
| return False | |
| return self.key in value['conversations'] | |
| def toggle_state(self, state, active=TOGGLE): | |
| """ | |
| Toggle the given state for this conversation. | |
| The state will be set ``active`` is ``True``, otherwise the state will be removed. | |
| If ``active`` is not given, it will default to the inverse of the current state | |
| (i.e., ``False`` if the state is currently set, ``True`` if it is not; essentially | |
| toggling the state). | |
| For example:: | |
| conv.toggle_state('{relation_name}.foo', value=='foo') | |
| This will set the state if ``value`` is equal to ``foo``. | |
| """ | |
| if active is TOGGLE: | |
| active = not self.is_state(state) | |
| if active: | |
| self.set_state(state) | |
| else: | |
| self.remove_state(state) | |
| def set_remote(self, key=None, value=None, data=None, **kwdata): | |
| """ | |
| Set data for the remote end(s) of this conversation. | |
| Data can be passed in either as a single dict, or as key-word args. | |
| Note that, in Juju, setting relation data is inherently service scoped. | |
| That is, if the conversation only includes a single unit, the data will | |
| still be set for that unit's entire service. | |
| However, if this conversation's scope encompasses multiple services, | |
| the data will be set for all of those services. | |
| :param str key: The name of a field to set. | |
| :param value: A value to set. | |
| :param dict data: A mapping of keys to values. | |
| :param \*\*kwdata: A mapping of keys to values, as keyword arguments. | |
| """ | |
| if data is None: | |
| data = {} | |
| if key is not None: | |
| data[key] = value | |
| data.update(kwdata) | |
| if not data: | |
| return | |
| for relation_id in self.relation_ids: | |
| hookenv.relation_set(relation_id, data) | |
| def get_remote(self, key, default=None): | |
| """ | |
| Get a value from the remote end(s) of this conversation. | |
| Note that if a conversation's scope encompasses multiple units, then | |
| those units are expected to agree on their data, whether that is through | |
| relying on a single leader to set the data or by all units eventually | |
| converging to identical data. Thus, this method returns the first | |
| value that it finds set by any of its units. | |
| """ | |
| cur_rid = hookenv.relation_id() | |
| departing = hookenv.hook_name().endswith('-relation-departed') | |
| for relation_id in self.relation_ids: | |
| units = hookenv.related_units(relation_id) | |
| if departing and cur_rid == relation_id: | |
| # Work around the fact that Juju 2.0 doesn't include the | |
| # departing unit in relation-list during the -departed hook, | |
| # by adding it back in ourselves. | |
| units.append(hookenv.remote_unit()) | |
| for unit in units: | |
| if unit not in self.units: | |
| continue | |
| value = hookenv.relation_get(key, unit, relation_id) | |
| if value: | |
| return value | |
| return default | |
| def set_local(self, key=None, value=None, data=None, **kwdata): | |
| """ | |
| Locally store some data associated with this conversation. | |
| Data can be passed in either as a single dict, or as key-word args. | |
| For example, if you need to store the previous value of a remote field | |
| to determine if it has changed, you can use the following:: | |
| prev = conversation.get_local('field') | |
| curr = conversation.get_remote('field') | |
| if prev != curr: | |
| handle_change(prev, curr) | |
| conversation.set_local('field', curr) | |
| Note: This uses :mod:`charmhelpers.core.unitdata` and requires that | |
| :meth:`~charmhelpers.core.unitdata.Storage.flush` be called. | |
| :param str key: The name of a field to set. | |
| :param value: A value to set. | |
| :param dict data: A mapping of keys to values. | |
| :param \*\*kwdata: A mapping of keys to values, as keyword arguments. | |
| """ | |
| if data is None: | |
| data = {} | |
| if key is not None: | |
| data[key] = value | |
| data.update(kwdata) | |
| if not data: | |
| return | |
| unitdata.kv().update(data, prefix='%s.%s.' % (self.key, 'local-data')) | |
| def get_local(self, key, default=None): | |
| """ | |
| Retrieve some data previously set via :meth:`set_local` for this conversation. | |
| """ | |
| key = '%s.%s.%s' % (self.key, 'local-data', key) | |
| return unitdata.kv().get(key, default) | |
| def _migrate_conversations(): | |
| """ | |
| Due to issue #28 (https://github.com/juju-solutions/charms.reactive/issues/28), | |
| conversations needed to be updated to be namespaced per relation ID for SERVICE | |
| and UNIT scope. To ensure backwards compatibility, this updates all convs in | |
| the old format to the new. | |
| TODO: Remove in 2.0.0 | |
| """ | |
| for key, data in unitdata.kv().getrange('reactive.conversations.').items(): | |
| if 'local-data' in key: | |
| continue | |
| if 'namespace' in data: | |
| continue | |
| relation_name = data.pop('relation_name') | |
| if data['scope'] == scopes.GLOBAL: | |
| data['namespace'] = relation_name | |
| unitdata.kv().set(key, data) | |
| else: | |
| # split the conv based on the relation ID | |
| new_keys = [] | |
| for rel_id in hookenv.relation_ids(relation_name): | |
| new_key = Conversation._key(rel_id, data['scope']) | |
| new_units = set(hookenv.related_units(rel_id)) & set(data['units']) | |
| if new_units: | |
| unitdata.kv().set(new_key, { | |
| 'namespace': rel_id, | |
| 'scope': data['scope'], | |
| 'units': sorted(new_units), | |
| }) | |
| new_keys.append(new_key) | |
| unitdata.kv().unset(key) | |
| # update the states pointing to the old conv key to point to the | |
| # (potentially multiple) new key(s) | |
| for state, value in get_states().items(): | |
| if not value: | |
| continue | |
| if key not in value['conversations']: | |
| continue | |
| value['conversations'].remove(key) | |
| value['conversations'].extend(new_keys) | |
| set_state(state, value) | |
| @cmdline.subcommand() | |
| def relation_call(method, relation_name=None, state=None, *args): | |
| """Invoke a method on the class implementing a relation via the CLI""" | |
| if relation_name: | |
| relation = RelationBase.from_name(relation_name) | |
| if relation is None: | |
| raise ValueError('Relation not found: %s' % relation_name) | |
| elif state: | |
| relation = RelationBase.from_state(state) | |
| if relation is None: | |
| raise ValueError('Relation not found: %s' % state) | |
| else: | |
| raise ValueError('Must specify either relation_name or state') | |
| result = getattr(relation, method)(*args) | |
| if method == 'conversations': | |
| # special case for conversations to make them work from CLI | |
| result = [c.scope for c in result] | |
| return result |