]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: cephfs volume rm and rename 52645/head
authoravanthakkar <avanjohn@gmail.com>
Tue, 1 Aug 2023 08:05:22 +0000 (13:35 +0530)
committeravanthakkar <avanjohn@gmail.com>
Fri, 11 Aug 2023 16:55:12 +0000 (22:25 +0530)
Fixes: https://tracker.ceph.com/issues/62408
Signed-off-by: avanthakkar <avanjohn@gmail.com>
src/pybind/mgr/dashboard/controllers/cephfs.py
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-list/cephfs-list.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-list/cephfs-list.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-subvolume-group/cephfs-subvolume-group.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts
src/pybind/mgr/dashboard/openapi.yaml

index 79a4c5d4645abf9fc8d14bf33c3877c23bd0b751..363bef8d86033f70a2072c0c57e27a60914b5f47 100644 (file)
@@ -15,7 +15,8 @@ from ..services.ceph_service import CephService
 from ..services.cephfs import CephFS as CephFS_
 from ..services.exception import handle_cephfs_error
 from ..tools import ViewCache
-from . import APIDoc, APIRouter, EndpointDoc, RESTController, UIRouter, allow_empty_body
+from . import APIDoc, APIRouter, DeletePermission, Endpoint, EndpointDoc, \
+    RESTController, UIRouter, UpdatePermission, allow_empty_body
 
 GET_QUOTAS_SCHEMA = {
     'max_bytes': (int, ''),
@@ -63,6 +64,41 @@ class CephFS(RESTController):
                 f'Error creating volume {name} with placement {str(service_spec)}: {err}')
         return f'Volume {name} created successfully'
 
+    @EndpointDoc("Remove CephFS Volume",
+                 parameters={
+                     'name': (str, 'File System Name'),
+                 })
+    @allow_empty_body
+    @Endpoint('DELETE')
+    @DeletePermission
+    def remove(self, name):
+        error_code, _, err = mgr.remote('volumes', '_cmd_fs_volume_rm', None,
+                                        {'vol_name': name,
+                                         'yes-i-really-mean-it': "--yes-i-really-mean-it"})
+        if error_code != 0:
+            raise DashboardException(
+                msg=f'Error deleting volume {name}: {err}',
+                component='cephfs')
+        return f'Volume {name} removed successfully'
+
+    @EndpointDoc("Rename CephFS Volume",
+                 parameters={
+                     'name': (str, 'Existing FS Name'),
+                     'new_name': (str, 'New FS Name'),
+                 })
+    @allow_empty_body
+    @UpdatePermission
+    @Endpoint('PUT')
+    def rename(self, name: str, new_name: str):
+        error_code, _, err = mgr.remote('volumes', '_cmd_fs_volume_rename', None,
+                                        {'vol_name': name, 'new_vol_name': new_name,
+                                         'yes_i_really_mean_it': True})
+        if error_code != 0:
+            raise DashboardException(
+                msg=f'Error renaming volume {name} to {new_name}: {err}',
+                component='cephfs')
+        return f'Volume {name} renamed successfully to {new_name}'
+
     def get(self, fs_id):
         fs_id = self.fs_id_to_int(fs_id)
         return self.fs_status(fs_id)
index 47923d5e0d3ac4afa1e4df3f4889c3191c1fad35..5659f131c99147246ffc2b94345fb1f513bafa3d 100644 (file)
@@ -4,11 +4,17 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
 import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
 import { RouterTestingModule } from '@angular/router/testing';
 
+import { ToastrModule } from 'ngx-toastr';
+
 import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
 import { SharedModule } from '~/app/shared/shared.module';
 import { configureTestBed } from '~/testing/unit-test-helper';
+import { CriticalConfirmationModalComponent } from '~/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component';
 import { CephfsVolumeFormComponent } from '../cephfs-form/cephfs-form.component';
+import { ModalService } from '~/app/shared/services/modal.service';
+import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
 import { CephfsListComponent } from './cephfs-list.component';
