From: Ernesto Puerta Date: Fri, 25 Jan 2019 20:17:10 +0000 (+0100) Subject: mgr/dashboard: feature-toggles: add plugin support X-Git-Tag: v14.1.0~165^2~9 X-Git-Url: http://git-server-git.apps.pok.os.sepia.ceph.com/?a=commitdiff_plain;h=356a067300fafcf77add85d3151877bb5af09308;p=ceph.git mgr/dashboard: feature-toggles: add plugin support Provide plugin infrastructure and a minimal set of hooks. As python-pluggy library is not yet available for all the distros that Ceph is targeted at, a minimal implementation has been provided. Fixes: http://tracker.ceph.com/issues/37530 Signed-off-by: Ernesto Puerta --- diff --git a/src/pybind/mgr/dashboard/module.py b/src/pybind/mgr/dashboard/module.py index 78b7edeabbcb..2cde09ff4ba8 100644 --- a/src/pybind/mgr/dashboard/module.py +++ b/src/pybind/mgr/dashboard/module.py @@ -66,6 +66,8 @@ from .services.exception import dashboard_exception_handler from .settings import options_command_list, options_schema_list, \ handle_option_command +from .plugins import PLUGIN_MANAGER + # cherrypy likes to sys.exit on error. don't let it take us down too! # pylint: disable=W0613 @@ -123,6 +125,10 @@ class CherryPyConfig(object): # Initialize custom handlers. cherrypy.tools.authenticate = AuthManagerTool() + cherrypy.tools.plugin_hooks = cherrypy.Tool( + 'before_handler', + lambda: PLUGIN_MANAGER.hook.filter_request_before_handler(request=cherrypy.request), + priority=10) cherrypy.tools.request_logging = RequestLoggingTool() cherrypy.tools.dashboard_exception_handler = HandlerWrapperTool(dashboard_exception_handler, priority=31) @@ -143,7 +149,8 @@ class CherryPyConfig(object): 'application/javascript', ], 'tools.json_in.on': True, - 'tools.json_in.force': False + 'tools.json_in.force': False, + 'tools.plugin_hooks.on': True, } if ssl: @@ -237,6 +244,7 @@ class Module(MgrModule, CherryPyConfig): ] COMMANDS.extend(options_command_list()) COMMANDS.extend(SSO_COMMANDS) + PLUGIN_MANAGER.hook.register_commands() MODULE_OPTIONS = [ {'name': 'server_addr'}, @@ -250,6 +258,8 @@ class Module(MgrModule, CherryPyConfig): {'name': 'ssl'} ] MODULE_OPTIONS.extend(options_schema_list()) + for options in PLUGIN_MANAGER.hook.get_options() or []: + MODULE_OPTIONS.extend(options) __pool_stats = collections.defaultdict(lambda: collections.defaultdict( lambda: collections.deque(maxlen=10))) @@ -312,6 +322,8 @@ class Module(MgrModule, CherryPyConfig): } cherrypy.tree.mount(None, config=config) + PLUGIN_MANAGER.hook.setup() + cherrypy.engine.start() NotificationQueue.start_queue() TaskManager.init() diff --git a/src/pybind/mgr/dashboard/plugins/__init__.py b/src/pybind/mgr/dashboard/plugins/__init__.py new file mode 100644 index 000000000000..d3c54ce6c2b1 --- /dev/null +++ b/src/pybind/mgr/dashboard/plugins/__init__.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import + +import abc +import six + +from .pluggy import HookspecMarker, HookimplMarker, PluginManager + + +@six.add_metaclass(abc.ABCMeta) +class Interface(object): + pass + + +class DashboardPluginManager(object): + def __init__(self, project_name): + self.__pm = PluginManager(project_name) + self.__add_spec = HookspecMarker(project_name) + self.__add_abcspec = lambda *args, **kwargs: abc.abstractmethod( + self.__add_spec(*args, **kwargs)) + self.__add_hook = HookimplMarker(project_name) + + pm = property(lambda self: self.__pm) + hook = property(lambda self: self.pm.hook) + + add_spec = property(lambda self: self.__add_spec) + add_abcspec = property(lambda self: self.__add_abcspec) + add_hook = property(lambda self: self.__add_hook) + + def add_interface(self, cls): + assert issubclass(cls, Interface) + self.pm.add_hookspecs(cls) + return cls + + def add_plugin(self, plugin): + """ Provides decorator interface for PluginManager.register(): + @PLUGIN_MANAGER.add_plugin + class Plugin(...): + ... + Additionally it checks whether the Plugin instance has all Interface + methods implemented and marked with add_hook decorator. + As a con of this approach, plugins cannot call super() from __init__() + """ + assert issubclass(plugin, Interface) + from inspect import getmembers, ismethod + for interface in plugin.__bases__: + for method_name, _ in getmembers(interface, predicate=ismethod): + if self.pm.parse_hookimpl_opts(plugin, method_name) is None: + raise NotImplementedError( + "Plugin '{}' implements interface '{}' but existing" + " method '{}' is not declared added as hook".format( + plugin.__name__, + interface.__name__, + method_name)) + self.pm.register(plugin()) + return plugin + + +PLUGIN_MANAGER = DashboardPluginManager("ceph-mgr.dashboard") + +# Load all interfaces and their hooks +from . import interfaces diff --git a/src/pybind/mgr/dashboard/plugins/interfaces.py b/src/pybind/mgr/dashboard/plugins/interfaces.py new file mode 100644 index 000000000000..56cb1d9c0453 --- /dev/null +++ b/src/pybind/mgr/dashboard/plugins/interfaces.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import + +from . import PLUGIN_MANAGER as PM, Interface + + +class CanMgr(Interface): + from .. import mgr + mgr = mgr + + +class CanLog(Interface): + from .. import logger + log = logger + + +@PM.add_interface +class Setupable(Interface): + @PM.add_abcspec + def setup(self): + """ + Placeholder for plugin setup, right after server start. + CanMgr.mgr and CanLog.log are initialized by then. + """ + pass + + +@PM.add_interface +class HasOptions(Interface): + @PM.add_abcspec + def get_options(self): pass + + +@PM.add_interface +class HasCommands(Interface): + @PM.add_abcspec + def register_commands(self): pass + + +@PM.add_interface +class HasEndpoints(Interface): + @PM.add_abcspec + def register_endpoints(self): pass + + +class FilterRequest: + @PM.add_interface + class BeforeHandler(Interface): + @PM.add_abcspec + def filter_request_before_handler(self, request): pass diff --git a/src/pybind/mgr/dashboard/plugins/pluggy.py b/src/pybind/mgr/dashboard/plugins/pluggy.py new file mode 100644 index 000000000000..7517e6d65829 --- /dev/null +++ b/src/pybind/mgr/dashboard/plugins/pluggy.py @@ -0,0 +1,111 @@ +# -*- coding: utf-8 -*- +""" +The MIT License (MIT) + +Copyright (c) 2015 holger krekel (rather uses bitbucket/hpk42) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + +""" +CAVEAT: +This is a minimal implementation of python-pluggy (based on 0.8.0 interface: +https://github.com/pytest-dev/pluggy/releases/tag/0.8.0). + + +Despite being a widely available Python library, it does not reach all the +distros and releases currently targeted for Ceph Nautilus: +- CentOS/RHEL 7.5 [ ] +- CentOS/RHEL 8 [ ] +- Debian 8.0 [ ] +- Debian 9.0 [ ] +- Ubuntu 14.05 [ ] +- Ubuntu 16.04 [X] + +TODO: Once this becomes available in the above distros, this file should be +REMOVED, and the fully featured python-pluggy should be used instead. +""" + + +class HookspecMarker(object): + """ Dummy implementation. No spec validation. """ + def __init__(self, project_name): + self.project_name = project_name + + def __call__(self, function, *args, **kwargs): + """ No options supported. """ + if any(args) or any(kwargs): + raise NotImplementedError( + "This is a minimal implementation of pluggy") + return function + + +class HookimplMarker(object): + def __init__(self, project_name): + self.project_name = project_name + + def __call__(self, function, *args, **kwargs): + """ No options supported.""" + if any(args) or any(kwargs): + raise NotImplementedError( + "This is a minimal implementation of pluggy") + setattr(function, self.project_name + "_impl", {}) + return function + + +class _HookRelay(object): + """ + Provides the PluginManager.hook.() syntax and + functionality. + """ + def __init__(self): + from collections import defaultdict + self._registry = defaultdict(list) + + def __getattr__(self, hook_name): + return lambda *args, **kwargs: [ + hook(*args, **kwargs) for hook in self._registry[hook_name]] + + def _add_hookimpl(self, hook_name, hook_method): + self._registry[hook_name].append(hook_method) + + +class PluginManager(object): + def __init__(self, project_name): + self.project_name = project_name + self.__hook = _HookRelay() + + @property + def hook(self): + return self.__hook + + def parse_hookimpl_opts(self, plugin, name): + return getattr( + getattr(plugin, name), + self.project_name + "_impl", + None) + + def add_hookspecs(self, module_or_class): + """ Dummy method""" + pass + + def register(self, plugin, name=None): + for attr in dir(plugin): + if self.parse_hookimpl_opts(plugin, attr) is not None: + self.hook._add_hookimpl(attr, getattr(plugin, attr))