Merge pull request #69850 from rhcs-dashboard/edit-auth-subsystem

mgr/dashboard: NVMe-oF – Add edit authentication support for subsystem

Reviewed-by: Sagar Gopale <sagar.gopale@ibm.com>
This commit is contained in:
Afreen Misbah 2026-07-04 01:48:27 +05:30 committed by GitHub
commit c11c7144fc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 942 additions and 188 deletions

View File

@ -616,6 +616,8 @@ else:
)
)
@Endpoint('PUT', '{nqn}/change_key')
@UpdatePermission
@EndpointDoc(
"Change subsystem inband authentication key",
parameters={

View File

@ -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]

View File

@ -0,0 +1,29 @@
<cd-tearsheet [steps]="steps"
[title]="title"
[description]="description"
size="md"
[isSubmitLoading]="isSubmitLoading"
[submitButtonLabel]="'Save changes'"
[submitButtonLoadingLabel]="'Saving'"
(submitRequested)="onSubmit()"
(closeRequested)="closeModal()">
<cd-tearsheet-step>
@if (showAuthAlert) {
<cd-alert-panel type="warning"
title="Subsystem DHCHAP Key will be deleted permanently"
i18n-title
class="cd-nvmeof-edit-auth-downgrade-alert">
<span i18n>
Switching from bidirectional authentication to unidirectional method will remove the subsystem authentication key,
exposing the subsystem to unauthorised access.
</span>
</cd-alert-panel>
}
<cd-nvmeof-subsystem-step-three
#tearsheetStep
[group]="groupName"
[initialAuthType]="initialAuthType"
[stepTwoValue]="stepTwoValue">
</cd-nvmeof-subsystem-step-three>
</cd-tearsheet-step>
</cd-tearsheet>

View File

@ -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<NvmeofEditAuthenticationComponent>;
let nvmeofService: jest.Mocked<Pick<
NvmeofService,
'getInitiators' | 'getSubsystem' | 'updateAuthenticationKey'
>>;
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);
});
});
});

View File

@ -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<void>();
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
};
}
}

View File

