]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: invalidate rbd image cache on CRUD ops 52917/head
authorPere Diaz Bou <pdiazbou@redhat.com>
Thu, 9 Feb 2023 13:10:19 +0000 (14:10 +0100)
committerPere Diaz Bou <pere-altea@hotmail.com>
Fri, 11 Aug 2023 11:10:01 +0000 (13:10 +0200)
Signed-off-by: Pere Diaz Bou <pdiazbou@redhat.com>
Fixes: https://tracker.ceph.com/issues/58710
src/pybind/mgr/dashboard/controllers/rbd.py
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster.component.ts
src/pybind/mgr/dashboard/plugins/ttl_cache.py
src/pybind/mgr/dashboard/services/rbd.py
src/pybind/mgr/dashboard/tests/test_cache.py [new file with mode: 0644]

index eef3be4975a1e8f063c8996621f067c6743d751f..7312722df1d0046700a8a567d85ae9be8ff9b68d 100644 (file)
@@ -49,36 +49,10 @@ def RbdTask(name, metadata, wait_for):  # noqa: N802
     return composed_decorator
 
 
-def _sort_features(features, enable=True):
-    """
-    Sorts image features according to feature dependencies:
-
-    object-map depends on exclusive-lock
-    journaling depends on exclusive-lock
-    fast-diff depends on object-map
-    """
-    ORDER = ['exclusive-lock', 'journaling', 'object-map', 'fast-diff']  # noqa: N806
-
-    def key_func(feat):
-        try:
-            return ORDER.index(feat)
-        except ValueError:
-            return id(feat)
-
-    features.sort(key=key_func, reverse=not enable)
-
-
 @APIRouter('/block/image', Scope.RBD_IMAGE)
 @APIDoc("RBD Management API", "Rbd")
 class Rbd(RESTController):
 
-    # set of image features that can be enable on existing images
-    ALLOW_ENABLE_FEATURES = {"exclusive-lock", "object-map", "fast-diff", "journaling"}
-
-    # set of image features that can be disabled on existing images
-    ALLOW_DISABLE_FEATURES = {"exclusive-lock", "object-map", "fast-diff", "deep-flatten",
-                              "journaling"}
-
     DEFAULT_LIMIT = 5
 
     def _rbd_list(self, pool_name=None, offset=0, limit=DEFAULT_LIMIT, search='', sort=''):
@@ -126,27 +100,10 @@ class Rbd(RESTController):
     def create(self, name, pool_name, size, namespace=None, schedule_interval='',
                obj_size=None, features=None, stripe_unit=None, stripe_count=None,
                data_pool=None, configuration=None, mirror_mode=None):
+        RbdService.create(name, pool_name, size, namespace,
+                          obj_size, features, stripe_unit, stripe_count,
+                          data_pool, configuration)
 
-        size = int(size)
-
-        def _create(ioctx):
-            rbd_inst = rbd.RBD()
-
-            # Set order
-            l_order = None
-            if obj_size and obj_size > 0:
-                l_order = int(round(math.log(float(obj_size), 2)))
-
-            # Set features
-            feature_bitmask = format_features(features)
-
-            rbd_inst.create(ioctx, name, size, order=l_order, old_format=False,
-                            features=feature_bitmask, stripe_unit=stripe_unit,
-                            stripe_count=stripe_count, data_pool=data_pool)
-            RbdConfiguration(pool_ioctx=ioctx, namespace=namespace,
-                             image_name=name).set_configuration(configuration)
-
-        rbd_call(pool_name, namespace, _create)
         if mirror_mode:
             RbdMirroringService.enable_image(name, pool_name, namespace,
                                              MIRROR_IMAGE_MODE[mirror_mode])
@@ -157,84 +114,17 @@ class Rbd(RESTController):
 
     @RbdTask('delete', ['{image_spec}'], 2.0)
     def delete(self, image_spec):
-        pool_name, namespace, image_name = parse_image_spec(image_spec)
-
-        image = RbdService.get_image(image_spec)
-        snapshots = image['snapshots']
-        for snap in snapshots:
-            RbdSnapshotService.remove_snapshot(image_spec, snap['name'], snap['is_protected'])
-
-        rbd_inst = rbd.RBD()
-        return rbd_call(pool_name, namespace, rbd_inst.remove, image_name)
+        return RbdService.delete(image_spec)
 
     @RbdTask('edit', ['{image_spec}', '{name}'], 4.0)
     def set(self, image_spec, name=None, size=None, features=None,
             configuration=None, enable_mirror=None, primary=None,
             force=False, resync=False, mirror_mode=None, schedule_interval='',
             remove_scheduling=False):
