]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: show partially deleted RBDs 41887/head
authorTatjana Dehler <tdehler@suse.com>
Thu, 27 May 2021 09:46:50 +0000 (11:46 +0200)
committerTatjana Dehler <tdehler@suse.com>
Fri, 18 Jun 2021 15:23:50 +0000 (17:23 +0200)
An RBD might be partially deleted if the deletion
process has been started but was interrupted. In
this case return the RBD as part of the RBD list
and mark it as partially deleted.

Fixes: https://tracker.ceph.com/issues/48603
Signed-off-by: Tatjana Dehler <tdehler@suse.com>
(cherry picked from commit d83c277ac1861df31d2a39d16e20c7bebbea676e)

Conflicts:
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-details/rbd-details.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/services/rbd.py
src/pybind/mgr/dashboard/tests/test_rbd_service.py
Resolved various conflicts because octopus and
master diverged a lot.

src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-details/rbd-details.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form.model.ts
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.scss
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/services/rbd.py
src/pybind/mgr/dashboard/tests/test_rbd_service.py

index b42094d1d0b1be10c8297a72b26100149d5fd832..960b6111ca9b71eb9c9f9e5beccefe046a435c77 100644 (file)
@@ -2,7 +2,7 @@
   <ng-container i18n>Only available for RBD images with <strong>fast-diff</strong> enabled</ng-container>
 </ng-template>
 
-<tabset *ngIf="selection">
+<tabset *ngIf="selection && selection.source !== 'REMOVING'">
   <tab i18n-heading
        heading="Details">
     <table class="table table-striped table-bordered">
     </cd-grafana>
   </tab>
 </tabset>
+<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
index 6a4999d0fc57de47cfa59bcc1927b821a01c7209..e257a07c10127dab81a406607c561d05c6faa72a 100644 (file)
@@ -14,4 +14,7 @@ export class RbdFormModel {
 
   /* Configuration */
   configuration: RbdConfigurationEntry[];
+
+  /* Deletion process */
+  source?: string;
 }
index dc198e90724f6d0b8d2329b463cb5ca3f7d931a4..cd2af51c394f901b0393509655dfd5d0e7183c75 100644 (file)
     </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>
index 87f8e78f994fec3048ae86a8f8cc6903e3e0be4a..a89abf450309c22d4f6edbdf1ccd4325060c8910 100644 (file)
@@ -306,4 +306,34 @@ describe('RbdListComponent', () => {
       }
     });
   });
+
+  const getActionDisable = (name: string) =>
+    component.tableActions.find((o) => o.name === name).disable;
+
+  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.moveTrash || false);
+  };
+
+  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);
+  });
 });
index da53bb3fd549a201beb2787176dc7fdbfc3c659c..fccc5e660082a123914110f2dea1495d75016e01 100644 (file)
@@ -10,7 +10,6 @@ import { ConfirmationModalComponent } from '../../../shared/components/confirmat
 import { CriticalConfirmationModalComponent } from '../../../shared/components/critical-confirmation-modal/critical-confirmation-modal.component';
 import { ActionLabelsI18n } from '../../../shared/constants/app.constants';
 import { TableComponent } from '../../../shared/datatable/table/table.component';
-import { CellTemplate } from '../../../shared/enum/cell-template.enum';
 import { Icons } from '../../../shared/enum/icons.enum';
 import { ViewCacheStatus } from '../../../shared/enum/view-cache-status.enum';
 import { CdTableAction } from '../../../shared/models/cd-table-action';
