# -*- coding: utf-8 -*-
-from typing import Optional
+import logging
+from typing import Any, Dict, Optional
+from .. import mgr
from ..model import nvmeof as model
from ..security import Scope
+from ..services.orchestrator import OrchClient
from ..tools import str_to_bool
-from . import APIDoc, APIRouter, Endpoint, EndpointDoc, Param, ReadPermission, RESTController
+from . import APIDoc, APIRouter, BaseController, Endpoint, EndpointDoc, Param, \
+ ReadPermission, RESTController, UIRouter
+
+logger = logging.getLogger(__name__)
+
+NVME_SCHEMA = {
+ "available": (bool, "Is NVMe/TCP available?"),
+ "message": (str, "Descriptions")
+}
try:
from ..services.nvmeof_client import NVMeoFClient, empty_response, \
handle_nvmeof_error, map_collection, map_model
-except ImportError:
- pass
+except ImportError as e:
+ logger.error("Failed to import NVMeoFClient and related components: %s", e)
else:
@APIRouter("/nvmeof/gateway", Scope.NVME_OF)
@APIDoc("NVMe-oF Gateway Management API", "NVMe-oF Gateway")
return NVMeoFClient().stub.list_connections(
NVMeoFClient.pb2.list_connections_req(subsystem=nqn)
)
+
+
+@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
component: ServiceFormComponent,
outlet: 'modal'
},
+ {
+ path: `${URLVerbs.CREATE}/:type`,
+ component: ServiceFormComponent,
+ outlet: 'modal'
+ },
{
path: `${URLVerbs.EDIT}/:type/:name`,
component: ServiceFormComponent,
import { RbdTrashMoveModalComponent } from './rbd-trash-move-modal/rbd-trash-move-modal.component';
import { RbdTrashPurgeModalComponent } from './rbd-trash-purge-modal/rbd-trash-purge-modal.component';
import { RbdTrashRestoreModalComponent } from './rbd-trash-restore-modal/rbd-trash-restore-modal.component';
+import { NvmeofGatewayComponent } from './nvmeof-gateway/nvmeof-gateway.component';
@NgModule({
imports: [
RbdConfigurationListComponent,
RbdConfigurationFormComponent,
RbdTabsComponent,
- RbdPerformanceComponent
+ RbdPerformanceComponent,
+ NvmeofGatewayComponent
],
exports: [RbdConfigurationListComponent, RbdConfigurationFormComponent]
})
]
}
]
+ },
+ // NVMe/TCP
+ {
+ path: 'nvmeof',
+ canActivate: [ModuleStatusGuardService],
+ data: {
+ breadcrumbs: true,
+ text: 'NVMe/TCP',
+ path: 'nvmeof',
+ disableSplit: true,
+ moduleStatusGuardConfig: {
+ uiApiPath: 'nvmeof',
+ redirectTo: 'error',
+ header: $localize`NVMe/TCP Gateway not configured`,
+ button_name: $localize`Configure NVMe/TCP`,
+ button_route: ['/services', { outlets: { modal: ['create', 'nvmeof'] } }],
+ uiConfig: false
+ }
+ },
+ children: [
+ { path: '', redirectTo: 'gateways', pathMatch: 'full' },
+ { path: 'gateways', component: NvmeofGatewayComponent, data: { breadcrumbs: 'Gateways' } }
+ ]
}
];
--- /dev/null
+<ul class="nav nav-tabs">
+ <li class="nav-item">
+ <a class="nav-link"
+ routerLink="/block/nvmeof/gateways"
+ routerLinkActive="active"
+ ariaCurrentWhenActive="page"
+ i18n>Gateways</a>
+ </li>
+</ul>
+
+<legend i18n>
+ Gateways
+ <cd-help-text>
+ The NVMe-oF gateway integrates Ceph with the NVMe over TCP (NVMe/TCP) protocol to provide an NVMe/TCP target that exports RADOS Block Device (RBD) images.
+ </cd-help-text>
+</legend>
+<div>
+ <cd-table [data]="gateways"
+ (fetchData)="getGateways()"
+ [columns]="gatewayColumns">
+ </cd-table>
+</div>
--- /dev/null
+import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
+import { of } from 'rxjs';
+import { NvmeofGatewayComponent } from './nvmeof-gateway.component';
+import { NvmeofService } from '../../../shared/api/nvmeof.service';
+import { HttpClientModule } from '@angular/common/http';
+import { SharedModule } from '~/app/shared/shared.module';
+
+const mockGateways = [
+ {
+ cli_version: '',
+ version: '1.2.5',
+ name: 'client.nvmeof.rbd.ceph-node-01.jnmnwa',
+ group: '',
+ addr: '192.168.100.101',
+ port: '5500',
+ load_balancing_group: 1,
+ spdk_version: '24.01'
+ }
+];
+
+class MockNvmeOfService {
+ listGateways() {
+ return of(mockGateways);
+ }
+}
+
+describe('NvmeofGatewayComponent', () => {
+ let component: NvmeofGatewayComponent;
+ let fixture: ComponentFixture<NvmeofGatewayComponent>;
+
+ beforeEach(fakeAsync(() => {
+ TestBed.configureTestingModule({
+ declarations: [NvmeofGatewayComponent],
+ imports: [HttpClientModule, SharedModule],
+ providers: [{ provide: NvmeofService, useClass: MockNvmeOfService }]
+ }).compileComponents();
+ }));
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(NvmeofGatewayComponent);
+ component = fixture.componentInstance;
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should retrieve gateways', fakeAsync(() => {
+ component.getGateways();
+ tick();
+ expect(component.gateways).toEqual(mockGateways);
+ }));
+});
--- /dev/null
+import { Component, OnInit } from '@angular/core';
+
+import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
+import { ListWithDetails } from '~/app/shared/classes/list-with-details.class';
+import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
+import { NvmeofGateway } from '~/app/shared/models/nvmeof';
+
+import { NvmeofService } from '../../../shared/api/nvmeof.service';
+
+@Component({
+ selector: 'cd-nvmeof-gateway',
+ templateUrl: './nvmeof-gateway.component.html',
+ styleUrls: ['./nvmeof-gateway.component.scss']
+})
+export class NvmeofGatewayComponent extends ListWithDetails implements OnInit {
+ gateways: NvmeofGateway[] = [];
+ gatewayColumns: any;
+ selection = new CdTableSelection();
+
+ constructor(private nvmeofService: NvmeofService, public actionLabels: ActionLabelsI18n) {
+ super();
+ }
+
+ ngOnInit() {
+ this.gatewayColumns = [
+ {
+ name: $localize`Name`,
+ prop: 'name'
+ },
+ {
+ name: $localize`Address`,
+ prop: 'addr'
+ },
+ {
+ name: $localize`Port`,
+ prop: 'port'
+ }
+ ];
+ }
+
+ getGateways() {
+ this.nvmeofService.listGateways().subscribe((gateways: NvmeofGateway[] | NvmeofGateway) => {
+ if (Array.isArray(gateways)) this.gateways = gateways;
+ else this.gateways = [gateways];
+ });
+ }
+}
<select id="placement"
class="form-select"
formControlName="placement"
- (change)="onPlacementChange($event.target.value)">
+ (change)="onServiceTypeChange($event.target.value)">
<option i18n
value="hosts">Hosts</option>
<option i18n
});
}
- ngOnInit(): void {
- this.action = this.actionLabels.CREATE;
+ resolveRoute() {
if (this.router.url.includes('services/(modal:create')) {
this.pageURL = 'services';
+ this.route.params.subscribe((params: { type: string }) => {
+ if (params?.type) {
+ this.serviceType = params.type;
+ this.serviceForm.get('service_type').setValue(this.serviceType);
+ }
+ });
} else if (this.router.url.includes('services/(modal:edit')) {
this.editing = true;
this.pageURL = 'services';
this.serviceType = params.type;
});
}
+ }
+
+ ngOnInit(): void {
+ this.action = this.actionLabels.CREATE;
+ this.resolveRoute();
this.cephServiceService
.list(new HttpParams({ fromObject: { limit: -1, offset: 0 } }))
this.poolService.getList().subscribe((resp: Pool[]) => {
this.pools = resp;
this.rbdPools = this.pools.filter(this.rbdService.isRBDPool);
+ if (!this.editing && this.serviceType) {
+ this.onServiceTypeChange(this.serviceType);
+ }
});
if (this.editing) {
case 'smb':
this.serviceForm.get('count').setValue(1);
break;
+ default:
+ this.serviceForm.get('count').setValue(null);
}
}
}
setNvmeofServiceId(): void {
- const defaultRbdPool: string = this.rbdPools.find((p: Pool) => p.pool_name === 'rbd')
+ const defaultRbdPool: string = this.rbdPools?.find((p: Pool) => p.pool_name === 'rbd')
?.pool_name;
if (defaultRbdPool) {
this.serviceForm.get('pool').setValue(defaultRbdPool);
tick();
expect(component.crumbs).toEqual([
{ path: null, text: 'Cluster' },
- { path: '/hosts', text: 'Hosts' }
+ { path: '/hosts', text: 'Hosts', disableSplit: false }
]);
}));
});
tick();
expect(component.crumbs).toEqual([
- { path: null, text: 'Block' },
- { path: '/block/rbd', text: 'Images' },
- { path: '/block/rbd/add', text: 'Add' }
+ { path: null, text: 'Block', disableSplit: false },
+ { path: '/block/rbd', text: 'Images', disableSplit: false },
+ { path: '/block/rbd/add', text: 'Add', disableSplit: false }
]);
}));
const result: IBreadcrumb[] = [];
breadcrumbs.forEach((element) => {
const split = element.text.split('/');
- if (split.length > 1) {
+ if (!element.disableSplit && split.length > 1) {
element.text = split[split.length - 1];
for (let i = 0; i < split.length - 1; i++) {
result.push({ text: split[i], path: null });
<a i18n
routerLink="/block/iscsi">iSCSI</a>
</li>
+
+ <li routerLinkActive="active"
+ class="tc_submenuitem">
+ <a i18n
+ routerLink="/block/nvmeof">NVMe/TCP</a>
+ </li>
</ul>
</li>
--- /dev/null
+import { TestBed } from '@angular/core/testing';
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { NvmeofService } from '../../shared/api/nvmeof.service';
+
+describe('NvmeofService', () => {
+ let service: NvmeofService;
+ let httpTesting: HttpTestingController;
+
+ configureTestBed({
+ providers: [NvmeofService],
+ imports: [HttpClientTestingModule]
+ });
+
+ beforeEach(() => {
+ service = TestBed.inject(NvmeofService);
+ httpTesting = TestBed.inject(HttpTestingController);
+ });
+
+ afterEach(() => {
+ httpTesting.verify();
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ it('should call listGateways', () => {
+ service.listGateways().subscribe();
+ const req = httpTesting.expectOne('api/nvmeof/gateway');
+ expect(req.request.method).toBe('GET');
+ });
+});
--- /dev/null
+import { HttpClient } from '@angular/common/http';
+import { Injectable } from '@angular/core';
+
+const BASE_URL = 'api/nvmeof';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class NvmeofService {
+ constructor(private http: HttpClient) {}
+
+ listGateways() {
+ return this.http.get(`${BASE_URL}/gateway`);
+ }
+}
): Observable<IBreadcrumb[]> | Promise<IBreadcrumb[]> | IBreadcrumb[] {
const data = route.routeConfig.data;
const path = data.path === null ? null : this.getFullPath(route);
+ const disableSplit = data.disableSplit || false;
const text =
typeof data.breadcrumbs === 'string'
? data.breadcrumbs
: data.breadcrumbs.text || data.text || path;
- const crumbs: IBreadcrumb[] = [{ text: text, path: path }];
+ const crumbs: IBreadcrumb[] = [{ text: text, path: path, disableSplit: disableSplit }];
return of(crumbs);
}
export interface IBreadcrumb {
text: string;
path: string;
+ disableSplit?: boolean;
}
--- /dev/null
+export interface NvmeofGateway {
+ cli_version: string;
+ version: string;
+ name: string;
+ group: string;
+ addr: string;
+ port: string;
+ load_balancing_group: string;
+ spdk_version: string;
+}
grafana: { create: false, delete: false, read: false, update: false },
hosts: { create: false, delete: false, read: false, update: false },
iscsi: { create: false, delete: false, read: false, update: false },
+ nvmeof: { create: false, delete: false, read: false, update: false },
log: { create: false, delete: false, read: false, update: false },
manager: { create: false, delete: false, read: false, update: false },
monitor: { create: false, delete: false, read: false, update: false },
grafana: ['create', 'read', 'update', 'delete'],
hosts: ['create', 'read', 'update', 'delete'],
iscsi: ['create', 'read', 'update', 'delete'],
+ 'nvme-of': ['create', 'read', 'update', 'delete'],
log: ['create', 'read', 'update', 'delete'],
manager: ['create', 'read', 'update', 'delete'],
monitor: ['create', 'read', 'update', 'delete'],
grafana: { create: true, delete: true, read: true, update: true },
hosts: { create: true, delete: true, read: true, update: true },
iscsi: { create: true, delete: true, read: true, update: true },
+ nvmeof: { create: true, delete: true, read: true, update: true },
log: { create: true, delete: true, read: true, update: true },
manager: { create: true, delete: true, read: true, update: true },
monitor: { create: true, delete: true, read: true, update: true },
monitor: Permission;
rbdImage: Permission;
iscsi: Permission;
+ nvmeof: Permission;
rbdMirroring: Permission;
rgw: Permission;
cephfs: Permission;
this.monitor = new Permission(serverPermissions['monitor']);
this.rbdImage = new Permission(serverPermissions['rbd-image']);
this.iscsi = new Permission(serverPermissions['iscsi']);
+ this.nvmeof = new Permission(serverPermissions['nvme-of']);
this.rbdMirroring = new Permission(serverPermissions['rbd-mirroring']);
this.rgw = new Permission(serverPermissions['rgw']);
this.cephfs = new Permission(serverPermissions['cephfs']);