diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 18:45:59 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 18:45:59 +0000 |
commit | 19fcec84d8d7d21e796c7624e521b60d28ee21ed (patch) | |
tree | 42d26aa27d1e3f7c0b8bd3fd14e7d7082f5008dc /src/pybind/mgr/dashboard/plugins | |
parent | Initial commit. (diff) | |
download | ceph-6d07fdb6bb33b1af39833b850bb6cf8af79fe293.tar.xz ceph-6d07fdb6bb33b1af39833b850bb6cf8af79fe293.zip |
Adding upstream version 16.2.11+ds.upstream/16.2.11+dsupstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
-rw-r--r-- | src/pybind/mgr/dashboard/plugins/__init__.py | 72 | ||||
-rw-r--r-- | src/pybind/mgr/dashboard/plugins/debug.py | 99 | ||||
-rw-r--r-- | src/pybind/mgr/dashboard/plugins/feature_toggles.py | 163 | ||||
-rw-r--r-- | src/pybind/mgr/dashboard/plugins/interfaces.py | 81 | ||||
-rw-r--r-- | src/pybind/mgr/dashboard/plugins/lru_cache.py | 44 | ||||
-rw-r--r-- | src/pybind/mgr/dashboard/plugins/motd.py | 98 | ||||
-rw-r--r-- | src/pybind/mgr/dashboard/plugins/pluggy.py | 116 | ||||
-rw-r--r-- | src/pybind/mgr/dashboard/plugins/plugin.py | 41 | ||||
-rw-r--r-- | src/pybind/mgr/dashboard/plugins/ttl_cache.py | 57 |
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 |