@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
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'}}})
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)
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)
from functools import partial
from datetime import datetime
-import cherrypy
-
import rbd
from . import ApiController, RESTController, Task, UpdatePermission, \
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
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:
@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)
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):
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}',
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')
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):
"""
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)
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)
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)
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}',
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)
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')
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):
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)
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
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',
<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>
});
});
+ 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[];
nameTpl: TemplateRef<any>;
@ViewChild('flattenTpl', { static: true })
flattenTpl: TemplateRef<any>;
+ @ViewChild('deleteTpl', { static: true })
+ deleteTpl: TemplateRef<any>;
permission: Permission;
tableActions: CdTableAction[];
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',
initialState: {
itemDescription: 'RBD',
itemNames: [imageSpec],
+ bodyTemplate: this.deleteTpl,
+ bodyContext: {
+ hasSnapshots: this.hasSnapshots(),
+ snapshots: this.listProtectedSnapshots()
+ },
submitActionObservable: () =>
this.taskWrapper.wrapTaskAroundCall({
task: new FinishedTask('rbd/delete', {
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 });
}
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 '';
+ }
}
[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>
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[];
it('tests rbd/delete messages', () => {
finishedTask.name = 'rbd/delete';
testDelete(defaultMsg);
+ testErrorCode(16, `${defaultMsg} is busy.`);
testErrorCode(39, `${defaultMsg} contains snapshots.`);
});
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)
})
# -*- coding: utf-8 -*-
+# pylint: disable=unused-argument
from __future__ import absolute_import
import six
+import cherrypy
+
import rbd
from .. import mgr
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()
# 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)
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()
@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()
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)