from ..security import Scope
from ..services.orchestrator import OrchClient
from ..tools import str_to_bool
-from . import APIDoc, APIRouter, BaseController, Endpoint, EndpointDoc, Param, \
- ReadPermission, RESTController, UIRouter
+from . import APIDoc, APIRouter, BaseController, CreatePermission, \
+ DeletePermission, Endpoint, EndpointDoc, Param, ReadPermission, \
+ RESTController, UIRouter
logger = logging.getLogger(__name__)
NVMeoFClient.pb2.list_connections_req(subsystem=nqn)
)
+ @UIRouter('/nvmeof', Scope.NVME_OF)
+ class NVMeoFTcpUI(BaseController):
+ @Endpoint('GET', '/status')
+ @ReadPermission
+ @EndpointDoc("Display NVMe/TCP service status",
+ responses={200: NVME_SCHEMA})
+ def status(self) -> dict:
+ status: Dict[str, Any] = {'available': True, 'message': None}
+ orch_backend = mgr.get_module_option_ex('orchestrator', 'orchestrator')
+ if orch_backend == 'cephadm':
+ orch = OrchClient.instance()
+ orch_status = orch.status()
+ if not orch_status['available']:
+ return status
+ if not orch.services.list_daemons(daemon_type='nvmeof'):
+ status["available"] = False
+ status["message"] = 'An NVMe/TCP service must be created.'
+ return status
+
+ @Endpoint('POST', "/subsystem/{subsystem_nqn}/host")
+ @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'),
+ })
+ @empty_response
+ @handle_nvmeof_error
+ @CreatePermission
+ def add(self, subsystem_nqn: str, host_nqn: str = ""):
+ response = None
+ all_host_nqns = host_nqn.split(',')
+
+ for nqn in all_host_nqns:
+ response = NVMeoFClient().stub.add_host(
+ NVMeoFClient.pb2.add_host_req(subsystem_nqn=subsystem_nqn, host_nqn=nqn)
+ )
+ if response.status != 0:
+ return response
+ return response
-@UIRouter('/nvmeof', Scope.NVME_OF)
-@APIDoc("NVMe/TCP Management API", "NVMe/TCP")
-class NVMeoFStatus(BaseController):
- @Endpoint()
- @ReadPermission
- @EndpointDoc("Display NVMe/TCP service Status",
- responses={200: NVME_SCHEMA})
- def status(self) -> dict:
- status: Dict[str, Any] = {'available': True, 'message': None}
- orch_backend = mgr.get_module_option_ex('orchestrator', 'orchestrator')
- if orch_backend == 'cephadm':
- orch = OrchClient.instance()
- orch_status = orch.status()
- if not orch_status['available']:
- return status
- if not orch.services.list_daemons(daemon_type='nvmeof'):
- status["available"] = False
- status["message"] = 'Create an NVMe/TCP service to get started.'
- return status
+ @Endpoint(method='DELETE', path="/subsystem/{subsystem_nqn}/host/{host_nqn}")
+ @EndpointDoc("Remove on or more initiator hosts from an NVMeoF subsystem",
+ parameters={
+ "subsystem_nqn": Param(str, "NVMeoF subsystem NQN"),
+ "host_nqn": Param(str, 'Comma separated list of NVMeoF host NQN.'),
+ })
+ @empty_response
+ @handle_nvmeof_error
+ @DeletePermission
+ def remove(self, subsystem_nqn: str, host_nqn: str):
+ response = None
+ to_delete_nqns = host_nqn.split(',')
+
+ for del_nqn in to_delete_nqns:
+ response = NVMeoFClient().stub.remove_host(
+ NVMeoFClient.pb2.remove_host_req(subsystem_nqn=subsystem_nqn, host_nqn=del_nqn)
+ )
+ if response.status != 0:
+ return response
+ logger.info("removed host %s from subsystem %s", del_nqn, subsystem_nqn)
+
+ return response
import { NvmeofListenersListComponent } from './nvmeof-listeners-list/nvmeof-listeners-list.component';
import { NvmeofNamespacesListComponent } from './nvmeof-namespaces-list/nvmeof-namespaces-list.component';
import { NvmeofNamespacesFormComponent } from './nvmeof-namespaces-form/nvmeof-namespaces-form.component';
+import { NvmeofInitiatorsListComponent } from './nvmeof-initiators-list/nvmeof-initiators-list.component';
+import { NvmeofInitiatorsFormComponent } from './nvmeof-initiators-form/nvmeof-initiators-form.component';
@NgModule({
imports: [
NvmeofListenersFormComponent,
NvmeofListenersListComponent,
NvmeofNamespacesListComponent,
- NvmeofNamespacesFormComponent
+ NvmeofNamespacesFormComponent,
+ NvmeofInitiatorsListComponent,
+ NvmeofInitiatorsFormComponent
],
exports: [RbdConfigurationListComponent, RbdConfigurationFormComponent]
})
component: NvmeofSubsystemsFormComponent,
outlet: 'modal'
},
- {
- path: `${URLVerbs.EDIT}/:subsystem_nqn/:max_ns`,
- component: NvmeofSubsystemsFormComponent,
- outlet: 'modal'
- },
// listeners
{
path: `${URLVerbs.CREATE}/:subsystem_nqn/listener`,
- component: NvmeofListenersFormComponent
+ component: NvmeofListenersFormComponent,
+ outlet: 'modal'
},
// namespaces
{
path: `${URLVerbs.EDIT}/:subsystem_nqn/namespace/:nsid`,
component: NvmeofNamespacesFormComponent,
outlet: 'modal'
+ },
+ // initiators
+ {
+ path: `${URLVerbs.ADD}/:subsystem_nqn/initiator`,
+ component: NvmeofInitiatorsFormComponent,
+ outlet: 'modal'
}
]
},
--- /dev/null
+<cd-modal [pageURL]="pageURL">
+ <span class="modal-title"
+ i18n>{{ action | titlecase }} {{ resource | upperFirst }}</span>
+ <ng-container class="modal-content">
+ <form name="initiatorForm"
+ #formDir="ngForm"
+ [formGroup]="initiatorForm"
+ novalidate>
+ <div class="modal-body">
+ <!-- Hosts -->
+ <div class="form-group row">
+ <label class="cd-col-form-label required"
+ i18n>Hosts
+ </label>
+ <div class="cd-col-form-input">
+ <!-- Add host -->
+ <div class="custom-control custom-checkbox"
+ formGroupName="addHost">
+ <input type="checkbox"
+ class="custom-control-input"
+ id="addHostCheck"
+ name="addHostCheck"
+ formControlName="addHostCheck"
+ (change)="setAddHostCheck()"/>
+ <label class="custom-control-label mb-0"
+ for="addHostCheck"
+ i18n>Add host</label>
+ <cd-help-text>
+ <span i18n>Allow specific hosts to run NVMe/TCP commands to the NVMe subsystem.</span>
+ </cd-help-text>
+ <div formArrayName="addedHosts"
+ *ngIf="initiatorForm.get('addHost.addHostCheck').value" >
+ <div *ngFor="let host of addedHosts.controls; let hi = index"
+ class="input-group cd-mb my-1">
+ <input class="cd-form-control"
+ type="text"
+ i18n-placeholder
+ placeholder="Add host nqn"
+ [required]="!initiatorForm.getValue('allowAnyHost')"
+ [formControlName]="hi"/>
+ <button class="btn btn-light"
+ type="button"
+ id="add-button-{{hi}}"
+ [disabled]="initiatorForm.get('addHost.addedHosts').controls[hi].invalid
+ || initiatorForm.get('addHost.addedHosts').errors?.duplicate
+ || initiatorForm.get('addHost.addedHosts').controls.length === 32
+ || (initiatorForm.get('addHost.addedHosts').controls.length !== 1 && initiatorForm.get('addHost.addedHosts').controls.length !== hi+1)"
+ (click)="addHost()">
+ <i class="fa fa-plus"></i>
+ </button>
+ <button class="btn btn-light"
+ type="button"
+ id="delete-button-{{hi}}"
+ [disabled]="addedHosts.controls.length === 1"
+ (click)="removeHost(hi)">
+ <i class="fa fa-trash-o"></i>
+ </button>
+ <ng-container *ngIf="initiatorForm.get('addHost.addedHosts').controls[hi].invalid
+ && (initiatorForm.get('addHost.addedHosts').controls[hi].dirty
+ || initiatorForm.get('addHost.addedHosts').controls[hi].touched)">
+ <span class="invalid-feedback"
+ *ngIf="initiatorForm.get('addHost.addedHosts').controls[hi].errors?.required"
+ i18n>This field is required.</span>
+ <span class="invalid-feedback"
+ *ngIf="initiatorForm.get('addHost.addedHosts').controls[hi].errors?.pattern"
+ i18n>Expected NQN format<br/><<code>nqn.$year-$month.$reverseDomainName:$utf8-string</code>".> or <br/><<code>nqn.2014-08.org.nvmexpress:uuid:$UUID-string</code>".></span>
+ <span class="invalid-feedback"
+ *ngIf="initiatorForm.get('addHost.addedHosts').controls[hi].errors?.maxLength"
+ i18n>An NQN may not be more than 223 bytes in length.</span>
+ </ng-container>
+ </div>
+ <span class="invalid-feedback"
+ *ngIf="initiatorForm.get('addHost.addedHosts').errors?.duplicate"
+ i18n>Duplicate entry detected. Enter a unique value.</span>
+ </div>
+ </div>
+ <!-- Allow any host -->
+ <div class="custom-control custom-checkbox pt-0">
+ <input type="checkbox"
+ class="custom-control-input"
+ id="allowAnyHost"
+ name="allowAnyHost"
+ formControlName="allowAnyHost"/>
+ <label class="custom-control-label"
+ for="allowAnyHost"
+ i18n>Allow any host</label>
+ <cd-alert-panel *ngIf="initiatorForm.getValue('allowAnyHost')"
+ [showTitle]="false"
+ type="warning">Allowing any host to connect to the NVMe/TCP gateway may pose security risks.
+ </cd-alert-panel>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div class="modal-footer">
+ <div class="text-right">
+ <cd-form-button-panel (submitActionEvent)="onSubmit()"
+ [form]="initiatorForm"
+ [submitText]="(action | titlecase) + ' ' + (resource | upperFirst)"></cd-form-button-panel>
+ </div>
+ </div>
+ </form>
+ </ng-container>
+</cd-modal>
--- /dev/null
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+import { RouterTestingModule } from '@angular/router/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { ToastrModule } from 'ngx-toastr';
+
+import { NgbActiveModal, NgbTypeaheadModule } from '@ng-bootstrap/ng-bootstrap';
+
+import { SharedModule } from '~/app/shared/shared.module';
+import { NvmeofService } from '~/app/shared/api/nvmeof.service';
+
+import { NvmeofInitiatorsFormComponent } from './nvmeof-initiators-form.component';
+
+describe('NvmeofInitiatorsFormComponent', () => {
+ let component: NvmeofInitiatorsFormComponent;
+ let fixture: ComponentFixture<NvmeofInitiatorsFormComponent>;
+ let nvmeofService: NvmeofService;
+ const mockTimestamp = 1720693470789;
+
+ beforeEach(async () => {
+ spyOn(Date, 'now').and.returnValue(mockTimestamp);
+ await TestBed.configureTestingModule({
+ declarations: [NvmeofInitiatorsFormComponent],
+ providers: [NgbActiveModal],
+ imports: [
+ HttpClientTestingModule,
+ NgbTypeaheadModule,
+ ReactiveFormsModule,
+ RouterTestingModule,
+ SharedModule,
+ ToastrModule.forRoot()
+ ]
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(NvmeofInitiatorsFormComponent);
+ component = fixture.componentInstance;
+ component.ngOnInit();
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ describe('should test form', () => {
+ beforeEach(() => {
+ nvmeofService = TestBed.inject(NvmeofService);
+ spyOn(nvmeofService, 'addInitiators').and.stub();
+ });
+
+ it('should be creating request correctly', () => {
+ const subsystemNQN = 'nqn.2001-07.com.ceph:' + mockTimestamp;
+ component.subsystemNQN = subsystemNQN;
+ component.onSubmit();
+ expect(nvmeofService.addInitiators).toHaveBeenCalledWith(subsystemNQN, {
+ host_nqn: ''
+ });
+ });
+ });
+});
--- /dev/null
+import { Component, OnInit } from '@angular/core';
+import { UntypedFormArray, UntypedFormControl, Validators } from '@angular/forms';
+
+import { CdFormBuilder } from '~/app/shared/forms/cd-form-builder';
+import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { CdValidators } from '~/app/shared/forms/cd-validators';
+import { Permission } from '~/app/shared/models/permissions';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
+import { FinishedTask } from '~/app/shared/models/finished-task';
+import { ActivatedRoute, Router } from '@angular/router';
+import { NvmeofService } from '~/app/shared/api/nvmeof.service';
+
+@Component({
+ selector: 'cd-nvmeof-initiators-form',
+ templateUrl: './nvmeof-initiators-form.component.html',
+ styleUrls: ['./nvmeof-initiators-form.component.scss']
+})
+export class NvmeofInitiatorsFormComponent implements OnInit {
+ permission: Permission;
+ initiatorForm: CdFormGroup;
+ action: string;
+ resource: string;
+ pageURL: string;
+ remove: boolean = false;
+ subsystemNQN: string;
+ removeHosts: { name: string; value: boolean; id: number }[] = [];
+
+ constructor(
+ private authStorageService: AuthStorageService,
+ public actionLabels: ActionLabelsI18n,
+ private nvmeofService: NvmeofService,
+ private taskWrapperService: TaskWrapperService,
+ private router: Router,
+ private route: ActivatedRoute,
+ private formBuilder: CdFormBuilder
+ ) {
+ this.permission = this.authStorageService.getPermissions().nvmeof;
+ this.resource = $localize`Initiator`;
+ this.pageURL = 'block/nvmeof/subsystems';
+ }
+
+ NQN_REGEX = /^nqn\.(19|20)\d\d-(0[1-9]|1[0-2])\.\D{2,3}(\.[A-Za-z0-9-]+)+(:[A-Za-z0-9-\.]+(:[A-Za-z0-9-\.]+)*)$/;
+ NQN_REGEX_UUID = /^nqn\.2014-08\.org\.nvmexpress:uuid:[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
+ ALLOW_ALL_HOST = '*';
+
+ customNQNValidator = CdValidators.custom(
+ 'pattern',
+ (nqnInput: string) =>
+ !!nqnInput && !(this.NQN_REGEX.test(nqnInput) || this.NQN_REGEX_UUID.test(nqnInput))
+ );
+
+ ngOnInit() {
+ this.createForm();
+ this.action = this.actionLabels.ADD;
+ this.route.params.subscribe((params: { subsystem_nqn: string }) => {
+ this.subsystemNQN = params.subsystem_nqn;
+ });
+ }
+
+ createForm() {
+ this.initiatorForm = new CdFormGroup({
+ allowAnyHost: new UntypedFormControl(false),
+ addHost: new CdFormGroup({
+ addHostCheck: new UntypedFormControl(false),
+ addedHosts: this.formBuilder.array(
+ [],
+ [
+ CdValidators.custom(
+ 'duplicate',
+ (hosts: string[]) => !!hosts.length && new Set(hosts)?.size !== hosts.length
+ )
+ ]
+ )
+ })
+ });
+ }
+
+ get addedHosts(): UntypedFormArray {
+ return this.initiatorForm.get('addHost.addedHosts') as UntypedFormArray;
+ }
+
+ addHost() {
+ let newHostFormGroup;
+ newHostFormGroup = this.formBuilder.control('', [this.customNQNValidator, Validators.required]);
+ this.addedHosts.push(newHostFormGroup);
+ }
+
+ removeHost(index: number) {
+ this.addedHosts.removeAt(index);
+ }
+
+ setAddHostCheck() {
+ const addHostCheck = this.initiatorForm.get('addHost.addHostCheck').value;
+ if (!addHostCheck) {
+ while (this.addedHosts.length !== 0) {
+ this.addedHosts.removeAt(0);
+ }
+ } else {
+ this.addHost();
+ }
+ }
+
+ onSubmit() {
+ const component = this;
+ const allowAnyHost: boolean = this.initiatorForm.getValue('allowAnyHost');
+ const hosts: string[] = this.addedHosts.value;
+ let taskUrl = `nvmeof/initiator/${URLVerbs.ADD}`;
+
+ const request = {
+ host_nqn: hosts.join(',')
+ };
+
+ if (allowAnyHost) {
+ hosts.push('*');
+ request['host_nqn'] = hosts.join(',');
+ }
+ this.taskWrapperService
+ .wrapTaskAroundCall({
+ task: new FinishedTask(taskUrl, {
+ nqn: this.subsystemNQN
+ }),
+ call: this.nvmeofService.addInitiators(this.subsystemNQN, request)
+ })
+ .subscribe({
+ error() {
+ component.initiatorForm.setErrors({ cdSubmitButton: true });
+ },
+ complete: () => {
+ this.router.navigate([this.pageURL, { outlets: { modal: null } }]);
+ }
+ });
+ }
+}
--- /dev/null
+<legend>
+ <cd-help-text>
+ The client that connects to the NVMe-oF target to access NVMe storage.
+ </cd-help-text>
+</legend>
+<cd-table [data]="initiators"
+ columnMode="flex"
+ (fetchData)="listInitiators()"
+ [columns]="initiatorColumns"
+ selectionType="multiClick"
+ (updateSelection)="updateSelection($event)">
+ <div class="table-actions btn-toolbar">
+ <cd-table-actions [permission]="permission"
+ [selection]="selection"
+ class="btn-group"
+ [tableActions]="tableActions">
+ </cd-table-actions>
+ </div>
+</cd-table>
+<ng-template #hostTpl
+ let-value="value">
+ <span *ngIf="value === '*'"
+ i18n
+ class="font-monospace">Any host allowed (*)</span>
+ <span *ngIf="value !== '*'"
+ class="font-monospace">{{value}}</span>
+</ng-template>
--- /dev/null
+import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
+import { HttpClientModule } from '@angular/common/http';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { of } from 'rxjs';
+
+import { SharedModule } from '~/app/shared/shared.module';
+import { NvmeofService } from '~/app/shared/api/nvmeof.service';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { ModalService } from '~/app/shared/services/modal.service';
+import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
+
+import { NvmeofInitiatorsListComponent } from './nvmeof-initiators-list.component';
+
+const mockInitiators = [
+ {
+ nqn: '*'
+ }
+];
+
+class MockNvmeOfService {
+ getInitiators() {
+ return of(mockInitiators);
+ }
+}
+
+class MockAuthStorageService {
+ getPermissions() {
+ return { nvmeof: {} };
+ }
+}
+
+class MockModalService {}
+
+class MockTaskWrapperService {}
+
+describe('NvmeofInitiatorsListComponent', () => {
+ let component: NvmeofInitiatorsListComponent;
+ let fixture: ComponentFixture<NvmeofInitiatorsListComponent>;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ declarations: [NvmeofInitiatorsListComponent],
+ imports: [HttpClientModule, RouterTestingModule, SharedModule],
+ providers: [
+ { provide: NvmeofService, useClass: MockNvmeOfService },
+ { provide: AuthStorageService, useClass: MockAuthStorageService },
+ { provide: ModalService, useClass: MockModalService },
+ { provide: TaskWrapperService, useClass: MockTaskWrapperService }
+ ]
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(NvmeofInitiatorsListComponent);
+ component = fixture.componentInstance;
+ component.ngOnInit();
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should retrieve initiators', fakeAsync(() => {
+ component.listInitiators();
+ tick();
+ expect(component.initiators).toEqual(mockInitiators);
+ }));
+});
--- /dev/null
+import { Component, Input, OnChanges, OnInit, TemplateRef, ViewChild } from '@angular/core';
+import { Router } from '@angular/router';
+import { NvmeofService } from '~/app/shared/api/nvmeof.service';
+import { CriticalConfirmationModalComponent } from '~/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component';
+import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants';
+import { Icons } from '~/app/shared/enum/icons.enum';
+import { CdTableAction } from '~/app/shared/models/cd-table-action';
+import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
+import { FinishedTask } from '~/app/shared/models/finished-task';
+import { NvmeofSubsystemInitiator } from '~/app/shared/models/nvmeof';
+import { Permission } from '~/app/shared/models/permissions';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { ModalService } from '~/app/shared/services/modal.service';
+import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
+
+const BASE_URL = 'block/nvmeof/subsystems';
+
+@Component({
+ selector: 'cd-nvmeof-initiators-list',
+ templateUrl: './nvmeof-initiators-list.component.html',
+ styleUrls: ['./nvmeof-initiators-list.component.scss']
+})
+export class NvmeofInitiatorsListComponent implements OnInit, OnChanges {
+ @Input()
+ subsystemNQN: string;
+
+ @ViewChild('hostTpl', { static: true })
+ hostTpl: TemplateRef<any>;
+
+ initiatorColumns: any;
+ tableActions: CdTableAction[];
+ selection = new CdTableSelection();
+ permission: Permission;
+ initiators: NvmeofSubsystemInitiator[] = [];
+
+ constructor(
+ public actionLabels: ActionLabelsI18n,
+ private authStorageService: AuthStorageService,
+ private nvmeofService: NvmeofService,
+ private modalService: ModalService,
+ private router: Router,
+ private taskWrapper: TaskWrapperService
+ ) {
+ this.permission = this.authStorageService.getPermissions().nvmeof;
+ }
+
+ ngOnInit() {
+ this.initiatorColumns = [
+ {
+ name: $localize`Initiator`,
+ prop: 'nqn',
+ cellTemplate: this.hostTpl
+ }
+ ];
+ this.tableActions = [
+ {
+ name: this.actionLabels.ADD,
+ permission: 'create',
+ icon: Icons.add,
+ click: () =>
+ this.router.navigate([
+ BASE_URL,
+ { outlets: { modal: [URLVerbs.ADD, this.subsystemNQN, 'initiator'] } }
+ ]),
+ canBePrimary: (selection: CdTableSelection) => !selection.hasSelection
+ },
+ {
+ name: this.actionLabels.REMOVE,
+ permission: 'delete',
+ icon: Icons.destroy,
+ click: () => this.removeInitiatorModal(),
+ disable: () => !this.selection.hasSelection,
+ canBePrimary: (selection: CdTableSelection) => selection.hasSelection
+ }
+ ];
+ }
+
+ getAllowAllHostIndex() {
+ return this.selection.selected.findIndex((selected) => selected.nqn === '*');
+ }
+
+ ngOnChanges() {
+ this.listInitiators();
+ }
+
+ updateSelection(selection: CdTableSelection) {
+ this.selection = selection;
+ }
+
+ listInitiators() {
+ this.nvmeofService
+ .getInitiators(this.subsystemNQN)
+ .subscribe((initiators: NvmeofSubsystemInitiator[]) => {
+ this.initiators = initiators;
+ });
+ }
+
+ getSelectedNQNs() {
+ return this.selection.selected.map((selected) => selected.nqn);
+ }
+
+ removeInitiatorModal() {
+ const hostNQNs = this.getSelectedNQNs();
+ const allowAllHostIndex = this.getAllowAllHostIndex();
+ const host_nqn = hostNQNs.join(',');
+ let itemNames = hostNQNs;
+ if (allowAllHostIndex !== -1) {
+ hostNQNs.splice(allowAllHostIndex, 1);
+ itemNames = [...hostNQNs, $localize`Allow any host(*)`];
+ }
+ this.modalService.show(CriticalConfirmationModalComponent, {
+ itemDescription: 'Initiator',
+ itemNames,
+ actionDescription: 'remove',
+ submitActionObservable: () =>
+ this.taskWrapper.wrapTaskAroundCall({
+ task: new FinishedTask('nvmeof/initiator/remove', {
+ nqn: this.subsystemNQN,
+ plural: itemNames.length > 1
+ }),
+ call: this.nvmeofService.removeInitiators(this.subsystemNQN, { host_nqn })
+ })
+ });
+ }
+}
import { NgbActiveModal, NgbTypeaheadModule } from '@ng-bootstrap/ng-bootstrap';
import { SharedModule } from '~/app/shared/shared.module';
-import { NvmeofService } from '~/app/shared/api/nvmeof.service';
import { NvmeofListenersFormComponent } from './nvmeof-listeners-form.component';
describe('NvmeofListenersFormComponent', () => {
let component: NvmeofListenersFormComponent;
let fixture: ComponentFixture<NvmeofListenersFormComponent>;
- let nvmeofService: NvmeofService;
beforeEach(async () => {
await TestBed.configureTestingModule({
it('should create', () => {
expect(component).toBeTruthy();
});
-
- describe('should test form', () => {
- beforeEach(() => {
- nvmeofService = TestBed.inject(NvmeofService);
- spyOn(nvmeofService, 'createListener').and.stub();
- });
- });
});
expect(component).toBeTruthy();
});
- it('should retrieve subsystems', fakeAsync(() => {
+ it('should retrieve listeners', fakeAsync(() => {
component.listListeners();
tick();
expect(component.listeners).toEqual(mockListeners);
</span>
<span class="invalid-feedback"
*ngIf="edit && invalidSizeError">
- <ng-container i18n>Enter a value above than previous.</ng-container>
+ <ng-container i18n>Enter a value above than previous. A block device image can be expanded but not reduced.</ng-container>
</span>
</div>
</div>
<cd-nvmeof-namespaces-list [subsystemNQN]="subsystemNQN"></cd-nvmeof-namespaces-list>
</ng-template>
</ng-container>
+ <ng-container ngbNavItem="initiators">
+ <a ngbNavLink
+ i18n>Initiators</a>
+ <ng-template ngbNavContent>
+ <cd-nvmeof-initiators-list [subsystemNQN]="subsystemNQN"></cd-nvmeof-initiators-list>
+ </ng-template>
+ </ng-container>
</nav>
<div [ngbNavOutlet]="nav"></div>
if (this.selection) {
this.selectedItem = this.selection;
this.subsystemNQN = this.selectedItem.nqn;
+
this.data = {};
this.data[$localize`Serial Number`] = this.selectedItem.serial_number;
this.data[$localize`Model Number`] = this.selectedItem.model_number;
i18n>Expected NQN format<br/><<code>nqn.$year-$month.$reverseDomainName:$utf8-string</code>".> or <br/><<code>nqn.2014-08.org.nvmexpress:uuid:$UUID-string</code>".></span>
<span class="invalid-feedback"
*ngIf="subsystemForm.showError('nqn', formDir, 'maxLength')"
- i18n>An NQN should not be more than 223 bytes in length.</span>
+ i18n>An NQN may not be more than 223 bytes in length.</span>
</div>
</div>
<!-- Maximum Namespaces -->
export class NvmeofSubsystemsFormComponent implements OnInit {
permission: Permission;
subsystemForm: CdFormGroup;
-
action: string;
resource: string;
pageURL: string;
validators: [
this.customNQNValidator,
Validators.required,
- Validators.pattern(this.NQN_REGEX),
+ this.customNQNValidator,
CdValidators.custom(
'maxLength',
(nqnInput: string) => new TextEncoder().encode(nqnInput).length > 223
onSubmit() {
const component = this;
const nqn: string = this.subsystemForm.getValue('nqn');
- let max_namespaces: number = Number(this.subsystemForm.getValue('max_namespaces'));
+ const max_namespaces: number = Number(this.subsystemForm.getValue('max_namespaces'));
+ let taskUrl = `nvmeof/subsystem/${URLVerbs.CREATE}`;
const request = {
nqn,
if (!max_namespaces) {
delete request.max_namespaces;
}
-
- let taskUrl = `nvmeof/subsystem/${URLVerbs.CREATE}`;
-
this.taskWrapperService
.wrapTaskAroundCall({
task: new FinishedTask(taskUrl, {
click: () => this.router.navigate([BASE_URL, { outlets: { modal: [URLVerbs.CREATE] } }]),
canBePrimary: (selection: CdTableSelection) => !selection.hasSelection
},
+ {
+ name: this.actionLabels.EDIT,
+ permission: 'update',
+ icon: Icons.edit,
+ click: () =>
+ this.router.navigate([
+ BASE_URL,
+ {
+ outlets: {
+ modal: [
+ URLVerbs.EDIT,
+ this.selection.first().nqn,
+ this.selection.first().max_namespaces
+ ]
+ }
+ }
+ ])
+ },
{
name: this.actionLabels.DELETE,
permission: 'delete',
);
expect(req.request.method).toBe('GET');
});
-
- it('should call updateInitiators', () => {
- service.updateInitiators('nqn.2001-07.com.ceph:1721041732363', '*').subscribe();
- const req = httpTesting.expectOne(
- 'api/nvmeof/subsystem/nqn.2001-07.com.ceph:1721041732363/host/*'
- );
- expect(req.request.method).toBe('PUT');
- });
});
rbd_image_size: number;
}
-const BASE_URL = 'api/nvmeof';
+export interface InitiatorRequest {
+ host_nqn: string;
+}
+
+const API_PATH = 'api/nvmeof';
+const UI_API_PATH = 'ui-api/nvmeof';
@Injectable({
providedIn: 'root'
// Gateways
listGateways() {
- return this.http.get(`${BASE_URL}/gateway`);
+ return this.http.get(`${API_PATH}/gateway`);
}
// Subsystems
listSubsystems() {
- return this.http.get(`${BASE_URL}/subsystem`);
+ return this.http.get(`${API_PATH}/subsystem`);
}
getSubsystem(subsystemNQN: string) {
- return this.http.get(`${BASE_URL}/subsystem/${subsystemNQN}`);
+ return this.http.get(`${API_PATH}/subsystem/${subsystemNQN}`);
}
createSubsystem(request: { nqn: string; max_namespaces?: number; enable_ha: boolean }) {
- return this.http.post(`${BASE_URL}/subsystem`, request, { observe: 'response' });
+ return this.http.post(`${API_PATH}/subsystem`, request, { observe: 'response' });
}
deleteSubsystem(subsystemNQN: string) {
- return this.http.delete(`${BASE_URL}/subsystem/${subsystemNQN}`, {
+ return this.http.delete(`${API_PATH}/subsystem/${subsystemNQN}`, {
observe: 'response'
});
}
// Initiators
getInitiators(subsystemNQN: string) {
- return this.http.get(`${BASE_URL}/subsystem/${subsystemNQN}/host`);
+ return this.http.get(`${API_PATH}/subsystem/${subsystemNQN}/host`);
}
- updateInitiators(subsystemNQN: string, hostNQN: string) {
- return this.http.put(
- `${BASE_URL}/subsystem/${subsystemNQN}/host/${hostNQN}`,
- {},
- {
- observe: 'response'
- }
- );
+ addInitiators(subsystemNQN: string, request: InitiatorRequest) {
+ return this.http.post(`${UI_API_PATH}/subsystem/${subsystemNQN}/host`, request, {
+ observe: 'response'
+ });
+ }
+
+ removeInitiators(subsystemNQN: string, request: InitiatorRequest) {
+ return this.http.delete(`${UI_API_PATH}/subsystem/${subsystemNQN}/host/${request.host_nqn}`, {
+ observe: 'response'
+ });
}
// Listeners
listListeners(subsystemNQN: string) {
- return this.http.get(`${BASE_URL}/subsystem/${subsystemNQN}/listener`);
+ return this.http.get(`${API_PATH}/subsystem/${subsystemNQN}/listener`);
}
createListener(subsystemNQN: string, request: ListenerRequest) {
- return this.http.post(`${BASE_URL}/subsystem/${subsystemNQN}/listener`, request, {
+ return this.http.post(`${API_PATH}/subsystem/${subsystemNQN}/listener`, request, {
observe: 'response'
});
}
deleteListener(subsystemNQN: string, hostName: string, traddr: string, trsvcid: string) {
return this.http.delete(
- `${BASE_URL}/subsystem/${subsystemNQN}/listener/${hostName}/${traddr}`,
+ `${API_PATH}/subsystem/${subsystemNQN}/listener/${hostName}/${traddr}`,
{
observe: 'response',
params: {
// Namespaces
listNamespaces(subsystemNQN: string) {
- return this.http.get(`${BASE_URL}/subsystem/${subsystemNQN}/namespace`);
+ return this.http.get(`${API_PATH}/subsystem/${subsystemNQN}/namespace`);
}
getNamespace(subsystemNQN: string, nsid: string) {
- return this.http.get(`${BASE_URL}/subsystem/${subsystemNQN}/namespace/${nsid}`);
+ return this.http.get(`${API_PATH}/subsystem/${subsystemNQN}/namespace/${nsid}`);
}
createNamespace(subsystemNQN: string, request: NamespaceCreateRequest) {
- return this.http.post(`${BASE_URL}/subsystem/${subsystemNQN}/namespace`, request, {
+ return this.http.post(`${API_PATH}/subsystem/${subsystemNQN}/namespace`, request, {
observe: 'response'
});
}
updateNamespace(subsystemNQN: string, nsid: string, request: NamespaceEditRequest) {
- return this.http.patch(`${BASE_URL}/subsystem/${subsystemNQN}/namespace/${nsid}`, request, {
+ return this.http.patch(`${API_PATH}/subsystem/${subsystemNQN}/namespace/${nsid}`, request, {
observe: 'response'
});
}
deleteNamespace(subsystemNQN: string, nsid: string) {
- return this.http.delete(`${BASE_URL}/subsystem/${subsystemNQN}/namespace/${nsid}`, {
+ return this.http.delete(`${API_PATH}/subsystem/${subsystemNQN}/namespace/${nsid}`, {
observe: 'response'
});
}
max_namespaces: number;
}
+export interface NvmeofSubsystemInitiator {
+ nqn: string;
+}
+
export interface NvmeofListener {
host_name: string;
trtype: string;
id?: number; // for table
}
-export interface NvmeofSubsystemHost {
- nqn: string;
-}
-
export interface NvmeofSubsystemNamespace {
nsid: number;
uuid: string;
'nvmeof/listener/delete': this.newTaskMessage(this.commonOperations.delete, (metadata) =>
this.nvmeofListener(metadata)
),
- 'nvmeof/subsystem/edit': this.newTaskMessage(this.commonOperations.update, (metadata) =>
- this.nvmeofSubsystem(metadata)
- ),
'nvmeof/namespace/create': this.newTaskMessage(this.commonOperations.create, (metadata) =>
this.nvmeofNamespace(metadata)
),
'nvmeof/namespace/delete': this.newTaskMessage(this.commonOperations.delete, (metadata) =>
this.nvmeofNamespace(metadata)
),
+ 'nvmeof/initiator/add': this.newTaskMessage(this.commonOperations.add, (metadata) =>
+ this.nvmeofInitiator(metadata)
+ ),
+ 'nvmeof/initiator/remove': this.newTaskMessage(this.commonOperations.remove, (metadata) =>
+ this.nvmeofInitiator(metadata)
+ ),
// nfs
'nfs/create': this.newTaskMessage(this.commonOperations.create, (metadata) =>
this.nfs(metadata)
}
nvmeofListener(metadata: any) {
- return $localize`listener '${metadata.host_name} on subsystem ${metadata.nqn}`;
+ return $localize`listener '${metadata.host_name} for subsystem ${metadata.nqn}`;
}
nvmeofNamespace(metadata: any) {
return $localize`namespace for subsystem '${metadata.nqn}'`;
}
+ nvmeofInitiator(metadata: any) {
+ return $localize`initiator${metadata?.plural ? 's' : ''} for subsystem ${metadata.nqn}`;
+ }
+
nfs(metadata: any) {
return $localize`NFS '${metadata.cluster_id}\:${
metadata.export_id ? metadata.export_id : metadata.path