]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Fix nvmeof edit host key in subsystem resources page 67340/head
authorpujaoshahu <pshahu@redhat.com>
Fri, 13 Feb 2026 09:15:19 +0000 (14:45 +0530)
committerpujaoshahu <pshahu@redhat.com>
Tue, 24 Feb 2026 10:07:14 +0000 (15:37 +0530)
Fixes: https://tracker.ceph.com/issues/74881
Signed-off-by: pujaoshahu <pshahu@redhat.com>
17 files changed:
src/pybind/mgr/dashboard/controllers/nvmeof.py
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-edit-host-key-modal/nvmeof-edit-host-key-modal.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-edit-host-key-modal/nvmeof-edit-host-key-modal.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-edit-host-key-modal/nvmeof-edit-host-key-modal.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-edit-host-key-modal/nvmeof-edit-host-key-modal.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form/nvmeof-group-form.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form/nvmeof-group-form.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form/nvmeof-group-form.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/nvmeof.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/directives/validate.directive.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-validators.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts
src/pybind/mgr/dashboard/openapi.yaml

index ae58f94ed8ec29d18f122a872c390fef921db6ed..893560486ef0825254e26158dad6d35384bff66b 100644 (file)
@@ -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
index 92ed9d76872ccb9cfac7978b24cb1c1e9361295f..3194281fe6777b25252171ab7d98750556c83d54 100644 (file)
@@ -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 (file)
index 0000000..5357ece
--- /dev/null
@@ -0,0 +1,89 @@
+<cds-modal size="sm"
+           [open]="open"
+           (overlaySelected)="closeModal()">
+  <cds-modal-header (closeSelect)="closeModal()">
+    <h2 cdsModalHeaderLabel
+        i18n>
+        {{'Subsystem'}}
+    </h2>
+
+  <!-- Step 1: Enter DHCHAP key -->
+  <ng-container *ngIf="!showConfirmation">
+    <h2 class="cds--type-heading-03"
+        cdsModalHeaderHeading
+        i18n>Edit Host Key</h2>
+  </ng-container>
+
+  <!-- Step 2: Confirm changes -->
+  <ng-container *ngIf="showConfirmation">
+    <h2 class="cds--type-heading-03"
+        cdsModalHeaderHeading
+        i18n>Confirm changes</h2>
+  </ng-container>
+  </cds-modal-header>
+
+  <section cdsModalContent>
+    <!-- Step 1: Enter DHCHAP key -->
+    <ng-container *ngIf="!showConfirmation">
+      <p class="cds--type-body-01 cds-mb-5"
+         i18n>Update DHCHAP authentication key for the selected host.</p>
+      <form name="editForm"
+            #formDir="ngForm"
+            [formGroup]="editForm"
+            class="form-item "
+            novalidate>
+        <cds-text-label
+          i18n
+          helperText="Enter or update the authentication key for this host."
+          i18n-helperText
+          labelInputID="dhchapKey"
+          [invalid]="dhchapKey.isInvalid"
+          [invalidText]="dhchapKeyError">
+          DHCHAP Key | {{ hostNQN }}
+          <input cdsPassword
+                 cdValidate
+                 #dhchapKey="cdValidate"
+                 [invalid]="dhchapKey.isInvalid"
+                 formControlName="dhchapKey"
+                 type="password"
+                 id="dhchap_key"
+                 placeholder="Enter Host DH-HMAC-CHAP key"
+                 i18n-placeholder
+                 modal-primary-focus
+                 autofocus
+                 autocomplete="off">
+        </cds-text-label>
+        <ng-template #dhchapKeyError>
+          <span *ngIf="editForm.controls.dhchapKey.hasError('required')"
+                class="invalid-feedback"
+                i18n>This field is required.</span>
+          <span *ngIf="editForm.controls.dhchapKey.hasError('invalidBase64')"
+                class="invalid-feedback"
+                i18n>Invalid DH-HMAC-CHAP key. Please enter a valid Base64 encoded key.</span>
+        </ng-template>
+      </form>
+    </ng-container>
+
+    <!-- Step 2: Confirm changes -->
+    <ng-container *ngIf="showConfirmation">
+      <p class="cds--type-body-01"
+         i18n>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.</p>
+    </ng-container>
+  </section>
+
+  <!-- Step 1: Save button -->
+  <cd-form-button-panel *ngIf="!showConfirmation"
+                        (submitActionEvent)="onSave()"
+                        (backActionEvent)="closeModal()"
+                        [form]="editForm"
+                        [submitText]="'Save'"
+                        [modalForm]="true"></cd-form-button-panel>
+
+  <!-- Step 2: Confirm button -->
+  <cd-form-button-panel *ngIf="showConfirmation"
+                        (submitActionEvent)="onSubmit()"
+                        (backActionEvent)="goBack()"
+                        [form]="editForm"
+                        [submitText]="'Save changes'"
+                        [modalForm]="true"></cd-form-button-panel>
+</cds-modal>
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 (file)
index 0000000..e69de29
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 (file)
index 0000000..0fdf214
--- /dev/null
@@ -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<NvmeofEditHostKeyModalComponent>;
+  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 (file)
index 0000000..08dcf66
--- /dev/null
@@ -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();
+        }
+      });
+  }
+}
index 9c498ec9da05942e96223901535c4cbcfaaa8e8d..942051ef37abf57d5d9a49e3767d2f018ac7a810 100644 (file)
         </div>
       </div>
 
