error_code, out, err = mgr.remote(
'volumes', '_cmd_fs_subvolume_ls', None, {'vol_name': vol_name})
if error_code != 0:
- raise RuntimeError(
+ raise DashboardException(
f'Failed to list subvolumes for volume {vol_name}: {err}'
)
subvolumes = json.loads(out)
error_code, out, err = mgr.remote('volumes', '_cmd_fs_subvolume_info', None, {
'vol_name': vol_name, 'sub_name': subvolume['name']})
if error_code != 0:
- raise RuntimeError(
+ raise DashboardException(
f'Failed to get info for subvolume {subvolume["name"]}: {err}'
)
subvolume['info'] = json.loads(out)
return subvolumes
+
+ @RESTController.Resource('GET')
+ def info(self, vol_name: str, subvol_name: str):
+ error_code, out, err = mgr.remote('volumes', '_cmd_fs_subvolume_info', None, {
+ 'vol_name': vol_name, 'sub_name': subvol_name})
+ if error_code != 0:
+ raise DashboardException(
+ f'Failed to get info for subvolume {subvol_name}: {err}'
+ )
+ return json.loads(out)
+
+ def create(self, vol_name: str, subvol_name: str, **kwargs):
+ error_code, _, err = mgr.remote('volumes', '_cmd_fs_subvolume_create', None, {
+ 'vol_name': vol_name, 'sub_name': subvol_name, **kwargs})
+ if error_code != 0:
+ raise DashboardException(
+ f'Failed to create subvolume {subvol_name}: {err}'
+ )
+
+ return f'Subvolume {subvol_name} created successfully'
--- /dev/null
+<cd-modal [modalRef]="activeModal">
+ <ng-container i18n="form title"
+ class="modal-title">{{ action | titlecase }} {{ resource | upperFirst }}</ng-container>
+
+ <ng-container class="modal-content">
+ <form name="subvolumeForm"
+ #formDir="ngForm"
+ [formGroup]="subvolumeForm"
+ novalidate>
+ <div class="modal-body">
+ <div class="form-group row">
+ <label class="cd-col-form-label required"
+ for="subvolumeName"
+ i18n>Name</label>
+ <div class="cd-col-form-input">
+ <input class="form-control"
+ type="text"
+ placeholder="Subvolume name..."
+ id="subvolumeName"
+ name="subvolumeName"
+ formControlName="subvolumeName"
+ autofocus>
+ <span class="invalid-feedback"
+ *ngIf="subvolumeForm.showError('subvolumeName', formDir, 'required')"
+ i18n>This field is required.</span>
+ <span class="invalid-feedback"
+ *ngIf="subvolumeForm.showError('subvolumeName', formDir, 'notUnique')"
+ i18n>The subvolume already exists.</span>
+ </div>
+ </div>
+
+ <!-- Volume name -->
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ for="volumeName"
+ i18n>Volume name</label>
+ <div class="cd-col-form-input">
+ <input class="form-control"
+ id="volumeName"
+ name="volumeName"
+ formControlName="volumeName">
+ </div>
+ </div>
+
+ <!-- Size -->
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ for="size"
+ i18n>Size
+ <cd-helper>The size of the subvolume is specified by setting a quota on it</cd-helper>
+ </label>
+ <div class="cd-col-form-input">
+ <input class="form-control"
+ type="text"
+ id="size"
+ name="size"
+ formControlName="size"
+ i18n-placeholder
+ placeholder="e.g., 10GiB"
+ defaultUnit="GiB"
+ cdDimlessBinary>
+ </div>
+ </div>
+
+ <!-- CephFS Pools -->
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ for="pool"
+ i18n>Pool
+ <cd-helper>By default, the data_pool_layout of the parent directory is selected.</cd-helper>
+ </label>
+ <div class="cd-col-form-input">
+ <select class="form-select"
+ id="pool"
+ name="pool"
+ formControlName="pool">
+ <option *ngFor="let pool of dataPools"
+ [value]="pool.pool">{{ pool.pool }}</option>
+ </select>
+ </div>
+ </div>
+
+ <!-- UID -->
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ for="uid"
+ i18n>UID</label>
+ <div class="cd-col-form-input">
+ <input class="form-control"
+ type="number"
+ placeholder="Subvolume UID..."
+ id="uid"
+ name="uid"
+ formControlName="uid">
+ </div>
+ </div>
+
+ <!-- GID -->
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ for="gid"
+ i18n>GID</label>
+ <div class="cd-col-form-input">
+ <input class="form-control"
+ type="number"
+ placeholder="Subvolume GID..."
+ id="gid"
+ name="gid"
+ formControlName="gid">
+ </div>
+ </div>
+
+ <!-- Mode -->
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ for="mode"
+ i18n>Mode
+ <cd-helper>Permissions for the directory. Default mode is 755 which is rwxr-xr-x</cd-helper>
+ </label>
+ <div class="cd-col-form-input">
+ <cd-checked-table-form [data]="scopePermissions"
+ [columns]="columns"
+ [form]="subvolumeForm"
+ inputField="mode"
+ [isTableForOctalMode]="true"
+ [scopes]="scopes"></cd-checked-table-form>
+ </div>
+ </div>
+
+ <!-- Is namespace-isolated -->
+ <div class="form-group row">
+ <div class="cd-col-form-offset">
+ <div class="custom-control custom-checkbox">
+ <input class="custom-control-input"
+ type="checkbox"
+ id="isolatedNamespace"
+ name="isolatedNamespace"
+ formControlName="isolatedNamespace">
+ <label class="custom-control-label"
+ for="isolatedNamespace"
+ i18n>Isolated Namespace
+ <cd-helper>To create subvolume in a separate RADOS namespace.</cd-helper>
+ </label>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div class="modal-footer">
+ <cd-form-button-panel (submitActionEvent)="submit()"
+ [form]="subvolumeForm"
+ [submitText]="(action | titlecase) + ' ' + (resource | upperFirst)"></cd-form-button-panel>
+ </div>
+ </form>
+ </ng-container>
+</cd-modal>
--- /dev/null
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { CephfsSubvolumeFormComponent } from './cephfs-subvolume-form.component';
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+import { ToastrModule } from 'ngx-toastr';
+import { SharedModule } from '~/app/shared/shared.module';
+import { RouterTestingModule } from '@angular/router/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+
+describe('CephfsSubvolumeFormComponent', () => {
+ let component: CephfsSubvolumeFormComponent;
+ let fixture: ComponentFixture<CephfsSubvolumeFormComponent>;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ declarations: [CephfsSubvolumeFormComponent],
+ providers: [NgbActiveModal],
+ imports: [
+ SharedModule,
+ ToastrModule.forRoot(),
+ ReactiveFormsModule,
+ HttpClientTestingModule,
+ RouterTestingModule
+ ]
+ }).compileComponents();
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(CephfsSubvolumeFormComponent);
+ component = fixture.componentInstance;
+ component.pools = [];
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
--- /dev/null
+import { Component, OnInit } from '@angular/core';
+import { FormControl, Validators } from '@angular/forms';
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+import { CephfsSubvolumeService } from '~/app/shared/api/cephfs-subvolume.service';
+import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { FinishedTask } from '~/app/shared/models/finished-task';
+import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
+import { Pool } from '../../pool/pool';
+import { FormatterService } from '~/app/shared/services/formatter.service';
+import { CdTableColumn } from '~/app/shared/models/cd-table-column';
+import _ from 'lodash';
+import { CdValidators } from '~/app/shared/forms/cd-validators';
+
+@Component({
+ selector: 'cd-cephfs-subvolume-form',
+ templateUrl: './cephfs-subvolume-form.component.html',
+ styleUrls: ['./cephfs-subvolume-form.component.scss']
+})
+export class CephfsSubvolumeFormComponent implements OnInit {
+ fsName: string;
+ pools: Pool[];
+
+ subvolumeForm: CdFormGroup;
+
+ action: string;
+ resource: string;
+
+ dataPools: Pool[];
+
+ columns: CdTableColumn[];
+ scopePermissions: Array<any> = [];
+ scopes: string[] = ['owner', 'group', 'others'];
+
+ constructor(
+ public activeModal: NgbActiveModal,
+ private actionLabels: ActionLabelsI18n,
+ private taskWrapper: TaskWrapperService,
+ private cephFsSubvolumeService: CephfsSubvolumeService,
+ private formatter: FormatterService
+ ) {
+ this.action = this.actionLabels.CREATE;
+ this.resource = $localize`Subvolume`;
+ }
+
+ ngOnInit(): void {
+ this.columns = [
+ {
+ prop: 'scope',
+ name: $localize`All`,
+ flexGrow: 0.5
+ },
+ {
+ prop: 'read',
+ name: $localize`Read`,
+ flexGrow: 0.5,
+ cellClass: 'text-center'
+ },
+ {
+ prop: 'write',
+ name: $localize`Write`,
+ flexGrow: 0.5,
+ cellClass: 'text-center'
+ },
+ {
+ prop: 'execute',
+ name: $localize`Execute`,
+ flexGrow: 0.5,
+ cellClass: 'text-center'
+ }
+ ];
+
+ this.dataPools = this.pools.filter((pool) => pool.type === 'data');
+ this.createForm();
+ }
+
+ createForm() {
+ this.subvolumeForm = new CdFormGroup({
+ volumeName: new FormControl({ value: this.fsName, disabled: true }),
+ subvolumeName: new FormControl('', {
+ validators: [Validators.required],
+ asyncValidators: [
+ CdValidators.unique(
+ this.cephFsSubvolumeService.exists,
+ this.cephFsSubvolumeService,
+ null,
+ null,
+ this.fsName
+ )
+ ]
+ }),
+ pool: new FormControl(this.dataPools[0]?.pool, {
+ validators: [Validators.required]
+ }),
+ size: new FormControl(null, {
+ updateOn: 'blur'
+ }),
+ uid: new FormControl(null),
+ gid: new FormControl(null),
+ mode: new FormControl({}),
+ isolatedNamespace: new FormControl(false)
+ });
+ }
+
+ submit() {
+ const subVolumeName = this.subvolumeForm.getValue('subvolumeName');
+ const pool = this.subvolumeForm.getValue('pool');
+ const size = this.formatter.toBytes(this.subvolumeForm.getValue('size'));
+ const uid = this.subvolumeForm.getValue('uid');
+ const gid = this.subvolumeForm.getValue('gid');
+ const mode = this.formatter.toOctalPermission(this.subvolumeForm.getValue('mode'));
+ const isolatedNamespace = this.subvolumeForm.getValue('isolatedNamespace');
+ this.taskWrapper
+ .wrapTaskAroundCall({
+ task: new FinishedTask('cephfs/subvolume/' + URLVerbs.CREATE, {
+ subVolumeName: subVolumeName
+ }),
+ call: this.cephFsSubvolumeService.create(
+ this.fsName,
+ subVolumeName,
+ pool,
+ size,
+ uid,
+ gid,
+ mode,
+ isolatedNamespace
+ )
+ })
+ .subscribe({
+ error: () => {
+ this.subvolumeForm.setErrors({ cdSubmitButton: true });
+ },
+ complete: () => {
+ this.activeModal.close();
+ }
+ });
+ }
+}
columnMode="flex"
[columns]="columns"
selectionType="single"
- [hasDetails]="false">
+ [hasDetails]="false"
+ (fetchData)="fetchData()">
+
+ <div class="table-actions btn-toolbar">
+ <cd-table-actions [permission]="permissions.cephfs"
+ [selection]="selection"
+ class="btn-group"
+ id="cephfs-subvolume-actions"
+ [tableActions]="tableActions">
+ </cd-table-actions>
+ </div>
</cd-table>
</ng-container>
import { Component, Input, OnChanges, OnInit, ViewChild } from '@angular/core';
-import { Observable, of } from 'rxjs';
-import { catchError } from 'rxjs/operators';
+import { Observable, ReplaySubject, of } from 'rxjs';
+import { catchError, shareReplay, switchMap } from 'rxjs/operators';
import { CephfsSubvolumeService } from '~/app/shared/api/cephfs-subvolume.service';
+import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
import { CellTemplate } from '~/app/shared/enum/cell-template.enum';
import { Icons } from '~/app/shared/enum/icons.enum';
+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';
import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
import { CephfsSubvolume } from '~/app/shared/models/cephfs-subvolume.model';
+import { ModalService } from '~/app/shared/services/modal.service';
+import { CephfsSubvolumeFormComponent } from '../cephfs-subvolume-form/cephfs-subvolume-form.component';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { Permissions } from '~/app/shared/models/permissions';
@Component({
selector: 'cd-cephfs-subvolume-list',
quotaSizeTpl: any;
@Input() fsName: string;
+ @Input() pools: any[];
columns: CdTableColumn[] = [];
+ tableActions: CdTableAction[];
context: CdTableFetchDataContext;
selection = new CdTableSelection();
icons = Icons;
+ permissions: Permissions;
subVolumes$: Observable<CephfsSubvolume[]>;
+ subject = new ReplaySubject<CephfsSubvolume[]>();
- constructor(private cephfsSubVolume: CephfsSubvolumeService) {}
+ constructor(
+ private cephfsSubVolume: CephfsSubvolumeService,
+ private actionLabels: ActionLabelsI18n,
+ private modalService: ModalService,
+ private authStorageService: AuthStorageService
+ ) {
+ this.permissions = this.authStorageService.getPermissions();
+ }
ngOnInit(): void {
this.columns = [
cellTransformation: CellTemplate.timeAgo
}
];
+
+ this.tableActions = [
+ {
+ name: this.actionLabels.CREATE,
+ permission: 'create',
+ icon: Icons.add,
+ click: () =>
+ this.modalService.show(
+ CephfsSubvolumeFormComponent,
+ {
+ fsName: this.fsName,
+ pools: this.pools
+ },
+ { size: 'lg' }
+ )
+ }
+ ];
+
+ this.subVolumes$ = this.subject.pipe(
+ switchMap(() =>
+ this.cephfsSubVolume.get(this.fsName).pipe(
+ catchError(() => {
+ this.context.error();
+ return of(null);
+ })
+ )
+ ),
+ shareReplay(1)
+ );
+ }
+
+ fetchData() {
+ this.subject.next();
}
ngOnChanges() {
- this.subVolumes$ = this.cephfsSubVolume.get(this.fsName).pipe(
- catchError(() => {
- this.context.error();
- return of(null);
- })
- );
+ this.subject.next();
}
updateSelection(selection: CdTableSelection) {
<a ngbNavLink
i18n>Subvolumes</a>
<ng-template ngbNavContent>
- <cd-cephfs-subvolume-list [fsName]="selection.mdsmap.fs_name"></cd-cephfs-subvolume-list>
+ <cd-cephfs-subvolume-list [fsName]="selection.mdsmap.fs_name"
+ [pools]="details.pools"></cd-cephfs-subvolume-list>
</ng-template>
</ng-container>
<ng-container ngbNavItem="clients">
import { CephfsListComponent } from './cephfs-list/cephfs-list.component';
import { CephfsTabsComponent } from './cephfs-tabs/cephfs-tabs.component';
import { CephfsSubvolumeListComponent } from './cephfs-subvolume-list/cephfs-subvolume-list.component';
+import { CephfsSubvolumeFormComponent } from './cephfs-subvolume-form/cephfs-subvolume-form.component';
@NgModule({
imports: [
CephfsTabsComponent,
CephfsVolumeFormComponent,
CephfsDirectoriesComponent,
- CephfsSubvolumeListComponent
+ CephfsSubvolumeListComponent,
+ CephfsSubvolumeFormComponent
]
})
export class CephfsModule {}
styleUrls: ['./role-form.component.scss']
})
export class RoleFormComponent extends CdForm implements OnInit {
-
roleForm: CdFormGroup;
response: RoleFormModel;
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { CephfsSubvolume } from '../models/cephfs-subvolume.model';
-import { Observable } from 'rxjs';
+import { Observable, of } from 'rxjs';
+import { catchError, mapTo } from 'rxjs/operators';
+import _ from 'lodash';
@Injectable({
providedIn: 'root'
get(fsName: string): Observable<CephfsSubvolume[]> {
return this.http.get<CephfsSubvolume[]>(`${this.baseURL}/${fsName}`);
}
+
+ create(
+ fsName: string,
+ subVolumeName: string,
+ poolName: string,
+ size: number,
+ uid: number,
+ gid: number,
+ mode: string,
+ namespace: boolean
+ ) {
+ return this.http.post(
+ this.baseURL,
+ {
+ vol_name: fsName,
+ subvol_name: subVolumeName,
+ pool_layout: poolName,
+ size: size,
+ uid: uid,
+ gid: gid,
+ mode: mode,
+ namespace_isolated: namespace
+ },
+ { observe: 'response' }
+ );
+ }
+
+ info(fsName: string, subVolumeName: string) {
+ return this.http.get(`${this.baseURL}/${fsName}/info`, {
+ params: {
+ subvol_name: subVolumeName
+ }
+ });
+ }
+
+ exists(subVolumeName: string, fsName: string) {
+ return this.info(fsName, subVolumeName).pipe(
+ mapTo(true),
+ catchError((error: Event) => {
+ if (_.isFunction(error.preventDefault)) {
+ error.preventDefault();
+ }
+ return of(false);
+ })
+ );
+ }
}
}
);
}
+
+ isCephFsPool(pool: any) {
+ return _.indexOf(pool.application_metadata, 'cephfs') !== -1 && !pool.pool_name.includes('/');
+ }
}
return value == null || value.length === 0;
}
-export type existsServiceFn = (value: any) => Observable<boolean>;
+export type existsServiceFn = (value: any, args?: any) => Observable<boolean>;
export class CdValidators {
/**
serviceFn: existsServiceFn,
serviceFnThis: any = null,
usernameFn?: Function,
- uidField = false
+ uidField = false,
+ extraArgs = ''
): AsyncValidatorFn {
let uName: string;
return (control: AbstractControl): Observable<ValidationErrors | null> => {
}
return observableTimer().pipe(
- switchMapTo(serviceFn.call(serviceFnThis, uName)),
+ switchMapTo(serviceFn.call(serviceFnThis, uName, extraArgs)),
map((resp: boolean) => {
if (!resp) {
return null;
return 0;
}
+
+ toOctalPermission(modes: any) {
+ const scopes = ['owner', 'group', 'others'];
+ let octalMode = '';
+ for (const scope of scopes) {
+ let scopeValue = 0;
+ const mode = modes[scope];
+
+ if (mode) {
+ if (mode.includes('read')) scopeValue += 4;
+ if (mode.includes('write')) scopeValue += 2;
+ if (mode.includes('execute')) scopeValue += 1;
+ }
+
+ octalMode += scopeValue.toString();
+ }
+ return octalMode;
+ }
}
),
'cephfs/create': this.newTaskMessage(this.commonOperations.create, (metadata) =>
this.volume(metadata)
+ ),
+ 'cephfs/subvolume/create': this.newTaskMessage(this.commonOperations.create, (metadata) =>
+ this.subvolume(metadata)
)
};
return $localize`'${metadata.volumeName}'`;
}
+ subvolume(metadata: any) {
+ return $localize`subvolume '${metadata.subVolumeName}'`;
+ }
+
crudMessageId(id: string) {
return $localize`${id}`;
}
- jwt: []
tags:
- Cephfs
+ /api/cephfs/subvolume:
+ post:
+ parameters: []
+ requestBody:
+ content:
+ application/json:
+ schema:
+ properties:
+ subvol_name:
+ type: string
+ vol_name:
+ type: string
+ required:
+ - vol_name
+ - subvol_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: []
+ tags:
+ - CephFSSubvolume
/api/cephfs/subvolume/{vol_name}:
get:
parameters:
- jwt: []
tags:
- CephFSSubvolume
+ /api/cephfs/subvolume/{vol_name}/info:
+ get:
+ parameters:
+ - in: path
+ name: vol_name
+ required: true
+ schema:
+ type: string
+ - in: query
+ name: subvol_name
+ required: true
+ schema:
+ type: string
+ responses:
+ '200':
+ content:
+ application/vnd.ceph.api.v1.0+json:
+ type: object
+ description: OK
+ '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: []
+ tags:
+ - CephFSSubvolume
/api/cephfs/{fs_id}:
get:
parameters: