From d890779f7523cff9af4c740ea2a5ddb83910d993 Mon Sep 17 00:00:00 2001 From: pujashahu Date: Thu, 11 Jun 2026 15:59:51 +0530 Subject: [PATCH] mgr/dashboard: Add auto-fetch listeners option in subsystem page Fixes: https://tracker.ceph.com/issues/77083 Signed-off-by: pujaoshahu --- .../nvmeof-subsystem-step-1.component.html | 54 +++++++- .../nvmeof-subsystem-step-1.component.spec.ts | 9 +- .../nvmeof-subsystem-step-1.component.ts | 24 +++- .../nvmeof-subsystems-form.component.spec.ts | 94 ++++++++++++++ .../nvmeof-subsystems-form.component.ts | 117 +++++++++++------- .../src/app/shared/api/nvmeof.service.ts | 7 +- .../frontend/src/app/shared/models/nvmeof.ts | 2 + 7 files changed, 252 insertions(+), 55 deletions(-) diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component.html index a811c593088..aab200d7a1c 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component.html @@ -53,10 +53,59 @@ } +
+
+ Listeners +

Determine where and how hosts can connect to the subsystem over the network.

+ + + Auto-fetch + + + Add manually + + +
+
+ @if (listenerMode === LISTENER_MODE.AUTO_FETCH) { +
+ + Subnet-mask + + + + This field is required. + +
+ } + @if (listenerMode === LISTENER_MODE.MANUAL) {
} - Listeners + Select listeners Select listeners for this subsystem. @@ -88,6 +137,7 @@ This field is required.
+ } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component.spec.ts index 70db8540580..237a76cb3d1 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component.spec.ts @@ -2,6 +2,7 @@ import { HttpClientTestingModule } from '@angular/common/http/testing'; import { ReactiveFormsModule } from '@angular/forms'; import { RouterTestingModule } from '@angular/router/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { NgbActiveModal, NgbTypeaheadModule } from '@ng-bootstrap/ng-bootstrap'; @@ -10,7 +11,7 @@ import { SharedModule } from '~/app/shared/shared.module'; import { NvmeofSubsystemsStepOneComponent } from './nvmeof-subsystem-step-1.component'; import { FormHelper } from '~/testing/unit-test-helper'; import { NvmeofService } from '~/app/shared/api/nvmeof.service'; -import { ComboBoxModule, GridModule, InputModule } from 'carbon-components-angular'; +import { ComboBoxModule, GridModule, InputModule, RadioModule } from 'carbon-components-angular'; import { of } from 'rxjs'; @@ -35,9 +36,11 @@ describe('NvmeofSubsystemsStepOneComponent', () => { NgbTypeaheadModule, InputModule, GridModule, - ComboBoxModule + ComboBoxModule, + RadioModule ], - providers: [NgbActiveModal] + providers: [NgbActiveModal], + schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component.ts index 866717ef4a5..8894178b9d6 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component.ts @@ -33,6 +33,11 @@ export class NvmeofSubsystemsStepOneComponent implements OnInit, TearsheetStep { }; hosts: ListenerItem[] = []; + LISTENER_MODE = { + AUTO_FETCH: 'auto-fetch', + MANUAL: 'manual' + }; + listenerMode: string = this.LISTENER_MODE.AUTO_FETCH; constructor( public actionLabels: ActionLabelsI18n, @@ -76,9 +81,18 @@ export class NvmeofSubsystemsStepOneComponent implements OnInit, TearsheetStep { } createForm() { + const subnetMaskValidators = [ + CdValidators.composeIf({ listenerMode: this.LISTENER_MODE.AUTO_FETCH }, [Validators.required]) + ]; + const listenersValidators = [ + CdValidators.composeIf({ listenerMode: this.LISTENER_MODE.MANUAL }, [Validators.required]) + ]; + if (this.listenersOnly) { this.formGroup = new CdFormGroup({ - listeners: new UntypedFormControl([]) + listenerMode: new UntypedFormControl(this.LISTENER_MODE.AUTO_FETCH), + subnetMask: new UntypedFormControl('', subnetMaskValidators), + listeners: new UntypedFormControl([], listenersValidators) }); } else { this.formGroup = new CdFormGroup({ @@ -101,9 +115,15 @@ export class NvmeofSubsystemsStepOneComponent implements OnInit, TearsheetStep { ) ] }), - listeners: new UntypedFormControl([]) + listenerMode: new UntypedFormControl(this.LISTENER_MODE.AUTO_FETCH), + subnetMask: new UntypedFormControl('', subnetMaskValidators), + listeners: new UntypedFormControl([], listenersValidators) }); } + + this.formGroup.get('listenerMode').valueChanges.subscribe((mode: string) => { + this.listenerMode = mode; + }); } removeListener(index: number) { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.spec.ts index dc98d870f4d..4f65eaa1f3e 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.spec.ts @@ -92,6 +92,7 @@ describe('NvmeofSubsystemsFormComponent', () => { nvmeofService = TestBed.inject(NvmeofService); spyOn(nvmeofService, 'createSubsystem').and.returnValue(of({})); spyOn(nvmeofService, 'addSubsystemInitiators').and.returnValue(of({})); + spyOn(nvmeofService, 'createListeners').and.returnValue(of({})); }); it('should be creating request correctly', () => { @@ -105,6 +106,99 @@ describe('NvmeofSubsystemsFormComponent', () => { }); }); + it('should include network_mask in createSubsystem request when listenerMode is auto-fetch and subnetMask is set', () => { + const payload: SubsystemPayload = { + nqn: 'test-nqn', + gw_group: mockGroupName, + addedHosts: [], + hostType: HOST_TYPE.ALL, + subsystemDchapKey: '', + listeners: [], + authType: AUTHENTICATION.Unidirectional, + hostDchapKeyList: [], + listenerMode: 'auto-fetch', + subnetMask: '192.168.1.0/24' + }; + + component.group = mockGroupName; + component.onSubmit(payload); + + expect(nvmeofService.createSubsystem).toHaveBeenCalledWith({ + nqn: 'test-nqn', + gw_group: mockGroupName, + dhchap_key: '', + network_mask: ['192.168.1.0/24'] + }); + }); + + it('should not include network_mask in createSubsystem request when listenerMode is manual', () => { + const payload: SubsystemPayload = { + nqn: 'test-nqn', + gw_group: mockGroupName, + addedHosts: [], + hostType: HOST_TYPE.ALL, + subsystemDchapKey: '', + listeners: [], + authType: AUTHENTICATION.Unidirectional, + hostDchapKeyList: [], + listenerMode: 'manual', + subnetMask: '192.168.1.0/24' + }; + + component.group = mockGroupName; + component.onSubmit(payload); + + expect(nvmeofService.createSubsystem).toHaveBeenCalledWith({ + nqn: 'test-nqn', + gw_group: mockGroupName, + dhchap_key: '' + }); + }); + + it('should call createListeners when listenerMode is manual and listeners are provided', () => { + const listeners = [{ content: 'host1', addr: '10.0.0.1' }]; + const payload: SubsystemPayload = { + nqn: 'test-nqn', + gw_group: mockGroupName, + addedHosts: [], + hostType: HOST_TYPE.ALL, + subsystemDchapKey: '', + listeners, + authType: AUTHENTICATION.Unidirectional, + hostDchapKeyList: [], + listenerMode: 'manual' + }; + + component.group = mockGroupName; + component.onSubmit(payload); + + expect(nvmeofService.createListeners).toHaveBeenCalledWith( + 'test-nqn.default', + mockGroupName, + listeners + ); + }); + + it('should not call createListeners when listenerMode is auto-fetch', () => { + const payload: SubsystemPayload = { + nqn: 'test-nqn', + gw_group: mockGroupName, + addedHosts: [], + hostType: HOST_TYPE.ALL, + subsystemDchapKey: '', + listeners: [{ content: 'host1', addr: '10.0.0.1' }], + authType: AUTHENTICATION.Unidirectional, + hostDchapKeyList: [], + listenerMode: 'auto-fetch', + subnetMask: '10.0.0.0/24' + }; + + component.group = mockGroupName; + component.onSubmit(payload); + + expect(nvmeofService.createListeners).not.toHaveBeenCalled(); + }); + it('should add initiators with wildcard when hostType is ALL', () => { const payload: SubsystemPayload = { nqn: 'test-nqn', diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.ts index b3d46425568..88178fb0c8c 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.ts @@ -30,10 +30,26 @@ export type SubsystemPayload = { listeners: ListenerItem[]; authType: AUTHENTICATION.Bidirectional | AUTHENTICATION.Unidirectional; hostDchapKeyList: Array<{ dhchap_key: string; host_nqn: string }>; + listenerMode?: string; + subnetMask?: string; }; type StepResult = { step: string; success: boolean; error?: string }; +type CreateSubsystemRequest = { + nqn: string; + gw_group: string; + dhchap_key: string; + network_mask?: string[]; +}; + +type SequentialStep = { step: string; call: () => Observable }; + +const LISTENER_MODE = { + AUTO_FETCH: 'auto-fetch', + MANUAL: 'manual' +}; + const STEP_LABELS = { DETAILS: 'Subsystem details', HOSTS: 'Host access control', @@ -159,62 +175,69 @@ export class NvmeofSubsystemsFormComponent implements OnInit { hosts: payload.hostType === HOST_TYPE.SPECIFIC ? payload.hostDchapKeyList : [], gw_group: this.group }; - this.nvmeofService - .createSubsystem({ - nqn: payload.nqn, - gw_group: this.group, - dhchap_key: payload.subsystemDchapKey - }) - .subscribe({ - next: () => { - stepResults.push({ step: this.steps[0].label, success: true }); - const sequentialSteps: { step: string; call: () => Observable }[] = []; - - if (payload.listeners && payload.listeners.length > 0) { - sequentialSteps.push({ - step: $localize`Listeners`, - call: () => - this.nvmeofService.createListeners( - `${payload.nqn}.${this.group}`, - this.group, - payload.listeners - ) - }); - } + // Prepare subsystem creation request + const createSubsystemRequest: CreateSubsystemRequest = { + nqn: payload.nqn, + gw_group: this.group, + dhchap_key: payload.subsystemDchapKey + }; + + if (payload.listenerMode === LISTENER_MODE.AUTO_FETCH && payload.subnetMask) { + createSubsystemRequest.network_mask = [payload.subnetMask]; + } + + this.nvmeofService.createSubsystem(createSubsystemRequest).subscribe({ + next: () => { + stepResults.push({ step: this.steps[0].label, success: true }); + const sequentialSteps: SequentialStep[] = []; + + if ( + payload.listenerMode !== LISTENER_MODE.AUTO_FETCH && + payload.listeners && + payload.listeners.length > 0 + ) { sequentialSteps.push({ - step: this.steps[1].label, + step: $localize`Listeners`, call: () => - this.nvmeofService.addSubsystemInitiators( + this.nvmeofService.createListeners( `${payload.nqn}.${this.group}`, - initiatorRequest + this.group, + payload.listeners ) }); - - this.runSequentialSteps(sequentialSteps, stepResults).subscribe({ - complete: () => this.showFinalNotification(stepResults) - }); - }, - error: (err) => { - err.preventDefault(); - const errorMsg = err?.error?.detail || $localize`Subsystem creation failed`; - this.notificationService.show( - NotificationType.error, - $localize`Subsystem creation failed`, - errorMsg - ); - this.isSubmitLoading = false; - this.router.navigate(['block/nvmeof/subsystems'], { - queryParams: { group: this.group } - }); } - }); + + sequentialSteps.push({ + step: this.steps[1].label, + call: () => + this.nvmeofService.addSubsystemInitiators( + `${payload.nqn}.${this.group}`, + initiatorRequest + ) + }); + + this.runSequentialSteps(sequentialSteps, stepResults).subscribe({ + complete: () => this.showFinalNotification(stepResults) + }); + }, + error: (err) => { + err.preventDefault(); + const errorMsg = err?.error?.detail || $localize`Subsystem creation failed`; + this.notificationService.show( + NotificationType.error, + $localize`Subsystem creation failed`, + errorMsg + ); + this.isSubmitLoading = false; + this.router.navigate(['block/nvmeof/subsystems'], { + queryParams: { group: this.group } + }); + } + }); } - private runSequentialSteps( - steps: { step: string; call: () => Observable }[], - stepResults: StepResult[] - ): Observable { + private runSequentialSteps(steps: SequentialStep[], stepResults: StepResult[]): Observable { return from(steps).pipe( concatMap((step) => step.call().pipe( diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/nvmeof.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/nvmeof.service.ts index 3b2b57dbf35..77c31a6553d 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/nvmeof.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/nvmeof.service.ts @@ -192,7 +192,12 @@ export class NvmeofService { return this.http.get(`${API_PATH}/subsystem/${subsystemNQN}?gw_group=${group}`); } - createSubsystem(request: { nqn: string; gw_group: string; dhchap_key: string }) { + createSubsystem(request: { + nqn: string; + gw_group: string; + dhchap_key: string; + network_mask?: string[]; + }) { return this.http.post(`${API_PATH}/subsystem`, request, { observe: 'response' }); } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/nvmeof.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/nvmeof.ts index 11d7edab3b0..7d401ad5d08 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/nvmeof.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/nvmeof.ts @@ -169,4 +169,6 @@ export type AuthStepType = { export type DetailsStepType = { nqn: string; listeners: Array; + listenerMode?: string; + subnetMask?: string; }; -- 2.47.3