mirror of
https://github.com/ceph/ceph
synced 2026-08-02 07:03:18 +00:00
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:
commit
c11c7144fc
@ -616,6 +616,8 @@ else:
|
||||
)
|
||||
)
|
||||
|
||||
@Endpoint('PUT', '{nqn}/change_key')
|
||||
@UpdatePermission
|
||||
@EndpointDoc(
|
||||
"Change subsystem inband authentication key",
|
||||
parameters={
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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>
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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');
|
||||
}));
|
||||
});
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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}`,
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user