+import { CephfsService } from '~/app/shared/api/cephfs.service';
 
 @Component({ selector: 'cd-cephfs-tabs', template: '' })
 class CephfsTabsStubComponent {
@@ -19,19 +25,73 @@ class CephfsTabsStubComponent {
 describe('CephfsListComponent', () => {
   let component: CephfsListComponent;
   let fixture: ComponentFixture<CephfsListComponent>;
+  let cephfsService: CephfsService;
 
   configureTestBed({
-    imports: [BrowserAnimationsModule, SharedModule, HttpClientTestingModule, RouterTestingModule],
+    imports: [
+      BrowserAnimationsModule,
+      SharedModule,
+      HttpClientTestingModule,
+      ToastrModule.forRoot(),
+      RouterTestingModule
+    ],
     declarations: [CephfsListComponent, CephfsTabsStubComponent, CephfsVolumeFormComponent]
   });
 
   beforeEach(() => {
     fixture = TestBed.createComponent(CephfsListComponent);
     component = fixture.componentInstance;
+    cephfsService = TestBed.inject(CephfsService);
     fixture.detectChanges();
   });
 
   it('should create', () => {
     expect(component).toBeTruthy();
   });
+
+  describe('volume deletion', () => {
+    let taskWrapper: TaskWrapperService;
+    let modalRef: any;
+
+    const setSelectedVolume = (volName: string) =>
+      (component.selection.selected = [{ mdsmap: { fs_name: volName } }]);
+
+    const callDeletion = () => {
+      component.removeVolumeModal();
+      expect(modalRef).toBeTruthy();
+      const deletion: CriticalConfirmationModalComponent = modalRef && modalRef.componentInstance;
+      deletion.submitActionObservable();
+    };
+
+    const testVolumeDeletion = (volName: string) => {
+      setSelectedVolume(volName);
+      callDeletion();
+      expect(cephfsService.remove).toHaveBeenCalledWith(volName);
+      expect(taskWrapper.wrapTaskAroundCall).toHaveBeenCalledWith({
+        task: {
+          name: 'cephfs/remove',
+          metadata: {
+            volumeName: volName
+          }
+        },
+        call: undefined // because of stub
+      });
+    };
+
+    beforeEach(() => {
+      spyOn(TestBed.inject(ModalService), 'show').and.callFake((deletionClass, initialState) => {
+        modalRef = {
+          componentInstance: Object.assign(new deletionClass(), initialState)
+        };
+        return modalRef;
+      });
+      spyOn(cephfsService, 'remove').and.stub();
+      taskWrapper = TestBed.inject(TaskWrapperService);
+      spyOn(taskWrapper, 'wrapTaskAroundCall').and.callThrough();
+    });
+
+    it('should delete cephfs volume', () => {
+      testVolumeDeletion('somevolumeName');
+    });
+  });
 });
index c752a9c58e4b100bb5872d7a338d0719648e0407..b0f61fc6d516fdbbf6ccbfb9525e52879594037a 100644 (file)
@@ -2,11 +2,17 @@ import { Component, OnInit } from '@angular/core';
 import { Permissions } from '~/app/shared/models/permissions';
 import { Router } from '@angular/router';
 
+import _ from 'lodash';
+
 import { CephfsService } from '~/app/shared/api/cephfs.service';
+import { ConfigurationService } from '~/app/shared/api/configuration.service';
 import { ListWithDetails } from '~/app/shared/classes/list-with-details.class';
-import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
 import { CellTemplate } from '~/app/shared/enum/cell-template.enum';
+import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
 import { Icons } from '~/app/shared/enum/icons.enum';
+import { CriticalConfirmationModalComponent } from '~/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component';
+import { NotificationType } from '~/app/shared/enum/notification-type.enum';
+import { FormModalComponent } from '~/app/shared/components/form-modal/form-modal.component';
 import { CdTableAction } from '~/app/shared/models/cd-table-action';
 import { CdTableColumn } from '~/app/shared/models/cd-table-column';
 import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data-context';
@@ -14,6 +20,10 @@ import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
 import { CdDatePipe } from '~/app/shared/pipes/cd-date.pipe';
 import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
 import { URLBuilderService } from '~/app/shared/services/url-builder.service';
+import { ModalService } from '~/app/shared/services/modal.service';
+import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
+import { FinishedTask } from '~/app/shared/models/finished-task';
+import { NotificationService } from '~/app/shared/services/notification.service';
 
 const BASE_URL = 'cephfs';
 
@@ -29,6 +39,8 @@ export class CephfsListComponent extends ListWithDetails implements OnInit {
   selection = new CdTableSelection();
   tableActions: CdTableAction[];
   permissions: Permissions;
+  icons = Icons;
+  monAllowPoolDelete = false;
 
   constructor(
     private authStorageService: AuthStorageService,
@@ -36,7 +48,11 @@ export class CephfsListComponent extends ListWithDetails implements OnInit {
     private cdDatePipe: CdDatePipe,
     public actionLabels: ActionLabelsI18n,
     private router: Router,
-    private urlBuilder: URLBuilderService
+    private urlBuilder: URLBuilderService,
+    private configurationService: ConfigurationService,
+    private modalService: ModalService,
+    private taskWrapper: TaskWrapperService,
+    public notificationService: NotificationService
   ) {
     super();
     this.permissions = this.authStorageService.getPermissions();
@@ -69,8 +85,32 @@ export class CephfsListComponent extends ListWithDetails implements OnInit {
         icon: Icons.add,
         click: () => this.router.navigate([this.urlBuilder.getCreate()]),
         canBePrimary: (selection: CdTableSelection) => !selection.hasSelection
+      },
+      {
+        name: this.actionLabels.EDIT,
+        permission: 'update',
+        icon: Icons.edit,
+        click: () => this.editAction()
+      },
+      {
+        permission: 'delete',
+        icon: Icons.destroy,
+        click: () => this.removeVolumeModal(),
+        name: this.actionLabels.REMOVE,
+        disable: this.getDisableDesc.bind(this)
       }
     ];
+
+    if (this.permissions.configOpt.read) {
+      this.configurationService.get('mon_allow_pool_delete').subscribe((data: any) => {
+        if (_.has(data, 'value')) {
+          const monSection = _.find(data.value, (v) => {
+            return v.section === 'mon';
+          }) || { value: false };
+          this.monAllowPoolDelete = monSection.value === 'true' ? true : false;
+        }
+      });
+    }
   }
 
   loadFilesystems(context: CdTableFetchDataContext) {
@@ -87,4 +127,56 @@ export class CephfsListComponent extends ListWithDetails implements OnInit {
   updateSelection(selection: CdTableSelection) {
     this.selection = selection;
   }
+
+  removeVolumeModal() {
+    const volName = this.selection.first().mdsmap['fs_name'];
+    this.modalService.show(CriticalConfirmationModalComponent, {
+      itemDescription: 'Volume',
+      itemNames: [volName],
+      actionDescription: 'remove',
+      submitActionObservable: () =>
+        this.taskWrapper.wrapTaskAroundCall({
+          task: new FinishedTask('cephfs/remove', { volumeName: volName }),
+          call: this.cephfsService.remove(volName)
+        })
+    });
+  }
+
+  getDisableDesc(): boolean | string {
+    if (this.selection?.hasSelection) {
+      if (!this.monAllowPoolDelete) {
+        return $localize`Volume deletion is disabled by the mon_allow_pool_delete configuration setting.`;
+      }
+
+      return false;
+    }
+
+    return true;
+  }
+
+  editAction() {
+    const selectedVolume = this.selection.first().mdsmap['fs_name'];
+
+    this.modalService.show(FormModalComponent, {
+      titleText: $localize`Edit Volume: ${selectedVolume}`,
+      fields: [
+        {
+          type: 'text',
+          name: 'volumeName',
+          value: selectedVolume,
+          label: $localize`Name`,
+          required: true
+        }
+      ],
+      submitButtonText: $localize`Edit Volume`,
+      onSubmit: (values: any) => {
+        this.cephfsService.rename(selectedVolume, values.volumeName).subscribe(() => {
+          this.notificationService.show(
+            NotificationType.success,
+            $localize`Updated Volume '${selectedVolume}'`
+          );
+        });
+      }
+    });
+  }
 }
index e1fc307afaf99221b1f58e32ba1b9f426adf7c81..c67148d1a5ec4d44f2eb0279d39b71cb06eda514 100644 (file)
@@ -1,4 +1,4 @@
-import { Component, Input, OnInit, ViewChild } from '@angular/core';
+import { Component, Input, OnChanges, OnInit, ViewChild } from '@angular/core';
 import { Observable, ReplaySubject, of } from 'rxjs';
 import { catchError, shareReplay, switchMap } from 'rxjs/operators';
 
@@ -21,7 +21,7 @@ import { Permissions } from '~/app/shared/models/permissions';
   templateUrl: './cephfs-subvolume-group.component.html',
   styleUrls: ['./cephfs-subvolume-group.component.scss']
 })
-export class CephfsSubvolumeGroupComponent implements OnInit {
+export class CephfsSubvolumeGroupComponent implements OnInit, OnChanges {
   @ViewChild('quotaUsageTpl', { static: true })
   quotaUsageTpl: any;
 
index 58395cd6705f6ca52fc2ea85f6f617d71956ded4..90fa98845b439649e507da5e23b30ec21934c7d0 100644 (file)
@@ -95,4 +95,20 @@ describe('CephfsService', () => {
     expect(req.request.method).toBe('PUT');
     expect(req.request.body).toEqual({ max_bytes: 1024, max_files: 10 });
   });
+
+  it('should rename the cephfs volume', () => {
+    const volName = 'testvol';
+    const newVolName = 'newtestvol';
+    service.rename(volName, newVolName).subscribe();
+    const req = httpTesting.expectOne('api/cephfs/rename');
+    expect(req.request.method).toBe('PUT');
+    expect(req.request.body).toEqual({ name: 'testvol', new_name: 'newtestvol' });
+  });
+
+  it('should remove the cephfs volume', () => {
+    const volName = 'testvol';
+    service.remove(volName).subscribe();
+    const req = httpTesting.expectOne(`api/cephfs/remove/${volName}`);
+    expect(req.request.method).toBe('DELETE');
+  });
 });
index fb5c9e8120b0d1b9b162f85b233ed1ed304d51f4..6142d7359de26614225c8d2476dd1772f41d3e73 100644 (file)
@@ -87,4 +87,20 @@ export class CephfsService {
   isCephFsPool(pool: any) {
     return _.indexOf(pool.application_metadata, 'cephfs') !== -1 && !pool.pool_name.includes('/');
   }
+
+  remove(name: string) {
+    return this.http.delete(`${this.baseURL}/remove/${name}`, {
+      observe: 'response'
+    });
+  }
+
+  rename(vol_name: string, new_vol_name: string) {
+    let requestBody = {
+      name: vol_name,
+      new_name: new_vol_name
+    };
+    return this.http.put(`${this.baseURL}/rename`, requestBody, {
+      observe: 'response'
+    });
+  }
 }
index f0bc2825144a3a126485be09036059208e392408..607dd743a8a9d8df869e80fba5b3598baa435b00 100644 (file)
@@ -361,6 +361,9 @@ export class TaskMessageService {
     ),
     'cephfs/subvolume/group/create': this.newTaskMessage(this.commonOperations.create, (metadata) =>
       this.subvolumegroup(metadata)
+    ),
+    'cephfs/remove': this.newTaskMessage(this.commonOperations.remove, (metadata) =>
+      this.volume(metadata)
     )
   };
 
index 1772563ac0a1a6d996749e3f0ad716cc4058b2bb..efc1690da8caf94c29d2dbd3bb5524436817533b 100644 (file)
@@ -1681,6 +1681,83 @@ paths:
       - jwt: []
       tags:
       - Cephfs
+  /api/cephfs/remove/{name}:
+    delete:
+      parameters:
+      - description: File System Name
+        in: path
+        name: name
+        required: true
+        schema:
+          type: string
+      responses:
+        '202':
+          content:
+            application/vnd.ceph.api.v1.0+json:
+              type: object
+          description: Operation is still executing. Please check the task queue.
+        '204':
+          content:
+            application/vnd.ceph.api.v1.0+json:
+              type: object
+          description: Resource deleted.
+        '400':
+          description: Operation exception. Please check the response body for details.
+        '401':
+          description: Unauthenticated access. Please login first.
+        '403':
+          description: Unauthorized access. Please check your permissions.
+        '500':
+          description: Unexpected error. Please check the response body for the stack
+            trace.
+      security:
+      - jwt: []
+      summary: Remove CephFS Volume
+      tags:
+      - Cephfs
+  /api/cephfs/rename:
+    put:
+      parameters: []
+      requestBody:
+        content:
+          application/json:
+            schema:
+              properties:
+                name:
+                  description: Existing FS Name
+                  type: string
+                new_name:
+                  description: New FS Name
+                  type: string
+              required:
+              - name
+              - new_name
+              type: object
+      responses:
+        '200':
+          content:
+            application/vnd.ceph.api.v1.0+json:
+              type: object
+          description: Resource updated.
+        '202':
+          content:
+            application/vnd.ceph.api.v1.0+json:
+              type: object
+          description: Operation is still executing. Please check the task queue.
+        '400':
+          description: Operation exception. Please check the response body for details.
+        '401':
+          description: Unauthenticated access. Please login first.
+        '403':
+          description: Unauthorized access. Please check your permissions.
+        '500':
+          description: Unexpected error. Please check the response body for the stack
+            trace.
+      security:
+      - jwt: []
+      summary: Rename CephFS Volume
+      tags:
+      - Cephfs
   /api/cephfs/subvolume:
     post:
       parameters: []