From: pujashahu Date: Thu, 2 Jul 2026 13:35:52 +0000 (+0530) Subject: mgr/dashboard: NVMe-oF – Enable editing host access from the Subsystem Overview page X-Git-Url: http://git-server-git.apps.pok.os.sepia.ceph.com/?a=commitdiff_plain;h=6573766548a1d020b1414bb9e4944f7b6bf695a0;p=ceph.git mgr/dashboard: NVMe-oF – Enable editing host access from the Subsystem Overview page Fixes: https://tracker.ceph.com/issues/77912 Signed-off-by: pujaoshahu --- diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form/nvmeof-group-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form/nvmeof-group-form.component.spec.ts index 5b77ae89ceb..a4979e2c93d 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form/nvmeof-group-form.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form/nvmeof-group-form.component.spec.ts @@ -160,7 +160,7 @@ describe('NvmeofGroupFormComponent', () => { } as any; component.groupForm.get('groupName').setValue('encrypted-group'); - component.groupForm.get('enableEncryption').setValue(true); + component.groupForm.get('enable_auth').setValue(true); component.groupForm.get('encryptionKey').setValue('encryption-key-123'); component.onSubmit(); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form/nvmeof-group-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form/nvmeof-group-form.component.ts index 7053bbbe9f9..583626b7bd1 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form/nvmeof-group-form.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form/nvmeof-group-form.component.ts @@ -68,6 +68,7 @@ export class NvmeofGroupFormComponent extends CdForm implements OnInit { [CdValidators.unique(this.nvmeofService.exists, this.nvmeofService)] ), unmanaged: new UntypedFormControl(false), + enable_auth: new UntypedFormControl(false), enableEncryption: new UntypedFormControl(false), encryptionConfig: new UntypedFormControl(null), encryptionKey: new UntypedFormControl(null), @@ -158,7 +159,7 @@ export class NvmeofGroupFormComponent extends CdForm implements OnInit { unmanaged: formValues.unmanaged }; - if (formValues.enableEncryption) { + if (formValues.enableEncryption || formValues.enable_auth) { const encryptionKey = formValues.encryptionKey || formValues.encryptionConfig; if (encryptionKey) { serviceSpec['encryption_key'] = encryptionKey; diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-form/nvmeof-initiators-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-form/nvmeof-initiators-form.component.spec.ts index 388592eaea4..702c976954a 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-form/nvmeof-initiators-form.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-form/nvmeof-initiators-form.component.spec.ts @@ -10,7 +10,7 @@ import { NgbActiveModal, NgbTypeaheadModule } from '@ng-bootstrap/ng-bootstrap'; import { SharedModule } from '~/app/shared/shared.module'; import { NvmeofService } from '~/app/shared/api/nvmeof.service'; -import { HOST_TYPE } from '~/app/shared/models/nvmeof'; +import { ALLOW_ALL_HOST, HOST_TYPE } from '~/app/shared/models/nvmeof'; import { NvmeofInitiatorsFormComponent } from './nvmeof-initiators-form.component'; @@ -92,6 +92,7 @@ describe('NvmeofInitiatorsFormComponent', () => { beforeEach(() => { nvmeofService = TestBed.inject(NvmeofService); spyOn(nvmeofService, 'addSubsystemInitiators').and.returnValue(of({})); + spyOn(nvmeofService, 'removeInitiators').and.returnValue(of({})); }); it('should be creating request correctly', () => { @@ -147,5 +148,29 @@ describe('NvmeofInitiatorsFormComponent', () => { expect(nvmeofService.addSubsystemInitiators).not.toHaveBeenCalled(); expect(component.isSubmitLoading).toBe(false); }); + + it('should remove wildcard host before adding specific hosts', () => { + const subsystemNQN = 'nqn.test'; + component.subsystemNQN = subsystemNQN; + component.group = 'test-group'; + component.existingHosts = [ALLOW_ALL_HOST]; + + const payload: any = { + hostType: HOST_TYPE.SPECIFIC, + addedHosts: ['host3'] + }; + + component.onSubmit(payload); + + expect(nvmeofService.removeInitiators).toHaveBeenCalledWith(subsystemNQN, { + host_nqn: ALLOW_ALL_HOST, + gw_group: 'test-group' + }); + expect(nvmeofService.addSubsystemInitiators).toHaveBeenCalledWith(subsystemNQN, { + allow_all: false, + gw_group: 'test-group', + hosts: [{ dhchap_key: '', host_nqn: 'host3' }] + }); + }); }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-form/nvmeof-initiators-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-form/nvmeof-initiators-form.component.ts index d3cb2308f39..789054cc746 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-form/nvmeof-initiators-form.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-form/nvmeof-initiators-form.component.ts @@ -3,6 +3,7 @@ import { Step } from 'carbon-components-angular'; import { NvmeofService, SubsystemInitiatorRequest } from '~/app/shared/api/nvmeof.service'; import { FinishedTask } from '~/app/shared/models/finished-task'; import { + ALLOW_ALL_HOST, AuthStepType, HOST_TYPE, HostStepType, @@ -11,6 +12,8 @@ import { import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service'; import { ActivatedRoute, Params, Router } from '@angular/router'; import { TearsheetComponent } from '~/app/shared/components/tearsheet/tearsheet.component'; +import { Observable } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; type InitiatorsFormPayload = Pick & Partial>; @@ -149,12 +152,27 @@ export class NvmeofInitiatorsFormComponent implements OnInit { hosts, gw_group: this.group }; + + const shouldRemoveAllowAllHost = + payload.hostType === HOST_TYPE.SPECIFIC && this.existingHosts.includes(ALLOW_ALL_HOST); + + const submitCall$: Observable = shouldRemoveAllowAllHost + ? this.nvmeofService + .removeInitiators(this.subsystemNQN, { + host_nqn: ALLOW_ALL_HOST, + gw_group: this.group + }) + .pipe( + switchMap(() => this.nvmeofService.addSubsystemInitiators(this.subsystemNQN, request)) + ) + : this.nvmeofService.addSubsystemInitiators(this.subsystemNQN, request); + this.taskWrapperService .wrapTaskAroundCall({ task: new FinishedTask(taskUrl, { nqn: this.subsystemNQN }), - call: this.nvmeofService.addSubsystemInitiators(this.subsystemNQN, request) + call: submitCall$ }) .subscribe({ error: () => { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.html index 0988d0563d5..b97d6684bad 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.html @@ -1,43 +1,35 @@ @if (allowAllHosts) { -
-
- Host access: All hosts allowed -

