@EndpointDoc("Add one or more initiator hosts to an NVMeoF subsystem",
parameters={
'subsystem_nqn': (str, 'Subsystem NQN'),
- "host_nqn": Param(str, 'Comma separated list of NVMeoF host NQNs'),
+ "allow_all": Param(bool, 'Allow all hosts. Default is True.'),
+ "hosts": Param(List, 'List containg host nqn and dhchap key'),
"gw_group": Param(str, "NVMeoF gateway group")
})
@empty_response
@CreatePermission
def add(self, subsystem_nqn: str,
gw_group: str,
- host_nqn: str = "",
+ hosts: Optional[list[dict]] = None,
+ allow_all: bool = True,
server_address: Optional[str] = None):
response = None
- all_host_nqns = host_nqn.split(',')
- for nqn in all_host_nqns:
+ if allow_all:
+ return NVMeoFClient(gw_group=gw_group,
+ server_address=server_address).stub.add_host(
+ NVMeoFClient.pb2.add_host_req(
+ subsystem_nqn=subsystem_nqn,
+ host_nqn="*",
+ dhchap_key=None
+ ))
+
+ for h in (hosts or []):
+ nqn = h["host_nqn"]
+ key = h.get("dhchap_key")
+
response = NVMeoFClient(gw_group=gw_group,
server_address=server_address).stub.add_host(
- NVMeoFClient.pb2.add_host_req(subsystem_nqn=subsystem_nqn, host_nqn=nqn)
+ NVMeoFClient.pb2.add_host_req(
+ subsystem_nqn=subsystem_nqn,
+ host_nqn=nqn,
+ dhchap_key=key
+ )
)
if response.status != 0:
return response
return response
+
# UI API for deleting one or more than one hosts to subsystem
@Endpoint(method='DELETE', path="/subsystem/{subsystem_nqn}/host/{host_nqn}")
let fixture: ComponentFixture<NvmeofInitiatorsFormComponent>;
let nvmeofService: NvmeofService;
const mockTimestamp = 1720693470789;
+ const mockGroupName = 'default';
beforeEach(async () => {
spyOn(Date, 'now').and.returnValue(mockTimestamp);
component = fixture.componentInstance;
component.ngOnInit();
fixture.detectChanges();
+ component.group = mockGroupName;
});
it('should create', () => {
</div>
}
<div cdsRow
- class="form-item step-3-form-item">
+ class="form-item step-3-form-item"
+ formArrayName="hostDchapKeyList">
<div class="step-3-heading">
<h1
class="cds--type-heading-compact-01"
<p class="cds--type-label-01 text-helper"
i18n>{{formGroup.get('authType').value === AUTHENTICATION.Bidirectional ? 'All fields are required.' : 'Optional fields.'}}</p>
</div>
- @for(hostDchapKeyItem of hostDchapKeyList.controls; track hostDchapKeyItem; let i = $index) {
- <cds-text-label
- [formGroupName]="i"
- i18n>
- DHCHAP Key | {{hostNQN}}
- <input cdsPassword
- formControlName="key"
- type="password"
- placeholder="Enter DHCHAP key"
- class="step-3-form-item"
- i18n-placeholder
- autocomplete>
- </cds-text-label>
+ @for (hostDchapKeyItem of hostDchapKeyList.controls; track hostDchapKeyItem.get('host_nqn')?.value; let i = $index) {
+ <div [formGroupName]="i">
+ <cds-text-label
+ class="cds-mb-3">
+ <span
+ class="cds-mb-3"
+ i18n>DHCHAP Key | {{hostDchapKeyItem.get('host_nqn')?.value }}</span>
+ <input cdsPassword
+ formControlName="dhchap_key"
+ type="password"
+ placeholder="Enter DHCHAP key"
+ class="step-3-form-item"
+ i18n-placeholder
+ autocomplete>
+ </cds-text-label>
+ </div>
}
</div>
</div>
}
.step-3-form-item {
- max-inline-size: 23rem;
+ max-inline-size: 30rem;
}
import { SharedModule } from '~/app/shared/shared.module';
import { NvmeofSubsystemsStepThreeComponent } from './nvmeof-subsystem-step-3.component';
import { NvmeofService } from '~/app/shared/api/nvmeof.service';
-import { GridModule, RadioModule, TagModule } from 'carbon-components-angular';
+import { GridModule, InputModule, RadioModule, TagModule } from 'carbon-components-angular';
import { AUTHENTICATION } from '~/app/shared/models/nvmeof';
describe('NvmeofSubsystemsStepThreeComponent', () => {
GridModule,
RadioModule,
TagModule,
+ InputModule,
ToastrModule.forRoot()
]
}).compileComponents();
import { Component, Input, OnInit } from '@angular/core';
import { FormArray, UntypedFormControl } from '@angular/forms';
-import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
-import { AUTHENTICATION } from '~/app/shared/models/nvmeof';
+import { AUTHENTICATION, StepTwoType } from '~/app/shared/models/nvmeof';
import { TearsheetStep } from '~/app/shared/models/tearsheet-step';
@Component({
})
export class NvmeofSubsystemsStepThreeComponent implements OnInit, TearsheetStep {
@Input() group!: string;
+ @Input() set stepTwoValue(value: StepTwoType | null) {
+ this._addedHosts = value?.addedHosts ?? [];
+ if (this.formGroup) {
+ this.syncHostList();
+ }
+ }
+
formGroup: CdFormGroup;
action: string;
pageURL: string;
};
AUTHENTICATION = AUTHENTICATION;
- constructor(public actionLabels: ActionLabelsI18n, public activeModal: NgbActiveModal) {}
+ _addedHosts: Array<string> = [];
- ngOnInit() {
- this.createForm();
+ constructor(public actionLabels: ActionLabelsI18n) {}
+
+ private syncHostList() {
+ const currentList = this.hostDchapKeyList;
+
+ // save existing dhchap keys by host_nqn
+ const existing = new Map<string, string | null>();
+ currentList.getRawValue().forEach((x: any) => existing.set(x.host_nqn, x.dhchap_key));
+
+ currentList.clear();
+
+ const hosts = this._addedHosts;
+
+ if (hosts.length) {
+ hosts.forEach((nqn) => {
+ currentList.push(this.createHostDhchapKeyFormGroup(nqn, existing.get(nqn) ?? null));
+ });
+ } else {
+ currentList.push(this.createHostDhchapKeyFormGroup('', null));
+ }
}
- createForm() {
+ private createForm() {
this.formGroup = new CdFormGroup({
authType: new UntypedFormControl(AUTHENTICATION.Unidirectional),
subsystemDchapKey: new UntypedFormControl(null),
- hostDchapKeyList: new FormArray([this.createHostDchapKeyItem()])
+ hostDchapKeyList: new FormArray([])
});
+
+ this.syncHostList();
}
- createHostDchapKeyItem() {
+ private createHostDhchapKeyFormGroup(hostNQN: string = '', key: string | null = null) {
return new CdFormGroup({
- key: new UntypedFormControl(null),
- hostNQN: new UntypedFormControl('')
+ dhchap_key: new UntypedFormControl(key),
+ host_nqn: new UntypedFormControl(hostNQN)
});
}
+ ngOnInit() {
+ this.createForm();
+ }
+
get hostDchapKeyList() {
return this.formGroup.get('hostDchapKeyList') as FormArray;
}
+
+ trackByIndex = (i: number) => i;
}
[description]="description"
[isSubmitLoading]="isSubmitLoading"
(submitRequested)="onSubmit($event)"
+<<<<<<< HEAD
(stepChanged)="populateReviewData()"
+=======
+ (stepChanged)="onStepChanged($event)"
+>>>>>>> 6d877ea7101 (mgr/dashboard: Allow adding authentication to subsystem flow)
>
<cd-tearsheet-step>
<cd-nvmeof-subsystem-step-one
#tearsheetStep
[group]="group"></cd-nvmeof-subsystem-step-two>
</cd-tearsheet-step>
+ @if(showAuthStep) {
<cd-tearsheet-step>
<cd-nvmeof-subsystem-step-three
#tearsheetStep
+ [stepTwoValue]="stepTwoValue"
[group]="group"></cd-nvmeof-subsystem-step-three>
</cd-tearsheet-step>
+<<<<<<< HEAD
<cd-tearsheet-step>
<cd-nvmeof-subsystem-step-four
#tearsheetStep
[subsystemDchapKey]="reviewSubsystemDchapKey"
[hostDchapKeyCount]="reviewHostDchapKeyCount"></cd-nvmeof-subsystem-step-four>
</cd-tearsheet-step>
+=======
+ }
+>>>>>>> 6d877ea7101 (mgr/dashboard: Allow adding authentication to subsystem flow)
</cd-tearsheet>
TagModule
} from 'carbon-components-angular';
import { NvmeofSubsystemsStepThreeComponent } from './nvmeof-subsystem-step-3/nvmeof-subsystem-step-3.component';
-import { HOST_TYPE } from '~/app/shared/models/nvmeof';
+import { AUTHENTICATION, HOST_TYPE } from '~/app/shared/models/nvmeof';
import { NvmeofSubsystemsStepTwoComponent } from './nvmeof-subsystem-step-2/nvmeof-subsystem-step-2.component';
import { NvmeofSubsystemsStepFourComponent } from './nvmeof-subsystem-step-4/nvmeof-subsystem-step-4.component';
import { of } from 'rxjs';
subsystemDchapKey: 'Q2VwaE52bWVvRkNoYXBTeW50aGV0aWNLZXkxMjM0NTY=',
addedHosts: [],
hostType: HOST_TYPE.ALL,
- listeners: []
+ listeners: [],
+ hostDchapKeyList: [],
+ authType: AUTHENTICATION.Bidirectional
};
beforeEach(async () => {
addedHosts: [],
hostType: HOST_TYPE.ALL,
subsystemDchapKey: 'Q2VwaE52bWVvRkNoYXBTeW50aGV0aWNLZXkxMjM0NTY=',
+<<<<<<< HEAD
listeners: []
+=======
+ authType: AUTHENTICATION.Bidirectional,
+ hostDchapKeyList: []
+>>>>>>> 6d877ea7101 (mgr/dashboard: Allow adding authentication to subsystem flow)
};
component.group = mockGroupName;
component.onSubmit(payload);
+<<<<<<< HEAD
expect(nvmeofService.addInitiators).toHaveBeenCalledWith('test-nqn.default', {
host_nqn: '*',
+=======
+ expect(nvmeofService.addSubsystemInitiators).toHaveBeenCalledWith('test-nqn.default', {
+ allow_all: true,
+ hosts: [],
+>>>>>>> 6d877ea7101 (mgr/dashboard: Allow adding authentication to subsystem flow)
gw_group: mockGroupName
});
});
import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
import { ActivatedRoute, Router } from '@angular/router';
import { Step } from 'carbon-components-angular';
-import { InitiatorRequest, NvmeofService } from '~/app/shared/api/nvmeof.service';
+import { NvmeofService, SubsystemInitiatorRequest } from '~/app/shared/api/nvmeof.service';
import { TearsheetComponent } from '~/app/shared/components/tearsheet/tearsheet.component';
import { HOST_TYPE, ListenerItem, AUTHENTICATION } from '~/app/shared/models/nvmeof';
+import { AUTHENTICATION, HOST_TYPE, StepTwoType } from '~/app/shared/models/nvmeof';
import { from, Observable, of } from 'rxjs';
import { NotificationService } from '~/app/shared/services/notification.service';
import { NotificationType } from '~/app/shared/enum/notification-type.enum';
addedHosts: string[];
hostType: string;
listeners: ListenerItem[];
+ authType: AUTHENTICATION.Bidirectional | AUTHENTICATION.Unidirectional;
+ hostDchapKeyList: Array<{ dhchap_key: string; host_nqn: string }>;
};
type StepResult = { step: string; success: boolean; error?: string };
export class NvmeofSubsystemsFormComponent implements OnInit {
action: string;
group: string;
- steps: Step[] = [
- {
- label: $localize`Subsystem details`,
- complete: false,
- invalid: false
- },
- {
- label: $localize`Host access control`,
- invalid: false
- },
- {
- label: $localize`Authentication`,
- complete: false
- },
- {
- label: $localize`Review`,
- complete: false
- }
- ];
+ steps: Step[] = [];
title: string = $localize`Create Subsystem`;
description: string = $localize`Subsytems define how hosts connect to NVMe namespaces and ensure secure access to storage.`;
isSubmitLoading: boolean = false;
private lastCreatedNqn: string;
+ stepTwoValue: StepTwoType = null;
+ showAuthStep = true;
@ViewChild(TearsheetComponent) tearsheet!: TearsheetComponent;
this.route.queryParams.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => {
this.group = params?.['group'];
});
+ this.rebuildSteps();
}
populateReviewData() {
this.reviewSubsystemDchapKey = step3Form.get('subsystemDchapKey')?.value || '';
const hostKeys = step3Form.get('hostDchapKeyList')?.value || [];
this.reviewHostDchapKeyCount = hostKeys.filter((k: any) => k?.key).length;
+
+ } }
+ onStepChanged(_e: { current: number }) {
+ const stepTwo = this.tearsheet?.getStepValue(1);
+ this.stepTwoValue = stepTwo;
+
+ this.showAuthStep = stepTwo?.hostType !== HOST_TYPE.ALL;
+
+ this.rebuildSteps();
+ }
+
+ rebuildSteps() {
+ const steps: Step[] = [
+ { label: 'Subsystem details', invalid: false },
+ { label: 'Host access control', invalid: false }
+ ];
+
+ if (this.showAuthStep) {
+ steps.push({ label: 'Authentication', invalid: false });
+ }
+
+ steps.push({ label: 'Review', invalid: false });
+
+ this.steps = steps;
+
+ if (this.tearsheet?.currentStep >= steps.length) {
+ this.tearsheet.currentStep = steps.length - 1;
}
}
this.isSubmitLoading = true;
this.lastCreatedNqn = payload.nqn;
const stepResults: StepResult[] = [];
- const initiatorRequest: InitiatorRequest = {
- host_nqn: payload.hostType === HOST_TYPE.ALL ? '*' : payload.addedHosts.join(','),
+ const initiatorRequest: SubsystemInitiatorRequest = {
+ allow_all: payload.hostType === HOST_TYPE.ALL,
+ hosts: payload.hostType === HOST_TYPE.SPECIFIC ? payload.hostDchapKeyList : [],
gw_group: this.group
};
this.nvmeofService
describe('test initiators APIs', () => {
let request = { host_nqn: '', gw_group: mockGroupName };
+ let addRequest = { hosts: [], allow_all: true, gw_group: mockGroupName };
it('should call getInitiators', () => {
service.getInitiators(mockNQN, mockGroupName).subscribe();
const req = httpTesting.expectOne(
expect(req.request.method).toBe('GET');
});
it('should call addInitiators', () => {
- service.addInitiators(mockNQN, request).subscribe();
+ service.addSubsystemInitiators(mockNQN, addRequest).subscribe();
const req = httpTesting.expectOne(`${UI_API_PATH}/subsystem/${mockNQN}/host`);
expect(req.request.method).toBe('POST');
});
dhchap_key?: string;
};
+export type SubsystemInitiatorRequest = NvmeofRequest & {
+ hosts: Array<{ dhchap_key: string; host_nqn: string }>;
+ allow_all: boolean;
+};
+
export type NamespaceInitiatorRequest = InitiatorRequest & {
subsystem_nqn: string;
};
return this.http.get(`${API_PATH}/subsystem/${subsystemNQN}/host?gw_group=${group}`);
}
- addInitiators(subsystemNQN: string, request: InitiatorRequest) {
+ addSubsystemInitiators(subsystemNQN: string, request: SubsystemInitiatorRequest) {
return this.http.post(`${UI_API_PATH}/subsystem/${subsystemNQN}/host`, request, {
observe: 'response'
});
}
.tearsheet-content {
+ max-block-size: 39.7rem;
background-color: var(--cds-layer-01);
margin: 0;
padding: var(--cds-spacing-06) var(--cds-spacing-07);
@Output() submitRequested = new EventEmitter<void>();
@Output() closeRequested = new EventEmitter<void>();
- @Output() stepChanged = new EventEmitter<number>();
+ @Output() stepChanged = new EventEmitter<{ current: number }>();
@ContentChildren(TearsheetStepComponent)
stepContents!: QueryList<TearsheetStepComponent>;
return this.stepContents?.toArray()[this.currentStep]?.showRightInfluencer;
}
+ getStepValue<T = any>(index: number): T | null {
+ const wrapper = this.stepContents?.toArray()?.[index];
+ return wrapper?.stepComponent?.formGroup?.value ?? null;
+ }
+
currentStep: number = 0;
lastStep: number = null;
isOpen: boolean = true;
onStepSelect(event: { step: Step; index: number }) {
this.currentStep = event.index;
- this.stepChanged.emit(this.currentStep);
+ this.stepChanged.emit({ current: this.currentStep });
}
closeTearsheet() {
onPrevious() {
if (this.currentStep !== 0) {
this.currentStep = this.currentStep - 1;
- this.stepChanged.emit(this.currentStep);
+ this.stepChanged.emit({ current: this.currentStep });
}
}
formEl?.dispatchEvent(new Event('submit', { bubbles: true }));
if (this.currentStep !== this.lastStep && !this.steps[this.currentStep].invalid) {
this.currentStep = this.currentStep + 1;
- this.stepChanged.emit(this.currentStep);
+ this.stepChanged.emit({ current: this.currentStep });
}
}
}
ngAfterViewInit() {
- if (!this.stepContents?.length) return;
-
- this.stepContents.forEach((wrapper, index) => {
- const form = wrapper.stepComponent?.formGroup;
- if (!form) return;
+ const setup = () => {
+ // keep lastStep in sync with steps input
+ this.lastStep = this.steps.length - 1;
+
+ // clamp currentStep so template lookup never goes out of range
+ if (this.currentStep > this.lastStep) {
+ this.currentStep = this.lastStep;
+ }
+
+ // subscribe to each form statusChanges
+ this.stepContents.forEach((wrapper, index) => {
+ const form = wrapper.stepComponent?.formGroup;
+ if (!form) return;
+
+ form.statusChanges
+ .pipe(takeUntilDestroyed(this.destroyRef))
+ .subscribe(() => this._updateStepInvalid(index, form.invalid));
+ });
+ };
- // initial state
- this._updateStepInvalid(index, form.invalid);
+ setup();
- form.statusChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
- this._updateStepInvalid(index, form.invalid);
- });
- });
+ this.stepContents.changes.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => setup());
}
ngOnDestroy() {
content: string;
selected: boolean;
};
+
+export type StepTwoType = {
+ addedHosts: Array<string>;
+ hostname: string;
+ hostType: string;
+};