From 6bb951b1bf466c2a3d9e133935109d9afdd2a17b Mon Sep 17 00:00:00 2001 From: Tiago Melo Date: Tue, 19 Jun 2018 14:33:41 +0100 Subject: [PATCH] mgr/dashboard: Add RBD Trash endpoints Fixes: http://tracker.ceph.com/issues/24272 Signed-off-by: Tiago Melo --- qa/tasks/mgr/dashboard/test_rbd.py | 107 ++++++++++++++++++++ src/pybind/mgr/dashboard/controllers/rbd.py | 78 +++++++++++++- 2 files changed, 184 insertions(+), 1 deletion(-) diff --git a/qa/tasks/mgr/dashboard/test_rbd.py b/qa/tasks/mgr/dashboard/test_rbd.py index c240d0609eaa5..db637ca625479 100644 --- a/qa/tasks/mgr/dashboard/test_rbd.py +++ b/qa/tasks/mgr/dashboard/test_rbd.py @@ -3,6 +3,8 @@ from __future__ import absolute_import +import time + from .helper import DashboardTestCase, JObj, JLeaf, JList @@ -136,6 +138,31 @@ class RbdTest(DashboardTestCase): cls._ceph_cmd(['osd', 'pool', 'delete', 'rbd_data', 'rbd_data', '--yes-i-really-really-mean-it']) + @classmethod + def create_image_in_trash(cls, pool, name, delay=0, **kwargs): + cls.create_image(pool, name, 10240) + img = cls._get('/api/block/image/{}/{}'.format(pool, name)) + + cls._task_post("/api/block/image/{}/{}/move_trash".format(pool, name), + {'delay': delay}) + + return img['id'] + + @classmethod + def remove_trash(cls, pool, image_id, image_name, force=False): + return cls._task_delete('/api/block/image/trash/{}/{}/?image_name={}&force={}'.format('rbd', image_id, image_name, force)) + + @classmethod + def get_trash(cls, pool, image_id): + trash = cls._get('/api/block/image/trash/?pool_name={}'.format(pool)) + if isinstance(trash, list): + for pool in trash: + for image in pool['value']: + if image['id'] == image_id: + return image + + return None + def _validate_image(self, img, **kwargs): """ Example of an RBD image json: @@ -599,3 +626,83 @@ class RbdTest(DashboardTestCase): 'object-map']) self.remove_image('rbd', rbd_name_encoded) + + def test_move_image_to_trash(self): + id = self.create_image_in_trash('rbd', 'test_rbd') + self.assertStatus(200) + + self._get('/api/block/image/rbd/test_rbd') + self.assertStatus(404) + + time.sleep(1) + + image = self.get_trash('rbd', id) + self.assertIsNotNone(image) + + self.remove_trash('rbd', id, 'test_rbd') + + def test_list_trash(self): + id = self.create_image_in_trash('rbd', 'test_rbd', 0) + data = self._get('/api/block/image/trash/?pool_name={}'.format('rbd')) + self.assertStatus(200) + self.assertIsInstance(data, list) + self.assertIsNotNone(data) + + self.remove_trash('rbd', id, 'test_rbd') + self.assertStatus(204) + + def test_restore_trash(self): + id = self.create_image_in_trash('rbd', 'test_rbd') + + self._task_post('/api/block/image/trash/{}/{}/restore'.format('rbd', id), {'new_image_name': 'test_rbd'}) + + self._get('/api/block/image/rbd/test_rbd') + self.assertStatus(200) + + image = self.get_trash('rbd', id) + self.assertIsNone(image) + + self.remove_image('rbd', 'test_rbd') + + def test_remove_expired_trash(self): + id = self.create_image_in_trash('rbd', 'test_rbd', 0) + self.remove_trash('rbd', id, 'test_rbd', False) + self.assertStatus(204) + + image = self.get_trash('rbd', id) + self.assertIsNone(image) + + def test_remove_not_expired_trash(self): + id = self.create_image_in_trash('rbd', 'test_rbd', 9999) + self.remove_trash('rbd', id, 'test_rbd', False) + self.assertStatus(400) + + image = self.get_trash('rbd', id) + self.assertIsNotNone(image) + + self.remove_trash('rbd', id, 'test_rbd', True) + + def test_remove_not_expired_trash_with_force(self): + id = self.create_image_in_trash('rbd', 'test_rbd', 9999) + self.remove_trash('rbd', id, 'test_rbd', True) + self.assertStatus(204) + + image = self.get_trash('rbd', id) + self.assertIsNone(image) + + def test_purge_trash(self): + id_expired = self.create_image_in_trash('rbd', 'test_rbd_expired', 0) + id_not_expired = self.create_image_in_trash('rbd', 'test_rbd', 9999) + + time.sleep(1) + + self._task_post('/api/block/image/trash/purge?pool_name={}'.format('rbd')) + self.assertStatus(200) + + time.sleep(1) + + trash_not_expired = self.get_trash('rbd', id_not_expired) + self.assertIsNotNone(trash_not_expired) + + trash_expired = self.get_trash('rbd', id_expired) + self.assertIsNone(trash_expired) diff --git a/src/pybind/mgr/dashboard/controllers/rbd.py b/src/pybind/mgr/dashboard/controllers/rbd.py index d9b217fd6f75b..a8f4d452a45ec 100644 --- a/src/pybind/mgr/dashboard/controllers/rbd.py +++ b/src/pybind/mgr/dashboard/controllers/rbd.py @@ -5,6 +5,7 @@ from __future__ import absolute_import import math from functools import partial +from datetime import datetime import cherrypy import six @@ -15,7 +16,7 @@ from . import ApiController, RESTController, Task, UpdatePermission from .. import mgr from ..security import Scope from ..services.ceph_service import CephService -from ..tools import ViewCache +from ..tools import ViewCache, str_to_bool from ..services.exception import handle_rados_error, handle_rbd_error, \ serialize_dashboard_exception @@ -374,6 +375,16 @@ class Rbd(RESTController): rbd_default_features = mgr.get('config')['rbd_default_features'] return _format_bitmask(int(rbd_default_features)) + @RbdTask('trash/move', ['{pool_name}', '{image_name}'], 2.0) + @RESTController.Resource('POST') + def move_trash(self, pool_name, image_name, delay=0): + """Move an image to the trash. + Images, even ones actively in-use by clones, + can be moved to the trash and deleted at a later time. + """ + rbd_inst = rbd.RBD() + return _rbd_call(pool_name, rbd_inst.trash_move, image_name, delay) + @ApiController('/block/image/{pool_name}/{image_name}/snap', Scope.RBD_IMAGE) class RbdSnapshot(RESTController): @@ -453,3 +464,68 @@ class RbdSnapshot(RESTController): return _rbd_call(child_pool_name, _clone) return _rbd_call(pool_name, _parent_clone) + + +@ApiController('/block/image/trash') +class RbdTrash(RESTController): + RESOURCE_ID = "pool_name/image_id" + rbd_inst = rbd.RBD() + + @ViewCache() + def _trash_pool_list(self, pool_name): + with mgr.rados.open_ioctx(pool_name) as ioctx: + images = self.rbd_inst.trash_list(ioctx) + result = [] + for trash in images: + trash['pool_name'] = pool_name + trash['deletion_time'] = "{}Z".format(trash['deletion_time'].isoformat()) + trash['deferment_end_time'] = "{}Z".format(trash['deferment_end_time'].isoformat()) + result.append(trash) + return result + + def _trash_list(self, pool_name=None): + if pool_name: + pools = [pool_name] + else: + pools = [p['pool_name'] for p in CephService.get_pool_list('rbd')] + + result = [] + for pool in pools: + # pylint: disable=unbalanced-tuple-unpacking + status, value = self._trash_pool_list(pool) + result.append({'status': status, 'value': value, 'pool_name': pool}) + return result + + @handle_rbd_error() + @handle_rados_error('pool') + def list(self, pool_name=None): + """List all entries from trash.""" + return self._trash_list(pool_name) + + @handle_rbd_error() + @handle_rados_error('pool') + @RbdTask('trash/purge', ['{pool_name}'], 2.0) + @RESTController.Collection('POST', query_params=['pool_name']) + def purge(self, pool_name=None): + """Remove all expired images from trash.""" + now = "{}Z".format(datetime.now().isoformat()) + pools = self._trash_list(pool_name) + + for pool in pools: + for image in pool['value']: + if image['deferment_end_time'] < now: + _rbd_call(pool['pool_name'], self.rbd_inst.trash_remove, image['id'], 0) + + @RbdTask('trash/restore', ['{pool_name}', '{image_id}', '{new_image_name}'], 2.0) + @RESTController.Resource('POST') + def restore(self, pool_name, image_id, new_image_name): + """Restore an image from trash.""" + return _rbd_call(pool_name, self.rbd_inst.trash_restore, image_id, new_image_name) + + @RbdTask('trash/remove', ['{pool_name}', '{image_id}', '{image_name}'], 2.0) + def delete(self, pool_name, image_id, image_name, force=False): + """Delete an image from trash. + If image deferment time has not expired you can not removed it unless use force. + But an actively in-use by clones or has snapshots can not be removed. + """ + return _rbd_call(pool_name, self.rbd_inst.trash_remove, image_id, int(str_to_bool(force))) -- 2.39.5