1 import { Component, OnInit } from '@angular/core';
2 import { FormArray, FormControl, Validators } from '@angular/forms';
3 import { ActivatedRoute, Router } from '@angular/router';
5 import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
6 import { I18n } from '@ngx-translate/i18n-polyfill';
7 import * as _ from 'lodash';
8 import { forkJoin } from 'rxjs';
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 { CdForm } from '../../../shared/forms/cd-form';
17 import { CdFormGroup } from '../../../shared/forms/cd-form-group';
18 import { CdValidators } from '../../../shared/forms/cd-validators';
19 import { FinishedTask } from '../../../shared/models/finished-task';
20 import { ModalService } from '../../../shared/services/modal.service';
21 import { TaskWrapperService } from '../../../shared/services/task-wrapper.service';
22 import { IscsiTargetImageSettingsModalComponent } from '../iscsi-target-image-settings-modal/iscsi-target-image-settings-modal.component';
23 import { IscsiTargetIqnSettingsModalComponent } from '../iscsi-target-iqn-settings-modal/iscsi-target-iqn-settings-modal.component';
26 selector: 'cd-iscsi-target-form',
27 templateUrl: './iscsi-target-form.component.html',
28 styleUrls: ['./iscsi-target-form.component.scss']
30 export class IscsiTargetFormComponent extends CdForm implements OnInit {
31 cephIscsiConfigVersion: number;
32 targetForm: CdFormGroup;
33 modalRef: NgbModalRef;
36 target_default_controls: any;
37 target_controls_limits: any;
38 disk_default_controls: any;
39 disk_controls_limits: any;
41 default_backstore: string;
42 unsupported_rbd_features: any;
43 required_rbd_features: any;
51 imagesSelections: SelectOption[];
52 portalsSelections: SelectOption[] = [];
54 imagesInitiatorSelections: SelectOption[][] = [];
55 groupDiskSelections: SelectOption[][] = [];
56 groupMembersSelections: SelectOption[][] = [];
58 imagesSettings: any = {};
60 portals: new SelectMessages(
61 { noOptions: this.i18n('There are no portals available.') },
64 images: new SelectMessages(
65 { noOptions: this.i18n('There are no images available.') },
68 initiatorImage: new SelectMessages(
71 'There are no images available. Please make sure you add an image to the target.'
76 groupInitiator: new SelectMessages(
79 'There are no initiators available. Please make sure you add an initiator to the target.'
86 IQN_REGEX = /^iqn\.(19|20)\d\d-(0[1-9]|1[0-2])\.\D{2,3}(\.[A-Za-z0-9-]+)+(:[A-Za-z0-9-\.]+)*$/;
87 USER_REGEX = /^[\w\.:@_-]{8,64}$/;
88 PASSWORD_REGEX = /^[\w@\-_\/]{12,16}$/;
93 private iscsiService: IscsiService,
94 private modalService: ModalService,
95 private rbdService: RbdService,
96 private router: Router,
97 private route: ActivatedRoute,
99 private taskWrapper: TaskWrapperService,
100 public actionLabels: ActionLabelsI18n
103 this.resource = this.i18n('target');
107 const promises: any[] = [
108 this.iscsiService.listTargets(),
109 this.rbdService.list(),
110 this.iscsiService.portals(),
111 this.iscsiService.settings(),
112 this.iscsiService.version()
115 if (this.router.url.startsWith('/block/iscsi/targets/edit')) {
117 this.route.params.subscribe((params: { target_iqn: string }) => {
118 this.target_iqn = decodeURIComponent(params.target_iqn);
119 promises.push(this.iscsiService.getTarget(this.target_iqn));
122 this.action = this.isEdit ? this.actionLabels.EDIT : this.actionLabels.CREATE;
124 forkJoin(promises).subscribe((data: any[]) => {
125 // iscsiService.listTargets
126 const usedImages = _(data[0])
127 .filter((target) => target.target_iqn !== this.target_iqn)
128 .flatMap((target) => target.disks)
129 .map((image) => `${image.pool}/${image.image}`)
132 // iscsiService.settings()
133 if ('api_version' in data[3]) {
134 this.api_version = data[3].api_version;
136 this.minimum_gateways = data[3].config.minimum_gateways;
137 this.target_default_controls = data[3].target_default_controls;
138 this.target_controls_limits = data[3].target_controls_limits;
139 this.disk_default_controls = data[3].disk_default_controls;
140 this.disk_controls_limits = data[3].disk_controls_limits;
141 this.backstores = data[3].backstores;
142 this.default_backstore = data[3].default_backstore;
143 this.unsupported_rbd_features = data[3].unsupported_rbd_features;
144 this.required_rbd_features = data[3].required_rbd_features;
147 this.imagesAll = _(data[1])
148 .flatMap((pool) => pool.value)
150 // Namespaces are not supported by ceph-iscsi
151 if (image.namespace) {
154 const imageId = `${image.pool_name}/${image.name}`;
155 if (usedImages.indexOf(imageId) !== -1) {
158 const validBackstores = this.getValidBackstores(image);
159 if (validBackstores.length === 0) {
166 this.imagesSelections = this.imagesAll.map(
167 (image) => new SelectOption(false, `${image.pool_name}/${image.name}`, '')
170 // iscsiService.portals()
171 const portals: SelectOption[] = [];
172 data[2].forEach((portal: Record<string, any>) => {
173 portal.ip_addresses.forEach((ip: string) => {
174 portals.push(new SelectOption(false, portal.name + ':' + ip, ''));
177 this.portalsSelections = [...portals];
179 // iscsiService.version()
180 this.cephIscsiConfigVersion = data[4]['ceph_iscsi_config_version'];
184 // iscsiService.getTarget()
186 this.resolveModel(data[5]);
194 this.targetForm = new CdFormGroup({
195 target_iqn: new FormControl('iqn.2001-07.com.ceph:' + Date.now(), {
196 validators: [Validators.required, Validators.pattern(this.IQN_REGEX)]
198 target_controls: new FormControl({}),
199 portals: new FormControl([], {
201 CdValidators.custom('minGateways', (value: any[]) => {
202 const gateways = _.uniq(value.map((elem) => elem.split(':')[0]));
203 return gateways.length < Math.max(1, this.minimum_gateways);
207 disks: new FormControl([], {
209 CdValidators.custom('dupLunId', (value: any[]) => {
210 const lunIds = this.getLunIds(value);
211 return lunIds.length !== _.uniq(lunIds).length;
213 CdValidators.custom('dupWwn', (value: any[]) => {
214 const wwns = this.getWwns(value);
215 return wwns.length !== _.uniq(wwns).length;
219 initiators: new FormArray([]),
220 groups: new FormArray([]),
221 acl_enabled: new FormControl(false)
223 // Target level authentication was introduced in ceph-iscsi config v11
224 if (this.cephIscsiConfigVersion > 10) {
225 const authFormGroup = new CdFormGroup({
226 user: new FormControl(''),
227 password: new FormControl(''),
228 mutual_user: new FormControl(''),
229 mutual_password: new FormControl('')
231 this.setAuthValidator(authFormGroup);
232 this.targetForm.addControl('auth', authFormGroup);
236 resolveModel(res: Record<string, any>) {
237 this.targetForm.patchValue({
238 target_iqn: res.target_iqn,
239 target_controls: res.target_controls,
240 acl_enabled: res.acl_enabled
242 // Target level authentication was introduced in ceph-iscsi config v11
243 if (this.cephIscsiConfigVersion > 10) {
244 this.targetForm.patchValue({
248 const portals: any[] = [];
249 _.forEach(res.portals, (portal) => {
250 const id = `${portal.host}:${portal.ip}`;
253 this.targetForm.patchValue({
257 const disks: any[] = [];
258 _.forEach(res.disks, (disk) => {
259 const id = `${disk.pool}/${disk.image}`;
261 this.imagesSettings[id] = {
262 backstore: disk.backstore
264 this.imagesSettings[id][disk.backstore] = disk.controls;
266 this.imagesSettings[id]['lun'] = disk.lun;
269 this.imagesSettings[id]['wwn'] = disk.wwn;
272 this.onImageSelection({ option: { name: id, selected: true } });
274 this.targetForm.patchValue({
278 _.forEach(res.clients, (client) => {
279 const initiator = this.addInitiator();
280 client.luns = _.map(client.luns, (lun) => `${lun.pool}/${lun.image}`);
281 initiator.patchValue(client);
282 // updatedInitiatorSelector()
285 _.forEach(res.groups, (group) => {
286 const fg = this.addGroup();
287 group.disks = _.map(group.disks, (disk) => `${disk.pool}/${disk.image}`);
288 fg.patchValue(group);
289 _.forEach(group.members, (member) => {
290 this.onGroupMemberSelection({ option: new SelectOption(true, member, '') });
295 hasAdvancedSettings(settings: any) {
296 return Object.values(settings).length > 0;
301 return this.targetForm.get('portals') as FormControl;
304 onPortalSelection() {
305 this.portals.setValue(this.portals.value);
308 removePortal(index: number, portal: string) {
309 this.portalsSelections.forEach((value) => {
310 if (value.name === portal) {
311 value.selected = false;
315 this.portals.value.splice(index, 1);
316 this.portals.setValue(this.portals.value);
322 return this.targetForm.get('disks') as FormControl;
325 removeImage(index: number, image: string) {
326 this.imagesSelections.forEach((value) => {
327 if (value.name === image) {
328 value.selected = false;
331 this.disks.value.splice(index, 1);
332 this.removeImageRefs(image);
333 this.targetForm.get('disks').updateValueAndValidity({ emitEvent: false });
337 removeImageRefs(name: string) {
338 this.initiators.controls.forEach((element) => {
339 const newImages = element.value.luns.filter((item: string) => item !== name);
340 element.get('luns').setValue(newImages);
343 this.groups.controls.forEach((element) => {
344 const newDisks = element.value.disks.filter((item: string) => item !== name);
345 element.get('disks').setValue(newDisks);
348 _.forEach(this.imagesInitiatorSelections, (selections, i) => {
349 this.imagesInitiatorSelections[i] = selections.filter((item: any) => item.name !== name);
351 _.forEach(this.groupDiskSelections, (selections, i) => {
352 this.groupDiskSelections[i] = selections.filter((item: any) => item.name !== name);
356 getDefaultBackstore(imageId: string) {
357 let result = this.default_backstore;
358 const image = this.getImageById(imageId);
359 if (!this.validFeatures(image, this.default_backstore)) {
360 this.backstores.forEach((backstore) => {
361 if (backstore !== this.default_backstore) {
362 if (this.validFeatures(image, backstore)) {
371 isLunIdInUse(lunId: string, imageId: string) {
372 const images = this.disks.value.filter((currentImageId: string) => currentImageId !== imageId);
373 return this.getLunIds(images).includes(lunId);
376 getLunIds(images: object) {
377 return _.map(images, (image) => this.imagesSettings[image]['lun']);
380 nextLunId(imageId: string) {
381 const images = this.disks.value.filter((currentImageId: string) => currentImageId !== imageId);
382 const lunIdsInUse = this.getLunIds(images);
383 let lunIdCandidate = 0;
384 while (lunIdsInUse.includes(lunIdCandidate)) {
387 return lunIdCandidate;
390 getWwns(images: object) {
391 const wwns = _.map(images, (image) => this.imagesSettings[image]['wwn']);
392 return wwns.filter((wwn) => _.isString(wwn) && wwn !== '');
395 onImageSelection($event: any) {
396 const option = $event.option;
398 if (option.selected) {
399 if (!this.imagesSettings[option.name]) {
400 const defaultBackstore = this.getDefaultBackstore(option.name);
401 this.imagesSettings[option.name] = {
402 backstore: defaultBackstore,
403 lun: this.nextLunId(option.name)
405 this.imagesSettings[option.name][defaultBackstore] = {};
406 } else if (this.isLunIdInUse(this.imagesSettings[option.name]['lun'], option.name)) {
407 // If the lun id is now in use, we have to generate a new one
408 this.imagesSettings[option.name]['lun'] = this.nextLunId(option.name);
411 _.forEach(this.imagesInitiatorSelections, (selections, i) => {
412 selections.push(new SelectOption(false, option.name, ''));
413 this.imagesInitiatorSelections[i] = [...selections];
416 _.forEach(this.groupDiskSelections, (selections, i) => {
417 selections.push(new SelectOption(false, option.name, ''));
418 this.groupDiskSelections[i] = [...selections];
421 this.removeImageRefs(option.name);
423 this.targetForm.get('disks').updateValueAndValidity({ emitEvent: false });
428 return this.targetForm.get('initiators') as FormArray;
432 const fg = new CdFormGroup({
433 client_iqn: new FormControl('', {
436 CdValidators.custom('notUnique', (client_iqn: string) => {
437 const flattened = this.initiators.controls.reduce(function (accumulator, currentValue) {
438 return accumulator.concat(currentValue.value.client_iqn);
441 return flattened.indexOf(client_iqn) !== flattened.lastIndexOf(client_iqn);
443 Validators.pattern(this.IQN_REGEX)
446 auth: new CdFormGroup({
447 user: new FormControl(''),
448 password: new FormControl(''),
449 mutual_user: new FormControl(''),
450 mutual_password: new FormControl('')
452 luns: new FormControl([]),
453 cdIsInGroup: new FormControl(false)
456 this.setAuthValidator(fg);
458 this.initiators.push(fg);
460 _.forEach(this.groupMembersSelections, (selections, i) => {
461 selections.push(new SelectOption(false, '', ''));
462 this.groupMembersSelections[i] = [...selections];
466 this.targetForm.getValue('disks'),
467 (disk) => new SelectOption(false, disk, '')
469 this.imagesInitiatorSelections.push(disks);
474 setAuthValidator(fg: CdFormGroup) {
475 CdValidators.validateIf(
477 () => fg.getValue('password') || fg.getValue('mutual_user') || fg.getValue('mutual_password'),
478 [Validators.required],
479 [Validators.pattern(this.USER_REGEX)],
480 [fg.get('password'), fg.get('mutual_user'), fg.get('mutual_password')]
483 CdValidators.validateIf(
485 () => fg.getValue('user') || fg.getValue('mutual_user') || fg.getValue('mutual_password'),
486 [Validators.required],
487 [Validators.pattern(this.PASSWORD_REGEX)],
488 [fg.get('user'), fg.get('mutual_user'), fg.get('mutual_password')]
491 CdValidators.validateIf(
492 fg.get('mutual_user'),
493 () => fg.getValue('mutual_password'),
494 [Validators.required],
495 [Validators.pattern(this.USER_REGEX)],
496 [fg.get('user'), fg.get('password'), fg.get('mutual_password')]
499 CdValidators.validateIf(
500 fg.get('mutual_password'),
501 () => fg.getValue('mutual_user'),
502 [Validators.required],
503 [Validators.pattern(this.PASSWORD_REGEX)],
504 [fg.get('user'), fg.get('password'), fg.get('mutual_user')]
508 removeInitiator(index: number) {
509 const removed = this.initiators.value[index];
511 this.initiators.removeAt(index);
513 _.forEach(this.groupMembersSelections, (selections, i) => {
514 selections.splice(index, 1);
515 this.groupMembersSelections[i] = [...selections];
518 this.groups.controls.forEach((element) => {
519 const newMembers = element.value.members.filter(
520 (item: string) => item !== removed.client_iqn
522 element.get('members').setValue(newMembers);
525 this.imagesInitiatorSelections.splice(index, 1);
528 updatedInitiatorSelector() {
529 // Validate all client_iqn
530 this.initiators.controls.forEach((control) => {
531 control.get('client_iqn').updateValueAndValidity({ emitEvent: false });
534 // Update Group Initiator Selector
535 _.forEach(this.groupMembersSelections, (group, group_index) => {
536 _.forEach(group, (elem, index) => {
537 const oldName = elem.name;
538 elem.name = this.initiators.controls[index].value.client_iqn;
540 this.groups.controls.forEach((element) => {
541 const members = element.value.members;
542 const i = members.indexOf(oldName);
545 members[i] = elem.name;
547 element.get('members').setValue(members);
550 this.groupMembersSelections[group_index] = [...this.groupMembersSelections[group_index]];
554 removeInitiatorImage(initiator: any, lun_index: number, initiator_index: number, image: string) {
555 const luns = initiator.getValue('luns');
556 luns.splice(lun_index, 1);
557 initiator.patchValue({ luns: luns });
559 this.imagesInitiatorSelections[initiator_index].forEach((value: Record<string, any>) => {
560 if (value.name === image) {
561 value.selected = false;
570 return this.targetForm.get('groups') as FormArray;
574 const fg = new CdFormGroup({
575 group_id: new FormControl('', { validators: [Validators.required] }),
576 members: new FormControl([]),
577 disks: new FormControl([])
580 this.groups.push(fg);
583 this.targetForm.getValue('disks'),
584 (disk) => new SelectOption(false, disk, '')
586 this.groupDiskSelections.push(disks);
588 const initiators = _.map(
589 this.initiators.value,
590 (initiator) => new SelectOption(false, initiator.client_iqn, '', !initiator.cdIsInGroup)
592 this.groupMembersSelections.push(initiators);
597 removeGroup(index: number) {
598 this.groups.removeAt(index);
599 this.groupDiskSelections.splice(index, 1);
602 onGroupMemberSelection($event: any) {
603 const option = $event.option;
605 let initiator_index: number;
606 this.initiators.controls.forEach((element, index) => {
607 if (element.value.client_iqn === option.name) {
608 element.patchValue({ luns: [] });
609 element.get('cdIsInGroup').setValue(option.selected);
610 initiator_index = index;
614 // Members can only be at one group at a time, so when a member is selected
615 // in one group we need to disable its selection in other groups
616 _.forEach(this.groupMembersSelections, (group) => {
617 group[initiator_index].enabled = !option.selected;
621 removeGroupInitiator(group: CdFormGroup, member_index: number, group_index: number) {
622 const name = group.getValue('members')[member_index];
623 group.getValue('members').splice(member_index, 1);
625 this.groupMembersSelections[group_index].forEach((value) => {
626 if (value.name === name) {
627 value.selected = false;
630 this.groupMembersSelections[group_index] = [...this.groupMembersSelections[group_index]];
632 this.onGroupMemberSelection({ option: new SelectOption(false, name, '') });
635 removeGroupDisk(group: CdFormGroup, disk_index: number, group_index: number) {
636 const name = group.getValue('disks')[disk_index];
637 group.getValue('disks').splice(disk_index, 1);
639 this.groupDiskSelections[group_index].forEach((value) => {
640 if (value.name === name) {
641 value.selected = false;
644 this.groupDiskSelections[group_index] = [...this.groupDiskSelections[group_index]];
648 const formValue = _.cloneDeep(this.targetForm.value);
650 const request: Record<string, any> = {
651 target_iqn: this.targetForm.getValue('target_iqn'),
652 target_controls: this.targetForm.getValue('target_controls'),
653 acl_enabled: this.targetForm.getValue('acl_enabled'),
660 // Target level authentication was introduced in ceph-iscsi config v11
661 if (this.cephIscsiConfigVersion > 10) {
662 const targetAuth: CdFormGroup = this.targetForm.get('auth') as CdFormGroup;
663 if (!targetAuth.getValue('user')) {
664 targetAuth.get('user').setValue('');
666 if (!targetAuth.getValue('password')) {
667 targetAuth.get('password').setValue('');
669 if (!targetAuth.getValue('mutual_user')) {
670 targetAuth.get('mutual_user').setValue('');
672 if (!targetAuth.getValue('mutual_password')) {
673 targetAuth.get('mutual_password').setValue('');
675 const acl_enabled = this.targetForm.getValue('acl_enabled');
677 user: acl_enabled ? '' : targetAuth.getValue('user'),
678 password: acl_enabled ? '' : targetAuth.getValue('password'),
679 mutual_user: acl_enabled ? '' : targetAuth.getValue('mutual_user'),
680 mutual_password: acl_enabled ? '' : targetAuth.getValue('mutual_password')
685 formValue.disks.forEach((disk: string) => {
686 const imageSplit = disk.split('/');
687 const backstore = this.imagesSettings[disk].backstore;
690 image: imageSplit[1],
691 backstore: backstore,
692 controls: this.imagesSettings[disk][backstore],
693 lun: this.imagesSettings[disk]['lun'],
694 wwn: this.imagesSettings[disk]['wwn']
699 formValue.portals.forEach((portal: string) => {
700 const index = portal.indexOf(':');
701 request.portals.push({
702 host: portal.substring(0, index),
703 ip: portal.substring(index + 1)
708 if (request.acl_enabled) {
709 formValue.initiators.forEach((initiator: Record<string, any>) => {
710 if (!initiator.auth.user) {
711 initiator.auth.user = '';
713 if (!initiator.auth.password) {
714 initiator.auth.password = '';
716 if (!initiator.auth.mutual_user) {
717 initiator.auth.mutual_user = '';
719 if (!initiator.auth.mutual_password) {
720 initiator.auth.mutual_password = '';
722 delete initiator.cdIsInGroup;
724 const newLuns: any[] = [];
725 initiator.luns.forEach((lun: string) => {
726 const imageSplit = lun.split('/');
733 initiator.luns = newLuns;
735 request.clients = formValue.initiators;
739 if (request.acl_enabled) {
740 formValue.groups.forEach((group: Record<string, any>) => {
741 const newDisks: any[] = [];
742 group.disks.forEach((disk: string) => {
743 const imageSplit = disk.split('/');
750 group.disks = newDisks;
752 request.groups = formValue.groups;
757 request['new_target_iqn'] = request.target_iqn;
758 request.target_iqn = this.target_iqn;
759 wrapTask = this.taskWrapper.wrapTaskAroundCall({
760 task: new FinishedTask('iscsi/target/edit', {
761 target_iqn: request.target_iqn
763 call: this.iscsiService.updateTarget(this.target_iqn, request)
766 wrapTask = this.taskWrapper.wrapTaskAroundCall({
767 task: new FinishedTask('iscsi/target/create', {
768 target_iqn: request.target_iqn
770 call: this.iscsiService.createTarget(request)
776 this.targetForm.setErrors({ cdSubmitButton: true });
778 complete: () => this.router.navigate(['/block/iscsi/targets'])
782 targetSettingsModal() {
783 const initialState = {
784 target_controls: this.targetForm.get('target_controls'),
785 target_default_controls: this.target_default_controls,
786 target_controls_limits: this.target_controls_limits
789 this.modalRef = this.modalService.show(IscsiTargetIqnSettingsModalComponent, initialState);
792 imageSettingsModal(image: string) {
793 const initialState = {
794 imagesSettings: this.imagesSettings,
796 api_version: this.api_version,
797 disk_default_controls: this.disk_default_controls,
798 disk_controls_limits: this.disk_controls_limits,
799 backstores: this.getValidBackstores(this.getImageById(image)),
800 control: this.targetForm.get('disks')
803 this.modalRef = this.modalService.show(IscsiTargetImageSettingsModalComponent, initialState);
806 validFeatures(image: Record<string, any>, backstore: string) {
807 const imageFeatures = image.features;
808 const requiredFeatures = this.required_rbd_features[backstore];
809 const unsupportedFeatures = this.unsupported_rbd_features[backstore];
810 // tslint:disable-next-line:no-bitwise
811 const validRequiredFeatures = (imageFeatures & requiredFeatures) === requiredFeatures;
812 // tslint:disable-next-line:no-bitwise
813 const validSupportedFeatures = (imageFeatures & unsupportedFeatures) === 0;
814 return validRequiredFeatures && validSupportedFeatures;
817 getImageById(imageId: string) {
818 return this.imagesAll.find((image) => imageId === `${image.pool_name}/${image.name}`);
821 getValidBackstores(image: object) {
822 return this.backstores.filter((backstore) => this.validFeatures(image, backstore));