diff options
Diffstat (limited to 'src/pybind/mgr/dashboard/plugins')
-rw-r--r-- | src/pybind/mgr/dashboard/plugins/__init__.py | 74 | ||||
-rw-r--r-- | src/pybind/mgr/dashboard/plugins/debug.py | 91 | ||||
-rw-r--r-- | src/pybind/mgr/dashboard/plugins/feature_toggles.py | 140 | ||||
-rw-r--r-- | src/pybind/mgr/dashboard/plugins/interfaces.py | 82 | ||||
-rw-r--r-- | src/pybind/mgr/dashboard/plugins/lru_cache.py | 47 | ||||
-rw-r--r-- | src/pybind/mgr/dashboard/plugins/pluggy.py | 111 | ||||
-rw-r--r-- | src/pybind/mgr/dashboard/plugins/plugin.py | 33 | ||||
-rw-r--r-- | src/pybind/mgr/dashboard/plugins/ttl_cache.py | 55 |
8 files changed, 633 insertions, 0 deletions
diff --git a/src/pybind/mgr/dashboard/plugins/__init__.py b/src/pybind/mgr/dashboard/plugins/__init__.py new file mode 100644 index 00000000..43d71fde --- /dev/null +++ b/src/pybind/mgr/dashboard/plugins/__init__.py @@ -0,0 +1,74 @@ +# -*- 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 Mixin(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 + + @staticmethod + def final(func): + setattr(func, '__final__', True) + return func + + 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 hasattr(getattr(interface, method_name), '__final__'): + continue + + 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/debug.py b/src/pybind/mgr/dashboard/plugins/debug.py new file mode 100644 index 00000000..5f4cdbea --- /dev/null +++ b/src/pybind/mgr/dashboard/plugins/debug.py @@ -0,0 +1,91 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import + +from enum import Enum +import json + +from . import PLUGIN_MANAGER as PM +from . import interfaces as I # noqa: E741,N812 +from .plugin import SimplePlugin as SP + + +class Actions(Enum): + ENABLE = 'enable' + DISABLE = 'disable' + STATUS = 'status' + + +@PM.add_plugin # pylint: disable=too-many-ancestors +class Debug(SP, I.CanCherrypy, I.ConfiguresCherryPy, # pylint: disable=too-many-ancestors + I.Setupable, I.ConfigNotify): + NAME = 'debug' + + OPTIONS = [ + SP.Option( + name=NAME, + default=False, + type='bool', + desc="Enable/disable debug options" + ) + ] + + def _refresh_health_checks(self): + debug = self.get_option(self.NAME) + if debug: + self.mgr.health_checks.update({'DASHBOARD_DEBUG': { + 'severity': 'warning', + 'summary': 'Dashboard debug mode is enabled', + 'detail': [ + 'Please disable debug mode in production environments using ' + '"ceph dashboard {} {}"'.format(self.NAME, Actions.DISABLE.value) + ] + }}) + else: + self.mgr.health_checks.pop('DASHBOARD_DEBUG', None) + self.mgr.refresh_health_checks() + + @PM.add_hook + def setup(self): + self._refresh_health_checks() + + def handler(self, action): + ret = 0 + msg = '' + if action in [Actions.ENABLE.value, Actions.DISABLE.value]: + self.set_option(self.NAME, action == Actions.ENABLE.value) + self.mgr.update_cherrypy_config({}) + self._refresh_health_checks() + else: + debug = self.get_option(self.NAME) + msg = "Debug: '{}'".format('enabled' if debug else 'disabled') + return ret, msg, None + + COMMANDS = [ + SP.Command( + prefix="dashboard {name}".format(name=NAME), + args="name=action,type=CephChoices,strings={states}".format( + states="|".join(a.value for a in Actions)), + desc="Control and report debug status in Ceph-Dashboard", + handler=handler + ) + ] + + def custom_error_response(self, status, message, traceback, version): + self.response.headers['Content-Type'] = 'application/json' + error_response = dict(status=status, detail=message, request_id=str(self.request.unique_id)) + + if self.get_option(self.NAME): + error_response.update(dict(traceback=traceback, version=version)) + + return json.dumps(error_response) + + @PM.add_hook + def configure_cherrypy(self, config): + config.update({ + 'environment': 'test_suite' if self.get_option(self.NAME) else 'production', + 'error_page.default': self.custom_error_response, + }) + + @PM.add_hook + def config_notify(self): + self._refresh_health_checks() diff --git a/src/pybind/mgr/dashboard/plugins/feature_toggles.py b/src/pybind/mgr/dashboard/plugins/feature_toggles.py new file mode 100644 index 00000000..7edbfa8f --- /dev/null +++ b/src/pybind/mgr/dashboard/plugins/feature_toggles.py @@ -0,0 +1,140 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import + +from enum import Enum +import cherrypy +from mgr_module import CLICommand, Option + +from . import PLUGIN_MANAGER as PM +from . import interfaces as I +from .ttl_cache import ttl_cache + +from ..controllers.rbd import Rbd, RbdSnapshot, RbdTrash +from ..controllers.rbd_mirroring import ( + RbdMirroringSummary, RbdMirroringPoolMode, RbdMirroringPoolPeer) +from ..controllers.iscsi import Iscsi, IscsiTarget +from ..controllers.cephfs import CephFS +from ..controllers.rgw import Rgw, RgwDaemon, RgwBucket, RgwUser + + +class Features(Enum): + RBD = 'rbd' + MIRRORING = 'mirroring' + ISCSI = 'iscsi' + CEPHFS = 'cephfs' + RGW = 'rgw' + + +PREDISABLED_FEATURES = set() + + +Feature2Controller = { + Features.RBD: [Rbd, RbdSnapshot, RbdTrash], + Features.MIRRORING: [ + RbdMirroringSummary, RbdMirroringPoolMode, RbdMirroringPoolPeer], + Features.ISCSI: [Iscsi, IscsiTarget], + Features.CEPHFS: [CephFS], + Features.RGW: [Rgw, RgwDaemon, RgwBucket, RgwUser], +} + + +class Actions(Enum): + ENABLE = 'enable' + DISABLE = 'disable' + STATUS = 'status' + + +@PM.add_plugin +class FeatureToggles(I.CanMgr, I.CanLog, I.Setupable, I.HasOptions, + I.HasCommands, I.FilterRequest.BeforeHandler, + I.HasControllers): + OPTION_FMT = 'FEATURE_TOGGLE_{}' + CACHE_MAX_SIZE = 128 # Optimum performance with 2^N sizes + CACHE_TTL = 10 # seconds + + @PM.add_hook + def setup(self): + self.Controller2Feature = { + controller: feature + for feature, controllers in Feature2Controller.items() + for controller in controllers} + + @PM.add_hook + def get_options(self): + return [Option( + name=self.OPTION_FMT.format(feature.value), + default=(feature not in PREDISABLED_FEATURES), + type='bool',) for feature in Features] + + @PM.add_hook + def register_commands(self): + @CLICommand( + "dashboard feature", + "name=action,type=CephChoices,strings={} ".format( + "|".join(a.value for a in Actions)) + + "name=features,type=CephChoices,strings={},req=false,n=N".format( + "|".join(f.value for f in Features)), + "Enable or disable features in Ceph-Mgr Dashboard") + def cmd(mgr, action, features=None): + ret = 0 + msg = [] + if action in [Actions.ENABLE.value, Actions.DISABLE.value]: + if features is None: + ret = 1 + msg = ["At least one feature must be specified"] + else: + for feature in features: + mgr.set_module_option( + self.OPTION_FMT.format(feature), + action == Actions.ENABLE.value) + msg += ["Feature '{}': {}".format( + feature, + 'enabled' if action == Actions.ENABLE.value else + 'disabled')] + else: + for feature in features or [f.value for f in Features]: + enabled = mgr.get_module_option(self.OPTION_FMT.format(feature)) + msg += ["Feature '{}': '{}'".format( + feature, + 'enabled' if enabled else 'disabled')] + return ret, '\n'.join(msg), '' + return {'handle_command': cmd} + + def _get_feature_from_request(self, request): + try: + return self.Controller2Feature[ + request.handler.callable.__self__] + except (AttributeError, KeyError): + return None + + @ttl_cache(ttl=CACHE_TTL, maxsize=CACHE_MAX_SIZE) + def _is_feature_enabled(self, feature): + return self.mgr.get_module_option(self.OPTION_FMT.format(feature.value)) + + @PM.add_hook + def filter_request_before_handler(self, request): + feature = self._get_feature_from_request(request) + if feature is None: + return + + if not self._is_feature_enabled(feature): + raise cherrypy.HTTPError( + 404, "Feature='{}' disabled by option '{}'".format( + feature.value, + self.OPTION_FMT.format(feature.value), + ) + ) + + @PM.add_hook + def get_controllers(self): + from ..controllers import ApiController, RESTController + + @ApiController('/feature_toggles') + class FeatureTogglesEndpoint(RESTController): + + def list(_): + return { + feature.value: self._is_feature_enabled(feature) + for feature in Features + } + return [FeatureTogglesEndpoint] diff --git a/src/pybind/mgr/dashboard/plugins/interfaces.py b/src/pybind/mgr/dashboard/plugins/interfaces.py new file mode 100644 index 00000000..dbcd60a0 --- /dev/null +++ b/src/pybind/mgr/dashboard/plugins/interfaces.py @@ -0,0 +1,82 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import + +from . import PLUGIN_MANAGER as PM, Interface, Mixin # pylint: disable=cyclic-import + + +class CanMgr(Mixin): + from .. import mgr + mgr = mgr + + +class CanLog(Mixin): + from .. import logger + log = logger + + +class CanCherrypy(Mixin): + import cherrypy + request = cherrypy.request + response = cherrypy.response + + +@PM.add_interface +class Initializable(Interface): + @PM.add_abcspec + def init(self): + """ + Placeholder for module scope initialization + """ + + +@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 HasControllers(Interface): + @PM.add_abcspec + def get_controllers(self): pass + + +@PM.add_interface +class ConfiguresCherryPy(Interface): + @PM.add_abcspec + def configure_cherrypy(self, config): + pass + + +class FilterRequest(object): + @PM.add_interface + class BeforeHandler(Interface): + @PM.add_abcspec + def filter_request_before_handler(self, request): pass + + +@PM.add_interface +class ConfigNotify(Interface): + @PM.add_abcspec + def config_notify(self): + """ + This method is called whenever a option of this mgr module has + been modified. + """ diff --git a/src/pybind/mgr/dashboard/plugins/lru_cache.py b/src/pybind/mgr/dashboard/plugins/lru_cache.py new file mode 100644 index 00000000..19ad1b85 --- /dev/null +++ b/src/pybind/mgr/dashboard/plugins/lru_cache.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +""" +This is a minimal implementation of lru_cache function. + +Based on Python 3 functools and backports.functools_lru_cache. +""" +from __future__ import absolute_import + +from functools import wraps +from collections import OrderedDict +from threading import RLock + + +def lru_cache(maxsize=128, typed=False): + if typed is not False: + raise NotImplementedError("typed caching not supported") + + def decorating_function(function): + cache = OrderedDict() + stats = [0, 0] + rlock = RLock() + setattr( + function, + 'cache_info', + lambda: + "hits={}, misses={}, maxsize={}, currsize={}".format( + stats[0], stats[1], maxsize, len(cache))) + + @wraps(function) + def wrapper(*args, **kwargs): + key = args + tuple(kwargs.items()) + with rlock: + if key in cache: + ret = cache[key] + del cache[key] + cache[key] = ret + stats[0] += 1 + else: + ret = function(*args, **kwargs) + if len(cache) == maxsize: + cache.popitem(last=False) + cache[key] = ret + stats[1] += 1 + return ret + + return wrapper + return decorating_function diff --git a/src/pybind/mgr/dashboard/plugins/pluggy.py b/src/pybind/mgr/dashboard/plugins/pluggy.py new file mode 100644 index 00000000..7517e6d6 --- /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.<method_name>() 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)) diff --git a/src/pybind/mgr/dashboard/plugins/plugin.py b/src/pybind/mgr/dashboard/plugins/plugin.py new file mode 100644 index 00000000..0f52aefb --- /dev/null +++ b/src/pybind/mgr/dashboard/plugins/plugin.py @@ -0,0 +1,33 @@ +from mgr_module import Option, Command + +from . import PLUGIN_MANAGER as PM +from . import interfaces as I # noqa: E741,N812 + + +class SimplePlugin(I.CanMgr, I.CanLog, I.HasOptions, I.HasCommands): + """ + Helper class that provides simplified creation of plugins: + - Default Mixins/Interfaces: CanMgr, CanLog, HasOptions & HasCommands + - Options are defined by OPTIONS class variable, instead from get_options hook + - Commands are created with by COMMANDS list of Commands() and handlers + (less compact than CLICommand, but allows using method instances) + """ + Option = Option + Command = Command + + @PM.add_hook + def get_options(self): + return self.OPTIONS + + @PM.final + def get_option(self, option): + return self.mgr.get_module_option(option) + + @PM.final + def set_option(self, option, value): + self.mgr.set_module_option(option, value) + + @PM.add_hook + def register_commands(self): + for cmd in self.COMMANDS: + cmd.register(instance=self) diff --git a/src/pybind/mgr/dashboard/plugins/ttl_cache.py b/src/pybind/mgr/dashboard/plugins/ttl_cache.py new file mode 100644 index 00000000..a0698542 --- /dev/null +++ b/src/pybind/mgr/dashboard/plugins/ttl_cache.py @@ -0,0 +1,55 @@ +""" +This is a minimal implementation of TTL-ed lru_cache function. + +Based on Python 3 functools and backports.functools_lru_cache. +""" +from __future__ import absolute_import + +from functools import wraps +from collections import OrderedDict +from threading import RLock +from time import time + + +def ttl_cache(ttl, maxsize=128, typed=False): + if typed is not False: + raise NotImplementedError("typed caching not supported") + + def decorating_function(function): + cache = OrderedDict() + stats = [0, 0, 0] + rlock = RLock() + setattr( + function, + 'cache_info', + lambda: + "hits={}, misses={}, expired={}, maxsize={}, currsize={}".format( + stats[0], stats[1], stats[2], maxsize, len(cache))) + + @wraps(function) + def wrapper(*args, **kwargs): + key = args + tuple(kwargs.items()) + with rlock: + refresh = True + if key in cache: + (ret, ts) = cache[key] + del cache[key] + if time() - ts < ttl: + refresh = False + stats[0] += 1 + else: + stats[2] += 1 + + if refresh: + ret = function(*args, **kwargs) + ts = time() + if len(cache) == maxsize: + cache.popitem(last=False) + stats[1] += 1 + + cache[key] = (ret, ts) + + return ret + + return wrapper + return decorating_function |