- Allowing all hosts grants access to every initiator on the network. Authentication is not supported in this mode, which may expose the subsystem to unauthorized access. -

-
- Edit host access -
-
-} @else { - -
- - -
-
+ class="cds-mb-4" + type="warning" + [showTitle]="false"> +
+ All hosts allowed +

+ Allowing all hosts grants access to every initiator on the network. Authentication is not supported in this mode, which may expose the subsystem to unauthorized access. +

+
+ } + +
+ + +
+
+ {{ getDisplayedHostNqn(row.nqn) }} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.spec.ts index 0f72f0225b6..61f5d39ac74 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.spec.ts @@ -180,7 +180,7 @@ describe('NvmeofInitiatorsListComponent', () => { expect(getSubsystemSpy).toHaveBeenCalledTimes(2); }); - it('should filter ALLOW_ALL_HOST from array response', fakeAsync(() => { + it('should include ALLOW_ALL_HOST from array response', fakeAsync(() => { spyOn(nvmeofService, 'getInitiators').and.returnValue( of([ { nqn: ALLOW_ALL_HOST, use_dhchap: false }, @@ -191,7 +191,10 @@ describe('NvmeofInitiatorsListComponent', () => { component.listInitiators(); tick(); - expect(component.initiators).toEqual([{ nqn: 'nqn.2016-06.io.spdk:host2', use_dhchap: false }]); + expect(component.initiators).toEqual([ + { nqn: ALLOW_ALL_HOST, use_dhchap: false }, + { nqn: 'nqn.2016-06.io.spdk:host2', use_dhchap: false } + ]); })); it('should support hosts-wrapper response from getInitiators', fakeAsync(() => { @@ -207,6 +210,23 @@ describe('NvmeofInitiatorsListComponent', () => { component.listInitiators(); tick(); - expect(component.initiators).toEqual([{ nqn: 'nqn.2016-06.io.spdk:host3', use_dhchap: true }]); + expect(component.initiators).toEqual([ + { nqn: ALLOW_ALL_HOST, use_dhchap: false }, + { nqn: 'nqn.2016-06.io.spdk:host3', use_dhchap: true } + ]); + })); + + it('should set allowAllHosts to true when wildcard initiator exists', fakeAsync(() => { + const allowAllSubsystem = { ...mockSubsystem, allow_any_host: true }; + spyOn(nvmeofService, 'getInitiators').and.returnValue( + of([{ nqn: ALLOW_ALL_HOST, use_dhchap: false }]) + ); + spyOn(nvmeofService, 'getSubsystem').and.returnValue(of(allowAllSubsystem)); + + component.listInitiators(); + component.getSubsystem(); + tick(); + + expect(component.allowAllHosts).toBe(true); })); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.ts index 7f4461e96e0..872d004ad73 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.ts @@ -183,7 +183,11 @@ export class NvmeofInitiatorsListComponent implements OnInit, OnDestroy { } hasAllHostsAllowed(): boolean { - return !!this.subsystem?.allow_any_host && this.initiators.length === 0; + return ( + !!this.subsystem?.allow_any_host && + (this.initiators.length === 0 || + this.initiators.some((initiator) => initiator.nqn === ALLOW_ALL_HOST)) + ); } updateSelection(selection: CdTableSelection) { @@ -195,7 +199,7 @@ export class NvmeofInitiatorsListComponent implements OnInit, OnDestroy { .getInitiators(this.subsystemNQN, this.group) .subscribe((response: NvmeofSubsystemInitiator[] | { hosts: NvmeofSubsystemInitiator[] }) => { const initiators = Array.isArray(response) ? response : response?.hosts || []; - this.initiators = initiators.filter((i) => i.nqn !== ALLOW_ALL_HOST); + this.initiators = initiators; this.updateAuthStatus(); }); } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystem-overview/nvmeof-subsystem-overview.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystem-overview/nvmeof-subsystem-overview.component.html index c975058fe7c..922b0d03990 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystem-overview/nvmeof-subsystem-overview.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystem-overview/nvmeof-subsystem-overview.component.html @@ -68,8 +68,7 @@
{{ detail.value ? 'Allow all hosts' : 'Restrict to specific hosts' }} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystem-overview/nvmeof-subsystem-overview.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystem-overview/nvmeof-subsystem-overview.component.spec.ts index f86a3acb95d..8c3557c6372 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystem-overview/nvmeof-subsystem-overview.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystem-overview/nvmeof-subsystem-overview.component.spec.ts @@ -1,14 +1,15 @@ import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; import { HttpClientTestingModule } from '@angular/common/http/testing'; import { RouterTestingModule } from '@angular/router/testing'; -import { ActivatedRoute } from '@angular/router'; -import { of } from 'rxjs'; +import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; +import { of, Subject } from 'rxjs'; import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; import { GridModule, TilesModule } from 'carbon-components-angular'; import { NvmeofSubsystemOverviewComponent } from './nvmeof-subsystem-overview.component'; import { NvmeofService } from '~/app/shared/api/nvmeof.service'; +import { URLVerbs } from '~/app/shared/constants/app.constants'; import { SharedModule } from '~/app/shared/shared.module'; import { NvmeofSubsystem, NvmeofSubsystemInitiator } from '~/app/shared/models/nvmeof'; @@ -16,6 +17,9 @@ describe('NvmeofSubsystemOverviewComponent', () => { let component: NvmeofSubsystemOverviewComponent; let fixture: ComponentFixture; let nvmeofService: NvmeofService; + let router: Router; + let activatedRoute: ActivatedRoute; + let routerEvents$: Subject; const mockSubsystem: NvmeofSubsystem = { nqn: 'nqn.2016-06.io.spdk:cnode1', @@ -79,6 +83,8 @@ describe('NvmeofSubsystemOverviewComponent', () => { } beforeEach(async () => { + routerEvents$ = new Subject(); + await TestBed.configureTestingModule({ declarations: [NvmeofSubsystemOverviewComponent], imports: [ @@ -97,6 +103,13 @@ describe('NvmeofSubsystemOverviewComponent', () => { getSubsystem: jest.fn().mockReturnValue(of(mockSubsystem)), getInitiators: jest.fn().mockReturnValue(of([])) } + }, + { + provide: Router, + useValue: { + events: routerEvents$.asObservable(), + navigate: jest.fn().mockResolvedValue(true) + } } ] }).compileComponents(); @@ -106,6 +119,8 @@ describe('NvmeofSubsystemOverviewComponent', () => { fixture = TestBed.createComponent(NvmeofSubsystemOverviewComponent); component = fixture.componentInstance; nvmeofService = TestBed.inject(NvmeofService); + router = TestBed.inject(Router); + activatedRoute = TestBed.inject(ActivatedRoute); fixture.detectChanges(); }); @@ -189,4 +204,30 @@ describe('NvmeofSubsystemOverviewComponent', () => { }); expect(f.nativeElement.textContent).toContain('Unidirectional'); })); + + it('should open host access edit modal route when edit is clicked', () => { + const navigateSpy = jest.spyOn(router, 'navigate').mockResolvedValue(true); + component.groupName = 'group1'; + component.openEditHostAccessModal(); + + expect(navigateSpy).toHaveBeenCalledWith( + [{ outlets: { modal: [URLVerbs.ADD, 'initiator'] } }], + { + queryParams: { group: 'group1' }, + relativeTo: activatedRoute.parent + } + ); + }); + + it('should refresh subsystem on non-modal navigation end', () => { + (nvmeofService.getSubsystem as jest.Mock).mockClear(); + + routerEvents$.next(new NavigationEnd(1, '/nvmeof/(modal:add)', '/nvmeof/(modal:add)')); + expect(nvmeofService.getSubsystem).not.toHaveBeenCalled(); + + routerEvents$.next( + new NavigationEnd(2, '/nvmeof/subsystems/overview', '/nvmeof/subsystems/overview') + ); + expect(nvmeofService.getSubsystem).toHaveBeenCalledWith('nqn.2016-06.io.spdk:cnode1', 'group1'); + }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystem-overview/nvmeof-subsystem-overview.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystem-overview/nvmeof-subsystem-overview.component.ts index acb97b9a4e8..84e1abee4a8 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystem-overview/nvmeof-subsystem-overview.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystem-overview/nvmeof-subsystem-overview.component.ts @@ -1,6 +1,7 @@ -import { Component, OnInit } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; -import { forkJoin } from 'rxjs'; +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; +import { forkJoin, Subscription } from 'rxjs'; +import { filter } from 'rxjs/operators'; import { NvmeofService } from '~/app/shared/api/nvmeof.service'; import { @@ -9,6 +10,7 @@ import { NO_AUTH, getSubsystemAuthStatus } from '~/app/shared/models/nvmeof'; +import { URLVerbs } from '~/app/shared/constants/app.constants'; import { ICON_TYPE } from '~/app/shared/enum/icons.enum'; import { ModalCdsService } from '~/app/shared/services/modal-cds.service'; import { NvmeofEditAuthenticationComponent } from '../nvmeof-edit-authentication/nvmeof-edit-authentication.component'; @@ -27,27 +29,49 @@ export interface SubsystemDetail { styleUrls: ['./nvmeof-subsystem-overview.component.scss'], standalone: false }) -export class NvmeofSubsystemOverviewComponent implements OnInit { +export class NvmeofSubsystemOverviewComponent implements OnInit, OnDestroy { subsystemNQN!: string; groupName!: string; subsystem!: NvmeofSubsystem; details: SubsystemDetail[] = []; + private subscriptions = new Subscription(); constructor( private route: ActivatedRoute, + private router: Router, private nvmeofService: NvmeofService, private modalService: ModalCdsService ) {} ngOnInit() { - this.route.parent?.params.subscribe((params) => { - this.subsystemNQN = params['subsystem_nqn']; - this.fetchIfReady(); - }); - this.route.queryParams.subscribe((qp) => { - this.groupName = qp['group']; - this.fetchIfReady(); - }); + this.subscriptions.add( + this.route.parent?.params.subscribe((params) => { + this.subsystemNQN = params['subsystem_nqn']; + this.fetchIfReady(); + }) + ); + this.subscriptions.add( + this.route.queryParams.subscribe((qp) => { + this.groupName = qp['group']; + this.fetchIfReady(); + }) + ); + this.subscriptions.add( + this.router.events + .pipe( + filter( + (event): event is NavigationEnd => + event instanceof NavigationEnd && !event.urlAfterRedirects.includes('(modal:') + ) + ) + .subscribe(() => { + this.fetchIfReady(); + }) + ); + } + + ngOnDestroy() { + this.subscriptions.unsubscribe(); } private fetchIfReady() { @@ -179,4 +203,11 @@ export class NvmeofSubsystemOverviewComponent implements OnInit { modalRef.closeChange.subscribe(() => this.fetchSubsystem()); } } + + openEditHostAccessModal() { + this.router.navigate([{ outlets: { modal: [URLVerbs.ADD, 'initiator'] } }], { + queryParams: { group: this.groupName }, + relativeTo: this.route.parent + }); + } }