]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/blob
1a6e47a9a8365e90454c9807228a705cd22c62e7
[ceph.git] /
1 import { Component, OnDestroy, OnInit } from '@angular/core';
2 import { UntypedFormControl, Validators } from '@angular/forms';
3 import { ActivatedRoute, Router } from '@angular/router';
4 import {
5   NamespaceCreateRequest,
6   NamespaceInitiatorRequest,
7   NamespaceUpdateRequest,
8   NvmeofService
9 } from '~/app/shared/api/nvmeof.service';
10 import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants';
11 import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
12 import { FinishedTask } from '~/app/shared/models/finished-task';
13 import {
14   NvmeofSubsystem,
15   NvmeofSubsystemInitiator,
16   NvmeofSubsystemNamespace,
17   NvmeofNamespaceListResponse,
18   NvmeofInitiatorCandidate,
19   NsFormField,
20   RbdImageCreation,
21   HOST_TYPE
22 } from '~/app/shared/models/nvmeof';
23 import { Permission } from '~/app/shared/models/permissions';
24 import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
25 import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
26 import { Pool } from '../../pool/pool';
27 import { PoolService } from '~/app/shared/api/pool.service';
28 import { RbdPool, RbdImage } from '~/app/shared/api/rbd.model';
29 import { RbdService } from '~/app/shared/api/rbd.service';
30 import { FormatterService } from '~/app/shared/services/formatter.service';
31 import { forkJoin, Observable, of, Subject } from 'rxjs';
32 import { filter, switchMap, takeUntil, tap } from 'rxjs/operators';
33 import { CdValidators } from '~/app/shared/forms/cd-validators';
34 import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe';
35 import { HttpResponse } from '@angular/common/http';
36
37 @Component({
38   selector: 'cd-nvmeof-namespaces-form',
39   templateUrl: './nvmeof-namespaces-form.component.html',
40   styleUrls: ['./nvmeof-namespaces-form.component.scss'],
41   standalone: false
42 })
43 export class NvmeofNamespacesFormComponent implements OnInit, OnDestroy {
44   action: string;
45   permission: Permission;
46   poolPermission: Permission;
47   resource: string;
48   pageURL: string;
49   edit: boolean = false;
50   nsForm: CdFormGroup;
51   subsystemNQN: string;
52   subsystems?: NvmeofSubsystem[];
53   rbdPools: Pool[] | null = null;
54   rbdImages: RbdImage[] = [];
55   initiatorCandidates: NvmeofInitiatorCandidate[] = [];
56
57   // Stores all RBD images fetched for the selected pool
58   private allRbdImages: RbdImage[] = [];
59   // Maps pool name to a Set of used image names for O(1) lookup
60   private usedRbdImages: Map<string, Set<string>> = new Map();
61   private lastSubsystemNqn: string;
62
63   nsid: string;
64   currentBytes: number = 0;
65   group: string;
66   MAX_NAMESPACE_CREATE: number = 5;
67   MIN_NAMESPACE_CREATE: number = 1;
68   private destroy$ = new Subject<void>();
69   INVALID_TEXTS: Record<string, string> = {
70     required: $localize`This field is required.`,
71     min: $localize`The namespace count should be between 1 and 5.`,
72     max: $localize`The namespace count should be between 1 and 5.`,
73     minSize: $localize`Enter a value larger than previous. A block device image can be expanded but not reduced.`,
74     rbdImageName: $localize`Image name contains invalid characters.`
75   };
76
77   constructor(
78     public actionLabels: ActionLabelsI18n,
79     private authStorageService: AuthStorageService,
80     private taskWrapperService: TaskWrapperService,
81     private nvmeofService: NvmeofService,
82     private poolService: PoolService,
83     private rbdService: RbdService,
84     private router: Router,
85     private route: ActivatedRoute,
86     public formatterService: FormatterService,
87     public dimlessBinaryPipe: DimlessBinaryPipe
88   ) {
89     this.permission = this.authStorageService.getPermissions().nvmeof;
90     this.poolPermission = this.authStorageService.getPermissions().pool;
91     this.resource = $localize`Namespace`;
92     this.pageURL = 'block/nvmeof/gateways';
93   }
94
95   ngOnDestroy() {
96     this.destroy$.next();
97     this.destroy$.complete();
98   }
99
100   init() {
101     this.route.queryParams.subscribe((params) => {
102       this.group = params?.['group'];
103       if (params?.['subsystem_nqn']) {
104         this.subsystemNQN = params?.['subsystem_nqn'];
105       }
106     });
107
108     this.createForm();
109     this.action = this.actionLabels.CREATE;
110     this.route.params.subscribe((params: { subsystem_nqn: string; nsid: string }) => {
111       this.subsystemNQN = params.subsystem_nqn;
112       this.nsid = params?.nsid;
113     });
114   }
115
116   initForEdit() {
117     this.edit = true;
118     this.action = this.actionLabels.EDIT;
119     this.nvmeofService
120       .getNamespace(this.subsystemNQN, this.nsid, this.group)
121       .subscribe((res: NvmeofSubsystemNamespace) => {
122         this.currentBytes =
123           typeof res.rbd_image_size === 'string' ? Number(res.rbd_image_size) : res.rbd_image_size;
124         this.nsForm.get(NsFormField.POOL).setValue(res.rbd_pool_name);
125         this.nsForm
126           .get(NsFormField.IMAGE_SIZE)
127           .setValue(this.dimlessBinaryPipe.transform(res.rbd_image_size));
128         this.nsForm.get(NsFormField.IMAGE_SIZE).addValidators(Validators.required);
129         this.nsForm.get(NsFormField.POOL).disable();
130         this.nsForm.get(NsFormField.SUBSYSTEM).disable();
131         this.nsForm.get(NsFormField.SUBSYSTEM).setValue(this.subsystemNQN);
132       });
133   }
134
135   initForCreate() {
136     this.poolService.getList().subscribe((resp: Pool[]) => {
137       this.rbdPools = resp.filter(this.rbdService.isRBDPool);
138     });
139     this.route.queryParams
140       .pipe(
141         filter((params) => params?.['group']),
142         tap((params) => {
143           this.group = params['group'];
144           this.fetchUsedImages();
145         }),
146         switchMap(() => this.nvmeofService.listSubsystems(this.group))
147       )
148       .subscribe((subsystems: NvmeofSubsystem[]) => {
149         this.subsystems = subsystems;
150         if (this.subsystemNQN) {
151           const selectedSubsystem = this.subsystems.find((s) => s.nqn === this.subsystemNQN);
152           if (selectedSubsystem) {
153             this.nsForm.get(NsFormField.SUBSYSTEM).setValue(selectedSubsystem.nqn);
154           }
155         }
156       });
157   }
158
159   ngOnInit() {
160     this.init();
161     if (this.router.url.includes('subsystems/(modal:edit')) {
162       this.initForEdit();
163     } else {
164       this.initForCreate();
165     }
166     const subsystemControl = this.nsForm.get(NsFormField.SUBSYSTEM);
167     if (subsystemControl) {
168       subsystemControl.valueChanges.subscribe((nqn: string) => {
169         this.onSubsystemChange(nqn);
170       });
171     }
172   }
173
174   onPoolChange(): void {
175     const pool = this.nsForm.getValue(NsFormField.POOL);
176     if (!pool) return;
177
178     this.rbdService
179       .list({ pool_name: pool, offset: '0', limit: '-1' })
180       .subscribe((pools: RbdPool[]) => {
181         const selectedPool = pools.find((p) => p.pool_name === pool);
182         this.allRbdImages = selectedPool?.value ?? [];
183         this.filterImages();
184
185         const imageControl = this.nsForm.get(NsFormField.RBD_IMAGE_NAME);
186         const currentImage = this.nsForm.getValue(NsFormField.RBD_IMAGE_NAME);
187         if (currentImage && !this.rbdImages.some((img) => img.name === currentImage)) {
188           imageControl.setValue(null);
189         }
190         imageControl.markAsUntouched();
191         imageControl.markAsPristine();
192       });
193   }
194
195   fetchUsedImages(): void {
196     if (!this.group) return;
197
198     this.nvmeofService
199       .listNamespaces(this.group)
200       .subscribe((response: NvmeofNamespaceListResponse) => {
201         const namespaces: NvmeofSubsystemNamespace[] = Array.isArray(response)
202           ? response
203           : response?.namespaces ?? [];
204         this.usedRbdImages = namespaces.reduce((map, ns) => {
205           if (!map.has(ns.rbd_pool_name)) {
206             map.set(ns.rbd_pool_name, new Set<string>());
207           }
208           map.get(ns.rbd_pool_name)!.add(ns.rbd_image_name);
209           return map;
210         }, new Map<string, Set<string>>());
211         this.filterImages();
212       });
213   }
214
215   onSubsystemChange(nqn: string): void {
216     if (!nqn || nqn === this.lastSubsystemNqn) return;
217     this.lastSubsystemNqn = nqn;
218     this.nvmeofService
219       .getInitiators(nqn, this.group)
220       .subscribe((response: NvmeofSubsystemInitiator[] | { hosts: NvmeofSubsystemInitiator[] }) => {
221         const initiators = Array.isArray(response) ? response : response?.hosts || [];
222         this.initiatorCandidates = initiators.map((initiator) => ({
223           content: initiator.nqn,
224           selected: false
225         }));
226       });
227   }
228
229   onInitiatorSelection(event: NvmeofInitiatorCandidate[]) {
230     // Carbon ComboBox (selected) emits the full array of selected items
231     const selectedInitiators = Array.isArray(event) ? event.map((e) => e.content) : [];
232     this.nsForm
233       .get(NsFormField.INITIATORS)
234       .setValue(selectedInitiators.length > 0 ? selectedInitiators : null);
235     this.nsForm.get(NsFormField.INITIATORS).markAsDirty();
236     this.nsForm.get(NsFormField.INITIATORS).markAsTouched();
237   }
238
239   private filterImages(): void {
240     const pool = this.nsForm.getValue(NsFormField.POOL);
241     if (!pool) {
242       this.rbdImages = [];
243       return;
244     }
245     const usedInPool = this.usedRbdImages.get(pool);
246     this.rbdImages = usedInPool
247       ? this.allRbdImages.filter((img) => !usedInPool.has(img.name))
248       : [...this.allRbdImages];
249   }
250
251   createForm() {
252     this.nsForm = new CdFormGroup({
253       [NsFormField.POOL]: new UntypedFormControl('', {
254         validators: [Validators.required]
255       }),
256       [NsFormField.SUBSYSTEM]: new UntypedFormControl('', {
257         validators: [Validators.required]
258       }),
259       [NsFormField.IMAGE_SIZE]: new UntypedFormControl(null, {
260         validators: [
261           Validators.required,
262           CdValidators.custom('minSize', (value: any) => {
263             if (value !== null && value !== undefined && value !== '') {
264               const bytes = this.formatterService.toBytes(value);
265               if (
266                 (!this.edit && bytes <= 0) ||
267                 (this.edit && this.currentBytes && bytes <= this.currentBytes)
268               ) {
269                 return { minSize: true };
270               }
271             }
272             return null;
273           })
274         ],
275         updateOn: 'blur'
276       }),
277       [NsFormField.NS_COUNT]: new UntypedFormControl(this.MAX_NAMESPACE_CREATE, [
278         Validators.required,
279         Validators.max(this.MAX_NAMESPACE_CREATE),
280         Validators.min(this.MIN_NAMESPACE_CREATE)
281       ]),
282       [NsFormField.RBD_IMAGE_CREATION]: new UntypedFormControl(
283         RbdImageCreation.GATEWAY_PROVISIONED
284       ),
285
286       [NsFormField.RBD_IMAGE_NAME]: new UntypedFormControl(null, [
287         CdValidators.custom('rbdImageName', (value: any) => {
288           if (!value) return null;
289           return /^[^@/]+$/.test(value) ? null : { rbdImageName: true };
290         })
291       ]),
292       [NsFormField.NAMESPACE_SIZE]: new UntypedFormControl(null, [Validators.min(0)]), // sent as block_size in create request
293       [NsFormField.HOST_ACCESS]: new UntypedFormControl(HOST_TYPE.ALL), // drives no_auto_visible in create request
294       [NsFormField.INITIATORS]: new UntypedFormControl([]) // sent via addNamespaceInitiators API
295     });
296
297     this.nsForm
298       .get(NsFormField.POOL)
299       .valueChanges.pipe(takeUntil(this.destroy$))
300       .subscribe(() => {
301         this.onPoolChange();
302       });
303
304     this.nsForm
305       .get(NsFormField.NS_COUNT)
306       .valueChanges.pipe(takeUntil(this.destroy$))
307       .subscribe((count: number) => {
308         if (count > 1) {
309           const creationControl = this.nsForm.get(NsFormField.RBD_IMAGE_CREATION);
310           if (creationControl.value === RbdImageCreation.EXTERNALLY_MANAGED) {
311             creationControl.setValue(RbdImageCreation.GATEWAY_PROVISIONED);
312           }
313         }
314       });
315
316     this.nsForm
317       .get(NsFormField.RBD_IMAGE_CREATION)
318       .valueChanges.pipe(takeUntil(this.destroy$))
319       .subscribe((mode: string) => {
320         const nameControl = this.nsForm.get(NsFormField.RBD_IMAGE_NAME);
321         const countControl = this.nsForm.get(NsFormField.NS_COUNT);
322         const imageSizeControl = this.nsForm.get(NsFormField.IMAGE_SIZE);
323
324         if (mode === RbdImageCreation.EXTERNALLY_MANAGED) {
325           countControl.setValue(1);
326           countControl.disable();
327           this.onPoolChange();
328           nameControl.addValidators(Validators.required);
329           imageSizeControl.disable();
330           imageSizeControl.removeValidators(Validators.required);
331         } else {
332           countControl.enable();
333           nameControl.removeValidators(Validators.required);
334           imageSizeControl.enable();
335           imageSizeControl.addValidators(Validators.required);
336         }
337         nameControl.updateValueAndValidity();
338         imageSizeControl.updateValueAndValidity();
339       });
340
341     this.nsForm
342       .get(NsFormField.HOST_ACCESS)
343       .valueChanges.pipe(takeUntil(this.destroy$))
344       .subscribe((mode: string) => {
345         const initiatorsControl = this.nsForm.get(NsFormField.INITIATORS);
346         if (mode === HOST_TYPE.SPECIFIC) {
347           initiatorsControl.addValidators(Validators.required);
348         } else {
349           initiatorsControl.removeValidators(Validators.required);
350           initiatorsControl.setValue([]);
351           this.initiatorCandidates.forEach((i) => (i.selected = false));
352         }
353         initiatorsControl.updateValueAndValidity();
354       });
355   }
356
357   buildUpdateRequest(rbdImageSize: number): Observable<HttpResponse<Object>> {
358     const request: NamespaceUpdateRequest = {
359       gw_group: this.group,
360       rbd_image_size: rbdImageSize
361     };
362     return this.nvmeofService.updateNamespace(
363       this.subsystemNQN,
364       this.nsid,
365       request as NamespaceUpdateRequest
366     );
367   }
368
369   randomString() {
370     return Math.random().toString(36).substring(2);
371   }
372
373   buildCreateRequest(
374     rbdImageSize: number,
375     nsCount: number,
376     noAutoVisible: boolean
377   ): Observable<HttpResponse<Object>>[] {
378     const pool = this.nsForm.getValue(NsFormField.POOL);
379     const requests: Observable<HttpResponse<Object>>[] = [];
380     const creationMode = this.nsForm.getValue(NsFormField.RBD_IMAGE_CREATION);
381     const isGatewayProvisioned = creationMode === RbdImageCreation.GATEWAY_PROVISIONED;
382
383     const loopCount = isGatewayProvisioned ? nsCount : 1;
384
385     for (let i = 1; i <= loopCount; i++) {
386       const request: NamespaceCreateRequest = {
387         gw_group: this.group,
388         rbd_pool: pool,
389         create_image: isGatewayProvisioned,
390         no_auto_visible: noAutoVisible
391       };
392
393       const blockSize = this.nsForm.getValue(NsFormField.NAMESPACE_SIZE);
394       if (blockSize) {
395         request.block_size = blockSize;
396       }
397
398       if (isGatewayProvisioned) {
399         request.rbd_image_name = `nvme_${pool}_${this.group}_${this.randomString()}`;
400         if (rbdImageSize) {
401           request['rbd_image_size'] = rbdImageSize;
402         }
403       }
404
405       const rbdImageName = this.nsForm.getValue(NsFormField.RBD_IMAGE_NAME);
406       if (rbdImageName) {
407         request['rbd_image_name'] = loopCount > 1 ? `${rbdImageName}-${i}` : rbdImageName;
408       }
409
410       const subsystemNQN = this.nsForm.getValue(NsFormField.SUBSYSTEM) || this.subsystemNQN;
411       requests.push(this.nvmeofService.createNamespace(subsystemNQN, request));
412     }
413
414     return requests;
415   }
416
417   onSubmit() {
418     if (this.nsForm.invalid) {
419       this.nsForm.setErrors({ cdSubmitButton: true });
420       this.nsForm.markAllAsTouched();
421       return;
422     }
423
424     const component = this;
425     const taskUrl: string = `nvmeof/namespace/${this.edit ? URLVerbs.EDIT : URLVerbs.CREATE}`;
426     const image_size = this.nsForm.getValue(NsFormField.IMAGE_SIZE);
427     const nsCount = this.nsForm.getValue(NsFormField.NS_COUNT);
428     const hostAccess = this.nsForm.getValue(NsFormField.HOST_ACCESS);
429     const selectedHosts: string[] = this.nsForm.getValue(NsFormField.INITIATORS) || [];
430     const noAutoVisible = hostAccess === HOST_TYPE.SPECIFIC;
431     let action: Observable<any>;
432     let rbdImageSize: number = null;
433
434     if (image_size) {
435       rbdImageSize = this.formatterService.toBytes(image_size);
436     }
437
438     if (this.edit) {
439       action = this.taskWrapperService.wrapTaskAroundCall({
440         task: new FinishedTask(taskUrl, {
441           nqn: this.subsystemNQN,
442           nsid: this.nsid
443         }),
444         call: this.buildUpdateRequest(rbdImageSize)
445       });
446     } else {
447       const subsystemNQN = this.nsForm.getValue(NsFormField.SUBSYSTEM);
448
449       // Step 1: Create namespaces
450       // Step 2: If specific hosts selected, chain addNamespaceInitiators calls
451       const createObs = forkJoin(this.buildCreateRequest(rbdImageSize, nsCount, noAutoVisible));
452
453       const combinedObs = createObs.pipe(
454         switchMap((responses: HttpResponse<Object>[]) => {
455           if (noAutoVisible && selectedHosts.length > 0) {
456             const initiatorObs: Observable<any>[] = [];
457
458             responses.forEach((res) => {
459               const body: any = res.body;
460               if (body && body.nsid) {
461                 selectedHosts.forEach((host: string) => {
462                   const req: NamespaceInitiatorRequest = {
463                     gw_group: this.group,
464                     subsystem_nqn: subsystemNQN || this.subsystemNQN,
465                     host_nqn: host
466                   };
467                   initiatorObs.push(this.nvmeofService.addNamespaceInitiators(body.nsid, req));
468                 });
469               }
470             });
471
472             if (initiatorObs.length > 0) {
473               return forkJoin(initiatorObs);
474             }
475           }
476           return of(responses);
477         })
478       );
479
480       action = this.taskWrapperService.wrapTaskAroundCall({
481         task: new FinishedTask(taskUrl, {
482           nqn: subsystemNQN,
483           nsCount
484         }),
485         call: combinedObs
486       });
487     }
488
489     action.subscribe({
490       error: () => {
491         component.nsForm.setErrors({ cdSubmitButton: true });
492       },
493       complete: () => {
494         this.router.navigate([this.pageURL], {
495           queryParams: { group: this.group, tab: 'namespace' }
496         });
497       }
498     });
499   }
500 }