charm-openvswitch-odl/hooks/charmhelpers/core/reactive/bus.py

460 lines
15 KiB
Python

# 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
import re
import sys
import subprocess
from itertools import chain
from functools import partial
from six.moves import range
from charmhelpers.core import hookenv
from charmhelpers.core import unitdata
from charmhelpers.cli import cmdline
try:
# Python 3
from importlib import SourceFileLoader
def load_source(modname, realpath):
return SourceFileLoader(modname, realpath).load_module()
except ImportError:
# Python 2
from imp import load_source
_log_opts = os.environ.get('REACTIVE_LOG_OPTS', '').split(',')
LOG_OPTS = {
'register': 'register' in _log_opts,
}
@cmdline.subcommand()
@cmdline.no_output
def set_state(state, value=None):
"""Set the given state as active, optionally associating with a relation"""
old_states = get_states()
unitdata.kv().update({state: value}, prefix='reactive.states.')
if state not in old_states:
StateWatch.change(state)
@cmdline.subcommand()
@cmdline.no_output
def remove_state(state):
"""Remove / deactivate a state"""
old_states = get_states()
unitdata.kv().unset('reactive.states.%s' % state)
unitdata.kv().set('reactive.dispatch.removed_state', True)
if state in old_states:
StateWatch.change(state)
@cmdline.subcommand()
def get_states():
"""Return a mapping of all active states to their values"""
return unitdata.kv().getrange('reactive.states.', strip=True) or {}
class StateWatch(object):
key = 'reactive.state_watch'
@classmethod
def _store(cls):
return unitdata.kv()
@classmethod
def _get(cls):
return cls._store().get(cls.key, {
'iteration': 0,
'changes': [],
'pending': [],
})
@classmethod
def _set(cls, data):
cls._store().set(cls.key, data)
@classmethod
def reset(cls):
cls._store().unset(cls.key)
@classmethod
def iteration(cls, i):
data = cls._get()
data['iteration'] = i
cls._set(data)
@classmethod
def watch(cls, watcher, states):
data = cls._get()
iteration = data['iteration']
changed = bool(set(states) & set(data['changes']))
return iteration == 0 or changed
@classmethod
def change(cls, state):
data = cls._get()
data['pending'].append(state)
cls._set(data)
@classmethod
def commit(cls):
data = cls._get()
data['changes'] = data['pending']
data['pending'] = []
cls._set(data)
def get_state(state, default=None):
"""Return the value associated with an active state, or None"""
return unitdata.kv().get('reactive.states.%s' % state, default)
@cmdline.subcommand()
@cmdline.test_command
def all_states(*desired_states):
"""Assert that all desired_states are active"""
active_states = get_states()
return all(state in active_states for state in desired_states)
@cmdline.subcommand()
@cmdline.test_command
def any_states(*desired_states):
"""Assert that any of the desired_states are active"""
active_states = get_states()
return any(state in active_states for state in desired_states)
@cmdline.subcommand()
@cmdline.test_command
def any_hook(*hook_patterns):
"""
Assert that the currently executing hook matches one of the given patterns.
Each pattern will match one or more hooks, and can use the following
special syntax:
* ``db-relation-{joined,changed}`` can be used to match multiple hooks
(in this case, ``db-relation-joined`` and ``db-relation-changed``).
* ``{provides:mysql}-relation-joined`` can be used to match a relation
hook by the role and interface instead of the relation name. The role
must be one of ``provides``, ``requires``, or ``peer``.
* The previous two can be combined, of course: ``{provides:mysql}-relation-{joined,changed}``
"""
current_hook = hookenv.hook_name()
# expand {role:interface} patterns
i_pat = re.compile(r'{([^:}]+):([^}]+)}')
hook_patterns = _expand_replacements(i_pat, hookenv.role_and_interface_to_relations, hook_patterns)
# expand {A,B,C,...} patterns
c_pat = re.compile(r'{((?:[^:,}]+,?)+)}')
hook_patterns = _expand_replacements(c_pat, lambda v: v.split(','), hook_patterns)
return current_hook in hook_patterns
def _expand_replacements(pat, subf, values):
while any(pat.search(r) for r in values):
new_values = []
for value in values:
m = pat.search(value)
if not m:
new_values.append(value)
continue
whole_match = m.group(0)
selected_groups = m.groups()
for replacement in subf(*selected_groups):
# have to replace one match at a time, or we'll lose combinations
# e.g., '{A,B}{A,B}' would turn to ['AA', 'BB'] instead of
# ['A{A,B}', 'B{A,B}'] -> ['AA', 'AB', 'BA', 'BB']
new_values.append(value.replace(whole_match, replacement, 1))
values = new_values
return values
def _action_id(action):
return "%s:%s:%s" % (action.__code__.co_filename,
action.__code__.co_firstlineno,
action.__code__.co_name)
def _short_action_id(action):
filepath = os.path.relpath(action.__code__.co_filename, hookenv.charm_dir())
return "%s:%s:%s" % (filepath,
action.__code__.co_firstlineno,
action.__code__.co_name)
class Handler(object):
"""
Class representing a reactive state handler.
"""
_HANDLERS = {}
@classmethod
def get(cls, action):
"""
Get or register a handler for the given action.
:param func action: Callback that is called when invoking the Handler
:param func args_source: Optional callback that generates args for the action
"""
action_id = _action_id(action)
if action_id not in cls._HANDLERS:
if LOG_OPTS['register']:
hookenv.log('Registering reactive handler for %s' % _short_action_id(action), level=hookenv.DEBUG)
cls._HANDLERS[action_id] = cls(action)
return cls._HANDLERS[action_id]
@classmethod
def get_handlers(cls):
"""
Clear all registered handlers.
"""
return cls._HANDLERS.values()
@classmethod
def clear(cls):
"""
Clear all registered handlers.
"""
cls._HANDLERS = {}
def __init__(self, action):
"""
Create a new Handler.
:param func action: Callback that is called when invoking the Handler
:param func args_source: Optional callback that generates args for the action
"""
self._action_id = _short_action_id(action)
self._action = action
self._args = []
self._predicates = []
def id(self):
return self._action_id
def add_args(self, args):
"""
Add arguments to be passed to the action when invoked.
:param args: Any sequence or iterable, which will be lazily evaluated
to provide args. Subsequent calls to :meth:`add_args` can be used
to add additional arguments.
"""
self._args.append(args)
def add_predicate(self, predicate):
"""
Add a new predicate callback to this handler.
"""
_predicate = predicate
if isinstance(predicate, partial):
_predicate = 'partial(%s, %s, %s)' % (predicate.func, predicate.args, predicate.keywords)
if LOG_OPTS['register']:
hookenv.log(' Adding predicate for %s: %s' % (self.id(), _predicate), level=hookenv.DEBUG)
self._predicates.append(predicate)
def test(self):
"""
Check the predicate(s) and return True if this handler should be invoked.
"""
return all(predicate() for predicate in self._predicates)
def _get_args(self):
"""
Lazily evaluate the args.
"""
return list(chain.from_iterable(self._args))
def invoke(self):
"""
Invoke this handler.
"""
args = self._get_args()
self._action(*args)
class ExternalHandler(Handler):
"""
A variant Handler for external executable actions (such as bash scripts).
External handlers must adhere to the following protocol:
* The handler can be any executable
* When invoked with the ``--test`` command-line flag, it should exit with
an exit code of zero to indicate that the handler should be invoked, and
a non-zero exit code to indicate that it need not be invoked. It can
also provide a line of output to be passed to the ``--invoke`` call, e.g.,
to indicate which sub-handlers should be invoked. The handler should
**not** perform its action when given this flag.
* When invoked with the ``--invoke`` command-line flag (which will be
followed by any output returned by the ``--test`` call), the handler
should perform its action(s).
"""
@classmethod
def register(cls, filepath):
if filepath not in Handler._HANDLERS:
_filepath = os.path.relpath(filepath, hookenv.charm_dir())
if LOG_OPTS['register']:
hookenv.log('Registering external reactive handler for %s' % _filepath, level=hookenv.DEBUG)
Handler._HANDLERS[filepath] = cls(filepath)
return Handler._HANDLERS[filepath]
def __init__(self, filepath):
self._filepath = filepath
self._test_output = ''
def id(self):
_filepath = os.path.relpath(self._filepath, hookenv.charm_dir())
return '%s "%s"' % (_filepath, self._test_output)
def test(self):
"""
Call the external handler to test whether it should be invoked.
"""
# flush to ensure external process can see states as they currently
# are, and write states (flush releases lock)
unitdata.kv().flush()
proc = subprocess.Popen([self._filepath, '--test'], stdout=subprocess.PIPE, env=os.environ)
self._test_output, _ = proc.communicate()
return proc.returncode == 0
def invoke(self):
"""
Call the external handler to be invoked.
"""
# flush to ensure external process can see states as they currently
# are, and write states (flush releases lock)
unitdata.kv().flush()
subprocess.check_call([self._filepath, '--invoke', self._test_output], env=os.environ)
def dispatch():
"""
Dispatch registered handlers.
Handlers are dispatched according to the following rules:
* Handlers are repeatedly tested and invoked in iterations, until the system
settles into quiescence (that is, until no new handlers match to be invoked).
* In the first iteration, :func:`@hook <charmhelpers.core.reactive.decorators.hook>`
and :func:`@action <charmhelpers.core.reactive.decorators.action>` handlers will
be invoked, if they match.
* In subsequent iterations, other handlers are invoked, if they match.
* Added states will not trigger new handlers until the next iteration,
to ensure that chained states are invoked in a predictable order.
* Removed states will cause the current set of matched handlers to be
re-tested, to ensure that no handler is invoked after its matching
state has been removed.
* Other than the guarantees mentioned above, the order in which matching
handlers are invoked is undefined.
* States are preserved between hook and action invocations, and all matching
handlers are re-invoked for every hook and action. There are
:doc:`decorators <charmhelpers.core.reactive.decorators>` and
:doc:`helpers <charmhelpers.core.reactive.helpers>`
to prevent unnecessary reinvocations, such as
:func:`~charmhelpers.core.reactive.decorators.only_once`.
"""
StateWatch.reset()
def _test(to_test):
return list(filter(lambda h: h.test(), to_test))
def _invoke(to_invoke):
while to_invoke:
unitdata.kv().set('reactive.dispatch.removed_state', False)
for handler in list(to_invoke):
to_invoke.remove(handler)
hookenv.log('Invoking reactive handler: %s' % handler.id(), level=hookenv.INFO)
handler.invoke()
if unitdata.kv().get('reactive.dispatch.removed_state'):
# re-test remaining handlers
to_invoke = _test(to_invoke)
break
StateWatch.commit()
unitdata.kv().set('reactive.dispatch.phase', 'hooks')
hook_handlers = _test(Handler.get_handlers())
_invoke(hook_handlers)
unitdata.kv().set('reactive.dispatch.phase', 'other')
for i in range(100):
StateWatch.iteration(i)
other_handlers = _test(Handler.get_handlers())
if not other_handlers:
break
_invoke(other_handlers)
StateWatch.reset()
def discover():
"""
Discover handlers based on convention.
Handlers will be loaded from the following directories and their subdirectories:
* ``$CHARM_DIR/reactive/``
* ``$CHARM_DIR/hooks/reactive/``
* ``$CHARM_DIR/hooks/relations/``
They can be Python files, in which case they will be imported and decorated
functions registered. Or they can be executables, in which case they must
adhere to the :class:`ExternalHandler` protocol.
"""
for search_dir in ('reactive', 'hooks/reactive', 'hooks/relations'):
search_path = os.path.join(hookenv.charm_dir(), search_dir)
for dirpath, dirnames, filenames in os.walk(search_path):
for filename in filenames:
filepath = os.path.join(dirpath, filename)
_register_handlers_from_file(filepath)
def _load_module(filepath):
realpath = os.path.realpath(filepath)
for module in sys.modules.values():
if not hasattr(module, '__file__'):
continue # ignore builtins
modpath = os.path.realpath(re.sub(r'\.pyc$', '.py', module.__file__))
if realpath == modpath:
return module
else:
modname = realpath.replace('.', '_').replace(os.sep, '_')
sys.modules[modname] = load_source(modname, realpath)
return sys.modules[modname]
def _register_handlers_from_file(filepath):
if filepath.endswith('.py'):
_load_module(filepath)
elif os.access(filepath, os.X_OK):
ExternalHandler.register(filepath)