@@ -54,6 +53,8 @@ export class RbdListComponent extends ListWithDetails implements OnInit {
   flattenTpl: TemplateRef<any>;
   @ViewChild('deleteTpl', { static: true })
   deleteTpl: TemplateRef<any>;
+  @ViewChild('removingStatTpl', { static: true })
+  removingStatTpl: TemplateRef<any>;
 
   permission: Permission;
   tableActions: CdTableAction[];
@@ -62,6 +63,7 @@ export class RbdListComponent extends ListWithDetails implements OnInit {
   retries: number;
   viewCacheStatusList: any[];
   selection = new CdTableSelection();
+  icons = Icons;
 
   modalRef: BsModalRef;
 
@@ -131,7 +133,9 @@ export class RbdListComponent extends ListWithDetails implements OnInit {
       permission: 'update',
       icon: Icons.edit,
       routerLink: () => this.urlBuilder.getEdit(getImageUri()),
-      name: this.actionLabels.EDIT
+      name: this.actionLabels.EDIT,
+      disable: (selection: CdTableSelection) =>
+        !selection.hasSingleSelection || this.getRemovingStatusDesc(selection)
     };
     const deleteAction: CdTableAction = {
       permission: 'delete',
@@ -144,7 +148,9 @@ export class RbdListComponent extends ListWithDetails implements OnInit {
       permission: 'create',
       canBePrimary: (selection: CdTableSelection) => selection.hasSingleSelection,
       disable: (selection: CdTableSelection) =>
-        !selection.hasSingleSelection || selection.first().cdExecuting,
+        !selection.hasSingleSelection ||
+        selection.first().cdExecuting ||
+        this.getRemovingStatusDesc(selection),
       icon: Icons.copy,
       routerLink: () => `/block/rbd/copy/${getImageUri()}`,
       name: this.actionLabels.COPY
@@ -152,7 +158,10 @@ export class RbdListComponent extends ListWithDetails implements OnInit {
     const flattenAction: CdTableAction = {
       permission: 'update',
       disable: (selection: CdTableSelection) =>
-        !selection.hasSingleSelection || selection.first().cdExecuting || !selection.first().parent,
+        !selection.hasSingleSelection ||
+        selection.first().cdExecuting ||
+        !selection.first().parent ||
+        this.getRemovingStatusDesc(selection),
       icon: Icons.flatten,
       click: () => this.flattenRbdModal(),
       name: this.actionLabels.FLATTEN
@@ -165,7 +174,8 @@ export class RbdListComponent extends ListWithDetails implements OnInit {
       disable: (selection: CdTableSelection) =>
         !selection.first() ||
         !selection.hasSingleSelection ||
-        selection.first().image_format === RBDImageFormat.V1
+        selection.first().image_format === RBDImageFormat.V1 ||
+        this.getRemovingStatusDesc(selection)
     };
     this.tableActions = [
       addAction,
@@ -183,7 +193,7 @@ export class RbdListComponent extends ListWithDetails implements OnInit {
         name: this.i18n('Name'),
         prop: 'name',
         flexGrow: 2,
-        cellTransformation: CellTemplate.executing
+        cellTemplate: this.removingStatTpl
       },
       {
         name: this.i18n('Pool'),
@@ -441,4 +451,12 @@ export class RbdListComponent extends ListWithDetails implements OnInit {
       this.hasClonedSnapshots(selection.first())
     );
   }
+
+  getRemovingStatusDesc(selection: CdTableSelection): string | boolean {
+    const first = selection.first();
+    if (first && first.source && first.source === 'REMOVING') {
+      return this.i18n(`Action not possible for an RBD in status 'Removing'`);
+    }
+    return false;
+  }
 }
index 4e628342282eb1ed5e92aa1ac3fe843f852c043f..944e479046ee7f96491d8b542f586ce8145b9321 100644 (file)
@@ -2,6 +2,7 @@
 # pylint: disable=unused-argument
 from __future__ import absolute_import
 
+import errno
 import six
 
 import cherrypy
@@ -334,14 +335,30 @@ class RbdService(object):
             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):
@@ -356,13 +373,19 @@ class RbdService(object):
                 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
 
index caee4029180f52f5e53fa8c9994cbee719d2a232..6989c9080c90b3140a7e8164230ab08982802d51 100644 (file)
@@ -3,12 +3,22 @@
 from __future__ import absolute_import
 
 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 get_image_spec, parse_image_spec, RbdConfiguration
+from .. import mgr
+from ..services.rbd import get_image_spec, parse_image_spec, RbdConfiguration, RbdService
+
+
+class ImageNotFoundStub(Exception):
+    def __init__(self, message, errno=None):
+        super(ImageNotFoundStub, self).__init__(
+            'RBD image not found (%s)' % message, errno)
 
 
 class RbdServiceTest(unittest.TestCase):
@@ -43,3 +53,90 @@ 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': ''
+        }]))