<ng-container i18n>Only available for RBD images with <strong>fast-diff</strong> enabled</ng-container>
</ng-template>
-<ng-container *ngIf="selection">
+<ng-container *ngIf="selection && selection.source !== 'REMOVING'">
<ul ngbNav
#nav="ngbNav"
class="nav-tabs"
<div [ngbNavOutlet]="nav"></div>
</ng-container>
+<ng-container *ngIf="selection && selection.source === 'REMOVING'">
+ <cd-alert-panel type="warning"
+ i18n>Information can not be displayed for RBD in status 'Removing'.</cd-alert-panel>
+</ng-container>
<ng-template #poolConfigurationSourceTpl
let-row="row"
/* Configuration */
configuration: RbdConfigurationEntry[];
+
+ /* Deletion process */
+ source?: string;
}
</ng-container>
</div>
</ng-template>
+
+<ng-template #removingStatTpl
+ let-column="column"
+ let-value="value"
+ let-row="row">
+
+ <i [ngClass]="[icons.spinner, icons.spin]"
+ *ngIf="row.cdExecuting"></i>
+ <span [ngClass]="column?.customTemplateConfig?.valueClass">
+ {{ value }}
+ </span>
+ <span *ngIf="row.cdExecuting"
+ [ngClass]="column?.customTemplateConfig?.executingClass ?
+ column.customTemplateConfig.executingClass :
+ 'text-muted italic'">
+ ({{ row.cdExecuting }})
+ </span>
+ <i *ngIf="row.source && row.source === 'REMOVING'"
+ i18n-title
+ title="RBD in status 'Removing'"
+ class="{{ icons.warning }} warn"></i>
+</ng-template>
+@use './src/styles/vendor/variables' as vv;
+
+.warn {
+ color: vv.$warning;
+}
const getActionDisable = (name: string) =>
component.tableActions.find((o) => o.name === name).disable;
- const testActions = (selection: any, expected: string | boolean) => {
- expect(getActionDisable('Edit')(selection)).toBe(expected);
- expect(getActionDisable('Delete')(selection)).toBe(expected);
- expect(getActionDisable('Copy')(selection)).toBe(expected);
+ const testActions = (selection: any, expected: { [action: string]: string | boolean }) => {
+ expect(getActionDisable('Edit')(selection)).toBe(expected.edit || false);
+ expect(getActionDisable('Delete')(selection)).toBe(expected.delete || false);
+ expect(getActionDisable('Copy')(selection)).toBe(expected.copy || false);
expect(getActionDisable('Flatten')(selection)).toBeTruthy();
- expect(getActionDisable('Move to Trash')(selection)).toBe(expected);
+ expect(getActionDisable('Move to Trash')(selection)).toBe(expected.moveTrash || false);
};
it('should test TableActions with valid/invalid image name', () => {
snapshots: []
}
];
- testActions(component.selection, false);
+ testActions(component.selection, {});
component.selection.selected = [
{
snapshots: []
}
];
- testActions(
- component.selection,
- `This RBD image has an invalid name and can't be managed by ceph.`
- );
+ const message = `This RBD image has an invalid name and can't be managed by ceph.`;
+ const expected = {
+ edit: message,
+ delete: message,
+ copy: message,
+ moveTrash: message
+ };
+ testActions(component.selection, expected);
+ });
+
+ it('should disable edit, copy, flatten and move action if RBD is in status `Removing`', () => {
+ component.selection.selected = [
+ {
+ name: 'foobar',
+ pool_name: 'rbd',
+ snapshots: [],
+ source: 'REMOVING'
+ }
+ ];
+
+ const message = `Action not possible for an RBD in status 'Removing'`;
+ const expected = {
+ edit: message,
+ copy: message,
+ moveTrash: message
+ };
+ testActions(component.selection, expected);
});
});
import { CriticalConfirmationModalComponent } from '~/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component';
import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
import { TableComponent } from '~/app/shared/datatable/table/table.component';
-import { CellTemplate } from '~/app/shared/enum/cell-template.enum';
import { Icons } from '~/app/shared/enum/icons.enum';
import { ViewCacheStatus } from '~/app/shared/enum/view-cache-status.enum';
import { CdTableAction } from '~/app/shared/models/cd-table-action';
flattenTpl: TemplateRef<any>;
@ViewChild('deleteTpl', { static: true })
deleteTpl: TemplateRef<any>;
+ @ViewChild('removingStatTpl', { static: true })
+ removingStatTpl: TemplateRef<any>;
permission: Permission;
tableActions: CdTableAction[];
retries: number;
tableStatus = new TableStatusViewCache();
selection = new CdTableSelection();
+ icons = Icons;
modalRef: NgbModalRef;
icon: Icons.edit,
routerLink: () => this.urlBuilder.getEdit(getImageUri()),
name: this.actionLabels.EDIT,
- disable: this.getInvalidNameDisable
+ disable: (selection: CdTableSelection) =>
+ this.getRemovingStatusDesc(selection) || this.getInvalidNameDisable(selection)
};
const deleteAction: CdTableAction = {
permission: 'delete',
permission: 'create',
canBePrimary: (selection: CdTableSelection) => selection.hasSingleSelection,
disable: (selection: CdTableSelection) =>
- this.getInvalidNameDisable(selection) || !!selection.first().cdExecuting,
+ this.getRemovingStatusDesc(selection) ||
+ this.getInvalidNameDisable(selection) ||
+ !!selection.first().cdExecuting,
icon: Icons.copy,
routerLink: () => `/block/rbd/copy/${getImageUri()}`,
name: this.actionLabels.COPY
const flattenAction: CdTableAction = {
permission: 'update',
disable: (selection: CdTableSelection) =>
+ this.getRemovingStatusDesc(selection) ||
this.getInvalidNameDisable(selection) ||
selection.first().cdExecuting ||
!selection.first().parent,
click: () => this.trashRbdModal(),
name: this.actionLabels.TRASH,
disable: (selection: CdTableSelection) =>
+ this.getRemovingStatusDesc(selection) ||
this.getInvalidNameDisable(selection) ||
selection.first().image_format === RBDImageFormat.V1
};
name: $localize`Name`,
prop: 'name',
flexGrow: 2,
- cellTransformation: CellTemplate.executing
+ cellTemplate: this.removingStatTpl
},
{
name: $localize`Pool`,
return !selection.first() || !selection.hasSingleSelection;
}
+
+ getRemovingStatusDesc(selection: CdTableSelection): string | boolean {
+ const first = selection.first();
+ if (first?.source === 'REMOVING') {
+ return $localize`Action not possible for an RBD in status 'Removing'`;
+ }
+ return false;
+ }
}
# -*- coding: utf-8 -*-
# pylint: disable=unused-argument
+import errno
import cherrypy
import rbd
return stat
@classmethod
- def _rbd_image_names(cls, ioctx):
+ def _rbd_image_refs(cls, ioctx):
rbd_inst = rbd.RBD()
- return rbd_inst.list(ioctx)
+ return rbd_inst.list2(ioctx)
@classmethod
def _rbd_image_stat(cls, ioctx, pool_name, namespace, image_name):
return cls._rbd_image(ioctx, pool_name, namespace, image_name)
+ @classmethod
+ def _rbd_image_stat_removing(cls, ioctx, pool_name, namespace, image_id):
+ rbd_inst = rbd.RBD()
+ img = rbd_inst.trash_get(ioctx, image_id)
+ img_spec = get_image_spec(pool_name, namespace, image_id)
+
+ if img['source'] == 'REMOVING':
+ img['unique_id'] = img_spec
+ img['pool_name'] = pool_name
+ img['namespace'] = namespace
+ img['deletion_time'] = "{}Z".format(img['deletion_time'].isoformat())
+ img['deferment_end_time'] = "{}Z".format(img['deferment_end_time'].isoformat())
+ return img
+ raise rbd.ImageNotFound('No image {} in status `REMOVING` found.'.format(img_spec),
+ errno=errno.ENOENT)
+
@classmethod
@ViewCache()
def rbd_pool_list(cls, pool_name, namespace=None):
namespaces.append('')
for current_namespace in namespaces:
ioctx.set_namespace(current_namespace)
- names = cls._rbd_image_names(ioctx)
- for name in names:
+ image_refs = cls._rbd_image_refs(ioctx)
+ for image_ref in image_refs:
try:
- stat = cls._rbd_image_stat(ioctx, pool_name, current_namespace, name)
+ stat = cls._rbd_image_stat(
+ ioctx, pool_name, current_namespace, image_ref['name'])
except rbd.ImageNotFound:
- # may have been removed in the meanwhile
- continue
+ # Check if the RBD has been deleted partially. This happens for example if
+ # the deletion process of the RBD has been started and was interrupted.
+ try:
+ stat = cls._rbd_image_stat_removing(
+ ioctx, pool_name, current_namespace, image_ref['id'])
+ except rbd.ImageNotFound:
+ continue
result.append(stat)
return result
# pylint: disable=dangerous-default-value,too-many-public-methods
import unittest
+from datetime import datetime
+from unittest.mock import MagicMock
try:
import mock
except ImportError:
import unittest.mock as mock
-from ..services.rbd import RbdConfiguration, get_image_spec, parse_image_spec
+from .. import mgr
+from ..services.rbd import RbdConfiguration, RbdService, get_image_spec, parse_image_spec
+
+
+class ImageNotFoundStub(Exception):
+ def __init__(self, message, errno=None):
+ super(ImageNotFoundStub, self).__init__(
+ 'RBD image not found (%s)' % message, errno)
class RbdServiceTest(unittest.TestCase):
self.assertEqual(config.list(), [])
config = RbdConfiguration('good-pool')
self.assertEqual(config.list(), [1, 2, 3])
+
+ @mock.patch('dashboard.services.rbd.rbd.RBD')
+ def test_rbd_image_stat_removing(self, rbd_mock):
+ time = datetime.utcnow()
+ rbd_inst_mock = rbd_mock.return_value
+ rbd_inst_mock.trash_get.return_value = {
+ 'id': '3c1a5ee60a88',
+ 'name': 'test_rbd',
+ 'source': 'REMOVING',
+ 'deletion_time': time,
+ 'deferment_end_time': time
+ }
+
+ ioctx_mock = MagicMock()
+
+ # pylint: disable=protected-access
+ rbd = RbdService._rbd_image_stat_removing(ioctx_mock, 'test_pool', '', '3c1a5ee60a88')
+ self.assertEqual(rbd, {
+ 'id': '3c1a5ee60a88',
+ 'unique_id': 'test_pool/3c1a5ee60a88',
+ 'name': 'test_rbd',
+ 'source': 'REMOVING',
+ 'deletion_time': '{}Z'.format(time.isoformat()),
+ 'deferment_end_time': '{}Z'.format(time.isoformat()),
+ 'pool_name': 'test_pool',
+ 'namespace': ''
+ })
+
+ @mock.patch('dashboard.services.rbd.rbd.ImageNotFound', new_callable=lambda: ImageNotFoundStub)
+ @mock.patch('dashboard.services.rbd.rbd.RBD')
+ def test_rbd_image_stat_filter_source_user(self, rbd_mock, _):
+ rbd_inst_mock = rbd_mock.return_value
+ rbd_inst_mock.trash_get.return_value = {
+ 'id': '3c1a5ee60a88',
+ 'name': 'test_rbd',
+ 'source': 'USER'
+ }
+
+ ioctx_mock = MagicMock()
+ with self.assertRaises(ImageNotFoundStub) as ctx:
+ # pylint: disable=protected-access
+ RbdService._rbd_image_stat_removing(ioctx_mock, 'test_pool', '', '3c1a5ee60a88')
+ self.assertIn('No image test_pool/3c1a5ee60a88 in status `REMOVING` found.',
+ str(ctx.exception))
+
+ @mock.patch('dashboard.services.rbd.rbd.ImageNotFound', new_callable=lambda: ImageNotFoundStub)
+ @mock.patch('dashboard.services.rbd.RbdService._rbd_image_stat_removing')
+ @mock.patch('dashboard.services.rbd.RbdService._rbd_image_stat')
+ @mock.patch('dashboard.services.rbd.RbdService._rbd_image_refs')
+ @mock.patch('dashboard.services.rbd.rbd.RBD')
+ def test_rbd_pool_list(self, rbd_mock, rbd_image_ref_mock, rbd_image_stat_mock,
+ rbd_image_stat_removing_mock, _):
+ time = datetime.utcnow()
+
+ ioctx_mock = MagicMock()
+ mgr.rados = MagicMock()
+ mgr.rados.open_ioctx.return_value = ioctx_mock
+
+ rbd_inst_mock = rbd_mock.return_value
+ rbd_inst_mock.namespace_list.return_value = []
+ rbd_image_ref_mock.return_value = [{'name': 'test_rbd', 'id': '3c1a5ee60a88'}]
+
+ rbd_image_stat_mock.side_effect = mock.Mock(side_effect=ImageNotFoundStub(
+ 'RBD image not found test_pool/3c1a5ee60a88'))
+
+ rbd_image_stat_removing_mock.return_value = {
+ 'id': '3c1a5ee60a88',
+ 'unique_id': 'test_pool/3c1a5ee60a88',
+ 'name': 'test_rbd',
+ 'source': 'REMOVING',
+ 'deletion_time': '{}Z'.format(time.isoformat()),
+ 'deferment_end_time': '{}Z'.format(time.isoformat()),
+ 'pool_name': 'test_pool',
+ 'namespace': ''
+ }
+
+ rbd_pool_list = RbdService.rbd_pool_list('test_pool')
+ self.assertEqual(rbd_pool_list, (0, [{
+ 'id': '3c1a5ee60a88',
+ 'unique_id': 'test_pool/3c1a5ee60a88',
+ 'name': 'test_rbd',
+ 'source': 'REMOVING',
+ 'deletion_time': '{}Z'.format(time.isoformat()),
+ 'deferment_end_time': '{}Z'.format(time.isoformat()),
+ 'pool_name': 'test_pool',
+ 'namespace': ''
+ }]))