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