diff --git a/src/pybind/mgr/dashboard/controllers/cephfs.py b/src/pybind/mgr/dashboard/controllers/cephfs.py index 79a4c5d4645..363bef8d860 100644 --- a/src/pybind/mgr/dashboard/controllers/cephfs.py +++ b/src/pybind/mgr/dashboard/controllers/cephfs.py @@ -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) diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-list/cephfs-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-list/cephfs-list.component.spec.ts index 47923d5e0d3..5659f131c99 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-list/cephfs-list.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-list/cephfs-list.component.spec.ts @@ -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; + 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'); + }); + }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-list/cephfs-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-list/cephfs-list.component.ts index c752a9c58e4..b0f61fc6d51 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-list/cephfs-list.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-list/cephfs-list.component.ts @@ -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}'` + ); + }); + } + }); + } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-subvolume-group/cephfs-subvolume-group.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-subvolume-group/cephfs-subvolume-group.component.ts index e1fc307afaf..c67148d1a5e 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-subvolume-group/cephfs-subvolume-group.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-subvolume-group/cephfs-subvolume-group.component.ts @@ -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; diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.spec.ts index 58395cd6705..90fa98845b4 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.spec.ts @@ -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'); + }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.ts index fb5c9e8120b..6142d7359de 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.ts @@ -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' + }); + } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts index f0bc2825144..607dd743a8a 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts @@ -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) ) }; diff --git a/src/pybind/mgr/dashboard/openapi.yaml b/src/pybind/mgr/dashboard/openapi.yaml index 1772563ac0a..efc1690da8c 100644 --- a/src/pybind/mgr/dashboard/openapi.yaml +++ b/src/pybind/mgr/dashboard/openapi.yaml @@ -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: []