summaryrefslogtreecommitdiffstats
path: root/src/pybind/mgr/dashboard/plugins
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--src/pybind/mgr/dashboard/plugins/__init__.py72
-rw-r--r--src/pybind/mgr/dashboard/plugins/debug.py99
-rw-r--r--src/pybind/mgr/dashboard/plugins/feature_toggles.py163
-rw-r--r--src/pybind/mgr/dashboard/plugins/interfaces.py81
-rw-r--r--src/pybind/mgr/dashboard/plugins/lru_cache.py44
-rw-r--r--src/pybind/mgr/dashboard/plugins/motd.py98
-rw-r--r--src/pybind/mgr/dashboard/plugins/pluggy.py116
-rw-r--r--src/pybind/mgr/dashboard/plugins/plugin.py41
-rw-r--r--src/pybind/mgr/dashboard/plugins/ttl_cache.py57
9 files changed, 771 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 000000000..6cd03fa95
--- /dev/null
+++ b/src/pybind/mgr/dashboard/plugins/__init__.py
@@ -0,0 +1,72 @@
+# -*- coding: utf-8 -*-
+from __future__ import absolute_import
+
+import abc
+
+from .pluggy import HookimplMarker, HookspecMarker, PluginManager
+
+
+class Interface(object, metaclass=abc.ABCMeta):
+ 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 # noqa pylint: disable=C0413,W0406
diff --git a/src/pybind/mgr/dashboard/plugins/debug.py b/src/pybind/mgr/dashboard/plugins/debug.py
new file mode 100644
index 000000000..8eb2bb1e6
--- /dev/null
+++ b/src/pybind/mgr/dashboard/plugins/debug.py
@@ -0,0 +1,99 @@
+# -*- coding: utf-8 -*-
+
+from __future__ import absolute_import
+
+import json
+from enum import Enum
+
+from . import PLUGIN_MANAGER as PM
+from . import interfaces as I # noqa: E741,N812
+from .plugin import SimplePlugin as SP
+
+try:
+ from typing import no_type_check
+except ImportError:
+ no_type_check = object() # Just for type checking
+
+
+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"
+ )
+ ]
+
+ @no_type_check # https://github.com/python/mypy/issues/7806
+ 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()
+
+ @no_type_check
+ def handler(self, action: Actions):
+ '''
+ Control and report debug status in Ceph-Dashboard
+ '''
+ ret = 0
+ msg = ''
+ if action in [Actions.ENABLE, Actions.DISABLE]:
+ self.set_option(self.NAME, action == Actions.ENABLE)
+ 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),
+ 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 000000000..fc4619be3
--- /dev/null
+++ b/src/pybind/mgr/dashboard/plugins/feature_toggles.py
@@ -0,0 +1,163 @@
+# -*- coding: utf-8 -*-
+from __future__ import absolute_import
+
+from enum import Enum
+from typing import List, Optional
+
+import cherrypy
+from mgr_module import CLICommand, Option
+
+from ..controllers.cephfs import CephFS
+from ..controllers.iscsi import Iscsi, IscsiTarget
+from ..controllers.nfs import NFSGaneshaExports, NFSGaneshaUi
+from ..controllers.rbd import Rbd, RbdSnapshot, RbdTrash
+from ..controllers.rbd_mirroring import RbdMirroringPoolMode, \
+ RbdMirroringPoolPeer, RbdMirroringSummary
+from ..controllers.rgw import Rgw, RgwBucket, RgwDaemon, RgwUser
+from . import PLUGIN_MANAGER as PM
+from . import interfaces as I # noqa: E741,N812
+from .ttl_cache import ttl_cache
+
+try:
+ from typing import Set, no_type_check
+except ImportError:
+ no_type_check = object() # Just for type checking
+
+
+class Features(Enum):
+ RBD = 'rbd'
+ MIRRORING = 'mirroring'
+ ISCSI = 'iscsi'
+ CEPHFS = 'cephfs'
+ RGW = 'rgw'
+ NFS = 'nfs'
+
+
+PREDISABLED_FEATURES = set() # type: Set[str]
+
+Feature2Controller = {
+ Features.RBD: [Rbd, RbdSnapshot, RbdTrash],
+ Features.MIRRORING: [
+ RbdMirroringSummary, RbdMirroringPoolMode, RbdMirroringPoolPeer],
+ Features.ISCSI: [Iscsi, IscsiTarget],
+ Features.CEPHFS: [CephFS],
+ Features.RGW: [Rgw, RgwDaemon, RgwBucket, RgwUser],
+ Features.NFS: [NFSGaneshaUi, NFSGaneshaExports],
+}
+
+
+class Actions(Enum):
+ ENABLE = 'enable'
+ DISABLE = 'disable'
+ STATUS = 'status'
+
+
+# pylint: disable=too-many-ancestors
+@PM.add_plugin
+class FeatureToggles(I.CanMgr, I.Setupable, I.HasOptions,
+ I.HasCommands, I.FilterRequest.BeforeHandler,
+ I.HasControllers):
+ OPTION_FMT = 'FEATURE_TOGGLE_{.name}'
+ CACHE_MAX_SIZE = 128 # Optimum performance with 2^N sizes
+ CACHE_TTL = 10 # seconds
+
+ @PM.add_hook
+ def setup(self):
+ # pylint: disable=attribute-defined-outside-init
+ self.Controller2Feature = {
+ controller: feature
+ for feature, controllers in Feature2Controller.items()
+ for controller in controllers} # type: ignore
+
+ @PM.add_hook
+ def get_options(self):
+ return [Option(
+ name=self.OPTION_FMT.format(feature),
+ default=(feature not in PREDISABLED_FEATURES),
+ type='bool',) for feature in Features]
+
+ @PM.add_hook
+ def register_commands(self):
+ @CLICommand("dashboard feature")
+ def cmd(mgr,
+ action: Actions = Actions.STATUS,
+ features: Optional[List[Features]] = None):
+ '''
+ Enable or disable features in Ceph-Mgr Dashboard
+ '''
+ ret = 0
+ msg = []
+ if action in [Actions.ENABLE, Actions.DISABLE]:
+ 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)
+ msg += ["Feature '{.value}': {}".format(
+ feature,
+ 'enabled' if action == Actions.ENABLE else
+ 'disabled')]
+ else:
+ for feature in features or list(Features):
+ enabled = mgr.get_module_option(self.OPTION_FMT.format(feature))
+ msg += ["Feature '{.value}': {}".format(
+ feature,
+ 'enabled' if enabled else 'disabled')]
+ return ret, '\n'.join(msg), ''
+ return {'handle_command': cmd}
+
+ @no_type_check # https://github.com/python/mypy/issues/7806
+ 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)
+ @no_type_check # https://github.com/python/mypy/issues/7806
+ def _is_feature_enabled(self, feature):
+ return self.mgr.get_module_option(self.OPTION_FMT.format(feature))
+
+ @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),
+ )
+ )
+
+ @PM.add_hook
+ def get_controllers(self):
+ from ..controllers import APIDoc, APIRouter, EndpointDoc, RESTController
+
+ FEATURES_SCHEMA = {
+ "rbd": (bool, ''),
+ "mirroring": (bool, ''),
+ "iscsi": (bool, ''),
+ "cephfs": (bool, ''),
+ "rgw": (bool, ''),
+ "nfs": (bool, '')
+ }
+
+ @APIRouter('/feature_toggles')
+ @APIDoc("Manage Features API", "FeatureTogglesEndpoint")
+ class FeatureTogglesEndpoint(RESTController):
+ @EndpointDoc("Get List Of Features",
+ responses={200: FEATURES_SCHEMA})
+ def list(_): # pylint: disable=no-self-argument # noqa: N805
+ return {
+ # pylint: disable=protected-access
+ 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 000000000..9c050074f
--- /dev/null
+++ b/src/pybind/mgr/dashboard/plugins/interfaces.py
@@ -0,0 +1,81 @@
+# -*- coding: utf-8 -*-
+from __future__ import absolute_import
+
+from . import PLUGIN_MANAGER as PM # pylint: disable=cyclic-import
+from . import Interface, Mixin
+
+
+class CanMgr(Mixin):
+ from .. import mgr
+ mgr = mgr # type: ignore
+
+
+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 is initialized by then.
+ """
+
+
+@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 000000000..9b29a6012
--- /dev/null
+++ b/src/pybind/mgr/dashboard/plugins/lru_cache.py
@@ -0,0 +1,44 @@
+# -*- 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 collections import OrderedDict
+from functools import wraps
+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/motd.py b/src/pybind/mgr/dashboard/plugins/motd.py
new file mode 100644
index 000000000..22d6a294a
--- /dev/null
+++ b/src/pybind/mgr/dashboard/plugins/motd.py
@@ -0,0 +1,98 @@
+# -*- coding: utf-8 -*-
+
+import hashlib
+import json
+from enum import Enum
+from typing import Dict, NamedTuple, Optional
+
+from ceph.utils import datetime_now, datetime_to_str, parse_timedelta, str_to_datetime
+from mgr_module import CLICommand
+
+from . import PLUGIN_MANAGER as PM
+from .plugin import SimplePlugin as SP
+
+
+class MotdSeverity(Enum):
+ INFO = 'info'
+ WARNING = 'warning'
+ DANGER = 'danger'
+
+
+class MotdData(NamedTuple):
+ message: str
+ md5: str # The MD5 of the message.
+ severity: MotdSeverity
+ expires: str # The expiration date in ISO 8601. Does not expire if empty.
+
+
+@PM.add_plugin # pylint: disable=too-many-ancestors
+class Motd(SP):
+ NAME = 'motd'
+
+ OPTIONS = [
+ SP.Option(
+ name=NAME,
+ default='',
+ type='str',
+ desc='The message of the day'
+ )
+ ]
+
+ @PM.add_hook
+ def register_commands(self):
+ @CLICommand("dashboard {name} get".format(name=self.NAME))
+ def _get(_):
+ stdout: str
+ value: str = self.get_option(self.NAME)
+ if not value:
+ stdout = 'No message of the day has been set.'
+ else:
+ data = json.loads(value)
+ if not data['expires']:
+ data['expires'] = "Never"
+ stdout = 'Message="{message}", severity="{severity}", ' \
+ 'expires="{expires}"'.format(**data)
+ return 0, stdout, ''
+
+ @CLICommand("dashboard {name} set".format(name=self.NAME))
+ def _set(_, severity: MotdSeverity, expires: str, message: str):
+ if expires != '0':
+ delta = parse_timedelta(expires)
+ if not delta:
+ return 1, '', 'Invalid expires format, use "2h", "10d" or "30s"'
+ expires = datetime_to_str(datetime_now() + delta)
+ else:
+ expires = ''
+ value: str = json.dumps({
+ 'message': message,
+ 'md5': hashlib.md5(message.encode()).hexdigest(),
+ 'severity': severity.value,
+ 'expires': expires
+ })
+ self.set_option(self.NAME, value)
+ return 0, 'Message of the day has been set.', ''
+
+ @CLICommand("dashboard {name} clear".format(name=self.NAME))
+ def _clear(_):
+ self.set_option(self.NAME, '')
+ return 0, 'Message of the day has been cleared.', ''
+
+ @PM.add_hook
+ def get_controllers(self):
+ from ..controllers import RESTController, UIRouter
+
+ @UIRouter('/motd')
+ class MessageOfTheDay(RESTController):
+ def list(_) -> Optional[Dict]: # pylint: disable=no-self-argument
+ value: str = self.get_option(self.NAME)
+ if not value:
+ return None
+ data: MotdData = MotdData(**json.loads(value))
+ # Check if the MOTD has been expired.
+ if data.expires:
+ expires = str_to_datetime(data.expires)
+ if expires < datetime_now():
+ return None
+ return data._asdict()
+
+ return [MessageOfTheDay]
diff --git a/src/pybind/mgr/dashboard/plugins/pluggy.py b/src/pybind/mgr/dashboard/plugins/pluggy.py
new file mode 100644
index 000000000..53a0cf65d
--- /dev/null
+++ b/src/pybind/mgr/dashboard/plugins/pluggy.py
@@ -0,0 +1,116 @@
+# -*- 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.
+"""
+try:
+ from typing import DefaultDict
+except ImportError:
+ pass # For typing only
+
+
+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) # type: DefaultDict[str, 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"""
+
+ def register(self, plugin, name=None): # pylint: disable=unused-argument
+ for attr in dir(plugin):
+ if self.parse_hookimpl_opts(plugin, attr) is not None:
+ # pylint: disable=protected-access
+ 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 000000000..847a61872
--- /dev/null
+++ b/src/pybind/mgr/dashboard/plugins/plugin.py
@@ -0,0 +1,41 @@
+from mgr_module import Command, Option
+
+from . import PLUGIN_MANAGER as PM
+from . import interfaces as I # noqa: E741,N812
+
+try:
+ from typing import no_type_check
+except ImportError:
+ no_type_check = object() # Just for type checking
+
+
+class SimplePlugin(I.CanMgr, I.HasOptions, I.HasCommands):
+ """
+ Helper class that provides simplified creation of plugins:
+ - Default Mixins/Interfaces: CanMgr, 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 # type: ignore
+
+ @PM.final
+ @no_type_check # https://github.com/python/mypy/issues/7806
+ def get_option(self, option):
+ return self.mgr.get_module_option(option)
+
+ @PM.final
+ @no_type_check # https://github.com/python/mypy/issues/7806
+ def set_option(self, option, value):
+ self.mgr.set_module_option(option, value)
+
+ @PM.add_hook
+ @no_type_check # https://github.com/python/mypy/issues/7806
+ 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 000000000..b316151e7
--- /dev/null
+++ b/src/pybind/mgr/dashboard/plugins/ttl_cache.py
@@ -0,0 +1,57 @@
+"""
+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 collections import OrderedDict
+from functools import wraps
+from threading import RLock
+from time import time
+
+try:
+ from typing import Tuple
+except ImportError:
+ pass # For typing only
+
+
+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() # type: OrderedDict[object, Tuple[bool, float]]
+ 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