]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Allow removing RBD with snapshots
authorTiago Melo <tmelo@suse.com>
Mon, 3 Feb 2020 17:14:13 +0000 (16:14 -0100)
committerTiago Melo <tmelo@suse.com>
Fri, 6 Mar 2020 14:18:15 +0000 (13:18 -0100)
Fixes: https://tracker.ceph.com/issues/36404
Signed-off-by: Tiago Melo <tmelo@suse.com>
12 files changed:
qa/tasks/mgr/dashboard/helper.py
qa/tasks/mgr/dashboard/test_rbd.py
src/pybind/mgr/dashboard/controllers/rbd.py
src/pybind/mgr/dashboard/controllers/rbd_mirroring.py
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-list/rbd-list.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-list/rbd-list.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-list/rbd-list.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-move-modal/rbd-trash-move-modal.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-move-modal/rbd-trash-move-modal.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts
src/pybind/mgr/dashboard/services/rbd.py

index 24d0f455da7e525440dba67bb76cd13d1b377221..15b1fccb69ee2b93fe80c9f250dc5c5a730a6776 100644 (file)
@@ -261,7 +261,7 @@ class DashboardTestCase(MgrTestCase):
     @classmethod
     def _task_request(cls, method, url, data, timeout):
         res = cls._request(url, method, data)
-        cls._assertIn(cls._resp.status_code, [200, 201, 202, 204, 400, 403])
+        cls._assertIn(cls._resp.status_code, [200, 201, 202, 204, 400, 403, 404])
 
         if cls._resp.status_code == 403:
             return None
index 4623873a61db4fcb432c890ff9e742232af2b4ad..a3ef00b1b7f0e53e8348978919f653a4480d3962 100644 (file)
@@ -459,9 +459,9 @@ class RbdTest(DashboardTestCase):
 
     def test_delete_non_existent_image(self):
         res = self.remove_image('rbd', None, 'i_dont_exist')
