]> git.apps.os.sepia.ceph.com Git - ceph-ci.git/blob
576752539dc088a8849db1dd805b8f625e25c7a3
[ceph-ci.git] /
1 import { Component, OnInit } from '@angular/core';
2 import { FormArray, FormControl, Validators } from '@angular/forms';
3 import { ActivatedRoute, Router } from '@angular/router';
4
5 import { I18n } from '@ngx-translate/i18n-polyfill';
6 import * as _ from 'lodash';
7 import { BsModalRef, BsModalService } from 'ngx-bootstrap/modal';
8 import { forkJoin } from 'rxjs';
9
10 import { IscsiService } from '../../../shared/api/iscsi.service';
11 import { RbdService } from '../../../shared/api/rbd.service';
12 import { SelectMessages } from '../../../shared/components/select/select-messages.model';
13 import { SelectOption } from '../../../shared/components/select/select-option.model';
14 import { ActionLabelsI18n } from '../../../shared/constants/app.constants';
15 import { Icons } from '../../../shared/enum/icons.enum';
16 import { CdFormGroup } from '../../../shared/forms/cd-form-group';
17 import { CdValidators } from '../../../shared/forms/cd-validators';
18 import { FinishedTask } from '../../../shared/models/finished-task';
19 import { TaskWrapperService } from '../../../shared/services/task-wrapper.service';
20 import { IscsiTargetImageSettingsModalComponent } from '../iscsi-target-image-settings-modal/iscsi-target-image-settings-modal.component';
21 import { IscsiTargetIqnSettingsModalComponent } from '../iscsi-target-iqn-settings-modal/iscsi-target-iqn-settings-modal.component';
22
23 @Component({
24   selector: 'cd-iscsi-target-form',
25   templateUrl: './iscsi-target-form.component.html',
26   styleUrls: ['./iscsi-target-form.component.scss']
27 })
28 export class IscsiTargetFormComponent implements OnInit {
29   targetForm: CdFormGroup;
30   modalRef: BsModalRef;
31   minimum_gateways = 1;
32   target_default_controls: any;
33   target_controls_limits: any;
34   disk_default_controls: any;
35   disk_controls_limits: any;
36   backstores: string[];
37   default_backstore: string;
38   unsupported_rbd_features: any;
39   required_rbd_features: any;
40
41   icons = Icons;
42
43   isEdit = false;
44   target_iqn: string;
45
46   imagesAll: any[];
47   imagesSelections: SelectOption[];
48   portalsSelections: SelectOption[] = [];
49
50   imagesInitiatorSelections: SelectOption[][] = [];
51   groupDiskSelections: SelectOption[][] = [];
52   groupMembersSelections: SelectOption[][] = [];
53
54   imagesSettings: any = {};
55   messages = {
56     portals: new SelectMessages(
57       { noOptions: this.i18n('There are no portals available.') },
58       this.i18n
59     ),
60     images: new SelectMessages(
61       { noOptions: this.i18n('There are no images available.') },
62       this.i18n
63     ),
64     initiatorImage: new SelectMessages(
65       {
66         noOptions: this.i18n(
67           'There are no images available. Please make sure you add an image to the target.'
68         )
69       },
70       this.i18n
71     ),
72     groupInitiator: new SelectMessages(
73       {
74         noOptions: this.i18n(
75           'There are no initiators available. Please make sure you add an initiator to the target.'
76         )
77       },
78       this.i18n
79     )
80   };
81
82   IQN_REGEX = /^iqn\.(19|20)\d\d-(0[1-9]|1[0-2])\.\D{2,3}(\.[A-Za-z0-9-]+)+(:[A-Za-z0-9-\.]+)*$/;
83   USER_REGEX = /[\w\.:@_-]{8,64}/;
84   PASSWORD_REGEX = /[\w@\-_\/]{12,16}/;
85   action: string;
86   resource: string;
87
88   constructor(
89     private iscsiService: IscsiService,
90     private modalService: BsModalService,
91     private rbdService: RbdService,
92     private router: Router,
93     private route: ActivatedRoute,
94     private i18n: I18n,
95     private taskWrapper: TaskWrapperService,
96     public actionLabels: ActionLabelsI18n
97   ) {
98     this.resource = this.i18n('target');
99   }
100
101   ngOnInit() {
102     const promises: any[] = [
103       this.iscsiService.listTargets(),
104       this.rbdService.list(),
105       this.iscsiService.portals(),
106       this.iscsiService.settings()
107     ];
108
109     if (this.router.url.startsWith('/block/iscsi/targets/edit')) {
110       this.isEdit = true;
111       this.route.params.subscribe((params: { target_iqn: string }) => {
112         this.target_iqn = decodeURIComponent(params.target_iqn);
113         promises.push(this.iscsiService.getTarget(this.target_iqn));
114       });
115     }
116     this.action = this.isEdit ? this.actionLabels.EDIT : this.actionLabels.CREATE;
117
118     forkJoin(promises).subscribe((data: any[]) => {
119       // iscsiService.listTargets
120       const usedImages = _(data[0])
121         .filter((target) => target.target_iqn !== this.target_iqn)
122         .flatMap((target) => target.disks)
123         .map((image) => `${image.pool}/${image.image}`)
124         .value();
125
126       // iscsiService.settings()
127       this.minimum_gateways = data[3].config.minimum_gateways;
128       this.target_default_controls = data[3].target_default_controls;
129       this.target_controls_limits = data[3].target_controls_limits;
130       this.disk_default_controls = data[3].disk_default_controls;
131       this.disk_controls_limits = data[3].disk_controls_limits;
132       this.backstores = data[3].backstores;
133       this.default_backstore = data[3].default_backstore;
134       this.unsupported_rbd_features = data[3].unsupported_rbd_features;
135       this.required_rbd_features = data[3].required_rbd_features;
136
137       // rbdService.list()
138       this.imagesAll = _(data[1])
139         .flatMap((pool) => pool.value)
140         .filter((image) => {
141           const imageId = `${image.pool_name}/${image.name}`;
142           if (usedImages.indexOf(imageId) !== -1) {
143             return false;
144           }
145           const validBackstores = this.getValidBackstores(image);
146           if (validBackstores.length === 0) {
147             return false;
148           }
149           return true;
150         })
151         .value();
152
153       this.imagesSelections = this.imagesAll.map(
154         (image) => new SelectOption(false, `${image.pool_name}/${image.name}`, '')
155       );
156
157       // iscsiService.portals()
158       const portals: SelectOption[] = [];
159       data[2].forEach((portal) => {
160         portal.ip_addresses.forEach((ip) => {
161           portals.push(new SelectOption(false, portal.name + ':' + ip, ''));
162         });
163       });
164       this.portalsSelections = [...portals];
165
166       this.createForm();
167
168       // iscsiService.getTarget()
169       if (data[4]) {
170         this.resolveModel(data[4]);
171       }
172     });
173   }
174
175   createForm() {
176     this.targetForm = new CdFormGroup({
177       target_iqn: new FormControl('iqn.2001-07.com.ceph:' + Date.now(), {
178         validators: [Validators.required, Validators.pattern(this.IQN_REGEX)]
179       }),
180       target_controls: new FormControl({}),
181       portals: new FormControl([], {
182         validators: [
183           CdValidators.custom('minGateways', (value) => {
184             const gateways = _.uniq(value.map((elem) => elem.split(':')[0]));
185             return gateways.length < Math.max(1, this.minimum_gateways);
186           })
187         ]
188       }),
189       disks: new FormControl([]),
190       initiators: new FormArray([]),
191       groups: new FormArray([]),
192       acl_enabled: new FormControl(false)
193     });
194   }
195
196   resolveModel(res) {
197     this.targetForm.patchValue({
198       target_iqn: res.target_iqn,
199       target_controls: res.target_controls,
200       acl_enabled: res.acl_enabled
201     });
202
203     const portals = [];
204     _.forEach(res.portals, (portal) => {
205       const id = `${portal.host}:${portal.ip}`;
206       portals.push(id);
207     });
208     this.targetForm.patchValue({
209       portals: portals
210     });
211
212     const disks = [];
213     _.forEach(res.disks, (disk) => {
214       const id = `${disk.pool}/${disk.image}`;
215       disks.push(id);
216       this.imagesSettings[id] = {
217         backstore: disk.backstore
218       };
219       this.imagesSettings[id][disk.backstore] = disk.controls;
220
221       this.onImageSelection({ option: { name: id, selected: true } });
222     });
223     this.targetForm.patchValue({
224       disks: disks
225     });
226
227     _.forEach(res.clients, (client) => {
228       const initiator = this.addInitiator();
229       client.luns = _.map(client.luns, (lun) => `${lun.pool}/${lun.image}`);
230       initiator.patchValue(client);
231       // updatedInitiatorSelector()
232     });
233
234     _.forEach(res.groups, (group) => {
235       const fg = this.addGroup();
236       group.disks = _.map(group.disks, (disk) => `${disk.pool}/${disk.image}`);
237       fg.patchValue(group);
238       _.forEach(group.members, (member) => {
239         this.onGroupMemberSelection({ option: new SelectOption(true, member, '') });
240       });
241     });
242   }
243
244   hasAdvancedSettings(settings: any) {
245     return Object.values(settings).length > 0;
246   }
247
248   // Portals
249   get portals() {
250     return this.targetForm.get('portals') as FormControl;
251   }
252
253   onPortalSelection() {
254     this.portals.setValue(this.portals.value);
255   }
256
257   removePortal(index: number, portal: string) {
258     this.portalsSelections.forEach((value) => {
259       if (value.name === portal) {
260         value.selected = false;
261       }
262     });
263
264     this.portals.value.splice(index, 1);
265     this.portals.setValue(this.portals.value);
266     return false;
267   }
268
269   // Images
270   get disks() {
271     return this.targetForm.get('disks') as FormControl;
272   }
273
274   removeImage(index: number, image: string) {
275     this.imagesSelections.forEach((value) => {
276       if (value.name === image) {
277         value.selected = false;
278       }
279     });
280     this.disks.value.splice(index, 1);
281     this.removeImageRefs(image);
282     return false;
283   }
284
285   removeImageRefs(name) {
286     this.initiators.controls.forEach((element) => {
287       const newImages = element.value.luns.filter((item) => item !== name);
288       element.get('luns').setValue(newImages);
289     });
290
291     this.groups.controls.forEach((element) => {
292       const newDisks = element.value.disks.filter((item) => item !== name);
293       element.get('disks').setValue(newDisks);
294     });
295
296     _.forEach(this.imagesInitiatorSelections, (selections, i) => {
297       this.imagesInitiatorSelections[i] = selections.filter((item: any) => item.name !== name);
298     });
299     _.forEach(this.groupDiskSelections, (selections, i) => {
300       this.groupDiskSelections[i] = selections.filter((item: any) => item.name !== name);
301     });
302   }
303
304   getDefaultBackstore(imageId) {
305     let result = this.default_backstore;
306     const image = this.getImageById(imageId);
307     if (!this.validFeatures(image, this.default_backstore)) {
308       this.backstores.forEach((backstore) => {
309         if (backstore !== this.default_backstore) {
310           if (this.validFeatures(image, backstore)) {
311             result = backstore;
312           }
313         }
314       });
315     }
316     return result;
317   }
318
319   onImageSelection($event) {
320     const option = $event.option;
321
322     if (option.selected) {
323       if (!this.imagesSettings[option.name]) {
324         const defaultBackstore = this.getDefaultBackstore(option.name);
325         this.imagesSettings[option.name] = {
326           backstore: defaultBackstore
327         };
328         this.imagesSettings[option.name][defaultBackstore] = {};
329       }
330
331       _.forEach(this.imagesInitiatorSelections, (selections, i) => {
332         selections.push(new SelectOption(false, option.name, ''));
333         this.imagesInitiatorSelections[i] = [...selections];
334       });
335
336       _.forEach(this.groupDiskSelections, (selections, i) => {
337         selections.push(new SelectOption(false, option.name, ''));
338         this.groupDiskSelections[i] = [...selections];
339       });
340     } else {
341       this.removeImageRefs(option.name);
342     }
343   }
344
345   // Initiators
346   get initiators() {
347     return this.targetForm.get('initiators') as FormArray;
348   }
349
350   addInitiator() {
351     const fg = new CdFormGroup({
352       client_iqn: new FormControl('', {
353         validators: [
354           Validators.required,
355           CdValidators.custom('notUnique', (client_iqn) => {
356             const flattened = this.initiators.controls.reduce(function(accumulator, currentValue) {
357               return accumulator.concat(currentValue.value.client_iqn);
358             }, []);
359
360             return flattened.indexOf(client_iqn) !== flattened.lastIndexOf(client_iqn);
361           }),
362           Validators.pattern(this.IQN_REGEX)
363         ]
364       }),
365       auth: new CdFormGroup({
366         user: new FormControl(''),
367         password: new FormControl(''),
368         mutual_user: new FormControl(''),
369         mutual_password: new FormControl('')
370       }),
371       luns: new FormControl([]),
372       cdIsInGroup: new FormControl(false)
373     });
374
375     CdValidators.validateIf(
376       fg.get('user'),
377       () => fg.getValue('password') || fg.getValue('mutual_user') || fg.getValue('mutual_password'),
378       [Validators.required],
379       [Validators.pattern(this.USER_REGEX)],
380       [fg.get('password'), fg.get('mutual_user'), fg.get('mutual_password')]
381     );
382
383     CdValidators.validateIf(
384       fg.get('password'),
385       () => fg.getValue('user') || fg.getValue('mutual_user') || fg.getValue('mutual_password'),
386       [Validators.required],
387       [Validators.pattern(this.PASSWORD_REGEX)],
388       [fg.get('user'), fg.get('mutual_user'), fg.get('mutual_password')]
389     );
390
391     CdValidators.validateIf(
392       fg.get('mutual_user'),
393       () => fg.getValue('mutual_password'),
394       [Validators.required],
395       [Validators.pattern(this.USER_REGEX)],
396       [fg.get('user'), fg.get('password'), fg.get('mutual_password')]
397     );
398
399     CdValidators.validateIf(
400       fg.get('mutual_password'),
401       () => fg.getValue('mutual_user'),
402       [Validators.required],
403       [Validators.pattern(this.PASSWORD_REGEX)],
404       [fg.get('user'), fg.get('password'), fg.get('mutual_user')]
405     );
406
407     this.initiators.push(fg);
408
409     _.forEach(this.groupMembersSelections, (selections, i) => {
410       selections.push(new SelectOption(false, '', ''));
411       this.groupMembersSelections[i] = [...selections];
412     });
413
414     const disks = _.map(
415       this.targetForm.getValue('disks'),
416       (disk) => new SelectOption(false, disk, '')
417     );
418     this.imagesInitiatorSelections.push(disks);
419
420     return fg;
421   }
422
423   removeInitiator(index) {
424     const removed = this.initiators.value[index];
425
426     this.initiators.removeAt(index);
427
428     _.forEach(this.groupMembersSelections, (selections, i) => {
429       selections.splice(index, 1);
430       this.groupMembersSelections[i] = [...selections];
431     });
432
433     this.groups.controls.forEach((element) => {
434       const newMembers = element.value.members.filter((item) => item !== removed.client_iqn);
435       element.get('members').setValue(newMembers);
436     });
437
438     this.imagesInitiatorSelections.splice(index, 1);
439   }
440
441   updatedInitiatorSelector() {
442     // Validate all client_iqn
443     this.initiators.controls.forEach((control) => {
444       control.get('client_iqn').updateValueAndValidity({ emitEvent: false });
445     });
446
447     // Update Group Initiator Selector
448     _.forEach(this.groupMembersSelections, (group, group_index) => {
449       _.forEach(group, (elem, index) => {
450         const oldName = elem.name;
451         elem.name = this.initiators.controls[index].value.client_iqn;
452
453         this.groups.controls.forEach((element) => {
454           const members = element.value.members;
455           const i = members.indexOf(oldName);
456
457           if (i !== -1) {
458             members[i] = elem.name;
459           }
460           element.get('members').setValue(members);
461         });
462       });
463       this.groupMembersSelections[group_index] = [...this.groupMembersSelections[group_index]];
464     });
465   }
466
467   removeInitiatorImage(initiator: any, lun_index: number, initiator_index: string, image: string) {
468     const luns = initiator.getValue('luns');
469     luns.splice(lun_index, 1);
470     initiator.patchValue({ luns: luns });
471
472     this.imagesInitiatorSelections[initiator_index].forEach((value) => {
473       if (value.name === image) {
474         value.selected = false;
475       }
476     });
477
478     return false;
479   }
480
481   // Groups
482   get groups() {
483     return this.targetForm.get('groups') as FormArray;
484   }
485
486   addGroup() {
487     const fg = new CdFormGroup({
488       group_id: new FormControl('', { validators: [Validators.required] }),
489       members: new FormControl([]),
490       disks: new FormControl([])
491     });
492
493     this.groups.push(fg);
494
495     const disks = _.map(
496       this.targetForm.getValue('disks'),
497       (disk) => new SelectOption(false, disk, '')
498     );
499     this.groupDiskSelections.push(disks);
500
501     const initiators = _.map(
502       this.initiators.value,
503       (initiator) => new SelectOption(false, initiator.client_iqn, '', !initiator.cdIsInGroup)
504     );
505     this.groupMembersSelections.push(initiators);
506
507     return fg;
508   }
509
510   removeGroup(index) {
511     this.groups.removeAt(index);
512     this.groupDiskSelections.splice(index, 1);
513   }
514
515   onGroupMemberSelection($event) {
516     const option = $event.option;
517
518     let initiator_index: number;
519     this.initiators.controls.forEach((element, index) => {
520       if (element.value.client_iqn === option.name) {
521         element.patchValue({ luns: [] });
522         element.get('cdIsInGroup').setValue(option.selected);
523         initiator_index = index;
524       }
525     });
526
527     // Members can only be at one group at a time, so when a member is selected
528     // in one group we need to disable its selection in other groups
529     _.forEach(this.groupMembersSelections, (group) => {
530       group[initiator_index].enabled = !option.selected;
531     });
532   }
533
534   removeGroupInitiator(group, member_index, group_index) {
535     const name = group.getValue('members')[member_index];
536     group.getValue('members').splice(member_index, 1);
537
538     this.groupMembersSelections[group_index].forEach((value) => {
539       if (value.name === name) {
540         value.selected = false;
541       }
542     });
543     this.groupMembersSelections[group_index] = [...this.groupMembersSelections[group_index]];
544
545     this.onGroupMemberSelection({ option: new SelectOption(false, name, '') });
546   }
547
548   removeGroupDisk(group, disk_index, group_index) {
549     const name = group.getValue('disks')[disk_index];
550     group.getValue('disks').splice(disk_index, 1);
551
552     this.groupDiskSelections[group_index].forEach((value) => {
553       if (value.name === name) {
554         value.selected = false;
555       }
556     });
557     this.groupDiskSelections[group_index] = [...this.groupDiskSelections[group_index]];
558   }
559
560   submit() {
561     const formValue = _.cloneDeep(this.targetForm.value);
562
563     const request = {
564       target_iqn: this.targetForm.getValue('target_iqn'),
565       target_controls: this.targetForm.getValue('target_controls'),
566       acl_enabled: this.targetForm.getValue('acl_enabled'),
567       portals: [],
568       disks: [],
569       clients: [],
570       groups: []
571     };
572
573     // Disks
574     formValue.disks.forEach((disk) => {
575       const imageSplit = disk.split('/');
576       const backstore = this.imagesSettings[disk].backstore;
577       request.disks.push({
578         pool: imageSplit[0],
579         image: imageSplit[1],
580         backstore: backstore,
581         controls: this.imagesSettings[disk][backstore]
582       });
583     });
584
585     // Portals
586     formValue.portals.forEach((portal) => {
587       const index = portal.indexOf(':');
588       request.portals.push({
589         host: portal.substring(0, index),
590         ip: portal.substring(index + 1)
591       });
592     });
593
594     // Clients
595     if (request.acl_enabled) {
596       formValue.initiators.forEach((initiator) => {
597         if (!initiator.auth.user) {
598           initiator.auth.user = '';
599         }
600         if (!initiator.auth.password) {
601           initiator.auth.password = '';
602         }
603         if (!initiator.auth.mutual_user) {
604           initiator.auth.mutual_user = '';
605         }
606         if (!initiator.auth.mutual_password) {
607           initiator.auth.mutual_password = '';
608         }
609         delete initiator.cdIsInGroup;
610
611         const newLuns = [];
612         initiator.luns.forEach((lun) => {
613           const imageSplit = lun.split('/');
614           newLuns.push({
615             pool: imageSplit[0],
616             image: imageSplit[1]
617           });
618         });
619
620         initiator.luns = newLuns;
621       });
622       request.clients = formValue.initiators;
623     }
624
625     // Groups
626     if (request.acl_enabled) {
627       formValue.groups.forEach((group) => {
628         const newDisks = [];
629         group.disks.forEach((disk) => {
630           const imageSplit = disk.split('/');
631           newDisks.push({
632             pool: imageSplit[0],
633             image: imageSplit[1]
634           });
635         });
636
637         group.disks = newDisks;
638       });
639       request.groups = formValue.groups;
640     }
641
642     let wrapTask;
643     if (this.isEdit) {
644       request['new_target_iqn'] = request.target_iqn;
645       request.target_iqn = this.target_iqn;
646       wrapTask = this.taskWrapper.wrapTaskAroundCall({
647         task: new FinishedTask('iscsi/target/edit', {
648           target_iqn: request.target_iqn
649         }),
650         call: this.iscsiService.updateTarget(this.target_iqn, request)
651       });
652     } else {
653       wrapTask = this.taskWrapper.wrapTaskAroundCall({
654         task: new FinishedTask('iscsi/target/create', {
655           target_iqn: request.target_iqn
656         }),
657         call: this.iscsiService.createTarget(request)
658       });
659     }
660
661     wrapTask.subscribe(
662       undefined,
663       () => {
664         this.targetForm.setErrors({ cdSubmitButton: true });
665       },
666       () => this.router.navigate(['/block/iscsi/targets'])
667     );
668   }
669
670   targetSettingsModal() {
671     const initialState = {
672       target_controls: this.targetForm.get('target_controls'),
673       target_default_controls: this.target_default_controls,
674       target_controls_limits: this.target_controls_limits
675     };
676
677     this.modalRef = this.modalService.show(IscsiTargetIqnSettingsModalComponent, { initialState });
678   }
679
680   imageSettingsModal(image) {
681     const initialState = {
682       imagesSettings: this.imagesSettings,
683       image: image,
684       disk_default_controls: this.disk_default_controls,
685       disk_controls_limits: this.disk_controls_limits,
686       backstores: this.getValidBackstores(this.getImageById(image))
687     };
688
689     this.modalRef = this.modalService.show(IscsiTargetImageSettingsModalComponent, {
690       initialState
691     });
692   }
693
694   validFeatures(image, backstore) {
695     const imageFeatures = image.features;
696     const requiredFeatures = this.required_rbd_features[backstore];
697     const unsupportedFeatures = this.unsupported_rbd_features[backstore];
698     // tslint:disable-next-line:no-bitwise
699     const validRequiredFeatures = (imageFeatures & requiredFeatures) === requiredFeatures;
700     // tslint:disable-next-line:no-bitwise
701     const validSupportedFeatures = (imageFeatures & unsupportedFeatures) === 0;
702     return validRequiredFeatures && validSupportedFeatures;
703   }
704
705   getImageById(imageId) {
706     return this.imagesAll.find((image) => imageId === `${image.pool_name}/${image.name}`);
707   }
708
709   getValidBackstores(image) {
710     return this.backstores.filter((backstore) => this.validFeatures(image, backstore));
711   }
712 }