+      <!-- CDS Checkbox -->
+      <div cdsRow
+           class="form-item">
+        <div cdsCol>
+          <cds-checkbox id="enableCds"
+                        formControlName="enableEncryption"
+                        i18n>Enable encryption
+          </cds-checkbox>
+        </div>
+      </div>
+
+      <!-- CDS Input (shown when checkbox is checked) -->
+      @if (groupForm.controls.enableEncryption.value) {
+      <div cdsRow
+           class="form-item">
+        <div cdsCol>
+          <cds-textarea-label
+            labelInputID="cdsInput"
+            helperText="Provide the encryption configuration details."
+            i18n
+            i18n-helperText>Encryption configuration
+            <textarea cdsTextArea
+                      id="cdsInput"
+                      formControlName="encryptionConfig"
+                      cols="100"
+                      rows="4">
+            </textarea>
+          </cds-textarea-label>
+        </div>
+      </div>
+      }
+
       <!-- Target Nodes Selection -->
       <div
         cdsRow
index f3fae1748a3297cfcea04747305a8643d33afb57..0c87cd8059a96eaafe2f28bb76dfb565dd50066d 100644 (file)
@@ -14,7 +14,7 @@ import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
 import { SharedModule } from '~/app/shared/shared.module';
 
 import { NvmeofGroupFormComponent } from './nvmeof-group-form.component';
-import { GridModule, InputModule, SelectModule } from 'carbon-components-angular';
+import { CheckboxModule, GridModule, InputModule, SelectModule } from 'carbon-components-angular';
 import { PoolService } from '~/app/shared/api/pool.service';
 import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
 import { CephServiceService } from '~/app/shared/api/ceph-service.service';
@@ -46,6 +46,7 @@ describe('NvmeofGroupFormComponent', () => {
         ReactiveFormsModule,
         RouterTestingModule,
         SharedModule,
+        CheckboxModule,
         GridModule,
         InputModule,
         SelectModule,
index f91d156d68bf3060ff145c1496a679399bcf4861..7fd270e51ec772fff0822e455c5e87083222ac9b 100644 (file)
@@ -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<string, any> = {
       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, {
index e565c1ddb93e1987b0435226c01ddd0e2de8ab4e..97a520db0c6a6b36f52cf0c3508002a97d2776c7 100644 (file)
         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.
       </p>
     </div>
-    <span
-      class="text-nowrap cds-ml-3 text-muted"
-      i18n
-    >
-      Edit host access
-    </span>
+    <a cdsLink
+       class="text-nowrap cds-ml-3 text-muted"
+       (click)="editHostAccess()"
+       i18n>Edit host access</a>
   </div>
 </cd-alert-panel>
 
index 4da1696fffead47c9c4bc9cddf133e9d62f1f9b6..84e0391634f62445f091c77fc80f783857f69092 100644 (file)
@@ -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 === '*');
   }
index 32097cd044a0ab1777c4b4c65f6bbd6d38e3f735..0a9e6384804760509263e36e39396d00e267a576 100644 (file)
@@ -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,
index 3ede27f1ecb593f3313af7a82d39ea4aa345a20b..90007feec5d363a60bc8ca3471d5b7fb3a44201f 100644 (file)
@@ -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'
index 0211103c9a68260e8c540ec401d38a23c503795d..60912195e56331f00d0ea367edc65d3b2b7a3b4a 100644 (file)
@@ -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();
index 362e0e260bf899844b098fc834fcd5bd6c39469f..8166386405bfe532f8405323fb9063a96030ecfc 100644 (file)
@@ -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.
    *
index f69303206ab8aa364c30a231c5cd9789db57aca5..bd4ddc5f85fc203b8c7ffcebfd202cf864e47849 100644 (file)
@@ -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)
     ),
index 5ae0b103b0a2c06a99489edc369bdc617bc5011e..da097e2d7c9aaa66c308e9a3e1f99fc5cc11cf51 100755 (executable)
@@ -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: