From 392f7008b26c08d7e5e18ee0f5d2ff22a0afe40f Mon Sep 17 00:00:00 2001 From: pujaoshahu Date: Fri, 13 Feb 2026 14:45:19 +0530 Subject: [PATCH] mgr/dashboard: Fix nvmeof edit host key in subsystem resources page Fixes: https://tracker.ceph.com/issues/74881 Signed-off-by: pujaoshahu --- .../mgr/dashboard/controllers/nvmeof.py | 27 ++- .../src/app/ceph/block/block.module.ts | 4 +- .../nvmeof-edit-host-key-modal.component.html | 89 ++++++++ .../nvmeof-edit-host-key-modal.component.scss | 0 ...meof-edit-host-key-modal.component.spec.ts | 207 ++++++++++++++++++ .../nvmeof-edit-host-key-modal.component.ts | 76 +++++++ .../nvmeof-group-form.component.html | 32 +++ .../nvmeof-group-form.component.spec.ts | 3 +- .../nvmeof-group-form.component.ts | 10 +- .../nvmeof-initiators-list.component.html | 10 +- .../nvmeof-initiators-list.component.ts | 20 ++ .../nvmeof-subsystems-form.component.ts | 1 - .../src/app/shared/api/nvmeof.service.ts | 11 + .../shared/directives/validate.directive.ts | 2 +- .../src/app/shared/forms/cd-validators.ts | 20 ++ .../shared/services/task-message.service.ts | 3 + src/pybind/mgr/dashboard/openapi.yaml | 69 +++++- 17 files changed, 560 insertions(+), 24 deletions(-) create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-edit-host-key-modal/nvmeof-edit-host-key-modal.component.html create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-edit-host-key-modal/nvmeof-edit-host-key-modal.component.scss create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-edit-host-key-modal/nvmeof-edit-host-key-modal.component.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-edit-host-key-modal/nvmeof-edit-host-key-modal.component.ts diff --git a/src/pybind/mgr/dashboard/controllers/nvmeof.py b/src/pybind/mgr/dashboard/controllers/nvmeof.py index ae58f94ed8e..893560486ef 100644 --- a/src/pybind/mgr/dashboard/controllers/nvmeof.py +++ b/src/pybind/mgr/dashboard/controllers/nvmeof.py @@ -15,10 +15,11 @@ from ..services.orchestrator import OrchClient from ..tools import str_to_bool from . import APIDoc, APIRouter, BaseController, CreatePermission, \ DeletePermission, Endpoint, EndpointDoc, Param, ReadPermission, \ - RESTController, UIRouter + RESTController, UIRouter, UpdatePermission logger = logging.getLogger(__name__) + NVME_SCHEMA = { "available": (bool, "Is NVMe/TCP available?"), "message": (str, "Descriptions") @@ -259,8 +260,8 @@ else: "max_namespaces": Param(int, "Maximum number of namespaces", True, None), "no_group_append": Param(int, "Do not append gateway group name to the NQN", True, False), - "serial_number": Param(int, "Subsystem serial number", True, None), - "dhchap_key": Param(int, "Subsystem DH-HMAC-CHAP key", True, None), + "serial_number": Param(str, "Subsystem serial number", True, None), + "dhchap_key": Param(str, "Subsystem DH-HMAC-CHAP key", True, None), "gw_group": Param(str, "NVMeoF gateway group", True, None), "server_address": Param(str, "NVMeoF gateway address", True, None), }, @@ -278,7 +279,8 @@ else: NVMeoFClient.pb2.create_subsystem_req( subsystem_nqn=nqn, serial_number=serial_number, max_namespaces=max_namespaces, enable_ha=enable_ha, - no_group_append=no_group_append, dhchap_key=dhchap_key + no_group_append=no_group_append, + dhchap_key=dhchap_key ) ) @@ -326,7 +328,8 @@ else: server_address=server_address ).stub.change_subsystem_key( NVMeoFClient.pb2.change_subsystem_key_req( - subsystem_nqn=nqn, dhchap_key=dhchap_key + subsystem_nqn=nqn, + dhchap_key=dhchap_key ) ) @@ -1286,8 +1289,9 @@ else: gw_group=gw_group, server_address=server_address ).stub.add_host( - NVMeoFClient.pb2.add_host_req(subsystem_nqn=nqn, host_nqn=host_nqn, - dhchap_key=dhchap_key, psk=psk) + NVMeoFClient.pb2.add_host_req( + subsystem_nqn=nqn, host_nqn=host_nqn, + dhchap_key=dhchap_key, psk=psk) ) @empty_response @@ -1312,6 +1316,8 @@ else: NVMeoFClient.pb2.remove_host_req(subsystem_nqn=nqn, host_nqn=host_nqn) ) + @Endpoint('PUT', '{host_nqn}/change_key') + @UpdatePermission @empty_response @NvmeofCLICommand("nvmeof host change_key", model.RequestStatus) @EndpointDoc( @@ -1334,9 +1340,10 @@ else: gw_group=gw_group, server_address=server_address ).stub.change_host_key( - NVMeoFClient.pb2.change_host_key_req(subsystem_nqn=nqn, - host_nqn=host_nqn, - dhchap_key=dhchap_key) + NVMeoFClient.pb2.change_host_key_req( + subsystem_nqn=nqn, + host_nqn=host_nqn, + dhchap_key=dhchap_key) ) @empty_response 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 92ed9d76872..3194281fe67 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 @@ -53,6 +53,7 @@ import { NvmeofSubsystemsStepThreeComponent } from './nvmeof-subsystems-form/nvm import { NvmeofSubsystemsStepTwoComponent } from './nvmeof-subsystems-form/nvmeof-subsystem-step-2/nvmeof-subsystem-step-2.component'; import { NvmeofGatewayNodeComponent } from './nvmeof-gateway-node/nvmeof-gateway-node.component'; import { NvmeofGroupFormComponent } from './nvmeof-group-form/nvmeof-group-form.component'; +import { NvmeofEditHostKeyModalComponent } from './nvmeof-edit-host-key-modal/nvmeof-edit-host-key-modal.component'; import { ButtonModule, @@ -175,7 +176,8 @@ import { NvmeSubsystemViewComponent } from './nvme-subsystem-view/nvme-subsystem NvmeGatewayViewComponent, NvmeofGatewaySubsystemComponent, NvmeofGatewayNodeAddModalComponent, - NvmeSubsystemViewComponent + NvmeSubsystemViewComponent, + NvmeofEditHostKeyModalComponent ], exports: [RbdConfigurationListComponent, RbdConfigurationFormComponent] diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-edit-host-key-modal/nvmeof-edit-host-key-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-edit-host-key-modal/nvmeof-edit-host-key-modal.component.html new file mode 100644 index 00000000000..5357ece4cad --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-edit-host-key-modal/nvmeof-edit-host-key-modal.component.html @@ -0,0 +1,89 @@ + + +

