From: pujashahu Date: Tue, 30 Jun 2026 20:51:53 +0000 (+0530) Subject: mgr/dashboard: NVMe-oF – Add edit authentication support for subsystem X-Git-Url: http://git-server-git.apps.pok.os.sepia.ceph.com/?a=commitdiff_plain;h=refs%2Fpull%2F69850%2Fhead;p=ceph.git mgr/dashboard: NVMe-oF – Add edit authentication support for subsystem Fixes: https://tracker.ceph.com/issues/77840 Signed-off-by: pujaoshahu --- diff --git a/src/pybind/mgr/dashboard/controllers/nvmeof.py b/src/pybind/mgr/dashboard/controllers/nvmeof.py index 95065f361bd..832d3b28c16 100644 --- a/src/pybind/mgr/dashboard/controllers/nvmeof.py +++ b/src/pybind/mgr/dashboard/controllers/nvmeof.py @@ -616,6 +616,8 @@ else: ) ) + @Endpoint('PUT', '{nqn}/change_key') + @UpdatePermission @EndpointDoc( "Change subsystem inband authentication key", parameters={ diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts index 368a7de95b7..722832ae977 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts @@ -109,6 +109,7 @@ import { NvmeofTabsComponent } from './nvmeof-tabs/nvmeof-tabs.component'; import { NvmeofGatewayGroupDeleteGuardModalComponent } from './nvmeof-gateway-group/nvmeof-gateway-group-delete-guard-modal.component'; import { NvmeofSetupCardsComponent } from './nvmeof-setup-cards/nvmeof-setup-cards.component'; import { NvmeofGatewayGroupFilterComponent } from './nvmeof-gateway-group-filter/nvmeof-gateway-group-filter.component'; +import { NvmeofEditAuthenticationComponent } from './nvmeof-edit-authentication/nvmeof-edit-authentication.component'; @NgModule({ imports: [ @@ -201,7 +202,8 @@ import { NvmeofGatewayGroupFilterComponent } from './nvmeof-gateway-group-filter NvmeofSubsystemOverviewComponent, NvmeofSubsystemPerformanceComponent, NvmeofTabsComponent, - NvmeofGatewayGroupDeleteGuardModalComponent + NvmeofGatewayGroupDeleteGuardModalComponent, + NvmeofEditAuthenticationComponent ], exports: [RbdConfigurationListComponent, RbdConfigurationFormComponent] diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-edit-authentication/nvmeof-edit-authentication.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-edit-authentication/nvmeof-edit-authentication.component.html new file mode 100644 index 00000000000..4261e9c0c32 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-edit-authentication/nvmeof-edit-authentication.component.html @@ -0,0 +1,29 @@ + + + @if (showAuthAlert) { + + + Switching from bidirectional authentication to unidirectional method will remove the subsystem authentication key, + exposing the subsystem to unauthorised access. + + + } + + + + diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-edit-authentication/nvmeof-edit-authentication.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-edit-authentication/nvmeof-edit-authentication.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-edit-authentication/nvmeof-edit-authentication.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-edit-authentication/nvmeof-edit-authentication.component.spec.ts new file mode 100644 index 00000000000..a60fe0d3e81 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-edit-authentication/nvmeof-edit-authentication.component.spec.ts @@ -0,0 +1,270 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { of, throwError } from 'rxjs'; + +import { GridModule, InputModule, RadioModule, TagModule } from 'carbon-components-angular'; +import { SharedModule } from '~/app/shared/shared.module'; +import { NvmeofService } from '~/app/shared/api/nvmeof.service'; +import { NotificationService } from '~/app/shared/services/notification.service'; +import { ModalCdsService } from '~/app/shared/services/modal-cds.service'; +import { AUTHENTICATION } from '~/app/shared/models/nvmeof'; +import { + NvmeofEditAuthenticationComponent, + SUBSYSTEM_NQN_TOKEN, + GROUP_NAME_TOKEN +} from './nvmeof-edit-authentication.component'; +import { NvmeofSubsystemsStepThreeComponent } from '../nvmeof-subsystems-form/nvmeof-subsystem-step-3/nvmeof-subsystem-step-3.component'; + +describe('NvmeofEditAuthenticationComponent', () => { + let component: NvmeofEditAuthenticationComponent; + let fixture: ComponentFixture; + let nvmeofService: jest.Mocked>; + let notificationService: { show: jest.Mock }; + let modalService: { dismissAll: jest.Mock }; + + const mockSubsystemNQN = 'nqn.2014-08.org.nvmexpress:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6'; + const mockGroupName = 'default'; + const mockHosts = [ + { nqn: 'nqn.2014-08.org.nvmexpress:uuid:host-1', use_dhchap: false }, + { nqn: 'nqn.2014-08.org.nvmexpress:uuid:host-2', use_dhchap: false } + ]; + const mockHostsWithKey = [{ nqn: 'nqn.2014-08.org.nvmexpress:uuid:host-1', use_dhchap: true }]; + + beforeEach( + waitForAsync(() => { + nvmeofService = { + getInitiators: jest.fn().mockReturnValue(of([])), + getSubsystem: jest.fn().mockReturnValue(of({ has_dhchap_key: false })), + updateAuthenticationKey: jest.fn().mockReturnValue(of(undefined)) + }; + notificationService = { show: jest.fn() }; + modalService = { dismissAll: jest.fn() }; + + TestBed.configureTestingModule({ + declarations: [NvmeofEditAuthenticationComponent, NvmeofSubsystemsStepThreeComponent], + imports: [ + ReactiveFormsModule, + HttpClientTestingModule, + RouterTestingModule, + SharedModule, + GridModule, + InputModule, + RadioModule, + TagModule + ], + providers: [ + { provide: NvmeofService, useValue: nvmeofService }, + { provide: NotificationService, useValue: notificationService }, + { provide: ModalCdsService, useValue: modalService }, + { provide: SUBSYSTEM_NQN_TOKEN, useValue: mockSubsystemNQN }, + { provide: GROUP_NAME_TOKEN, useValue: mockGroupName } + ] + }).compileComponents(); + }) + ); + + beforeEach(() => { + fixture = TestBed.createComponent(NvmeofEditAuthenticationComponent); + component = fixture.componentInstance; + fixture.detectChanges(); // runs ngOnInit + ngAfterViewInit + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should inject subsystemNQN and groupName', () => { + expect(component.subsystemNQN).toBe(mockSubsystemNQN); + expect(component.groupName).toBe(mockGroupName); + }); + + it('should fetch initiators on init and build stepTwoValue with host NQNs', () => { + nvmeofService.getInitiators.mockReturnValue(of(mockHosts)); + component.ngOnInit(); + expect(nvmeofService.getInitiators).toHaveBeenCalledWith(mockSubsystemNQN, mockGroupName); + expect(component.stepTwoValue?.addedHosts).toEqual([ + 'nqn.2014-08.org.nvmexpress:uuid:host-1', + 'nqn.2014-08.org.nvmexpress:uuid:host-2' + ]); + }); + + it('should handle hosts returned as { hosts: [...] } response shape', () => { + nvmeofService.getInitiators.mockReturnValue(of({ hosts: mockHosts })); + component.ngOnInit(); + expect(component.stepTwoValue?.addedHosts.length).toBe(2); + }); + + it('should handle empty host list', () => { + nvmeofService.getInitiators.mockReturnValue(of([])); + component.ngOnInit(); + expect(component.stepTwoValue?.addedHosts).toEqual([]); + }); + + describe('initialAuthType pre-selection', () => { + it('should default to Unidirectional when subsystem has no DHCHAP key', () => { + expect(component.initialAuthType).toBe(AUTHENTICATION.Unidirectional); + expect(component.authStep.formGroup.get('authType')?.value).toBe( + AUTHENTICATION.Unidirectional + ); + }); + + it('should patch form control to Bidirectional when subsystem and host both have keys', () => { + nvmeofService.getSubsystem.mockReturnValue(of({ has_dhchap_key: true })); + nvmeofService.getInitiators.mockReturnValue(of(mockHostsWithKey)); + component.ngOnInit(); + expect(component.initialAuthType).toBe(AUTHENTICATION.Bidirectional); + component.authStep.initialAuthType = component.initialAuthType; + expect(component.authStep.formGroup.get('authType')?.value).toBe( + AUTHENTICATION.Bidirectional + ); + }); + + it('should set Unidirectional when only host has a key', () => { + nvmeofService.getSubsystem.mockReturnValue(of({ has_dhchap_key: false })); + nvmeofService.getInitiators.mockReturnValue(of(mockHostsWithKey)); + component.ngOnInit(); + expect(component.initialAuthType).toBe(AUTHENTICATION.Unidirectional); + expect(component.authStep.formGroup.get('authType')?.value).toBe( + AUTHENTICATION.Unidirectional + ); + }); + + it('should set Unidirectional when no keys are present', () => { + nvmeofService.getSubsystem.mockReturnValue(of({ has_dhchap_key: false })); + nvmeofService.getInitiators.mockReturnValue(of([])); + component.ngOnInit(); + expect(component.initialAuthType).toBe(AUTHENTICATION.Unidirectional); + }); + }); + + describe('showAuthAlert', () => { + it('should be false by default', () => { + expect(component.showAuthAlert).toBe(false); + }); + + it('should remain false when subsystem has no DHCHAP key and radio changes to Unidirectional', () => { + nvmeofService.getSubsystem.mockReturnValue(of({ has_dhchap_key: false })); + component.ngOnInit(); + component.authStep.formGroup.get('authType')?.setValue(AUTHENTICATION.Unidirectional); + expect(component.showAuthAlert).toBe(false); + }); + + it('should remain false when subsystem has DHCHAP key and radio changes to Bidirectional', () => { + nvmeofService.getSubsystem.mockReturnValue(of({ has_dhchap_key: true })); + component.ngOnInit(); + component.authStep.formGroup.get('authType')?.setValue(AUTHENTICATION.Bidirectional); + expect(component.showAuthAlert).toBe(false); + }); + + it('should become true when subsystem has DHCHAP key and radio changes to Unidirectional', () => { + nvmeofService.getSubsystem.mockReturnValue(of({ has_dhchap_key: true })); + component.ngOnInit(); + component.authStep.formGroup.get('authType')?.setValue(AUTHENTICATION.Unidirectional); + expect(component.showAuthAlert).toBe(true); + }); + }); + + describe('onSubmit', () => { + it('should not call updateAuthenticationKey when form is invalid', () => { + const form = component.authStep?.formGroup; + if (form) { + form.markAllAsTouched(); + form.setErrors({ test: true }); + } + component.onSubmit(); + expect(nvmeofService.updateAuthenticationKey).not.toHaveBeenCalled(); + }); + + it('should not call updateAuthenticationKey when authStep is absent', () => { + component.authStep = undefined as any; + component.onSubmit(); + expect(nvmeofService.updateAuthenticationKey).not.toHaveBeenCalled(); + }); + + it('should extract and submit authentication settings on valid Bidirectional form', () => { + const validKey = 'Q2VwaE52bWVvRkNoYXBTeW50aGV0aWNLZXkxMjM0NTY='; + const form = component.authStep.formGroup; + form.get('authType')?.setValue(AUTHENTICATION.Bidirectional); + form.get('subsystemDchapKey')?.setValue(validKey); + component.onSubmit(); + expect(nvmeofService.updateAuthenticationKey).toHaveBeenCalledWith( + mockSubsystemNQN, + mockGroupName, + expect.objectContaining({ + authType: AUTHENTICATION.Bidirectional, + subsystemKey: validKey + }) + ); + }); + + it('should submit Unidirectional authentication without subsystem key', () => { + const form = component.authStep?.formGroup; + form?.get('authType')?.setValue(AUTHENTICATION.Unidirectional); + component.onSubmit(); + expect(nvmeofService.updateAuthenticationKey).toHaveBeenCalledWith( + mockSubsystemNQN, + mockGroupName, + expect.objectContaining({ authType: AUTHENTICATION.Unidirectional }) + ); + }); + + it('should show notification and emit closeChange on successful submit', (done) => { + // closeChange fires synchronously inside onSubmit's complete handler, + // but dismissAll is called after — use setTimeout to assert the full chain. + component.closeChange.subscribe(() => { + setTimeout(() => { + expect(notificationService.show).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('updated'), + expect.anything() + ); + expect(modalService.dismissAll).toHaveBeenCalled(); + done(); + }, 0); + }); + + const form = component.authStep?.formGroup; + form?.get('authType')?.setValue(AUTHENTICATION.Unidirectional); + component.onSubmit(); + }); + + it('should mark form with submission error on API failure', (done) => { + const form = component.authStep?.formGroup; + form?.get('authType')?.setValue(AUTHENTICATION.Unidirectional); + nvmeofService.updateAuthenticationKey.mockReturnValue( + throwError(() => ({ status: 500, message: 'Server error' })) + ); + + component.onSubmit(); + + setTimeout(() => { + expect(component.isSubmitLoading).toBe(false); + expect(form?.hasError('cdSubmitButton')).toBe(true); + expect(notificationService.show).not.toHaveBeenCalled(); + expect(modalService.dismissAll).not.toHaveBeenCalled(); + done(); + }, 0); + }); + + it('should clear isSubmitLoading after error', (done) => { + const form = component.authStep?.formGroup; + form?.get('authType')?.setValue(AUTHENTICATION.Unidirectional); + nvmeofService.updateAuthenticationKey.mockReturnValue( + throwError(() => new Error('Network error')) + ); + + component.onSubmit(); + + setTimeout(() => { + expect(component.isSubmitLoading).toBe(false); + done(); + }, 0); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-edit-authentication/nvmeof-edit-authentication.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-edit-authentication/nvmeof-edit-authentication.component.ts new file mode 100644 index 00000000000..0c1b1aac9a3 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-edit-authentication/nvmeof-edit-authentication.component.ts @@ -0,0 +1,179 @@ +import { + AfterViewInit, + ChangeDetectorRef, + Component, + EventEmitter, + Inject, + OnInit, + Output, + ViewChild +} from '@angular/core'; +import { FormGroup } from '@angular/forms'; +import { forkJoin } from 'rxjs'; +import { finalize } from 'rxjs/operators'; + +import { BaseModal } from 'carbon-components-angular'; +import { AuthKeyUpdate, NvmeofService } from '~/app/shared/api/nvmeof.service'; +import { + AUTHENTICATION, + HostStepType, + NvmeofSubsystem, + NvmeofSubsystemInitiator, + getSubsystemAuthStatus, + NvmeofSubsystemAuthType +} from '~/app/shared/models/nvmeof'; +import { NotificationService } from '~/app/shared/services/notification.service'; +import { NotificationType } from '~/app/shared/enum/notification-type.enum'; +import { ModalCdsService } from '~/app/shared/services/modal-cds.service'; +import { NvmeofSubsystemsStepThreeComponent } from '../nvmeof-subsystems-form/nvmeof-subsystem-step-3/nvmeof-subsystem-step-3.component'; + +export const SUBSYSTEM_NQN_TOKEN = 'subsystemNQN'; +export const GROUP_NAME_TOKEN = 'groupName'; + +@Component({ + selector: 'cd-nvmeof-edit-authentication', + templateUrl: './nvmeof-edit-authentication.component.html', + styleUrls: ['./nvmeof-edit-authentication.component.scss'], + standalone: false +}) +export class NvmeofEditAuthenticationComponent extends BaseModal implements OnInit, AfterViewInit { + @ViewChild(NvmeofSubsystemsStepThreeComponent) + authStep!: NvmeofSubsystemsStepThreeComponent; + + @Output() closeChange = new EventEmitter(); + + isSubmitLoading = false; + stepTwoValue: HostStepType | null = null; + subsystemHasDhchapKey = false; + initialAuthType: AUTHENTICATION = AUTHENTICATION.Unidirectional; + showAuthAlert = false; + + readonly steps = [{ label: $localize`Authentication`, invalid: false }]; + readonly title = $localize`Edit authentication`; + readonly description = $localize`Configure authentication to verify the identity of connecting hosts and protect the subsystem from unauthorized access.`; + + constructor( + @Inject(SUBSYSTEM_NQN_TOKEN) public subsystemNQN: string, + @Inject(GROUP_NAME_TOKEN) public groupName: string, + private nvmeofService: NvmeofService, + private notificationService: NotificationService, + private modalCdsService: ModalCdsService, + private cdr: ChangeDetectorRef + ) { + super(); + } + + ngOnInit() { + forkJoin({ + subsystem: this.nvmeofService.getSubsystem(this.subsystemNQN, this.groupName), + initiators: this.nvmeofService.getInitiators(this.subsystemNQN, this.groupName) + }).subscribe(({ subsystem, initiators }) => { + const sub = subsystem as NvmeofSubsystem; + this.subsystemHasDhchapKey = sub.has_dhchap_key; + this.initialAuthType = this.deriveAuthType(sub, initiators); + this.stepTwoValue = this.buildStepTwoValue(initiators); + }); + } + + /** + * @ViewChild is guaranteed to be set by ngAfterViewInit. + * Subscribing here ensures the authType form control exists before we attach + * the valueChanges listener that drives showAuthAlert. + */ + ngAfterViewInit() { + this.authStep.formGroup.get('authType')?.valueChanges.subscribe((authType: AUTHENTICATION) => { + this.showAuthAlert = this.subsystemHasDhchapKey && authType === AUTHENTICATION.Unidirectional; + this.cdr.markForCheck(); + }); + } + + /** + * Maps the current subsystem auth status string to the AUTHENTICATION enum + * so the form radio pre-selects the correct option on open. + */ + private deriveAuthType(subsystem: NvmeofSubsystem, initiators: unknown): AUTHENTICATION { + const status = getSubsystemAuthStatus( + subsystem, + initiators as NvmeofSubsystemInitiator[] | { hosts?: NvmeofSubsystemInitiator[] } + ); + switch (status) { + case NvmeofSubsystemAuthType.BIDIRECTIONAL: + return AUTHENTICATION.Bidirectional; + case NvmeofSubsystemAuthType.UNIDIRECTIONAL: + return AUTHENTICATION.Unidirectional; + default: + return AUTHENTICATION.Unidirectional; + } + } + + private buildStepTwoValue(initiators: unknown): HostStepType { + const hosts = this.extractHostsFromResponse(initiators); + return { + addedHosts: hosts.map((h) => h.nqn), + hostname: '', + hostType: 'specific' + }; + } + + /** + * Handles both response shapes from getInitiators: + * - direct array: `NvmeofSubsystemInitiator[]` + * - object wrapper: `{ hosts: NvmeofSubsystemInitiator[] }` + */ + private extractHostsFromResponse(response: unknown): NvmeofSubsystemInitiator[] { + if (!response) { + return []; + } + if (Array.isArray(response)) { + return response; + } + const hostWrapper = response as { hosts?: NvmeofSubsystemInitiator[] }; + return hostWrapper.hosts ?? []; + } + + onSubmit() { + const form = this.getValidatedForm(); + if (!form) { + return; + } + + this.isSubmitLoading = true; + + const update = this.buildAuthenticationUpdate(form); + + this.nvmeofService + .updateAuthenticationKey(this.subsystemNQN, this.groupName, update) + .pipe( + finalize(() => { + this.isSubmitLoading = false; + }) + ) + .subscribe({ + error: () => { + form.setErrors({ cdSubmitButton: true }); + }, + complete: () => { + this.notificationService.show( + NotificationType.success, + $localize`Authentication updated`, + $localize`Authentication settings for subsystem ${this.subsystemNQN} have been saved.` + ); + this.closeChange.emit(); + this.modalCdsService.dismissAll(); + } + }); + } + + private getValidatedForm(): FormGroup | null { + const form = this.authStep?.formGroup; + return form && !form.invalid ? form : null; + } + + private buildAuthenticationUpdate(form: FormGroup): AuthKeyUpdate { + return { + authType: form.get('authType')?.value, + subsystemKey: form.get('subsystemDchapKey')?.value, + hostKeyList: form.get('hostDchapKeyList')?.value + }; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form/nvmeof-group-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form/nvmeof-group-form.component.html index f38f1fe0d4a..b14b435e9df 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form/nvmeof-group-form.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form/nvmeof-group-form.component.html @@ -48,18 +48,17 @@ i18n>Special characters are not allowed. -
Enable encryption
- @if (groupForm.controls.enable_auth.value) { + @if (groupForm.controls.enableEncryption.value) {
@@ -75,13 +74,13 @@ cdValidate #encryptionConfigRef="cdValidate" [invalid]="encryptionConfigRef.isInvalid" - formControlName="encryptionKey" + formControlName="encryptionConfig" cols="100" rows="4"> This field is required.
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 e873691c077..c975058fe7c 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 @@ -27,13 +27,12 @@ Authentication
- - - {{ detail.value ? 'Bi-directional' : 'No authentication' }} - + + {{ detail.value }} Edit 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 364ab36502a..4b4dc82ecf9 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 @@ -10,13 +10,14 @@ import { GridModule, TilesModule } from 'carbon-components-angular'; import { NvmeofSubsystemOverviewComponent } from './nvmeof-subsystem-overview.component'; import { NvmeofService } from '~/app/shared/api/nvmeof.service'; import { SharedModule } from '~/app/shared/shared.module'; +import { NvmeofSubsystem, NvmeofSubsystemInitiator } from '~/app/shared/models/nvmeof'; describe('NvmeofSubsystemOverviewComponent', () => { let component: NvmeofSubsystemOverviewComponent; let fixture: ComponentFixture; let nvmeofService: NvmeofService; - const mockSubsystem = { + const mockSubsystem: NvmeofSubsystem = { nqn: 'nqn.2016-06.io.spdk:cnode1', serial_number: 'Ceph30487186726692', model_number: 'Ceph bdev Controller', @@ -32,8 +33,21 @@ describe('NvmeofSubsystemOverviewComponent', () => { network_mask: [] }; - beforeEach(async () => { - await TestBed.configureTestingModule({ + const defaultActivatedRoute = { + parent: { params: of({ subsystem_nqn: 'nqn.2016-06.io.spdk:cnode1' }) }, + queryParams: of({ group: 'group1' }) + }; + + /** + * Creates a TestBed configuration with custom service overrides. + * Avoids repeating the full module declaration in tests that need different mock data. + */ + function createTestBed( + initiators: NvmeofSubsystemInitiator[], + subsystem: NvmeofSubsystem = mockSubsystem, + activatedRoute: object = defaultActivatedRoute + ): Promise> { + return TestBed.configureTestingModule({ declarations: [NvmeofSubsystemOverviewComponent], imports: [ HttpClientTestingModule, @@ -44,19 +58,44 @@ describe('NvmeofSubsystemOverviewComponent', () => { GridModule ], providers: [ + { provide: ActivatedRoute, useValue: activatedRoute }, { - provide: ActivatedRoute, + provide: NvmeofService, useValue: { - parent: { - params: of({ subsystem_nqn: 'nqn.2016-06.io.spdk:cnode1' }) - }, - queryParams: of({ group: 'group1' }) + getSubsystem: jest.fn().mockReturnValue(of(subsystem)), + getInitiators: jest.fn().mockReturnValue(of(initiators)) } - }, + } + ] + }) + .compileComponents() + .then(() => { + const f = TestBed.createComponent(NvmeofSubsystemOverviewComponent); + f.detectChanges(); + tick(); + f.detectChanges(); + return f; + }); + } + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [NvmeofSubsystemOverviewComponent], + imports: [ + HttpClientTestingModule, + RouterTestingModule, + SharedModule, + NgbTooltipModule, + TilesModule, + GridModule + ], + providers: [ + { provide: ActivatedRoute, useValue: defaultActivatedRoute }, { provide: NvmeofService, useValue: { - getSubsystem: jest.fn().mockReturnValue(of(mockSubsystem)) + getSubsystem: jest.fn().mockReturnValue(of(mockSubsystem)), + getInitiators: jest.fn().mockReturnValue(of([])) } } ] @@ -93,44 +132,13 @@ describe('NvmeofSubsystemOverviewComponent', () => { expect(component.subsystem.gw_group).toBe('gateway-prod'); })); - it('should not fetch when subsystemNQN is missing', fakeAsync(() => { + it('should not fetch when subsystemNQN is missing', fakeAsync(async () => { TestBed.resetTestingModule(); - TestBed.configureTestingModule({ - declarations: [NvmeofSubsystemOverviewComponent], - imports: [ - HttpClientTestingModule, - RouterTestingModule, - SharedModule, - NgbTooltipModule, - TilesModule, - GridModule - ], - providers: [ - { - provide: ActivatedRoute, - useValue: { - parent: { - params: of({}) - }, - queryParams: of({ group: 'group1' }) - } - }, - { - provide: NvmeofService, - useValue: { - getSubsystem: jest.fn().mockReturnValue(of(mockSubsystem)) - } - } - ] - }).compileComponents(); - - const newFixture = TestBed.createComponent(NvmeofSubsystemOverviewComponent); - const newComponent = newFixture.componentInstance; + const noNqnRoute = { parent: { params: of({}) }, queryParams: of({ group: 'group1' }) }; + const f = await createTestBed([], mockSubsystem, noNqnRoute); const newService = TestBed.inject(NvmeofService); - newFixture.detectChanges(); - tick(); expect(newService.getSubsystem).not.toHaveBeenCalled(); - expect(newComponent.subsystem).toBeUndefined(); + expect(f.componentInstance.subsystem).toBeUndefined(); })); it('should render detail labels in the template', fakeAsync(() => { @@ -160,7 +168,23 @@ describe('NvmeofSubsystemOverviewComponent', () => { const hostAccessText = fixture.nativeElement.textContent; expect(hostAccessText).toContain('Allow all hosts'); - expect(hostAccessText).toContain('Bi-directional'); + // has_dhchap_key=true but no initiators with use_dhchap → No authentication + expect(hostAccessText).toContain('No authentication'); expect(hostAccessText).toContain('Edit'); })); + + it('should display Bidirectional when subsystem and host both have keys', fakeAsync(async () => { + TestBed.resetTestingModule(); + const f = await createTestBed([{ nqn: 'nqn.host-1', use_dhchap: true }]); + expect(f.nativeElement.textContent).toContain('Bi-directional'); + })); + + it('should display Unidirectional when only host has a key', fakeAsync(async () => { + TestBed.resetTestingModule(); + const f = await createTestBed([{ nqn: 'nqn.host-1', use_dhchap: true }], { + ...mockSubsystem, + has_dhchap_key: false + }); + expect(f.nativeElement.textContent).toContain('Unidirectional'); + })); }); 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 5b9643065d9..1abf3c48262 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,8 +1,17 @@ import { Component, OnInit } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; +import { forkJoin } from 'rxjs'; + import { NvmeofService } from '~/app/shared/api/nvmeof.service'; -import { NvmeofSubsystem } from '~/app/shared/models/nvmeof'; +import { + NvmeofSubsystem, + NvmeofSubsystemInitiator, + NO_AUTH, + getSubsystemAuthStatus +} from '~/app/shared/models/nvmeof'; 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'; export interface SubsystemDetail { label: string; @@ -24,7 +33,11 @@ export class NvmeofSubsystemOverviewComponent implements OnInit { subsystem!: NvmeofSubsystem; details: SubsystemDetail[] = []; - constructor(private route: ActivatedRoute, private nvmeofService: NvmeofService) {} + constructor( + private route: ActivatedRoute, + private nvmeofService: NvmeofService, + private modalService: ModalCdsService + ) {} ngOnInit() { this.route.parent?.params.subscribe((params) => { @@ -44,13 +57,19 @@ export class NvmeofSubsystemOverviewComponent implements OnInit { } fetchSubsystem() { - this.nvmeofService.getSubsystem(this.subsystemNQN, this.groupName).subscribe((subsystem) => { + forkJoin({ + subsystem: this.nvmeofService.getSubsystem(this.subsystemNQN, this.groupName), + initiators: this.nvmeofService.getInitiators(this.subsystemNQN, this.groupName) + }).subscribe(({ subsystem, initiators }) => { this.subsystem = subsystem as NvmeofSubsystem; - this.buildDetails(); + const initiatorList = initiators as + | NvmeofSubsystemInitiator[] + | { hosts?: NvmeofSubsystemInitiator[] }; + this.buildDetails(getSubsystemAuthStatus(this.subsystem, initiatorList)); }); } - private buildDetails() { + private buildDetails(authStatus: string) { this.details = [ { label: $localize`Serial number`, @@ -73,7 +92,7 @@ export class NvmeofSubsystemOverviewComponent implements OnInit { }, { label: $localize`Authentication`, - value: this.subsystem.has_dhchap_key, + value: authStatus, type: 'auth', row: 2 }, @@ -124,6 +143,10 @@ export class NvmeofSubsystemOverviewComponent implements OnInit { return String(value); } + getAuthStatusIcon(authStatus: string): keyof typeof ICON_TYPE { + return authStatus === NO_AUTH ? 'error' : 'success'; + } + getStatusIcon(detail: SubsystemDetail): keyof typeof ICON_TYPE { return detail.value ? 'success' : 'error'; } @@ -140,4 +163,14 @@ export class NvmeofSubsystemOverviewComponent implements OnInit { const needed = 3 - this.getDetailsForRow(row).length; return Array.from({ length: needed }); } + + openEditAuthModal() { + const modalRef = this.modalService.show(NvmeofEditAuthenticationComponent, { + subsystemNQN: this.subsystemNQN, + groupName: this.groupName + }); + if (modalRef?.closeChange) { + modalRef.closeChange.subscribe(() => this.fetchSubsystem()); + } + } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-3/nvmeof-subsystem-step-3.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-3/nvmeof-subsystem-step-3.component.ts index 9bfc3c4c0cd..e6670359952 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-3/nvmeof-subsystem-step-3.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-3/nvmeof-subsystem-step-3.component.ts @@ -15,6 +15,13 @@ import { TearsheetStep } from '~/app/shared/models/tearsheet-step'; }) export class NvmeofSubsystemsStepThreeComponent implements OnInit, TearsheetStep { @Input() group!: string; + @Input() set initialAuthType(value: AUTHENTICATION) { + this._initialAuthType = value; + if (this.formGroup) { + this.formGroup.get('authType')?.setValue(value, { emitEvent: false }); + this.refreshHostKeyValidation(); + } + } @Input() set stepTwoValue(value: HostStepType | null) { this._addedHosts = value?.addedHosts ?? []; if (this.formGroup) { @@ -31,6 +38,7 @@ export class NvmeofSubsystemsStepThreeComponent implements OnInit, TearsheetStep }; AUTHENTICATION = AUTHENTICATION; + _initialAuthType: AUTHENTICATION = AUTHENTICATION.Unidirectional; _addedHosts: Array = []; constructor(public actionLabels: ActionLabelsI18n) {} @@ -55,7 +63,7 @@ export class NvmeofSubsystemsStepThreeComponent implements OnInit, TearsheetStep private createForm() { this.formGroup = new CdFormGroup({ - authType: new UntypedFormControl(AUTHENTICATION.Unidirectional), + authType: new UntypedFormControl(this._initialAuthType), subsystemDchapKey: new UntypedFormControl(null, [ CdValidators.base64(), CdValidators.requiredIf({ 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 121299a9d30..fb3bb1c9ede 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 @@ -5,7 +5,12 @@ import _ from 'lodash'; import { Observable, forkJoin, of as observableOf } from 'rxjs'; import { catchError, map, mapTo, mergeMap, switchMap } from 'rxjs/operators'; import { CephServiceSpec } from '../models/service.interface'; -import { ListenerItem, NvmeofSubsystem, NvmeofSubsystemNamespace } from '../models/nvmeof'; +import { + AUTHENTICATION, + ListenerItem, + NvmeofSubsystem, + NvmeofSubsystemNamespace +} from '../models/nvmeof'; import { HostService } from './host.service'; import { OrchestratorService } from './orchestrator.service'; import { HostStatus } from '../enum/host-status.enum'; @@ -65,6 +70,12 @@ export type NamespaceInitiatorRequest = InitiatorRequest & { subsystem_nqn: string; }; +export type AuthKeyUpdate = { + authType: AUTHENTICATION; + subsystemKey: string | null; + hostKeyList: Array<{ host_nqn: string; dhchap_key: string | null }>; +}; + const API_PATH = 'api/nvmeof'; const UI_API_PATH = 'ui-api/nvmeof'; @@ -300,6 +311,14 @@ export class NvmeofService { }); } + changeSubsystemKey(subsystemNQN: string, dhchapKey: string, gwGroup: string) { + return this.http.put( + `${API_PATH}/subsystem/${subsystemNQN}/change_key`, + { dhchap_key: dhchapKey, gw_group: gwGroup }, + { observe: 'response' } + ); + } + updateHostKey(subsystemNQN: string, request: InitiatorRequest) { return this.http.put( `${API_PATH}/subsystem/${subsystemNQN}/host/${request.host_nqn}/change_key`, @@ -310,6 +329,31 @@ export class NvmeofService { ); } + updateAuthenticationKey( + subsystemNQN: string, + gwGroup: string, + update: AuthKeyUpdate + ): Observable { + const { authType, subsystemKey, hostKeyList } = update; + + const subsystemKeyCall = + authType === AUTHENTICATION.Bidirectional && subsystemKey + ? this.changeSubsystemKey(subsystemNQN, subsystemKey, gwGroup) + : observableOf(null); + + const hostKeyCalls = hostKeyList + .filter((item) => !!item.dhchap_key) + .map((item) => + this.updateHostKey(subsystemNQN, { + host_nqn: item.host_nqn, + dhchap_key: item.dhchap_key, + gw_group: gwGroup + }).pipe(catchError(() => observableOf(null))) + ); + + return forkJoin([subsystemKeyCall, ...hostKeyCalls]).pipe(map(() => undefined)); + } + removeInitiators(subsystemNQN: string, request: InitiatorRequest) { return this.http.delete( `${UI_API_PATH}/subsystem/${subsystemNQN}/host/${request.host_nqn}/${request.gw_group}`, diff --git a/src/pybind/mgr/dashboard/openapi.yaml b/src/pybind/mgr/dashboard/openapi.yaml index b72f8e2893c..3b3a64eff03 100644 --- a/src/pybind/mgr/dashboard/openapi.yaml +++ b/src/pybind/mgr/dashboard/openapi.yaml @@ -13545,6 +13545,68 @@ paths: summary: Get information from a specific NVMeoF subsystem tags: - NVMe-oF Subsystem + /api/nvmeof/subsystem/{nqn}/change_key: + put: + parameters: + - description: NVMeoF subsystem NQN + in: path + name: nqn + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + properties: + dhchap_key: + description: Subsystem DH-HMAC-CHAP key + type: string + gw_group: + description: NVMeoF gateway group + type: string + server_address: + description: NVMeoF gateway address + type: string + traddr: + description: NVMeoF gateway address (deprecated) + type: string + required: + - dhchap_key + type: object + responses: + '200': + content: + application/json: + schema: + type: object + application/vnd.ceph.api.v1.0+json: + schema: + type: object + description: Resource updated. + '202': + content: + application/json: + schema: + type: object + application/vnd.ceph.api.v1.0+json: + schema: + type: object + description: Operation is still executing. Please check the task queue. + '400': + description: Operation exception. Please check the response body for details. + '401': + description: Unauthenticated access. Please login first. + '403': + description: Unauthorized access. Please check your permissions. + '500': + description: Unexpected error. Please check the response body for the stack + trace. + security: + - jwt: [] + summary: Change subsystem inband authentication key + tags: + - NVMe-oF Subsystem /api/nvmeof/subsystem/{nqn}/connection: get: parameters: