From 78c74d1a22183e6a76e60543d016a9540db99588 Mon Sep 17 00:00:00 2001 From: Nizamudeen A Date: Tue, 23 Jan 2024 22:17:46 +0530 Subject: [PATCH] mgr/dashboard: create cephfs snapshot clone Fixes: https://tracker.ceph.com/issues/64175 Signed-off-by: Nizamudeen A (cherry picked from commit 129f1db734777f3df551965c35f40ee0ab7d467a) Conflicts: src/pybind/mgr/dashboard/controllers/cephfs.py - Left the snapshot scheduler controller out --- .../mgr/dashboard/controllers/cephfs.py | 44 +++++++++- .../cephfs-subvolume-list.component.ts | 2 +- ...phfs-subvolume-snapshots-list.component.ts | 80 ++++++++++++++++++- .../shared/api/cephfs-subvolume.service.ts | 22 +++++ .../form-modal/form-modal.component.ts | 8 +- .../models/cd-form-modal-field-config.ts | 3 +- src/pybind/mgr/dashboard/openapi.yaml | 55 +++++++++++++ 7 files changed, 203 insertions(+), 11 deletions(-) diff --git a/src/pybind/mgr/dashboard/controllers/cephfs.py b/src/pybind/mgr/dashboard/controllers/cephfs.py index cfa40b2dd7c0b..7dc8b4e9b570a 100644 --- a/src/pybind/mgr/dashboard/controllers/cephfs.py +++ b/src/pybind/mgr/dashboard/controllers/cephfs.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +import errno import json import logging import os @@ -640,11 +641,17 @@ class CephFSSubvolume(RESTController): params['sub_name'] = subvolume['name'] error_code, out, err = mgr.remote('volumes', '_cmd_fs_subvolume_info', None, params) - if error_code != 0: + # just ignore this error for now so the subvolumes page will load. + # the ideal solution is to implement a status page where clone status + # can be displayed + if error_code == -errno.EAGAIN: + pass + elif error_code != 0: raise DashboardException( f'Failed to get info for subvolume {subvolume["name"]}: {err}' ) - subvolume['info'] = json.loads(out) + if out: + subvolume['info'] = json.loads(out) return subvolumes @RESTController.Resource('GET') @@ -804,11 +811,17 @@ class CephFSSubvolumeSnapshots(RESTController): params['snap_name'] = snapshot['name'] error_code, out, err = mgr.remote('volumes', '_cmd_fs_subvolume_snapshot_info', None, params) - if error_code != 0: + # just ignore this error for now so the subvolumes page will load. + # the ideal solution is to implement a status page where clone status + # can be displayed + if error_code == -errno.EAGAIN: + pass + elif error_code != 0: raise DashboardException( f'Failed to get info for subvolume snapshot {snapshot["name"]}: {err}' ) - snapshot['info'] = json.loads(out) + if out: + snapshot['info'] = json.loads(out) return snapshots @RESTController.Resource('GET') @@ -850,3 +863,26 @@ class CephFSSubvolumeSnapshots(RESTController): f'Failed to delete subvolume snapshot {snap_name}: {err}' ) return f'Subvolume snapshot {snap_name} removed successfully' + + +@APIRouter('/cephfs/subvolume/snapshot/clone', Scope.CEPHFS) +@APIDoc("Cephfs Snapshot Clone Management API", "CephfsSnapshotClone") +class CephFsSnapshotClone(RESTController): + @EndpointDoc("Create a clone of a subvolume snapshot") + def create(self, vol_name: str, subvol_name: str, snap_name: str, clone_name: str, + group_name='', target_group_name=''): + params = {'vol_name': vol_name, 'sub_name': subvol_name, 'snap_name': snap_name, + 'target_sub_name': clone_name} + if group_name: + params['group_name'] = group_name + + if target_group_name: + params['target_group_name'] = target_group_name + + error_code, _, err = mgr.remote('volumes', '_cmd_fs_subvolume_snapshot_clone', None, + params) + if error_code != 0: + raise DashboardException( + f'Failed to create clone {clone_name}: {err}' + ) + return f'Clone {clone_name} created successfully' diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-subvolume-list/cephfs-subvolume-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-subvolume-list/cephfs-subvolume-list.component.ts index 4f9cf27db0ffd..92c139f8e5dd5 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-subvolume-list/cephfs-subvolume-list.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-subvolume-list/cephfs-subvolume-list.component.ts @@ -245,7 +245,7 @@ export class CephfsSubvolumeListComponent extends CdForm implements OnInit, OnCh switchMap(() => this.cephfsSubVolumeService.get(this.fsName, this.activeGroupName).pipe( catchError(() => { - this.context.error(); + this.context?.error(); return of(null); }) ) diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-subvolume-snapshots-list/cephfs-subvolume-snapshots-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-subvolume-snapshots-list/cephfs-subvolume-snapshots-list.component.ts index 798307a0cf9f4..3cb5b0bd47b5e 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-subvolume-snapshots-list/cephfs-subvolume-snapshots-list.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-subvolume-snapshots-list/cephfs-subvolume-snapshots-list.component.ts @@ -20,6 +20,12 @@ import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; import { CriticalConfirmationModalComponent } from '~/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component'; import { FinishedTask } from '~/app/shared/models/finished-task'; import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service'; +import { FormModalComponent } from '~/app/shared/components/form-modal/form-modal.component'; +import { NotificationService } from '~/app/shared/services/notification.service'; +import { NotificationType } from '~/app/shared/enum/notification-type.enum'; +import moment from 'moment'; +import { Validators } from '@angular/forms'; +import { CdValidators } from '~/app/shared/forms/cd-validators'; @Component({ selector: 'cd-cephfs-subvolume-snapshots-list', @@ -59,7 +65,8 @@ export class CephfsSubvolumeSnapshotsListComponent implements OnInit, OnChanges private modalService: ModalService, private authStorageService: AuthStorageService, private cdDatePipe: CdDatePipe, - private taskWrapper: TaskWrapperService + private taskWrapper: TaskWrapperService, + private notificationService: NotificationService ) { this.permissions = this.authStorageService.getPermissions(); } @@ -99,9 +106,17 @@ export class CephfsSubvolumeSnapshotsListComponent implements OnInit, OnChanges click: () => this.openModal() }, { - name: this.actionLabels.REMOVE, + name: this.actionLabels.CLONE, + permission: 'create', + icon: Icons.clone, + disable: () => !this.selection.hasSingleSelection, + click: () => this.cloneModal() + }, + { + name: this.actionLabels.DELETE, permission: 'delete', icon: Icons.destroy, + disable: () => !this.selection.hasSingleSelection, click: () => this.deleteSnapshot() } ]; @@ -209,7 +224,7 @@ export class CephfsSubvolumeSnapshotsListComponent implements OnInit, OnChanges const subVolumeGroupName = this.activeGroupName; const fsName = this.fsName; this.modalRef = this.modalService.show(CriticalConfirmationModalComponent, { - actionDescription: 'Remove', + actionDescription: 'Delete', itemNames: [snapshotName], itemDescription: 'Snapshot', submitAction: () => @@ -234,4 +249,63 @@ export class CephfsSubvolumeSnapshotsListComponent implements OnInit, OnChanges }) }); } + + cloneModal() { + const cloneName = `clone_${moment().toISOString(true)}`; + const allGroups = Array.from(this.subvolumeGroupList).map((group) => { + return { value: group, text: group === '' ? '_nogroup' : group }; + }); + this.modalService.show(FormModalComponent, { + titleText: $localize`Create clone`, + fields: [ + { + type: 'text', + name: 'cloneName', + value: cloneName, + label: $localize`Name`, + validators: [Validators.required, Validators.pattern(/^[.A-Za-z0-9_+:-]+$/)], + asyncValidators: [ + CdValidators.unique( + this.cephfsSubvolumeService.exists, + this.cephfsSubvolumeService, + null, + null, + this.fsName + ) + ], + required: true, + errors: { + pattern: $localize`Allowed characters are letters, numbers, '.', '-', '+', ':' or '_'` + } + }, + { + type: 'select', + name: 'groupName', + value: this.activeGroupName, + label: $localize`Group Name`, + typeConfig: { + options: allGroups + } + } + ], + submitButtonText: $localize`Create Clone`, + onSubmit: (value: any) => { + this.cephfsSubvolumeService + .createSnapshotClone( + this.fsName, + this.activeSubVolumeName, + this.selection.first().name, + value.cloneName, + this.activeGroupName, + value.groupName + ) + .subscribe(() => + this.notificationService.show( + NotificationType.success, + $localize`Created Clone "${value.cloneName}" successfully.` + ) + ); + } + }); + } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs-subvolume.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs-subvolume.service.ts index ad0ce248064db..6a88fa1d52971 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs-subvolume.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs-subvolume.service.ts @@ -171,4 +171,26 @@ export class CephfsSubvolumeService { observe: 'response' }); } + + createSnapshotClone( + fsName: string, + subVolumeName: string, + snapshotName: string, + cloneName: string, + groupName = '', + targetGroupName = '' + ) { + return this.http.post( + `${this.baseURL}/snapshot/clone`, + { + vol_name: fsName, + subvol_name: subVolumeName, + snap_name: snapshotName, + clone_name: cloneName, + group_name: groupName, + target_group_name: targetGroupName + }, + { observe: 'response' } + ); + } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.ts index 59b0d2a8560a1..1b4af6cd69fc1 100755 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.ts @@ -1,5 +1,5 @@ import { Component, OnInit } from '@angular/core'; -import { UntypedFormControl, ValidatorFn, Validators } from '@angular/forms'; +import { AsyncValidatorFn, UntypedFormControl, ValidatorFn, Validators } from '@angular/forms'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; import _ from 'lodash'; @@ -47,18 +47,22 @@ export class FormModalComponent implements OnInit { private createFormControl(field: CdFormModalFieldConfig): UntypedFormControl { let validators: ValidatorFn[] = []; + let asyncValidators: AsyncValidatorFn[] = []; if (_.isBoolean(field.required) && field.required) { validators.push(Validators.required); } if (field.validators) { validators = validators.concat(field.validators); } + if (field.asyncValidators) { + asyncValidators = asyncValidators.concat(field.asyncValidators); + } return new UntypedFormControl( _.defaultTo( field.type === 'binary' ? this.dimlessBinaryPipe.transform(field.value) : field.value, null ), - { validators } + { validators, asyncValidators } ); } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-form-modal-field-config.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-form-modal-field-config.ts index e327be59a27a4..a899e6daa6902 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-form-modal-field-config.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-form-modal-field-config.ts @@ -1,4 +1,4 @@ -import { ValidatorFn } from '@angular/forms'; +import { AsyncValidatorFn, ValidatorFn } from '@angular/forms'; export class CdFormModalFieldConfig { // --- Generic field properties --- @@ -11,6 +11,7 @@ export class CdFormModalFieldConfig { value?: any; errors?: { [errorName: string]: string }; validators: ValidatorFn[]; + asyncValidators?: AsyncValidatorFn[]; // --- Specific field properties --- typeConfig?: { diff --git a/src/pybind/mgr/dashboard/openapi.yaml b/src/pybind/mgr/dashboard/openapi.yaml index 45af71681ef46..7d7508f5d2b78 100644 --- a/src/pybind/mgr/dashboard/openapi.yaml +++ b/src/pybind/mgr/dashboard/openapi.yaml @@ -2005,6 +2005,59 @@ paths: - jwt: [] tags: - CephfsSubvolumeSnapshot + /api/cephfs/subvolume/snapshot/clone: + post: + parameters: [] + requestBody: + content: + application/json: + schema: + properties: + clone_name: + type: string + group_name: + default: '' + type: string + snap_name: + type: string + subvol_name: + type: string + target_group_name: + default: '' + type: string + vol_name: + type: string + required: + - vol_name + - subvol_name + - snap_name + - clone_name + type: object + responses: + '201': + content: + application/vnd.ceph.api.v1.0+json: + type: object + description: Resource created. + '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: Create a clone of a subvolume snapshot + tags: + - CephfsSnapshotClone /api/cephfs/subvolume/snapshot/{vol_name}/{subvol_name}: delete: parameters: @@ -12832,6 +12885,8 @@ tags: name: CephFSSubvolume - description: Cephfs Management API name: Cephfs +- description: Cephfs Snapshot Clone Management API + name: CephfsSnapshotClone - description: Cephfs Subvolume Group Management API name: CephfsSubvolumeGroup - description: Cephfs Subvolume Snapshot Management API -- 2.39.5