-        self.assertStatus(400)
-        self.assertEqual(res, {u'code': u'2', "status": 400, "component": "rbd",
-                               "detail": "[errno 2] RBD image not found (error removing image)",
+        self.assertStatus(404)
+        self.assertEqual(res, {u'code': 404, "status": 404, "component": None,
+                               "detail": "(404, 'Image not found')",
                                'task': {'name': 'rbd/delete',
                                         'metadata': {'image_spec': 'rbd/i_dont_exist'}}})
 
@@ -491,6 +491,22 @@ class RbdTest(DashboardTestCase):
         self.remove_image('rbd', None, 'delete_me')
         self.assertStatus(204)
 
+    def test_image_delete_with_snapshot(self):
+        self.create_image('rbd', None, 'delete_me', 2**30)
+        self.assertStatus(201)
+        self.create_snapshot('rbd', None, 'delete_me', 'snap1')
+        self.assertStatus(201)
+        self.create_snapshot('rbd', None, 'delete_me', 'snap2')
+        self.assertStatus(201)
+
+        img = self.get_image('rbd', None, 'delete_me')
+        self.assertStatus(200)
+        self._validate_image(img, name='delete_me', size=2**30)
+        self.assertEqual(len(img['snapshots']), 2)
+
+        self.remove_image('rbd', None, 'delete_me')
+        self.assertStatus(204)
+
     def test_image_rename(self):
         self.create_image('rbd', None, 'edit_img', 2**30)
         self.assertStatus(201)
@@ -672,14 +688,10 @@ class RbdTest(DashboardTestCase):
         res = self.remove_image('rbd', None, 'cimg')
         self.assertStatus(400)
         self.assertIn('code', res)
-        self.assertEqual(res['code'], '39')
+        self.assertEqual(res['code'], '16')
 
         self.remove_image('rbd', None, 'cimg-clone')
         self.assertStatus(204)
-        self.update_snapshot('rbd', None, 'cimg', 'snap1', None, False)
-        self.assertStatus(200)
-        self.remove_snapshot('rbd', None, 'cimg', 'snap1')
-        self.assertStatus(204)
         self.remove_image('rbd', None, 'cimg')
         self.assertStatus(204)
 
index 7ea98360240bd6836d215409b0e08d3d8d4d2096..6bfff994d223ae3e4075ce3503eeb84786547193 100644 (file)
@@ -7,8 +7,6 @@ import math
 from functools import partial
 from datetime import datetime
 
-import cherrypy
-
 import rbd
 
 from . import ApiController, RESTController, Task, UpdatePermission, \
@@ -17,8 +15,8 @@ from .. import mgr
 from ..exceptions import DashboardException
 from ..security import Scope
 from ..services.ceph_service import CephService
-from ..services.rbd import RbdConfiguration, RbdService, format_bitmask, format_features,\
-    parse_image_spec
+from ..services.rbd import RbdConfiguration, RbdService, RbdSnapshotService, \
+    format_bitmask, format_features, parse_image_spec, rbd_call, rbd_image_call
 from ..tools import ViewCache, str_to_bool
 from ..services.exception import handle_rados_error, handle_rbd_error, \
     serialize_dashboard_exception
@@ -34,20 +32,6 @@ def RbdTask(name, metadata, wait_for):  # noqa: N802
     return composed_decorator
 
 
-def _rbd_call(pool_name, namespace, func, *args, **kwargs):
-    with mgr.rados.open_ioctx(pool_name) as ioctx:
-        ioctx.set_namespace(namespace if namespace is not None else '')
-        func(ioctx, *args, **kwargs)
-
-
-def _rbd_image_call(pool_name, namespace, image_name, func, *args, **kwargs):
-    def _ioctx_func(ioctx, image_name, func, *args, **kwargs):
-        with rbd.Image(ioctx, image_name) as img:
-            func(ioctx, img, *args, **kwargs)
-
-    return _rbd_call(pool_name, namespace, _ioctx_func, image_name, func, *args, **kwargs)
-
-
 def _sort_features(features, enable=True):
     """
     Sorts image features according to feature dependencies:
@@ -101,14 +85,7 @@ class Rbd(RESTController):
     @handle_rbd_error()
     @handle_rados_error('pool')
     def get(self, image_spec):
-        pool_name, namespace, image_name = parse_image_spec(image_spec)
-        ioctx = mgr.rados.open_ioctx(pool_name)
-        if namespace:
-            ioctx.set_namespace(namespace)
-        try:
-            return RbdService.rbd_image(ioctx, pool_name, namespace, image_name)
-        except rbd.ImageNotFound:
-            raise cherrypy.HTTPError(404)
+        return RbdService.get_image(image_spec)
 
     @RbdTask('create',
              {'pool_name': '{pool_name}', 'namespace': '{namespace}', 'image_name': '{name}'}, 2.0)
@@ -134,13 +111,19 @@ class Rbd(RESTController):
             RbdConfiguration(pool_ioctx=ioctx, namespace=namespace,
                              image_name=name).set_configuration(configuration)
 
-        _rbd_call(pool_name, namespace, _create)
+        rbd_call(pool_name, namespace, _create)
 
     @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 rbd_call(pool_name, namespace, rbd_inst.remove, image_name)
 
     @RbdTask('edit', ['{image_spec}', '{name}'], 4.0)
     def set(self, image_spec, name=None, size=None, features=None, configuration=None):
@@ -179,7 +162,7 @@ class Rbd(RESTController):
             RbdConfiguration(pool_ioctx=ioctx, image_name=image_name).set_configuration(
                 configuration)
 
-        return _rbd_image_call(pool_name, namespace, image_name, _edit)
+        return rbd_image_call(pool_name, namespace, image_name, _edit)
 
     @RbdTask('copy',
              {'src_image_spec': '{image_spec}',
@@ -210,9 +193,9 @@ class Rbd(RESTController):
                 RbdConfiguration(pool_ioctx=d_ioctx, image_name=dest_image_name).set_configuration(
                     configuration)
 
-            return _rbd_call(dest_pool_name, dest_namespace, _copy)
+            return rbd_call(dest_pool_name, dest_namespace, _copy)
 
-        return _rbd_image_call(pool_name, namespace, image_name, _src_copy)
+        return rbd_image_call(pool_name, namespace, image_name, _src_copy)
 
     @RbdTask('flatten', ['{image_spec}'], 2.0)
     @RESTController.Resource('POST')
@@ -223,7 +206,7 @@ class Rbd(RESTController):
             image.flatten()
 
         pool_name, namespace, image_name = parse_image_spec(image_spec)
-        return _rbd_image_call(pool_name, namespace, image_name, _flatten)
+        return rbd_image_call(pool_name, namespace, image_name, _flatten)
 
     @RESTController.Collection('GET')
     def default_features(self):
@@ -239,7 +222,7 @@ class Rbd(RESTController):
         """
         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 rbd_call(pool_name, namespace, rbd_inst.trash_move, image_name, delay)
 
 
 @ApiController('/block/image/{image_spec}/snap', Scope.RBD_IMAGE)
@@ -255,18 +238,13 @@ class RbdSnapshot(RESTController):
         def _create_snapshot(ioctx, img, snapshot_name):
             img.create_snap(snapshot_name)
 
-        return _rbd_image_call(pool_name, namespace, image_name, _create_snapshot,
-                               snapshot_name)
+        return rbd_image_call(pool_name, namespace, image_name, _create_snapshot,
+                              snapshot_name)
 
     @RbdTask('snap/delete',
              ['{image_spec}', '{snapshot_name}'], 2.0)
     def delete(self, image_spec, snapshot_name):
-        def _remove_snapshot(ioctx, img, snapshot_name):
-            img.remove_snap(snapshot_name)
-
-        pool_name, namespace, image_name = parse_image_spec(image_spec)
-        return _rbd_image_call(pool_name, namespace, image_name, _remove_snapshot,
-                               snapshot_name)
+        return RbdSnapshotService.remove_snapshot(image_spec, snapshot_name)
 
     @RbdTask('snap/edit',
              ['{image_spec}', '{snapshot_name}'], 4.0)
@@ -284,7 +262,7 @@ class RbdSnapshot(RESTController):
                     img.unprotect_snap(snapshot_name)
 
         pool_name, namespace, image_name = parse_image_spec(image_spec)
-        return _rbd_image_call(pool_name, namespace, image_name, _edit, snapshot_name)
+        return rbd_image_call(pool_name, namespace, image_name, _edit, snapshot_name)
 
     @RbdTask('snap/rollback',
              ['{image_spec}', '{snapshot_name}'], 5.0)
@@ -295,7 +273,7 @@ class RbdSnapshot(RESTController):
             img.rollback_to_snap(snapshot_name)
 
         pool_name, namespace, image_name = parse_image_spec(image_spec)
-        return _rbd_image_call(pool_name, namespace, image_name, _rollback, snapshot_name)
+        return rbd_image_call(pool_name, namespace, image_name, _rollback, snapshot_name)
 
     @RbdTask('clone',
              {'parent_image_spec': '{image_spec}',
@@ -330,9 +308,9 @@ class RbdSnapshot(RESTController):
                 RbdConfiguration(pool_ioctx=ioctx, image_name=child_image_name).set_configuration(
                     configuration)
 
-            return _rbd_call(child_pool_name, child_namespace, _clone)
+            return rbd_call(child_pool_name, child_namespace, _clone)
 
-        _rbd_call(pool_name, namespace, _parent_clone)
+        rbd_call(pool_name, namespace, _parent_clone)
 
 
 @ApiController('/block/image/trash', Scope.RBD_IMAGE)
@@ -391,8 +369,8 @@ class RbdTrash(RESTController):
         for pool in pools:
             for image in pool['value']:
                 if image['deferment_end_time'] < now:
-                    _rbd_call(pool['pool_name'], image['namespace'],
-                              self.rbd_inst.trash_remove, image['id'], 0)
+                    rbd_call(pool['pool_name'], image['namespace'],
+                             self.rbd_inst.trash_remove, image['id'], 0)
 
     @RbdTask('trash/restore', ['{image_id_spec}', '{new_image_name}'], 2.0)
     @RESTController.Resource('POST')
@@ -400,8 +378,8 @@ class RbdTrash(RESTController):
     def restore(self, image_id_spec, new_image_name):
         """Restore an image from trash."""
         pool_name, namespace, image_id = parse_image_spec(image_id_spec)
-        return _rbd_call(pool_name, namespace, self.rbd_inst.trash_restore, image_id,
-                         new_image_name)
+        return rbd_call(pool_name, namespace, self.rbd_inst.trash_restore, image_id,
+                        new_image_name)
 
     @RbdTask('trash/remove', ['{image_id_spec}'], 2.0)
     def delete(self, image_id_spec, force=False):
@@ -410,8 +388,8 @@ class RbdTrash(RESTController):
         But an actively in-use by clones or has snapshots can not be removed.
         """
         pool_name, namespace, image_id = parse_image_spec(image_id_spec)
-        return _rbd_call(pool_name, namespace, self.rbd_inst.trash_remove, image_id,
-                         int(str_to_bool(force)))
+        return rbd_call(pool_name, namespace, self.rbd_inst.trash_remove, image_id,
+                        int(str_to_bool(force)))
 
 
 @ApiController('/block/pool/{pool_name}/namespace', Scope.RBD_IMAGE)
index 497f8c3d72cb6b334606fb6da889f74e4ad0fdad..027289030854d8a1cc8cd43214f1bd682ad02d8a 100644 (file)
@@ -13,11 +13,11 @@ import rbd
 
 from . import ApiController, Endpoint, Task, BaseController, ReadPermission, \
     UpdatePermission, RESTController
-from .rbd import _rbd_call
 
 from .. import mgr
 from ..security import Scope
 from ..services.ceph_service import CephService
+from ..services.rbd import rbd_call
 from ..tools import ViewCache
 from ..services.exception import handle_rados_error, handle_rbd_error, \
     serialize_dashboard_exception
@@ -403,7 +403,7 @@ class RbdMirroringPoolMode(RESTController):
                     rbd.RBD().mirror_mode_set(ioctx, mode_enum)
                 _reset_view_cache()
 
-        return _rbd_call(pool_name, None, _edit, mirror_mode)
+        return rbd_call(pool_name, None, _edit, mirror_mode)
 
 
 @ApiController('/block/mirroring/pool/{pool_name}/bootstrap',
index 0058b10df17a317e79cd745238e9240337b7f61c..e539e5c081d183837aa4820008d663dfaa7f822f 100644 (file)
@@ -28,7 +28,8 @@
 
 <ng-template #parentTpl
              let-value="value">
-  <span *ngIf="value">{{ value.pool_name }}<span *ngIf="value.pool_namespace">/{{ value.pool_namespace }}</span>/{{ value.image_name }}@{{ value.snap_name }}</span>
+  <span *ngIf="value">{{ value.pool_name }}<span
+          *ngIf="value.pool_namespace">/{{ value.pool_namespace }}</span>/{{ value.image_name }}@{{ value.snap_name }}</span>
   <span *ngIf="!value">-</span>
 </ng-template>
 
   <strong>{{ value.parent }}</strong> to child
   <strong>{{ value.child }}</strong>.
 </ng-template>
+
+<ng-template #deleteTpl
+             let-hasSnapshots="hasSnapshots"
+             let-snapshots="snapshots">
+  <div class="alert alert-warning"
+       *ngIf="hasSnapshots"
+       role="alert">
+    <span i18n>Deleting this image will also delete all its snapshots.</span>
+    <br>
+    <ng-container *ngIf="snapshots.length > 0">
+      <span i18n>The following snapshots are currently protected and will be removed:</span>
+      <ul>
+        <li *ngFor="let snapshot of snapshots">{{ snapshot }}</li>
+      </ul>
+    </ng-container>
+  </div>
+</ng-template>
index b4b67710f33f3cc0e2a8d0708388fb03fac59c0e..5647abcf29e8222f71b79b43ceefe7e43c27aed0 100644 (file)
@@ -99,6 +99,67 @@ describe('RbdListComponent', () => {
     });
   });
 
+  describe('handling of deletion', () => {
+    beforeEach(() => {
+      fixture.detectChanges();
+    });
+
+    it('should check if there are no snapshots', () => {
+      component.selection.add({
+        id: '-1',
+        name: 'rbd1',
+        pool_name: 'rbd'
+      });
+      expect(component.hasSnapshots()).toBeFalsy();
+    });
+
+    it('should check if there are snapshots', () => {
+      component.selection.add({
+        id: '-1',
+        name: 'rbd1',
+        pool_name: 'rbd',
+        snapshots: [{}, {}]
+      });
+      expect(component.hasSnapshots()).toBeTruthy();
+    });
+
+    it('should get delete disable description', () => {
+      component.selection.add({
+        id: '-1',
+        name: 'rbd1',
+        pool_name: 'rbd',
+        snapshots: [
+          {
+            children: [{}]
+          }
+        ]
+      });
+      expect(component.getDeleteDisableDesc()).toBe(
+        'This RBD has cloned snapshots. Please delete related RBDs before deleting this RBD.'
+      );
+    });
+
+    it('should list all protected snapshots', () => {
+      component.selection.add({
+        id: '-1',
+        name: 'rbd1',
+        pool_name: 'rbd',
+        snapshots: [
+          {
+            name: 'snap1',
+            is_protected: false
+          },
+          {
+            name: 'snap2',
+            is_protected: true
+          }
+        ]
+      });
+
+      expect(component.listProtectedSnapshots()).toEqual(['snap2']);
+    });
+  });
+
   describe('handling of executing tasks', () => {
     let images: RbdModel[];
 
index 56187a69e51246489961dbf6a8e0cd7119fadd4b..aaea7eb89055490059fb0cc5cf50b0f51cad9f41 100644 (file)
@@ -51,6 +51,8 @@ export class RbdListComponent implements OnInit {
   nameTpl: TemplateRef<any>;
   @ViewChild('flattenTpl', { static: true })
   flattenTpl: TemplateRef<any>;
+  @ViewChild('deleteTpl', { static: true })
+  deleteTpl: TemplateRef<any>;
 
   permission: Permission;
   tableActions: CdTableAction[];
@@ -131,7 +133,12 @@ export class RbdListComponent implements OnInit {
       permission: 'delete',
       icon: Icons.destroy,
       click: () => this.deleteRbdModal(),
-      name: this.actionLabels.DELETE
+      name: this.actionLabels.DELETE,
+      disable: (selection: CdTableSelection) =>
+        !this.selection.first() ||
+        !this.selection.hasSingleSelection ||
+        this.hasClonedSnapshots(selection.first()),
+      disableDesc: () => this.getDeleteDisableDesc()
     };
     const copyAction: CdTableAction = {
       permission: 'create',
@@ -327,6 +334,11 @@ export class RbdListComponent implements OnInit {
       initialState: {
         itemDescription: 'RBD',
         itemNames: [imageSpec],
+        bodyTemplate: this.deleteTpl,
+        bodyContext: {
+          hasSnapshots: this.hasSnapshots(),
+          snapshots: this.listProtectedSnapshots()
+        },
         submitActionObservable: () =>
           this.taskWrapper.wrapTaskAroundCall({
             task: new FinishedTask('rbd/delete', {
@@ -342,7 +354,8 @@ export class RbdListComponent implements OnInit {
     const initialState = {
       poolName: this.selection.first().pool_name,
       namespace: this.selection.first().namespace,
-      imageName: this.selection.first().name
+      imageName: this.selection.first().name,
+      hasSnapshots: this.hasSnapshots()
     };
     this.modalRef = this.modalService.show(RbdTrashMoveModalComponent, { initialState });
   }
@@ -387,4 +400,36 @@ export class RbdListComponent implements OnInit {
 
     this.modalRef = this.modalService.show(ConfirmationModalComponent, { initialState });
   }
+
+  hasSnapshots() {
+    const snapshots = this.selection.first()['snapshots'] || [];
+    return snapshots.length > 0;
+  }
+
+  hasClonedSnapshots(image: object) {
+    const snapshots = image['snapshots'] || [];
+    return snapshots.some((snap: object) => snap['children'] && snap['children'].length > 0);
+  }
+
+  listProtectedSnapshots() {
+    const first = this.selection.first();
+    const snapshots = first['snapshots'];
+    return snapshots.reduce((accumulator: string[], snap: object) => {
+      if (snap['is_protected']) {
+        accumulator.push(snap['name']);
+      }
+      return accumulator;
+    }, []);
+  }
+
+  getDeleteDisableDesc(): string {
+    const first = this.selection.first();
+    if (first && this.hasClonedSnapshots(first)) {
+      return this.i18n(
+        'This RBD has cloned snapshots. Please delete related RBDs before deleting this RBD.'
+      );
+    }
+
+    return '';
+  }
 }
index c08f8861340b6395d976a48c9b293dd594c4b2a2..cb1ee7f89dc3a4154adc84fd101a2ec2c3589e16 100644 (file)
@@ -9,6 +9,13 @@
           [formGroup]="moveForm"
           novalidate>
       <div class="modal-body">
+        <div class="alert alert-warning"
+             *ngIf="hasSnapshots"
+             role="alert">
+          <span i18n>This image contains snapshot(s), which will prevent it
+            from being removed after moved to trash.</span>
+        </div>
+
         <p i18n>To move <kbd>{{ imageSpecStr }}</kbd> to trash,
           click <kbd>Move Image</kbd>. Optionally, you can pick an expiration date.</p>
 
index a968fe55698eb2c6ecbbea7a422c23afd98f5eb6..7ef805795da0a685a3952d8829d68033a404d378 100644 (file)
@@ -18,9 +18,12 @@ import { TaskWrapperService } from '../../../shared/services/task-wrapper.servic
   styleUrls: ['./rbd-trash-move-modal.component.scss']
 })
 export class RbdTrashMoveModalComponent implements OnInit {
+  // initial state
   poolName: string;
   namespace: string;
   imageName: string;
+  hasSnapshots: boolean;
+
   imageSpec: ImageSpec;
   imageSpecStr: string;
   executingTasks: ExecutingTask[];
index a88bfbf14761cc4a9ede093a3f7567317b6dbaa0..f0afff823dd3ad3e30943ffe81c8dd19b88087fe 100644 (file)
@@ -168,6 +168,7 @@ describe('TaskManagerMessageService', () => {
       it('tests rbd/delete messages', () => {
         finishedTask.name = 'rbd/delete';
         testDelete(defaultMsg);
+        testErrorCode(16, `${defaultMsg} is busy.`);
         testErrorCode(39, `${defaultMsg} contains snapshots.`);
       });
 
index 51d1ff06dfec148d27036c24c0a31c7d8e3d49b4..0f2ef4d46802d38ce9f15bf0e4e84b3c80f55a17 100644 (file)
@@ -220,6 +220,9 @@ export class TaskMessageService {
       this.commonOperations.delete,
       this.rbd.default,
       (metadata) => ({
+        '16': this.i18n('{{rbd_name}} is busy.', {
+          rbd_name: this.rbd.default(metadata)
+        }),
         '39': this.i18n('{{rbd_name}} contains snapshots.', {
           rbd_name: this.rbd.default(metadata)
         })
index e1670b696814ba3b467443d1d6d3b5190409841b..d4a38f22b290fdc6d3a6de0c21a2193b79de0c0c 100644 (file)
@@ -1,8 +1,11 @@
 # -*- coding: utf-8 -*-
+# pylint: disable=unused-argument
 from __future__ import absolute_import
 
 import six
 
+import cherrypy
+
 import rbd
 
 from .. import mgr
@@ -81,6 +84,20 @@ def parse_image_spec(image_spec):
     return pool_name, namespace, image_name
 
 
+def rbd_call(pool_name, namespace, func, *args, **kwargs):
+    with mgr.rados.open_ioctx(pool_name) as ioctx:
+        ioctx.set_namespace(namespace if namespace is not None else '')
+        func(ioctx, *args, **kwargs)
+
+
+def rbd_image_call(pool_name, namespace, image_name, func, *args, **kwargs):
+    def _ioctx_func(ioctx, image_name, func, *args, **kwargs):
+        with rbd.Image(ioctx, image_name) as img:
+            func(ioctx, img, *args, **kwargs)
+
+    return rbd_call(pool_name, namespace, _ioctx_func, image_name, func, *args, **kwargs)
+
+
 class RbdConfiguration(object):
     _rbd = rbd.RBD()
 
@@ -103,8 +120,11 @@ class RbdConfiguration(object):
         # type: () -> List[dict]
         def _list(ioctx):
             if self._image_name:  # image config
-                with rbd.Image(ioctx, self._image_name) as image:
-                    result = image.config_list()
+                try:
+                    with rbd.Image(ioctx, self._image_name) as image:
+                        result = image.config_list()
+                except rbd.ImageNotFound:
+                    result = []
             else:  # pool config
                 result = self._rbd.config_list(ioctx)
             return list(result)
@@ -214,7 +234,7 @@ class RbdService(object):
         return total_used_size, snap_map
 
     @classmethod
-    def rbd_image(cls, ioctx, pool_name, namespace, image_name):
+    def _rbd_image(cls, ioctx, pool_name, namespace, image_name):
         with rbd.Image(ioctx, image_name) as img:
 
             stat = img.stat()
@@ -298,7 +318,7 @@ class RbdService(object):
 
     @classmethod
     def _rbd_image_stat(cls, ioctx, pool_name, namespace, image_name):
-        return cls.rbd_image(ioctx, pool_name, namespace, image_name)
+        return cls._rbd_image(ioctx, pool_name, namespace, image_name)
 
     @classmethod
     @ViewCache()
@@ -323,3 +343,28 @@ class RbdService(object):
                         continue
                     result.append(stat)
             return result
+
+    @classmethod
+    def get_image(cls, image_spec):
+        pool_name, namespace, image_name = parse_image_spec(image_spec)
+        ioctx = mgr.rados.open_ioctx(pool_name)
+        if namespace:
+            ioctx.set_namespace(namespace)
+        try:
+            return cls._rbd_image(ioctx, pool_name, namespace, image_name)
+        except rbd.ImageNotFound:
+            raise cherrypy.HTTPError(404, 'Image not found')
+
+
+class RbdSnapshotService(object):
+
+    @classmethod
+    def remove_snapshot(cls, image_spec, snapshot_name, unprotect=False):
+        def _remove_snapshot(ioctx, img, snapshot_name, unprotect):
+            if unprotect:
+                img.unprotect_snap(snapshot_name)
+            img.remove_snap(snapshot_name)
+
+        pool_name, namespace, image_name = parse_image_spec(image_spec)
+        return rbd_image_call(pool_name, namespace, image_name,
+                              _remove_snapshot, snapshot_name, unprotect)