-
-        pool_name, namespace, image_name = parse_image_spec(image_spec)
-
-        def _edit(ioctx, image):
-            rbd_inst = rbd.RBD()
-            # check rename image
-            if name and name != image_name:
-                rbd_inst.rename(ioctx, image_name, name)
-
-            # check resize
-            if size and size != image.size():
-                image.resize(size)
-
-            mirror_image_info = image.mirror_image_get_info()
-            if enable_mirror and mirror_image_info['state'] == rbd.RBD_MIRROR_IMAGE_DISABLED:
-                RbdMirroringService.enable_image(
-                    image_name, pool_name, namespace,
-                    MIRROR_IMAGE_MODE[mirror_mode])
-            elif (enable_mirror is False
-                  and mirror_image_info['state'] == rbd.RBD_MIRROR_IMAGE_ENABLED):
-                RbdMirroringService.disable_image(
-                    image_name, pool_name, namespace)
-
-            # check enable/disable features
-            if features is not None:
-                curr_features = format_bitmask(image.features())
-                # check disabled features
-                _sort_features(curr_features, enable=False)
-                for feature in curr_features:
-                    if feature not in features and feature in self.ALLOW_DISABLE_FEATURES:
-                        if feature not in format_bitmask(image.features()):
-                            continue
-                        f_bitmask = format_features([feature])
-                        image.update_features(f_bitmask, False)
-                # check enabled features
-                _sort_features(features)
-                for feature in features:
-                    if feature not in curr_features and feature in self.ALLOW_ENABLE_FEATURES:
-                        if feature in format_bitmask(image.features()):
-                            continue
-                        f_bitmask = format_features([feature])
-                        image.update_features(f_bitmask, True)
-
-            RbdConfiguration(pool_ioctx=ioctx, image_name=image_name).set_configuration(
-                configuration)
-
-            if primary and not mirror_image_info['primary']:
-                RbdMirroringService.promote_image(
-                    image_name, pool_name, namespace, force)
-            elif primary is False and mirror_image_info['primary']:
-                RbdMirroringService.demote_image(
-                    image_name, pool_name, namespace)
-
-            if resync:
-                RbdMirroringService.resync_image(image_name, pool_name, namespace)
-
-            if schedule_interval:
-                RbdMirroringService.snapshot_schedule_add(image_spec, schedule_interval)
-
-            if remove_scheduling:
-                RbdMirroringService.snapshot_schedule_remove(image_spec)
-
-        return rbd_image_call(pool_name, namespace, image_name, _edit)
+        return RbdService.set(image_spec, name, size, features,
+                              configuration, enable_mirror, primary,
+                              force, resync, mirror_mode, schedule_interval,
+                              remove_scheduling)
 
     @RbdTask('copy',
              {'src_image_spec': '{image_spec}',
@@ -246,41 +136,17 @@ class Rbd(RESTController):
     def copy(self, image_spec, dest_pool_name, dest_namespace, dest_image_name,
              snapshot_name=None, obj_size=None, features=None,
              stripe_unit=None, stripe_count=None, data_pool=None, configuration=None):
-        pool_name, namespace, image_name = parse_image_spec(image_spec)
-
-        def _src_copy(s_ioctx, s_img):
-            def _copy(d_ioctx):
-                # Set order
-                l_order = None
-                if obj_size and obj_size > 0:
-                    l_order = int(round(math.log(float(obj_size), 2)))
-
-                # Set features
-                feature_bitmask = format_features(features)
-
-                if snapshot_name:
-                    s_img.set_snap(snapshot_name)
-
-                s_img.copy(d_ioctx, dest_image_name, feature_bitmask, l_order,
-                           stripe_unit, stripe_count, data_pool)
-                RbdConfiguration(pool_ioctx=d_ioctx, image_name=dest_image_name).set_configuration(
-                    configuration)
-
-            return rbd_call(dest_pool_name, dest_namespace, _copy)
-
-        return rbd_image_call(pool_name, namespace, image_name, _src_copy)
+        return RbdService.copy(image_spec, dest_pool_name, dest_namespace, dest_image_name,
+                               snapshot_name, obj_size, features,
+                               stripe_unit, stripe_count, data_pool,
+                               configuration)
 
     @RbdTask('flatten', ['{image_spec}'], 2.0)
     @RESTController.Resource('POST')
     @UpdatePermission
     @allow_empty_body
     def flatten(self, image_spec):
-
-        def _flatten(ioctx, image):
-            image.flatten()
-
-        pool_name, namespace, image_name = parse_image_spec(image_spec)
-        return rbd_image_call(pool_name, namespace, image_name, _flatten)
+        return RbdService.flatten(image_spec)
 
     @RESTController.Collection('GET')
     def default_features(self):
@@ -310,9 +176,7 @@ class Rbd(RESTController):
         Images, even ones actively in-use by clones,
         can be moved to the trash and deleted at a later time.
         """
-        pool_name, namespace, image_name = parse_image_spec(image_spec)
-        rbd_inst = rbd.RBD()
-        return rbd_call(pool_name, namespace, rbd_inst.trash_move, image_name, delay)
+        return RbdService.move_image_to_trash(image_spec, delay)
 
 
 @UIRouter('/block/rbd')
index 002ec50d0e3ab6426f50f6c3445845ab1d282bc4..670a3e00dfe5bbcee3b47c2fd16b5b3560bed637 100644 (file)
@@ -130,20 +130,20 @@ export class CreateClusterComponent implements OnInit, OnDestroy {
           }
         });
         forkJoin(this.observables)
-        .pipe(
-          finalize(() =>
-                   this.clusterService.updateStatus('POST_INSTALLED').subscribe(() => {
-                     this.notificationService.show(
-                       NotificationType.success,
-                       $localize`Cluster expansion was successful`
-                     );
-                     this.router.navigate(['/dashboard']);
-                   })
-                  )
-        )
-        .subscribe({
-          error: (error) => error.preventDefault()
-        });
+          .pipe(
+            finalize(() =>
+              this.clusterService.updateStatus('POST_INSTALLED').subscribe(() => {
+                this.notificationService.show(
+                  NotificationType.success,
+                  $localize`Cluster expansion was successful`
+                );
+                this.router.navigate(['/dashboard']);
+              })
+            )
+          )
+          .subscribe({
+            error: (error) => error.preventDefault()
+          });
       });
     }
 
index e94692e34d9e5f3bdf2055a7a5b365e47a38cc44..78221547acc3a065cadb2c8df220dbf9c0198fef 100644 (file)
@@ -9,6 +9,7 @@ from collections import OrderedDict
 from functools import wraps
 from threading import RLock
 from time import time
+from typing import Any, Dict
 
 try:
     from typing import Tuple
@@ -16,7 +17,70 @@ except ImportError:
     pass  # For typing only
 
 
-def ttl_cache(ttl, maxsize=128, typed=False):
+class TTLCache:
+    class CachedValue:
+        def __init__(self, value, timestamp):
+            self.value = value
+            self.timestamp = timestamp
+
+    def __init__(self, reference, ttl, maxsize=128):
+        self.reference = reference
+        self.ttl: int = ttl
+        self.maxsize = maxsize
+        self.cache: OrderedDict[Tuple[Any], TTLCache.CachedValue] = OrderedDict()
+        self.hits = 0
+        self.misses = 0
+        self.expired = 0
+        self.rlock = RLock()
+
+    def __getitem__(self, key):
+        with self.rlock:
+            if key not in self.cache:
+                self.misses += 1
+                raise KeyError(f'"{key}" is not set')
+
+            cached_value = self.cache[key]
+            if time() - cached_value.timestamp >= self.ttl:
+                del self.cache[key]
+                self.expired += 1
+                self.misses += 1
+                raise KeyError(f'"{key}" is not set')
+
+            self.hits += 1
+            return cached_value.value
+
+    def __setitem__(self, key, value):
+        with self.rlock:
+            if key in self.cache:
+                cached_value = self.cache[key]
+                if time() - cached_value.timestamp >= self.ttl:
+                    self.expired += 1
+            if len(self.cache) == self.maxsize:
+                self.cache.popitem(last=False)
+
+            self.cache[key] = TTLCache.CachedValue(value, time())
+
+    def clear(self):
+        with self.rlock:
+            self.cache.clear()
+
+    def info(self) -> str:
+        return (f'cache={self.reference} hits={self.hits}, misses={self.misses},'
+                f'expired={self.expired}, maxsize={self.maxsize}, currsize={len(self.cache)}')
+
+
+class CacheManager:
+    caches: Dict[str, TTLCache] = {}
+
+    @classmethod
+    def get(cls, reference: str, ttl=30, maxsize=128):
+        if reference in cls.caches:
+            return cls.caches[reference]
+        cls.caches[reference] = TTLCache(reference, ttl, maxsize)
+        return cls.caches[reference]
+
+
+def ttl_cache(ttl, maxsize=128, typed=False, label: str = ''):
     if typed is not False:
         raise NotImplementedError("typed caching not supported")
 
@@ -25,37 +89,31 @@ def ttl_cache(ttl, maxsize=128, typed=False):
         ttl = 0
 
     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)))
+        cache_name = label
+        if not cache_name:
+            cache_name = function.__name__
+        cache = CacheManager.get(cache_name, ttl, maxsize)
 
         @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)
+            try:
+                return cache[key]
+            except KeyError:
+                ret = function(*args, **kwargs)
+                cache[key] = ret
+                return ret
 
-            return ret
+        return wrapper
+    return decorating_function
 
+
+def ttl_cache_invalidator(label: str):
+    def decorating_function(function):
+        @wraps(function)
+        def wrapper(*args, **kwargs):
+            ret = function(*args, **kwargs)
+            CacheManager.get(label).clear()
+            return ret
         return wrapper
     return decorating_function
index 9186100adb1d707e69d9e63f3aa02adcf82547da..faf63fd5ef7c86f87d5a44d2ed45635622da7eb9 100644 (file)
@@ -2,6 +2,7 @@
 # pylint: disable=unused-argument
 import errno
 import json
+import math
 from enum import IntEnum
 
 import cherrypy
@@ -10,7 +11,7 @@ import rbd
 
 from .. import mgr
 from ..exceptions import DashboardException
-from ..plugins.ttl_cache import ttl_cache
+from ..plugins.ttl_cache import ttl_cache, ttl_cache_invalidator
 from ._paginate import ListPaginator
 from .ceph_service import CephService
 
@@ -32,6 +33,10 @@ RBD_FEATURES_NAME_MAPPING = {
     rbd.RBD_FEATURE_OPERATIONS: "operations",
 }
 
+RBD_IMAGE_REFS_CACHE_REFERENCE = 'rbd_image_refs'
+GET_IOCTX_CACHE = 'get_ioctx'
+POOL_NAMESPACES_CACHE = 'pool_namespaces'
+
 
 class MIRROR_IMAGE_MODE(IntEnum):
     journal = rbd.RBD_MIRROR_IMAGE_MODE_JOURNAL
@@ -86,6 +91,25 @@ def format_features(features):
     return res
 
 
+def _sort_features(features, enable=True):
+    """
+    Sorts image features according to feature dependencies:
+
+    object-map depends on exclusive-lock
+    journaling depends on exclusive-lock
+    fast-diff depends on object-map
+    """
+    ORDER = ['exclusive-lock', 'journaling', 'object-map', 'fast-diff']  # noqa: N806
+
+    def key_func(feat):
+        try:
+            return ORDER.index(feat)
+        except ValueError:
+            return id(feat)
+
+    features.sort(key=key_func, reverse=not enable)
+
+
 def get_image_spec(pool_name, namespace, rbd_name):
     namespace = '{}/'.format(namespace) if namespace else ''
     return '{}/{}{}'.format(pool_name, namespace, rbd_name)
@@ -244,6 +268,13 @@ class RbdConfiguration(object):
 class RbdService(object):
     _rbd_inst = rbd.RBD()
 
+    # set of image features that can be enable on existing images
+    ALLOW_ENABLE_FEATURES = {"exclusive-lock", "object-map", "fast-diff", "journaling"}
+
+    # set of image features that can be disabled on existing images
+    ALLOW_DISABLE_FEATURES = {"exclusive-lock", "object-map", "fast-diff", "deep-flatten",
+                              "journaling"}
+
     @classmethod
     def _rbd_disk_usage(cls, image, snaps, whole_object=True):
         class DUCallback(object):
@@ -380,14 +411,14 @@ class RbdService(object):
             return stat
 
     @classmethod
-    @ttl_cache(10)
+    @ttl_cache(10, label=GET_IOCTX_CACHE)
     def get_ioctx(cls, pool_name, namespace=''):
         ioctx = mgr.rados.open_ioctx(pool_name)
         ioctx.set_namespace(namespace)
         return ioctx
 
     @classmethod
-    @ttl_cache(30)
+    @ttl_cache(30, label=RBD_IMAGE_REFS_CACHE_REFERENCE)
     def _rbd_image_refs(cls, pool_name, namespace=''):
         # We add and set the namespace here so that we cache by ioctx and namespace.
         images = []
@@ -396,7 +427,7 @@ class RbdService(object):
         return images
 
     @classmethod
-    @ttl_cache(30)
+    @ttl_cache(30, label=POOL_NAMESPACES_CACHE)
     def _pool_namespaces(cls, pool_name, namespace=None):
         namespaces = []
         if namespace:
@@ -478,6 +509,159 @@ class RbdService(object):
         except rbd.ImageNotFound:
             raise cherrypy.HTTPError(404, 'Image not found')
 
+    @classmethod
+    @ttl_cache_invalidator(RBD_IMAGE_REFS_CACHE_REFERENCE)
+    def create(cls, name, pool_name, size, namespace=None,
+               obj_size=None, features=None, stripe_unit=None, stripe_count=None,
+               data_pool=None, configuration=None):
+        size = int(size)
+
+        def _create(ioctx):
+            rbd_inst = cls._rbd_inst
+
+            # Set order
+            l_order = None
+            if obj_size and obj_size > 0:
+                l_order = int(round(math.log(float(obj_size), 2)))
+
+            # Set features
+            feature_bitmask = format_features(features)
+
+            rbd_inst.create(ioctx, name, size, order=l_order, old_format=False,
+                            features=feature_bitmask, stripe_unit=stripe_unit,
+                            stripe_count=stripe_count, data_pool=data_pool)
+            RbdConfiguration(pool_ioctx=ioctx, namespace=namespace,
+                             image_name=name).set_configuration(configuration)
+        rbd_call(pool_name, namespace, _create)
+
+    @classmethod
+    @ttl_cache_invalidator(RBD_IMAGE_REFS_CACHE_REFERENCE)
+    def set(cls, image_spec, name=None, size=None, features=None,
+            configuration=None, enable_mirror=None, primary=None,
+            force=False, resync=False, mirror_mode=None, schedule_interval='',
+            remove_scheduling=False):
+        # pylint: disable=too-many-branches
+        pool_name, namespace, image_name = parse_image_spec(image_spec)
+
+        def _edit(ioctx, image):
+            rbd_inst = cls._rbd_inst
+            # check rename image
+            if name and name != image_name:
+                rbd_inst.rename(ioctx, image_name, name)
+
+            # check resize
+            if size and size != image.size():
+                image.resize(size)
+
+            mirror_image_info = image.mirror_image_get_info()
+            if enable_mirror and mirror_image_info['state'] == rbd.RBD_MIRROR_IMAGE_DISABLED:
+                RbdMirroringService.enable_image(
+                    image_name, pool_name, namespace,
+                    MIRROR_IMAGE_MODE[mirror_mode])
+            elif (enable_mirror is False
+                  and mirror_image_info['state'] == rbd.RBD_MIRROR_IMAGE_ENABLED):
+                RbdMirroringService.disable_image(
+                    image_name, pool_name, namespace)
+
+            # check enable/disable features
+            if features is not None:
+                curr_features = format_bitmask(image.features())
+                # check disabled features
+                _sort_features(curr_features, enable=False)
+                for feature in curr_features:
+                    if (feature not in features
+                       and feature in cls.ALLOW_DISABLE_FEATURES
+                       and feature in format_bitmask(image.features())):
+                        f_bitmask = format_features([feature])
+                        image.update_features(f_bitmask, False)
+                # check enabled features
+                _sort_features(features)
+                for feature in features:
+                    if (feature not in curr_features
+                       and feature in cls.ALLOW_ENABLE_FEATURES
+                       and feature not in format_bitmask(image.features())):
+                        f_bitmask = format_features([feature])
+                        image.update_features(f_bitmask, True)
+
+            RbdConfiguration(pool_ioctx=ioctx, image_name=image_name).set_configuration(
+                configuration)
+
+            if primary and not mirror_image_info['primary']:
+                RbdMirroringService.promote_image(
+                    image_name, pool_name, namespace, force)
+            elif primary is False and mirror_image_info['primary']:
+                RbdMirroringService.demote_image(
+                    image_name, pool_name, namespace)
+
+            if resync:
+                RbdMirroringService.resync_image(image_name, pool_name, namespace)
+
+            if schedule_interval:
+                RbdMirroringService.snapshot_schedule_add(image_spec, schedule_interval)
+
+            if remove_scheduling:
+                RbdMirroringService.snapshot_schedule_remove(image_spec)
+
+        return rbd_image_call(pool_name, namespace, image_name, _edit)
+
+    @classmethod
+    @ttl_cache_invalidator(RBD_IMAGE_REFS_CACHE_REFERENCE)
+    def delete(cls, image_spec):
+        pool_name, namespace, image_name = parse_image_spec(image_spec)
+
+        image = RbdService.get_image(image_spec)
+        snapshots = image['snapshots']
+        for snap in snapshots:
+            RbdSnapshotService.remove_snapshot(image_spec, snap['name'], snap['is_protected'])
+
+        rbd_inst = rbd.RBD()
+        return rbd_call(pool_name, namespace, rbd_inst.remove, image_name)
+
+    @classmethod
+    @ttl_cache_invalidator(RBD_IMAGE_REFS_CACHE_REFERENCE)
+    def copy(cls, image_spec, dest_pool_name, dest_namespace, dest_image_name,
+             snapshot_name=None, obj_size=None, features=None,
+             stripe_unit=None, stripe_count=None, data_pool=None,
+             configuration=None):
+        pool_name, namespace, image_name = parse_image_spec(image_spec)
+
+        def _src_copy(s_ioctx, s_img):
+            def _copy(d_ioctx):
+                # Set order
+                l_order = None
+                if obj_size and obj_size > 0:
+                    l_order = int(round(math.log(float(obj_size), 2)))
+
+                # Set features
+                feature_bitmask = format_features(features)
+
+                if snapshot_name:
+                    s_img.set_snap(snapshot_name)
+
+                s_img.copy(d_ioctx, dest_image_name, feature_bitmask, l_order,
+                           stripe_unit, stripe_count, data_pool)
+                RbdConfiguration(pool_ioctx=d_ioctx, image_name=dest_image_name).set_configuration(
+                    configuration)
+
+            return rbd_call(dest_pool_name, dest_namespace, _copy)
+
+        return rbd_image_call(pool_name, namespace, image_name, _src_copy)
+
+    @classmethod
+    @ttl_cache_invalidator(RBD_IMAGE_REFS_CACHE_REFERENCE)
+    def flatten(cls, image_spec):
+        def _flatten(ioctx, image):
+            image.flatten()
+
+        pool_name, namespace, image_name = parse_image_spec(image_spec)
+        return rbd_image_call(pool_name, namespace, image_name, _flatten)
+
+    @classmethod
+    def move_image_to_trash(cls, image_spec, delay):
+        pool_name, namespace, image_name = parse_image_spec(image_spec)
+        rbd_inst = cls._rbd_inst
+        return rbd_call(pool_name, namespace, rbd_inst.trash_move, image_name, delay)
+
 
 class RbdSnapshotService(object):
 
diff --git a/src/pybind/mgr/dashboard/tests/test_cache.py b/src/pybind/mgr/dashboard/tests/test_cache.py
new file mode 100644 (file)
index 0000000..f767676
--- /dev/null
@@ -0,0 +1,48 @@
+
+import unittest
+
+from ..plugins.ttl_cache import CacheManager, TTLCache
+
+
+class TTLCacheTest(unittest.TestCase):
+    def test_get(self):
+        ref = 'testcache'
+        cache = TTLCache(ref, 30)
+        with self.assertRaises(KeyError):
+            val = cache['foo']
+        cache['foo'] = 'var'
+        val = cache['foo']
+        self.assertEqual(val, 'var')
+        self.assertEqual(cache.hits, 1)
+        self.assertEqual(cache.misses, 1)
+
+    def test_ttl(self):
+        ref = 'testcache'
+        cache = TTLCache(ref, 0.0000001)
+        cache['foo'] = 'var'
+        # pylint: disable=pointless-statement
+        with self.assertRaises(KeyError):
+            cache['foo']
+        self.assertEqual(cache.hits, 0)
+        self.assertEqual(cache.misses, 1)
+        self.assertEqual(cache.expired, 1)
+
+    def test_maxsize_fifo(self):
+        ref = 'testcache'
+        cache = TTLCache(ref, 30, 2)
+        cache['foo0'] = 'var0'
+        cache['foo1'] = 'var1'
+        cache['foo2'] = 'var2'
+        # pylint: disable=pointless-statement
+        with self.assertRaises(KeyError):
+            cache['foo0']
+        self.assertEqual(cache.hits, 0)
+        self.assertEqual(cache.misses, 1)
+
+
+class TTLCacheManagerTest(unittest.TestCase):
+    def test_get(self):
+        ref = 'testcache'
+        cache0 = CacheManager.get(ref)
+        cache1 = CacheManager.get(ref)
+        self.assertEqual(id(cache0), id(cache1))