diff --git a/designate/hookpoints.py b/designate/hookpoints.py new file mode 100644 index 00000000..a6e8c8a6 --- /dev/null +++ b/designate/hookpoints.py @@ -0,0 +1,121 @@ +# Copyright 2015 Rackspace Hosting. +# +# Author: Eric Larson +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +import functools + +from oslo_config import cfg +from oslo_log import log as logging +from stevedore import hook + + +LOG = logging.getLogger(__name__) + + +class BaseHook(object): + + OPTS = [ + cfg.BoolOpt('disabled', default=False) + ] + + def __init__(self, group): + self.group = group + + @property + def disabled(self): + return cfg.CONF[self.group].get('disabled', False) + + def wrapper(self, *args, **kw): + return self.hook_target(*args, **kw) + + def __call__(self, f): + # Save our hook target as an attribute for our wrapper method + self.hook_target = f + + @functools.wraps(self.hook_target) + def wrapper(*args, **kw): + if self.disabled: + return self.hook_target(*args, **kw) + return self.hook(*args, **kw) + return wrapper + + +class hook_point(object): + NAMESPACE = 'designate.hook_point' + + def __init__(self, name=None): + self._name = name + + def update_config_opts(self, group, hooks): + hooks_found = False + for hook_impl in hooks: + hooks_found = True + + # Add any options defined by the hook + if hasattr(hook_impl.plugin, 'OPTS'): + cfg.CONF.register_opts(hook_impl.plugin.OPTS, group=group) + + if not hooks_found: + LOG.debug('No hooks found for %s', group) + else: + LOG.debug('Created hook: %s', group) + + def hook_manager(self, name): + LOG.debug('Looking for hooks with: %s %s', self.NAMESPACE, name) + return hook.HookManager(self.NAMESPACE, name) + + def find_name(self, func=None): + """Derive the hook target path from the function name, unless + a name has been passed in with the constuctor. + """ + if self._name: + return self._name + + if not func: + return None + + # derive the name from the function + self._name = '%s.%s' % (func.__module__, func.__name__) + return self._name + + def init_hook(self, f): + """Set up our hook + + Try to inspect the function for a hook target path if one + wasn't passed in and set up the necessary config options. + """ + self.name = self.find_name(f) + self.group = 'hook_point:%s' % self.name + self.hooks = self.hook_manager(self.name) + self.update_config_opts(self.group, self.hooks) + + def enable_hook(self, ext, f): + """Enable the hook. + + This instantiates the hook object and decorates the original + function. + """ + decorator = ext.plugin(self.group) + f = decorator(f) + f._hook_point = self # add ourselves for inspection + return f + + def __call__(self, f): + # Set up all our hook information based on the function or + # hook point + self.init_hook(f) + + for h in self.hooks: + f = self.enable_hook(h, f) + return f diff --git a/designate/pool_manager/service.py b/designate/pool_manager/service.py index 25b14a82..29c10e6c 100644 --- a/designate/pool_manager/service.py +++ b/designate/pool_manager/service.py @@ -229,6 +229,7 @@ class Service(service.RPCService, service.Service): 'synchronization occurred.')) # Standard Create/Update/Delete Methods + def create_domain(self, context, domain): """ :param context: Security context information. diff --git a/designate/tests/test_hookpoints.py b/designate/tests/test_hookpoints.py new file mode 100644 index 00000000..9158e6ac --- /dev/null +++ b/designate/tests/test_hookpoints.py @@ -0,0 +1,144 @@ +# Copyright 2015 Rackspace Hosting. +# +# Author: Eric Larson +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +from mock import Mock +from mock import patch +from oslo_config import cfg +from stevedore.hook import HookManager +from stevedore.extension import Extension + +from designate.hookpoints import hook_point +from designate.hookpoints import BaseHook +from designate.tests import TestCase + + +class AddHook(BaseHook): + + OPTS = [ + cfg.Opt('bar'), + ] + + @property + def bar(self): + return cfg.CONF[self.group].bar + + def hook(self, *args, **kw): + return self.hook_target(*args, **kw) + 1 + + +def get_hook_manager(*hooks): + hooks = hooks or [AddHook] + + group = 'hook_point:foo' + ext = [ + Extension('designate_hook', 'foo', hook, hook(group)) + for hook in hooks + ] + return HookManager.make_test_instance(ext, 'designate_hook') + + +def inc(num): + return num + 1 + + +class TestHookpoints(TestCase): + + def setUp(self): + TestCase.setUp(self) + group = 'hook_point:foo' + self.CONF.register_group(cfg.OptGroup(group)) + self.CONF.register_opts(BaseHook.OPTS, group=group) + + def test_no_hookpoint_is_noop(self): + + def doit(self, name): + return 'done: %s' % name + + self.assertEqual(doit, hook_point('foo')(doit)) + + def test_hook_is_decorator(self): + hp = hook_point('foo') + hp.hook_manager = Mock(return_value=get_hook_manager()) + assert hp(inc)(1) == 3 + + def test_apply_N_hooks(self): + hp = hook_point('foo') + hp.hook_manager = Mock(return_value=get_hook_manager(AddHook, AddHook)) + assert hp(inc)(1) == 4 + + def test_hook_init(self): + hp = hook_point('foo') + + # Make sure we set up our object when the hook point is + # applied to a function / method. + hp.find_name = Mock(return_value='foo.bar.baz') + hp.hook_manager = Mock(return_value=get_hook_manager()) + hp.find_config = Mock(return_value={'enabled': True}) + hp.update_config_opts = Mock() + + hp(inc) + self.assertEqual(hp.name, 'foo.bar.baz') + self.assertEqual(hp.group, 'hook_point:foo.bar.baz') + hp.update_config_opts.assert_called_with(hp.group, hp.hooks) + + +class TestHookpointsConfigOpts(TestCase): + """Make sure hooks add the necessary config opts. + """ + + def test_hook_adds_config_opts(self): + hp = hook_point('foo') + hp.hook_manager = Mock(return_value=get_hook_manager()) + hp(inc) + assert hp.group in self.CONF.keys() + + +class TestHookpointsEnabling(TestCase): + + def setUp(self): + TestCase.setUp(self) + + # NOTE: The options need to be added here via the test classes + # CONF in order to fall through + group = 'hook_point:foo' + self.CONF.register_group(cfg.OptGroup(group)) + self.CONF.register_opts(BaseHook.OPTS, group=group) + + @patch.object(hook_point, 'hook_manager', + Mock(return_value=get_hook_manager())) + def test_hook_disabled(self): + hp = hook_point('foo') + result_func = hp(inc) + + # We should now have a config option we can set to disabled + self.config(disabled=True, group='hook_point:foo') + + # The result is 2 so no extra add hook was applied + self.assertEqual(result_func(1), 2) + + @patch.object(hook_point, 'hook_manager', + Mock(return_value=get_hook_manager())) + def test_hook_enabled_when_config_key_exists(self): + hp = hook_point('foo') + hp(inc) + + # Add our config + self.config(bar='from config', group='hook_point:foo') + + # reapply our hook + result_func = hp(inc) + + # The result is 3 so the extra add hook was applied + self.assertEqual(result_func(1), 3) diff --git a/etc/designate/designate.conf.sample b/etc/designate/designate.conf.sample index 0fe35d7f..8add2b25 100644 --- a/etc/designate/designate.conf.sample +++ b/etc/designate/designate.conf.sample @@ -373,3 +373,24 @@ debug = False # Path for Oslo Concurrency to store lock files, defaults to the value # of the state_path setting. #lock_path = $state_path + + +######################## +## Hook Points +######################## +# Hook Points are enabled when added to the config and there has been +# a package that provides the corresponding named designate.hook_point +# entry point. + +# [hook_point:name_of_hook_point] +# some_param_for_hook = 42 +# Hooks can be disabled in the config +# enabled = False + +# Hook can also be applied to the import path when the hook has not +# been given an explicit name. The name is created from the hook +# target function / method: +# +# name = '%s.%s' % (func.__module__, func.__name__) + +# [hook_point:designate.api.v2.controllers.zones.get_one] diff --git a/setup.cfg b/setup.cfg index a136adf9..552f0cb2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -109,7 +109,6 @@ designate.manage = powerdns = designate.manage.powerdns:DatabaseCommands tlds = designate.manage.tlds:TLDCommands - [build_sphinx] all_files = 1 build-dir = doc/build