+ {{'Subsystem'}} +

+ + + +

Edit Host Key

+
+ + + +

Confirm changes

+
+
+ +
+ + +

Update DHCHAP authentication key for the selected host.

+
+ + DHCHAP Key | {{ hostNQN }} + + + + This field is required. + Invalid DH-HMAC-CHAP key. Please enter a valid Base64 encoded key. + +
+
+ + + +

Modifying or removing the Host key can invalidate existing NVMe sessions and interrupt secure communication with hosts. Ensure new keys are configured on all connected systems before continuing.

+
+
+ + + + + + +
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-edit-host-key-modal/nvmeof-edit-host-key-modal.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-edit-host-key-modal/nvmeof-edit-host-key-modal.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-edit-host-key-modal/nvmeof-edit-host-key-modal.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-edit-host-key-modal/nvmeof-edit-host-key-modal.component.spec.ts new file mode 100644 index 00000000000..0fdf214bfbb --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-edit-host-key-modal/nvmeof-edit-host-key-modal.component.spec.ts @@ -0,0 +1,207 @@ +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 { By } from '@angular/platform-browser'; +import { ToastrModule } from 'ngx-toastr'; +import { of, throwError } from 'rxjs'; + +import { SharedModule } from '~/app/shared/shared.module'; +import { NvmeofService } from '~/app/shared/api/nvmeof.service'; +import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service'; +import { NvmeofEditHostKeyModalComponent } from './nvmeof-edit-host-key-modal.component'; + +describe('NvmeofEditHostKeyModalComponent', () => { + let component: NvmeofEditHostKeyModalComponent; + let fixture: ComponentFixture; + let nvmeofService: NvmeofService; + let taskWrapperService: TaskWrapperService; + + const mockSubsystemNQN = 'nqn.2014-08.org.nvmexpress:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6'; + const mockHostNQN = 'nqn.2014-08.org.nvmexpress:uuid:12345678-1234-1234-1234-1234567890ab'; + const mockGroup = 'default'; + + const nvmeofServiceSpy = { + updateHostKey: jasmine.createSpy('updateHostKey').and.returnValue(of(null)) + }; + + const taskWrapperServiceSpy = { + wrapTaskAroundCall: jasmine.createSpy('wrapTaskAroundCall').and.callFake(({ call }) => call) + }; + + beforeEach( + waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [NvmeofEditHostKeyModalComponent], + imports: [ + ReactiveFormsModule, + HttpClientTestingModule, + RouterTestingModule, + SharedModule, + ToastrModule.forRoot() + ], + providers: [ + { provide: NvmeofService, useValue: nvmeofServiceSpy }, + { provide: TaskWrapperService, useValue: taskWrapperServiceSpy }, + { provide: 'subsystemNQN', useValue: mockSubsystemNQN }, + { provide: 'hostNQN', useValue: mockHostNQN }, + { provide: 'group', useValue: mockGroup }, + { provide: 'dhchapKey', useValue: '' } + ] + }).compileComponents(); + }) + ); + + beforeEach(() => { + fixture = TestBed.createComponent(NvmeofEditHostKeyModalComponent); + component = fixture.componentInstance; + nvmeofService = TestBed.inject(NvmeofService); + taskWrapperService = TestBed.inject(TaskWrapperService); + nvmeofServiceSpy.updateHostKey.calls.reset(); + taskWrapperServiceSpy.wrapTaskAroundCall.calls.reset(); + nvmeofServiceSpy.updateHostKey.and.returnValue(of(null)); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should initialize the form with empty dhchapKey', () => { + expect(component.editForm).toBeDefined(); + expect(component.editForm.get('dhchapKey').value).toBe(''); + expect(component.editForm.valid).toBe(false); + }); + + it('should inject subsystemNQN, hostNQN and group', () => { + expect(component.subsystemNQN).toBe(mockSubsystemNQN); + expect(component.hostNQN).toBe(mockHostNQN); + expect(component.group).toBe(mockGroup); + }); + + it('should display the host NQN in the form label', () => { + const label = fixture.debugElement.query(By.css('cds-text-label')); + expect(label.nativeElement.textContent).toContain(mockHostNQN); + }); + + it('should not submit if form is invalid', () => { + component.onSubmit(); + expect(nvmeofService.updateHostKey).not.toHaveBeenCalled(); + }); + + it('should submit successfully when form is valid', () => { + const mockKey = 'Q2VwaE52bWVvRkNoYXBTeW50aGV0aWNLZXkxMjM0NTY='; + component.editForm.patchValue({ dhchapKey: mockKey }); + expect(component.editForm.valid).toBe(true); + + spyOn(component, 'closeModal'); + component.onSubmit(); + + expect(nvmeofService.updateHostKey).toHaveBeenCalledWith(mockSubsystemNQN, { + host_nqn: mockHostNQN, + dhchap_key: mockKey, + gw_group: mockGroup + }); + expect(taskWrapperService.wrapTaskAroundCall).toHaveBeenCalled(); + expect(component.closeModal).toHaveBeenCalled(); + }); + + it('should call updateHostKey with correct task metadata on submit', () => { + const mockKey = 'Q2VwaE52bWVvRkNoYXBTeW50aGV0aWNLZXkxMjM0NTY='; + component.editForm.patchValue({ dhchapKey: mockKey }); + spyOn(component, 'closeModal'); + + component.onSubmit(); + + const callArgs = taskWrapperServiceSpy.wrapTaskAroundCall.calls.mostRecent().args[0]; + expect(callArgs.task.name).toBe('nvmeof/initiator/edit'); + expect(callArgs.task.metadata).toEqual({ + nqn: mockSubsystemNQN + }); + }); + + it('should handle error during submission', () => { + const mockKey = 'Q2VwaE52bWVvRkNoYXBTeW50aGV0aWNLZXkxMjM0NTY='; + component.editForm.patchValue({ dhchapKey: mockKey }); + spyOn(component.editForm, 'setErrors'); + + nvmeofServiceSpy.updateHostKey.and.returnValue(throwError(() => ({ status: 500 }))); + + component.onSubmit(); + + expect(nvmeofService.updateHostKey).toHaveBeenCalled(); + expect(component.editForm.setErrors).toHaveBeenCalledTimes(1); + expect(component.editForm.setErrors).toHaveBeenCalledWith({ cdSubmitButton: true }); + }); + + it('should not close modal on error', () => { + const mockKey = 'Q2VwaE52bWVvRkNoYXBTeW50aGV0aWNLZXkxMjM0NTY='; + component.editForm.patchValue({ dhchapKey: mockKey }); + spyOn(component, 'closeModal'); + + nvmeofServiceSpy.updateHostKey.and.returnValue(throwError(() => ({ status: 400 }))); + + component.onSubmit(); + + expect(component.closeModal).not.toHaveBeenCalled(); + }); + + it('should be valid with 43-character unpadded Base64 key with DHHC-1: prefix and trailing colon', () => { + const prefixedSuffixedKey = 'DHHC-1:00:Q2VwaE52bWVvRkNoYXBTeW50aGV0aWNLZXkxMjM0NTY:'; + component.editForm.patchValue({ dhchapKey: prefixedSuffixedKey }); + expect(component.editForm.get('dhchapKey').valid).toBe(true); + expect(component.editForm.valid).toBe(true); + }); + + describe('Save button click', () => { + it('should trigger onSubmit and call updateHostKey when Save is clicked with valid form', () => { + const mockKey = 'Q2VwaE52bWVvRkNoYXBTeW50aGV0aWNLZXkxMjM0NTY='; + component.editForm.patchValue({ dhchapKey: mockKey }); + fixture.detectChanges(); + + spyOn(component, 'closeModal'); + + // Step 1: Save (Confirmation) + fixture.debugElement + .query(By.css('cd-form-button-panel')) + .triggerEventHandler('submitActionEvent', null); + fixture.detectChanges(); + expect(component.showConfirmation).toBe(true); + + // Step 2: Save changes (Actual submit) + fixture.debugElement + .query(By.css('cd-form-button-panel')) + .triggerEventHandler('submitActionEvent', null); + fixture.detectChanges(); + + expect(nvmeofService.updateHostKey).toHaveBeenCalledWith(mockSubsystemNQN, { + host_nqn: mockHostNQN, + dhchap_key: mockKey, + gw_group: mockGroup + }); + expect(taskWrapperService.wrapTaskAroundCall).toHaveBeenCalled(); + expect(component.closeModal).toHaveBeenCalled(); + }); + + it('should not call updateHostKey when Save is clicked with empty form', () => { + fixture.detectChanges(); + + const submitPanel = fixture.debugElement.query(By.css('cd-form-button-panel')); + submitPanel.triggerEventHandler('submitActionEvent', null); + fixture.detectChanges(); + + expect(nvmeofService.updateHostKey).not.toHaveBeenCalled(); + }); + + it('should call closeModal when Cancel is clicked', () => { + spyOn(component, 'closeModal'); + fixture.detectChanges(); + + const submitPanel = fixture.debugElement.query(By.css('cd-form-button-panel')); + submitPanel.triggerEventHandler('backActionEvent', null); + fixture.detectChanges(); + + expect(component.closeModal).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-edit-host-key-modal/nvmeof-edit-host-key-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-edit-host-key-modal/nvmeof-edit-host-key-modal.component.ts new file mode 100644 index 00000000000..08dcf66ca89 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-edit-host-key-modal/nvmeof-edit-host-key-modal.component.ts @@ -0,0 +1,76 @@ +import { Component, Inject, OnInit } from '@angular/core'; +import { UntypedFormControl, Validators } from '@angular/forms'; +import { BaseModal } from 'carbon-components-angular'; +import { NvmeofService } from '~/app/shared/api/nvmeof.service'; +import { CdFormGroup } from '~/app/shared/forms/cd-form-group'; +import { CdValidators } from '~/app/shared/forms/cd-validators'; +import { FinishedTask } from '~/app/shared/models/finished-task'; +import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service'; + +@Component({ + selector: 'cd-nvmeof-edit-host-key-modal', + templateUrl: './nvmeof-edit-host-key-modal.component.html', + styleUrls: ['./nvmeof-edit-host-key-modal.component.scss'], + standalone: false +}) +export class NvmeofEditHostKeyModalComponent extends BaseModal implements OnInit { + editForm: CdFormGroup; + showConfirmation = false; + + constructor( + @Inject('subsystemNQN') public subsystemNQN: string, + @Inject('hostNQN') public hostNQN: string, + @Inject('group') public group: string, + @Inject('dhchapKey') public existingDhchapKey: string, + private nvmeofService: NvmeofService, + private taskWrapper: TaskWrapperService + ) { + super(); + } + + ngOnInit() { + this.editForm = new CdFormGroup({ + dhchapKey: new UntypedFormControl(this.existingDhchapKey || '', [ + Validators.required, + CdValidators.base64() + ]) + }); + } + + onSave() { + if (this.editForm.invalid) { + return; + } + this.showConfirmation = true; + } + + goBack() { + this.showConfirmation = false; + } + + onSubmit() { + if (this.editForm.invalid) { + return; + } + const dhchapKey = this.editForm.getValue('dhchapKey'); + this.taskWrapper + .wrapTaskAroundCall({ + task: new FinishedTask('nvmeof/initiator/edit', { + nqn: this.subsystemNQN + }), + call: this.nvmeofService.updateHostKey(this.subsystemNQN, { + host_nqn: this.hostNQN, + dhchap_key: dhchapKey, + gw_group: this.group + }) + }) + .subscribe({ + error: () => { + this.editForm.setErrors({ cdSubmitButton: true }); + }, + complete: () => { + this.closeModal(); + } + }); + } +} 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 9c498ec9da0..942051ef37a 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 @@ -89,6 +89,38 @@ + +
+
+ Enable encryption + +
+
+ + + @if (groupForm.controls.enableEncryption.value) { +
+
+ Encryption configuration + + +
+
+ } +
{ ReactiveFormsModule, RouterTestingModule, SharedModule, + CheckboxModule, GridModule, InputModule, SelectModule, 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 f91d156d68b..7fd270e51ec 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 @@ -71,7 +71,9 @@ export class NvmeofGroupFormComponent extends CdForm implements OnInit { pool: new UntypedFormControl('rbd', { validators: [Validators.required] }), - unmanaged: new UntypedFormControl(false) + unmanaged: new UntypedFormControl(false), + enableEncryption: new UntypedFormControl(false), + encryptionConfig: new UntypedFormControl(null) }); } @@ -146,7 +148,7 @@ export class NvmeofGroupFormComponent extends CdForm implements OnInit { let taskUrl = `service/${URLVerbs.CREATE}`; const serviceName = `${formValues.pool}.${formValues.groupName}`; - const serviceSpec = { + const serviceSpec: Record = { service_type: 'nvmeof', service_id: serviceName, pool: formValues.pool, @@ -157,6 +159,10 @@ export class NvmeofGroupFormComponent extends CdForm implements OnInit { unmanaged: formValues.unmanaged }; + if (formValues.enableCds && formValues.cdsInput) { + serviceSpec['encryption_key'] = formValues.cdsInput; + } + this.taskWrapperService .wrapTaskAroundCall({ task: new FinishedTask(taskUrl, { 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 e565c1ddb93..97a520db0c6 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 @@ -14,12 +14,10 @@ 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 - + Edit host access 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 4da1696fffe..84e0391634f 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 @@ -17,6 +17,7 @@ import { NvmeofSubsystemAuthType } from '~/app/shared/enum/nvmeof.enum'; import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; import { ModalCdsService } from '~/app/shared/services/modal-cds.service'; import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service'; +import { NvmeofEditHostKeyModalComponent } from '../nvmeof-edit-host-key-modal/nvmeof-edit-host-key-modal.component'; const BASE_URL = 'block/nvmeof/subsystems'; @@ -98,6 +99,14 @@ export class NvmeofInitiatorsListComponent implements OnInit { canBePrimary: (selection: CdTableSelection) => !selection.hasSelection, disable: () => this.hasAllHostsAllowed() }, + { + name: $localize`Edit host key`, + permission: 'update', + icon: Icons.edit, + click: () => this.editHostKeyModal(), + disable: () => this.selection.selected.length !== 1, + canBePrimary: (selection: CdTableSelection) => selection.selected.length === 1 + }, { name: this.actionLabels.REMOVE, permission: 'delete', @@ -116,6 +125,17 @@ export class NvmeofInitiatorsListComponent implements OnInit { } } + editHostKeyModal() { + const selected = this.selection.selected[0]; + if (!selected) return; + this.modalService.show(NvmeofEditHostKeyModalComponent, { + subsystemNQN: this.subsystemNQN, + hostNQN: selected.nqn, + group: this.group, + dhchapKey: selected.dhchap_key || '' + }); + } + getAllowAllHostIndex() { return this.selection.selected.findIndex((selected) => selected.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 32097cd044a..0a9e6384804 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 @@ -81,7 +81,6 @@ export class NvmeofSubsystemsFormComponent implements OnInit { host_nqn: payload.hostType === HOST_TYPE.ALL ? '*' : payload.addedHosts.join(','), gw_group: this.group }; - this.nvmeofService .createSubsystem({ nqn: payload.nqn, 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 3ede27f1ecb..90007feec5d 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 @@ -45,6 +45,7 @@ export type NamespaceUpdateRequest = NvmeofRequest & { export type InitiatorRequest = NvmeofRequest & { host_nqn: string; + dhchap_key?: string; }; export type NamespaceInitiatorRequest = InitiatorRequest & { @@ -185,6 +186,16 @@ export class NvmeofService { }); } + updateHostKey(subsystemNQN: string, request: InitiatorRequest) { + return this.http.put( + `${API_PATH}/subsystem/${subsystemNQN}/host/${request.host_nqn}/change_key`, + request, + { + observe: 'response' + } + ); + } + addNamespaceInitiators(nsid: string, request: NamespaceInitiatorRequest) { return this.http.post(`${UI_API_PATH}/namespace/${nsid}/host`, request, { observe: 'response' diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/validate.directive.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/validate.directive.ts index 0211103c9a6..60912195e56 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/validate.directive.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/validate.directive.ts @@ -38,7 +38,7 @@ export class ValidateDirective implements OnInit, OnDestroy { const submit$ = this.formGroupDir ? this.formGroupDir.ngSubmit : new Subject(); - merge(this.ngControl.control.statusChanges, submit$) + merge(this.ngControl.control.statusChanges, this.ngControl.control.valueChanges, submit$) .pipe(takeUntil(this.destroy$)) .subscribe(() => { this.updateState(); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-validators.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-validators.ts index 362e0e260bf..8166386405b 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-validators.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-validators.ts @@ -272,6 +272,26 @@ export class CdValidators { }; } + /** + * Validator for DH-HMAC-CHAP keys that must be Base64 encoded. + * Accepts plain Base64 or DHHC-1:XX:base64: format. + * Skips validation when value is empty (use with required validator if needed). + * @returns {ValidatorFn} Returns error map with `invalidBase64` if validation fails. + */ + static base64(): ValidatorFn { + const plainBase64Regex = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/; + const dhchapFormatRegex = /^DHHC-1:[0-9a-fA-F]{2}:[A-Za-z0-9+/]+:$/; + return (control: AbstractControl): { [key: string]: boolean } | null => { + if (isEmptyInputValue(control.value)) { + return null; + } + const value = control.value; + return plainBase64Regex.test(value) || dhchapFormatRegex.test(value) + ? null + : { invalidBase64: true }; + }; + } + /** * Validate form control if condition is true with validators. * diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts index f69303206ab..bd4ddc5f85f 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts @@ -409,6 +409,9 @@ export class TaskMessageService { 'nvmeof/initiator/add': this.newTaskMessage(this.commonOperations.add, (metadata) => this.nvmeofInitiator(metadata) ), + 'nvmeof/initiator/edit': this.newTaskMessage(this.commonOperations.update, (metadata) => + this.nvmeofInitiator(metadata) + ), 'nvmeof/initiator/remove': this.newTaskMessage(this.commonOperations.remove, (metadata) => this.nvmeofInitiator(metadata) ), diff --git a/src/pybind/mgr/dashboard/openapi.yaml b/src/pybind/mgr/dashboard/openapi.yaml index 5ae0b103b0a..da097e2d7c9 100755 --- a/src/pybind/mgr/dashboard/openapi.yaml +++ b/src/pybind/mgr/dashboard/openapi.yaml @@ -12885,7 +12885,7 @@ paths: properties: dhchap_key: description: Subsystem DH-HMAC-CHAP key - type: integer + type: string enable_ha: default: true description: Enable high availability @@ -12905,7 +12905,7 @@ paths: type: string serial_number: description: Subsystem serial number - type: integer + type: string server_address: description: NVMeoF gateway address type: string @@ -13267,6 +13267,71 @@ paths: summary: Disallow hosts from accessing an NVMeoF subsystem tags: - NVMe-oF Subsystem Host Allowlist + /api/nvmeof/subsystem/{nqn}/host/{host_nqn}/change_key: + put: + parameters: + - description: NVMeoF subsystem NQN + in: path + name: nqn + required: true + schema: + type: string + - description: NVMeoF host NQN + in: path + name: host_nqn + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + properties: + dhchap_key: + description: Host DH-HMAC-CHAP key + type: string + gw_group: + description: NVMeoF gateway group + type: string + server_address: + description: NVMeoF gateway address + 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 host DH-HMAC-CHAP key + tags: + - NVMe-oF Subsystem Host Allowlist /api/nvmeof/subsystem/{nqn}/listener: get: parameters: -- 2.47.3