]> git.proxmox.com Git - ceph.git/blob - ceph/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-form/nfs-form.component.ts
540b7bfe64bee69b4d8df3168507564c6770715d
[ceph.git] / ceph / src / pybind / mgr / dashboard / frontend / src / app / ceph / nfs / nfs-form / nfs-form.component.ts
1 import { ChangeDetectorRef, Component, OnInit, ViewChild } from '@angular/core';
2 import {
3 AbstractControl,
4 AsyncValidatorFn,
5 UntypedFormControl,
6 ValidationErrors,
7 Validators
8 } from '@angular/forms';
9 import { ActivatedRoute, Router } from '@angular/router';
10
11 import _ from 'lodash';
12 import { forkJoin, Observable, of } from 'rxjs';
13 import { catchError, debounceTime, distinctUntilChanged, map, mergeMap } from 'rxjs/operators';
14
15 import { NfsFSAbstractionLayer } from '~/app/ceph/nfs/models/nfs.fsal';
16 import { Directory, NfsService } from '~/app/shared/api/nfs.service';
17 import { RgwBucketService } from '~/app/shared/api/rgw-bucket.service';
18 import { RgwSiteService } from '~/app/shared/api/rgw-site.service';
19 import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
20 import { Icons } from '~/app/shared/enum/icons.enum';
21 import { CdForm } from '~/app/shared/forms/cd-form';
22 import { CdFormBuilder } from '~/app/shared/forms/cd-form-builder';
23 import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
24 import { CdValidators } from '~/app/shared/forms/cd-validators';
25 import { FinishedTask } from '~/app/shared/models/finished-task';
26 import { Permission } from '~/app/shared/models/permissions';
27 import { CdHttpErrorResponse } from '~/app/shared/services/api-interceptor.service';
28 import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
29 import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
30 import { NfsFormClientComponent } from '../nfs-form-client/nfs-form-client.component';
31
32 @Component({
33 selector: 'cd-nfs-form',
34 templateUrl: './nfs-form.component.html',
35 styleUrls: ['./nfs-form.component.scss']
36 })
37 export class NfsFormComponent extends CdForm implements OnInit {
38 @ViewChild('nfsClients', { static: true })
39 nfsClients: NfsFormClientComponent;
40
41 clients: any[] = [];
42
43 permission: Permission;
44 nfsForm: CdFormGroup;
45 isEdit = false;
46
47 cluster_id: string = null;
48 export_id: string = null;
49
50 allClusters: { cluster_id: string }[] = null;
51 icons = Icons;
52
53 allFsals: any[] = [];
54 allFsNames: any[] = null;
55 fsalAvailabilityError: string = null;
56
57 defaultAccessType = { RGW: 'RO' };
58 nfsAccessType: any[] = [];
59 nfsSquash: any[] = [];
60
61 action: string;
62 resource: string;
63
64 pathDataSource = (text$: Observable<string>) => {
65 return text$.pipe(
66 debounceTime(200),
67 distinctUntilChanged(),
68 mergeMap((token: string) => this.getPathTypeahead(token)),
69 map((val: string[]) => val)
70 );
71 };
72
73 bucketDataSource = (text$: Observable<string>) => {
74 return text$.pipe(
75 debounceTime(200),
76 distinctUntilChanged(),
77 mergeMap((token: string) => this.getBucketTypeahead(token))
78 );
79 };
80
81 constructor(
82 private authStorageService: AuthStorageService,
83 private nfsService: NfsService,
84 private route: ActivatedRoute,
85 private router: Router,
86 private rgwBucketService: RgwBucketService,
87 private rgwSiteService: RgwSiteService,
88 private formBuilder: CdFormBuilder,
89 private taskWrapper: TaskWrapperService,
90 private cdRef: ChangeDetectorRef,
91 public actionLabels: ActionLabelsI18n
92 ) {
93 super();
94 this.permission = this.authStorageService.getPermissions().pool;
95 this.resource = $localize`NFS export`;
96 }
97
98 ngOnInit() {
99 this.nfsAccessType = this.nfsService.nfsAccessType;
100 this.nfsSquash = Object.keys(this.nfsService.nfsSquash);
101 this.createForm();
102 const promises: Observable<any>[] = [
103 this.nfsService.listClusters(),
104 this.nfsService.fsals(),
105 this.nfsService.filesystems()
106 ];
107
108 if (this.router.url.startsWith('/nfs/edit')) {
109 this.isEdit = true;
110 }
111
112 if (this.isEdit) {
113 this.action = this.actionLabels.EDIT;
114 this.route.params.subscribe((params: { cluster_id: string; export_id: string }) => {
115 this.cluster_id = decodeURIComponent(params.cluster_id);
116 this.export_id = decodeURIComponent(params.export_id);
117 promises.push(this.nfsService.get(this.cluster_id, this.export_id));
118
119 this.getData(promises);
120 });
121 this.nfsForm.get('cluster_id').disable();
122 } else {
123 this.action = this.actionLabels.CREATE;
124 this.getData(promises);
125 }
126 }
127
128 getData(promises: Observable<any>[]) {
129 forkJoin(promises).subscribe((data: any[]) => {
130 this.resolveClusters(data[0]);
131 this.resolveFsals(data[1]);
132 this.resolveFilesystems(data[2]);
133 if (data[3]) {
134 this.resolveModel(data[3]);
135 }
136
137 this.loadingReady();
138 });
139 }
140
141 createForm() {
142 this.nfsForm = new CdFormGroup({
143 cluster_id: new UntypedFormControl('', {
144 validators: [Validators.required]
145 }),
146 fsal: new CdFormGroup({
147 name: new UntypedFormControl('', {
148 validators: [Validators.required]
149 }),
150 fs_name: new UntypedFormControl('', {
151 validators: [
152 CdValidators.requiredIf({
153 name: 'CEPH'
154 })
155 ]
156 })
157 }),
158 path: new UntypedFormControl('/'),
159 protocolNfsv4: new UntypedFormControl(true),
160 pseudo: new UntypedFormControl('', {
161 validators: [
162 CdValidators.requiredIf({ protocolNfsv4: true }),
163 Validators.pattern('^/[^><|&()]*$')
164 ]
165 }),
166 access_type: new UntypedFormControl('RW'),
167 squash: new UntypedFormControl(this.nfsSquash[0]),
168 transportUDP: new UntypedFormControl(true, {
169 validators: [
170 CdValidators.requiredIf({ transportTCP: false }, (value: boolean) => {
171 return !value;
172 })
173 ]
174 }),
175 transportTCP: new UntypedFormControl(true, {
176 validators: [
177 CdValidators.requiredIf({ transportUDP: false }, (value: boolean) => {
178 return !value;
179 })
180 ]
181 }),
182 clients: this.formBuilder.array([]),
183 security_label: new UntypedFormControl(false),
184 sec_label_xattr: new UntypedFormControl(
185 'security.selinux',
186 CdValidators.requiredIf({ security_label: true, 'fsal.name': 'CEPH' })
187 )
188 });
189 }
190
191 resolveModel(res: any) {
192 if (res.fsal.name === 'CEPH') {
193 res.sec_label_xattr = res.fsal.sec_label_xattr;
194 }
195
196 res.protocolNfsv4 = res.protocols.indexOf(4) !== -1;
197 delete res.protocols;
198
199 res.transportTCP = res.transports.indexOf('TCP') !== -1;
200 res.transportUDP = res.transports.indexOf('UDP') !== -1;
201 delete res.transports;
202
203 Object.entries(this.nfsService.nfsSquash).forEach(([key, value]) => {
204 if (value.includes(res.squash)) {
205 res.squash = key;
206 }
207 });
208
209 res.clients.forEach((client: any) => {
210 let addressStr = '';
211 client.addresses.forEach((address: string) => {
212 addressStr += address + ', ';
213 });
214 if (addressStr.length >= 2) {
215 addressStr = addressStr.substring(0, addressStr.length - 2);
216 }
217 client.addresses = addressStr;
218 });
219
220 this.nfsForm.patchValue(res);
221 this.setPathValidation();
222 this.clients = res.clients;
223 }
224
225 resolveClusters(clusters: string[]) {
226 this.allClusters = [];
227 for (const cluster of clusters) {
228 this.allClusters.push({ cluster_id: cluster });
229 }
230 if (!this.isEdit && this.allClusters.length > 0) {
231 this.nfsForm.get('cluster_id').setValue(this.allClusters[0].cluster_id);
232 }
233 }
234
235 resolveFsals(res: string[]) {
236 res.forEach((fsal) => {
237 const fsalItem = this.nfsService.nfsFsal.find((currentFsalItem) => {
238 return fsal === currentFsalItem.value;
239 });
240
241 if (_.isObjectLike(fsalItem)) {
242 this.allFsals.push(fsalItem);
243 }
244 });
245 if (!this.isEdit && this.allFsals.length > 0) {
246 this.nfsForm.patchValue({
247 fsal: {
248 name: this.allFsals[0].value
249 }
250 });
251 }
252 }
253
254 resolveFilesystems(filesystems: any[]) {
255 this.allFsNames = filesystems;
256 if (!this.isEdit && filesystems.length > 0) {
257 this.nfsForm.patchValue({
258 fsal: {
259 fs_name: filesystems[0].name
260 }
261 });
262 }
263 }
264
265 fsalChangeHandler() {
266 this.setPathValidation();
267 const fsalValue = this.nfsForm.getValue('name');
268 const checkAvailability =
269 fsalValue === 'RGW'
270 ? this.rgwSiteService.get('realms').pipe(
271 mergeMap((realms: string[]) =>
272 realms.length === 0
273 ? of(true)
274 : this.rgwSiteService.isDefaultRealm().pipe(
275 mergeMap((isDefaultRealm) => {
276 if (!isDefaultRealm) {
277 throw new Error('Selected realm is not the default.');
278 }
279 return of(true);
280 })
281 )
282 )
283 )
284 : this.nfsService.filesystems();
285
286 checkAvailability.subscribe({
287 next: () => {
288 this.setFsalAvailability(fsalValue, true);
289 if (!this.isEdit) {
290 this.nfsForm.patchValue({
291 path: fsalValue === 'RGW' ? '' : '/',
292 pseudo: this.generatePseudo(),
293 access_type: this.updateAccessType()
294 });
295 }
296
297 this.cdRef.detectChanges();
298 },
299 error: (error) => {
300 this.setFsalAvailability(fsalValue, false, error);
301 this.nfsForm.get('name').setValue('');
302 }
303 });
304 }
305
306 private setFsalAvailability(fsalValue: string, available: boolean, errorMessage: string = '') {
307 this.allFsals = this.allFsals.map((fsalItem: NfsFSAbstractionLayer) => {
308 if (fsalItem.value === fsalValue) {
309 fsalItem.disabled = !available;
310
311 this.fsalAvailabilityError = fsalItem.disabled
312 ? $localize`${fsalItem.descr} backend is not available. ${errorMessage}`
313 : null;
314 }
315 return fsalItem;
316 });
317 }
318
319 accessTypeChangeHandler() {
320 const name = this.nfsForm.getValue('name');
321 const accessType = this.nfsForm.getValue('access_type');
322 this.defaultAccessType[name] = accessType;
323 }
324
325 setPathValidation() {
326 const path = this.nfsForm.get('path');
327 path.setValidators([Validators.required]);
328 if (this.nfsForm.getValue('name') === 'RGW') {
329 path.setAsyncValidators([CdValidators.bucketExistence(true, this.rgwBucketService)]);
330 } else {
331 path.setAsyncValidators([this.pathExistence(true)]);
332 }
333
334 if (this.isEdit) {
335 path.markAsDirty();
336 }
337 }
338
339 getAccessTypeHelp(accessType: string) {
340 const accessTypeItem = this.nfsAccessType.find((currentAccessTypeItem) => {
341 if (accessType === currentAccessTypeItem.value) {
342 return currentAccessTypeItem;
343 }
344 });
345 return _.isObjectLike(accessTypeItem) ? accessTypeItem.help : '';
346 }
347
348 getId() {
349 if (
350 _.isString(this.nfsForm.getValue('cluster_id')) &&
351 _.isString(this.nfsForm.getValue('path'))
352 ) {
353 return this.nfsForm.getValue('cluster_id') + ':' + this.nfsForm.getValue('path');
354 }
355 return '';
356 }
357
358 private getPathTypeahead(path: any) {
359 if (!_.isString(path) || path === '/') {
360 return of([]);
361 }
362
363 const fsName = this.nfsForm.getValue('fsal').fs_name;
364 return this.nfsService.lsDir(fsName, path).pipe(
365 map((result: Directory) =>
366 result.paths.filter((dirName: string) => dirName.toLowerCase().includes(path)).slice(0, 15)
367 ),
368 catchError(() => of([$localize`Error while retrieving paths.`]))
369 );
370 }
371
372 pathChangeHandler() {
373 if (!this.isEdit) {
374 this.nfsForm.patchValue({
375 pseudo: this.generatePseudo()
376 });
377 }
378 }
379
380 private getBucketTypeahead(path: string): Observable<any> {
381 if (_.isString(path) && path !== '/' && path !== '') {
382 return this.rgwBucketService.list().pipe(
383 map((bucketList) =>
384 bucketList
385 .filter((bucketName: string) => bucketName.toLowerCase().includes(path))
386 .slice(0, 15)
387 ),
388 catchError(() => of([$localize`Error while retrieving bucket names.`]))
389 );
390 } else {
391 return of([]);
392 }
393 }
394
395 private generatePseudo() {
396 let newPseudo = this.nfsForm.getValue('pseudo');
397 if (this.nfsForm.get('pseudo') && !this.nfsForm.get('pseudo').dirty) {
398 newPseudo = undefined;
399 if (this.nfsForm.getValue('fsal') === 'CEPH') {
400 newPseudo = '/cephfs';
401 if (_.isString(this.nfsForm.getValue('path'))) {
402 newPseudo += this.nfsForm.getValue('path');
403 }
404 }
405 }
406 return newPseudo;
407 }
408
409 private updateAccessType() {
410 const name = this.nfsForm.getValue('name');
411 let accessType = this.defaultAccessType[name];
412
413 if (!accessType) {
414 accessType = 'RW';
415 }
416
417 return accessType;
418 }
419
420 submitAction() {
421 let action: Observable<any>;
422 const requestModel = this.buildRequest();
423
424 if (this.isEdit) {
425 action = this.taskWrapper.wrapTaskAroundCall({
426 task: new FinishedTask('nfs/edit', {
427 cluster_id: this.cluster_id,
428 export_id: _.parseInt(this.export_id)
429 }),
430 call: this.nfsService.update(this.cluster_id, _.parseInt(this.export_id), requestModel)
431 });
432 } else {
433 // Create
434 action = this.taskWrapper.wrapTaskAroundCall({
435 task: new FinishedTask('nfs/create', {
436 path: requestModel.path,
437 fsal: requestModel.fsal,
438 cluster_id: requestModel.cluster_id
439 }),
440 call: this.nfsService.create(requestModel)
441 });
442 }
443
444 action.subscribe({
445 error: (errorResponse: CdHttpErrorResponse) => this.setFormErrors(errorResponse),
446 complete: () => this.router.navigate(['/nfs'])
447 });
448 }
449
450 private setFormErrors(errorResponse: CdHttpErrorResponse) {
451 if (
452 errorResponse.error.detail &&
453 errorResponse.error.detail
454 .toString()
455 .includes(`Pseudo ${this.nfsForm.getValue('pseudo')} is already in use`)
456 ) {
457 this.nfsForm.get('pseudo').setErrors({ pseudoAlreadyExists: true });
458 }
459 this.nfsForm.setErrors({ cdSubmitButton: true });
460 }
461
462 private buildRequest() {
463 const requestModel: any = _.cloneDeep(this.nfsForm.value);
464
465 if (this.isEdit) {
466 requestModel.export_id = _.parseInt(this.export_id);
467 }
468
469 if (requestModel.fsal.name === 'RGW') {
470 delete requestModel.fsal.fs_name;
471 }
472
473 requestModel.protocols = [];
474 if (requestModel.protocolNfsv4) {
475 requestModel.protocols.push(4);
476 } else {
477 requestModel.pseudo = null;
478 }
479 delete requestModel.protocolNfsv4;
480
481 requestModel.transports = [];
482 if (requestModel.transportTCP) {
483 requestModel.transports.push('TCP');
484 }
485 delete requestModel.transportTCP;
486 if (requestModel.transportUDP) {
487 requestModel.transports.push('UDP');
488 }
489 delete requestModel.transportUDP;
490
491 requestModel.clients.forEach((client: any) => {
492 if (_.isString(client.addresses)) {
493 client.addresses = _(client.addresses)
494 .split(/[ ,]+/)
495 .uniq()
496 .filter((address) => address !== '')
497 .value();
498 } else {
499 client.addresses = [];
500 }
501
502 if (!client.squash) {
503 client.squash = requestModel.squash;
504 }
505
506 if (!client.access_type) {
507 client.access_type = requestModel.access_type;
508 }
509 });
510
511 if (requestModel.security_label === false || requestModel.fsal.name === 'RGW') {
512 requestModel.fsal.sec_label_xattr = null;
513 } else {
514 requestModel.fsal.sec_label_xattr = requestModel.sec_label_xattr;
515 }
516 delete requestModel.sec_label_xattr;
517
518 return requestModel;
519 }
520
521 private pathExistence(requiredExistenceResult: boolean): AsyncValidatorFn {
522 return (control: AbstractControl): Observable<ValidationErrors | null> => {
523 if (control.pristine || !control.value) {
524 return of({ required: true });
525 }
526 const fsName = this.nfsForm.getValue('fsal').fs_name;
527 return this.nfsService.lsDir(fsName, control.value).pipe(
528 map((directory: Directory) =>
529 directory.paths.includes(control.value) === requiredExistenceResult
530 ? null
531 : { pathNameNotAllowed: true }
532 ),
533 catchError(() => of({ pathNameNotAllowed: true }))
534 );
535 };
536 }
537 }