@ -8,10 +8,10 @@
[narrow]="true"
[fullWidth]="true">
<div cdsCol
[columnNumbers]="{sm: 4, md: 8}">
[columnNumbers]="{ sm: 4, md: 8 }">
<div cdsRow
class="form-heading form-item cds-mt-5">
<cd-help-text [formAllFieldsRequired]="true"></cd-help-text>
<cd-help-text [formAllFieldsRequired]="true"></cd-help-text>
</div>
<div cdsRow
class="form-item">
@ -32,8 +32,7 @@
autofocus
formControlName="groupName"
cdRequiredField="Gateway group name"
[invalid]="groupName.isInvalid"
/>
[invalid]="groupName.isInvalid" />
</cds-text-label>
<span
class="invalid-feedback"
@ -47,11 +46,8 @@
class="invalid-feedback"
*ngIf="groupForm.get('groupName')?.hasError('invalidChars') && (groupForm.get('groupName')?.dirty || groupForm.get('groupName')?.touched)"
i18n>Special characters are not allowed.</span>
</div>
</div>
<!-- CDS Checkbox -->
<div cdsRow
class="form-item">
<div cdsCol>
@ -62,7 +58,6 @@
</div>
</div>
<!-- Encryption Configuration (shown when checkbox is checked) -->
@if (groupForm.controls.enableEncryption.value) {
<div cdsRow
class="form-item">
@ -81,8 +76,7 @@
[invalid]="encryptionConfigRef.isInvalid"
formControlName="encryptionConfig"
cols="100"
rows="4">
</textarea>
rows="4"></textarea>
</cds-textarea-label>
<span
class="invalid-feedback"
@ -91,27 +85,22 @@
</div>
</div>
}
<!-- Target Nodes Selection -->
<div
cdsRow
class="form-item"
>
<div cdsCol>
<h1 class="cds--type-heading-02">Select target nodes</h1>
<cd-help-text>
<span i18n>
Gateway nodes to run NVMe-oF target pods/services
</span>
</cd-help-text>
</div>
<div
cdsCol
class="cds-pt-3 cds-pb-3"
>
<cd-nvmeof-gateway-node
(hostsLoaded)="onHostsLoaded($event)"
></cd-nvmeof-gateway-node>
</div>
<div cdsRow
class="form-item">
<div cdsCol>
<h1 class="cds--type-heading-02">Select target nodes</h1>
<cd-help-text>
<span i18n>
Gateway nodes to run NVMe-oF target pods/services
</span>
</cd-help-text>
</div>
<div cdsCol
class="cds-pt-3 cds-pb-3">
<cd-nvmeof-gateway-node
(hostsLoaded)="onHostsLoaded($event)"></cd-nvmeof-gateway-node>
</div>
</div>
<div cdsRow class="form-item cds-mb-0">
<div cdsCol>
@ -120,13 +109,13 @@
<div cdsRow class="form-item">
<div cdsCol>
<div class="cds--stack cds--stack-horizontal">
<label class="cds--type-label-01"
<label class="cds--type-label-01"
i18n>Enable encryption</label>
<cds-tag type="green"
size="sm"
i18n>Recommended</cds-tag>
</div>
<cds-checkbox id="enableEncryption"
<cds-checkbox id="enableEncryption"
formControlName="enableEncryption">
<span
class="cds--type-body-01"
@ -136,13 +125,13 @@
</div>
@if (!groupForm.controls.enableEncryption.value) {
<div cdsRow
<div cdsRow
class="cds-mt-2">
<div cdsCol>
<cd-alert-panel type="warning"
spacingClass="mb-0"
i18n>
<span class="cds--type-heading-01">Encryption is required if you plan to use DH-CHAP or mTLS authentication.</span>
<span class="cds--type-heading-01">Encryption is required if you plan to use DH-CHAP or mTLS authentication.</span>
</cd-alert-panel>
</div>
</div>
@ -205,7 +194,7 @@
@if (groupForm.controls.enableMtls.value) {
<div cdsRow class="form-item">
<div cdsCol>
<label class="cds--label fw-bold"
<label class="cds--label fw-bold"
i18n>Choose Certificate Authority</label>
<cds-radio-group
formControlName="certificateType"
@ -491,8 +480,7 @@
[form]="groupForm"
[submitText]="(action | titlecase) + ' ' + (resource)"
[disabled]="isCreateDisabled"
wrappingClass="text-right"
>
wrappingClass="text-right">
</cd-form-button-panel>
</div>
</div>

View File

@ -1,62 +1,105 @@
<cds-tile *ngIf="subsystem">
<h4 class="cds--type-heading-03 tile-title"
i18n>Subsystem details</h4>
<div class="details-grid">
<div class="detail-item">
<span class="cds--type-label-01"
i18n>Serial number</span>
<span class="cds--type-body-compact-01">{{ subsystem.serial_number }}</span>
</div>
<div class="detail-item">
<span class="cds--type-label-01"
i18n>Model Number</span>
<span class="cds--type-body-compact-01">{{ subsystem.model_number }}</span>
</div>
<div class="detail-item">
<span class="cds--type-label-01"
i18n>Gateway group</span>
<span class="cds--type-body-compact-01">{{ subsystem.gw_group || groupName }}</span>
<div [cdsStack]="'vertical'"
[gap]="6">
<div cdsGrid
[useCssGrid]="true"
[condensed]="true"
[fullWidth]="true">
<div cdsCol
[columnNumbers]="{ sm: 4, md: 8, lg: 12 }">
<h3 class="cds--type-heading-03"
i18n>Subsystem details</h3>
</div>
</div>
<div class="detail-item">
<span class="cds--type-label-01"
i18n>Subsystem Type</span>
<span class="cds--type-body-compact-01">{{ subsystem.subtype }}</span>
</div>
<div class="detail-item">
<span class="cds--type-label-01"
i18n>HA Enabled</span>
<span class="cds--type-body-compact-01">{{ subsystem.enable_ha ? 'Yes' : 'No' }}</span>
</div>
<div class="detail-item">
<span class="cds--type-label-01"
i18n>Hosts allowed</span>
<span class="cds--type-body-compact-01">{{ subsystem.allow_any_host ? 'Any host' : 'Restricted' }}</span>
</div>
@for (row of getRows(); track row) {
<div cdsGrid
[useCssGrid]="true"
[condensed]="true"
[fullWidth]="true">
<div class="detail-item">
<span class="cds--type-label-01"
i18n>Maximum Controller Identifier</span>
<span class="cds--type-body-compact-01">{{ subsystem.max_cntlid }}</span>
</div>
<div class="detail-item">
<span class="cds--type-label-01"
i18n>Minimum Controller Identifier</span>
<span class="cds--type-body-compact-01">{{ subsystem.min_cntlid }}</span>
</div>
<div class="detail-item"></div>
@for (detail of getDetailsForRow(row); track detail.label) {
@if (detail.type === 'auth') {
<div cdsCol
[columnNumbers]="{ sm: 4, md: 4, lg: 4 }"
[cdsStack]="'vertical'"
[gap]="2">
<span class="cds--type-label-01"
i18n>Authentication</span>
<div>
<cd-icon [type]="getAuthStatusIcon(detail.value.toString())"
class="cds-mr-3"></cd-icon>
<span class="cds--type-label-02">{{ detail.value }}</span>
<a cdsLink
class="cds-ml-2"
(click)="openEditAuthModal()"
[cdsStack]="'horizontal'"
[gap]="1">
<span i18n>Edit</span>
<cd-icon type="edit"></cd-icon>
</a>
</div>
</div>
}
<div class="detail-item">
<span class="cds--type-label-01"
i18n>Namespaces</span>
<span class="cds--type-body-compact-01">{{ subsystem.namespace_count }}</span>
@else if (detail.type === 'listeners') {
<div cdsCol
[columnNumbers]="{ sm: 4, md: 4, lg: 4 }"
[cdsStack]="'vertical'"
[gap]="2">
<div>
<span class="cds--type-label-01">{{ detail.label }}</span>
@if (detail.tooltip) {
<cd-icon type="infoCircle"
class="cds-ml-2"
[ngbTooltip]="detail.tooltip"></cd-icon>
}
</div>
<span class="cds--type-label-02">{{ detail.value }}</span>
</div>
}
@else if (detail.type === 'host-access') {
<div cdsCol
[columnNumbers]="getColNumbers(detail)"
[cdsStack]="'vertical'"
[gap]="2">
<span class="cds--type-label-01">{{ detail.label }}</span>
<div class="cds--type-label-02">
<span>{{ detail.value ? 'Allow all hosts' : 'Restrict to specific hosts' }}</span>
<a cdsLink
[routerLink]="['../hosts']"
[queryParams]="{ group: groupName }"
[cdsStack]="'horizontal'"
class="cds-ml-2"
[gap]="1">
<span i18n>Edit</span>
<cd-icon type="edit"></cd-icon>
</a>
</div>
</div>
}
<!-- Default: plain text row -->
@else {
<div cdsCol
[columnNumbers]="getColNumbers(detail)"
[cdsStack]="'vertical'"
[gap]="2">
<span class="cds--type-label-01">{{ detail.label }}</span>
<span class="cds--type-label-02">{{ getDisplayValue(detail.value) }}</span>
</div>
}
}
@if (getDetailsForRow(row).length < 3 && !isFullWidthRow(row)) {
@for (filler of getFillerCount(row); track filler) {
<div cdsCol
[columnNumbers]="{ sm: 4, md: 4, lg: 4 }"></div>
}
}
</div>
<div class="detail-item">
<span class="cds--type-label-01"
i18n>Maximum allowed namespaces</span>
<span class="cds--type-body-compact-01">{{ subsystem.max_namespaces }}</span>
</div>
<div class="detail-item"></div>
}
</div>
</cds-tile>

View File

@ -1,18 +1 @@
@use '@carbon/layout';
.tile-title {
margin-bottom: layout.$spacing-06;
}
.details-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
row-gap: layout.$spacing-06;
column-gap: layout.$spacing-07;
}
.detail-item {
display: flex;
flex-direction: column;
gap: layout.$spacing-02;
}

View File

@ -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<NvmeofSubsystemOverviewComponent>;
let nvmeofService: NvmeofService;
const mockSubsystem = {
const mockSubsystem: NvmeofSubsystem = {
nqn: 'nqn.2016-06.io.spdk:cnode1',
serial_number: 'Ceph30487186726692',
model_number: 'Ceph bdev Controller',
@ -28,9 +29,55 @@ describe('NvmeofSubsystemOverviewComponent', () => {
enable_ha: true,
allow_any_host: true,
gw_group: 'gateway-prod',
psk: 'some-key'
has_dhchap_key: true,
network_mask: []
};
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<ComponentFixture<NvmeofSubsystemOverviewComponent>> {
return TestBed.configureTestingModule({
declarations: [NvmeofSubsystemOverviewComponent],
imports: [
HttpClientTestingModule,
RouterTestingModule,
SharedModule,
NgbTooltipModule,
TilesModule,
GridModule
],
providers: [
{ provide: ActivatedRoute, useValue: activatedRoute },
{
provide: NvmeofService,
useValue: {
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],
@ -43,19 +90,12 @@ describe('NvmeofSubsystemOverviewComponent', () => {
GridModule
],
providers: [
{
provide: ActivatedRoute,
useValue: {
parent: {
params: of({ subsystem_nqn: 'nqn.2016-06.io.spdk:cnode1' })
},
queryParams: of({ group: 'group1' })
}
},
{ provide: ActivatedRoute, useValue: defaultActivatedRoute },
{
provide: NvmeofService,
useValue: {
getSubsystem: jest.fn().mockReturnValue(of(mockSubsystem))
getSubsystem: jest.fn().mockReturnValue(of(mockSubsystem)),
getInitiators: jest.fn().mockReturnValue(of([]))
}
}
]
@ -92,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(() => {
@ -143,38 +152,39 @@ describe('NvmeofSubsystemOverviewComponent', () => {
expect(labelTexts).toContain('Serial number');
expect(labelTexts).toContain('Model Number');
expect(labelTexts).toContain('Gateway group');
expect(labelTexts).toContain('Host access');
expect(labelTexts).toContain('Authentication');
expect(labelTexts).toContain('Listeners');
expect(labelTexts).toContain('Maximum Controller Identifier');
expect(labelTexts).toContain('Minimum Controller Identifier');
expect(labelTexts).toContain('Namespaces');
expect(labelTexts).toContain('Maximum allowed namespaces');
}));
it('should not display MTLS label in overview details', fakeAsync(() => {
it('should display host access and auth state from subsystem data', fakeAsync(() => {
component.ngOnInit();
tick();
fixture.detectChanges();
const values = fixture.nativeElement.querySelectorAll('.cds--type-body-compact-01');
const valueTexts = Array.from(values).map((el: HTMLElement) => el.textContent.trim());
expect(valueTexts).not.toContain('MTLS');
const hostAccessText = fixture.nativeElement.textContent;
expect(hostAccessText).toContain('Allow all hosts');
// has_dhchap_key=true but no initiators with use_dhchap → No authentication
expect(hostAccessText).toContain('No authentication');
expect(hostAccessText).toContain('Edit');
}));
it('should display hosts allowed from subsystem data', fakeAsync(() => {
component.ngOnInit();
tick();
fixture.detectChanges();
const values = fixture.nativeElement.querySelectorAll('.cds--type-body-compact-01');
const valueTexts = Array.from(values).map((el: HTMLElement) => el.textContent.trim());
expect(valueTexts).toContain('Any host');
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 not render Edit link for Hosts allowed', fakeAsync(() => {
component.ngOnInit();
tick();
fixture.detectChanges();
const editLink = fixture.nativeElement.querySelector('a[cdsLink]');
expect(editLink).toBeFalsy();
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');
}));
});

View File

@ -1,7 +1,25 @@
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;
value: string | number | boolean;
type: 'text' | 'host-access' | 'auth' | 'listeners';
tooltip?: string;
row: number;
}
@Component({
selector: 'cd-nvmeof-subsystem-overview',
@ -10,11 +28,16 @@ import { NvmeofSubsystem } from '~/app/shared/models/nvmeof';
standalone: false
})
export class NvmeofSubsystemOverviewComponent implements OnInit {
subsystemNQN: string;
groupName: string;
subsystem: NvmeofSubsystem;
subsystemNQN!: string;
groupName!: string;
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) => {
@ -34,10 +57,120 @@ export class NvmeofSubsystemOverviewComponent implements OnInit {
}
fetchSubsystem() {
this.nvmeofService
.getSubsystem(this.subsystemNQN, this.groupName)
.subscribe((subsystem: NvmeofSubsystem) => {
this.subsystem = 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;
const initiatorList = initiators as
| NvmeofSubsystemInitiator[]
| { hosts?: NvmeofSubsystemInitiator[] };
this.buildDetails(getSubsystemAuthStatus(this.subsystem, initiatorList));
});
}
private buildDetails(authStatus: string) {
this.details = [
{
label: $localize`Serial number`,
value: this.subsystem.serial_number,
type: 'text',
row: 1
},
{ label: $localize`Model Number`, value: this.subsystem.model_number, type: 'text', row: 1 },
{
label: $localize`Gateway group`,
value: this.subsystem.gw_group || this.groupName,
type: 'text',
row: 1
},
{
label: $localize`Host access`,
value: this.subsystem.allow_any_host ?? false,
type: 'host-access',
row: 2
},
{
label: $localize`Authentication`,
value: authStatus,
type: 'auth',
row: 2
},
{
label: $localize`Listeners`,
value:
(this.subsystem.network_mask?.length ?? 0) > 0
? $localize`Auto-fetched`
: $localize`Manually selected`,
type: 'listeners',
tooltip: $localize`Listeners are automatically fetched from the gateway`,
row: 3
},
{
label: $localize`Maximum Controller Identifier`,
value: this.subsystem.max_cntlid,
type: 'text',
row: 3
},
{
label: $localize`Minimum Controller Identifier`,
value: this.subsystem.min_cntlid,
type: 'text',
row: 3
},
{ label: $localize`Namespaces`, value: this.subsystem.namespace_count, type: 'text', row: 4 },
{
label: $localize`Maximum allowed namespaces`,
value: this.subsystem.max_namespaces,
type: 'text',
row: 4
}
];
}
getRows(): number[] {
return [...new Set(this.details.map((d) => d.row))];
}
getDetailsForRow(row: number): SubsystemDetail[] {
return this.details.filter((d) => d.row === row);
}
getDisplayValue(value: string | number | boolean): string {
if (typeof value === 'boolean') {
return value ? $localize`Enabled` : $localize`Disabled`;
}
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';
}
getColNumbers(detail: SubsystemDetail): { sm: number; md: number; lg: number } {
return detail.type === 'auth' ? { sm: 4, md: 8, lg: 12 } : { sm: 4, md: 4, lg: 4 };
}
isFullWidthRow(row: number): boolean {
return this.getDetailsForRow(row).some((d) => d.type === 'auth');
}
getFillerCount(row: number): number[] {
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());
}
}
}

View File

@ -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<string> = [];
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({

View File

@ -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<void> {
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}`,

View File

@ -24,6 +24,7 @@ export interface NvmeofSubsystem {
gw_group?: string;
initiator_count?: number;
has_dhchap_key: boolean;
network_mask?: string[];
}
export interface NvmeofSubsystemData extends NvmeofSubsystem {

View File

@ -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: