from ._api_router import APIRouter
from ._auth import ControllerAuthMixin
from ._base_controller import BaseController
+from ._crud import CRUDCollectionMethod, CRUDEndpoint, CRUDResourceMethod, SecretStr
from ._docs import APIDoc, EndpointDoc
from ._endpoint import Endpoint, Proxy
from ._helpers import ENDPOINT_MAP, allow_empty_body, \
'CreatePermission',
'ReadPermission',
'UpdatePermission',
- 'DeletePermission'
+ 'DeletePermission',
+ 'CRUDEndpoint',
+ 'CRUDCollectionMethod',
+ 'CRUDResourceMethod',
+ 'SecretStr',
]
--- /dev/null
+from typing import Any, Callable, Dict, Generator, Iterable, Iterator, List, \
+ NamedTuple, Optional, get_type_hints
+
+from ._api_router import APIRouter
+from ._docs import APIDoc, EndpointDoc
+from ._rest_controller import RESTController
+from ._ui_router import UIRouter
+
+
+class SecretStr(str):
+ pass
+
+
+def isnamedtuple(o):
+ return isinstance(o, tuple) and hasattr(o, '_asdict') and hasattr(o, '_fields')
+
+
+def serialize(o, expected_type=None):
+ # pylint: disable=R1705,W1116
+ if isnamedtuple(o):
+ hints = get_type_hints(o)
+ return {k: serialize(v, hints[k]) for k, v in zip(o._fields, o)}
+ elif isinstance(o, (list, tuple, set)):
+ # json serializes list and tuples to arrays, hence we also serialize
+ # sets to lists.
+ # NOTE: we could add a metadata value in a list to indentify tuples and,
+ # sets if we wanted but for now let's go for lists.
+ return [serialize(i) for i in o]
+ elif isinstance(o, (Iterator, Generator)):
+ return [serialize(i) for i in o]
+ elif expected_type and issubclass(expected_type, SecretStr):
+ return "***********"
+ else:
+ return o
+
+
+class TableColumn(NamedTuple):
+ prop: str
+ cellTemplate: str = ''
+ isHidden: bool = False
+ filterable: bool = True
+
+
+class TableComponent(NamedTuple):
+ columns: List[TableColumn] = []
+ columnMode: str = 'flex'
+ toolHeader: bool = True
+
+
+class CRUDMeta(NamedTuple):
+ table: TableComponent = TableComponent()
+
+
+class CRUDCollectionMethod(NamedTuple):
+ func: Callable[..., Iterable[Any]]
+ doc: EndpointDoc
+
+
+class CRUDResourceMethod(NamedTuple):
+ func: Callable[..., Any]
+ doc: EndpointDoc
+
+
+class CRUDEndpoint(NamedTuple):
+ router: APIRouter
+ doc: APIDoc
+ set_column: Optional[Dict[str, Dict[str, str]]] = None
+ meta: CRUDMeta = CRUDMeta()
+ get_all: Optional[CRUDCollectionMethod] = None
+
+ # for testing purposes
+ CRUDClass: Optional[RESTController] = None
+ CRUDClassMetadata: Optional[RESTController] = None
+ # ---------------------
+
+ def __call__(self, cls: NamedTuple):
+ self.create_crud_class(cls)
+
+ self.meta.table.columns.extend(TableColumn(prop=field) for field in cls._fields)
+ self.create_meta_class(cls)
+ return cls
+
+ def create_crud_class(self, cls):
+ outer_self: CRUDEndpoint = self
+
+ @self.router
+ @self.doc
+ class CRUDClass(RESTController):
+
+ if self.get_all:
+ @self.get_all.doc
+ def list(self):
+ return serialize(cls(**item)
+ for item in outer_self.get_all.func()) # type: ignore
+ cls.CRUDClass = CRUDClass
+
+ def create_meta_class(self, cls):
+ outer_self: CRUDEndpoint = self
+
+ @UIRouter(self.router.path, self.router.security_scope)
+ class CRUDClassMetadata(RESTController):
+ def list(self):
+ self.update_columns()
+ return serialize(outer_self.meta)
+
+ def update_columns(self):
+ if outer_self.set_column:
+ for i, column in enumerate(outer_self.meta.table.columns):
+ if column.prop in dict(outer_self.set_column):
+ new_template = outer_self.set_column[column.prop]["cellTemplate"]
+ new_column = TableColumn(column.prop,
+ new_template,
+ column.isHidden,
+ column.filterable)
+ outer_self.meta.table.columns[i] = new_column
+
+ cls.CRUDClassMetadata = CRUDClassMetadata
--- /dev/null
+from typing import NamedTuple
+
+from ..security import Scope
+from ..services.ceph_service import CephService
+from . import APIDoc, APIRouter, CRUDCollectionMethod, CRUDEndpoint, EndpointDoc, SecretStr
+
+
+class CephUserCaps(NamedTuple):
+ mon: str
+ osd: str
+ mgr: str
+ mds: str
+
+
+@CRUDEndpoint(
+ router=APIRouter('/cluster/user', Scope.CONFIG_OPT),
+ doc=APIDoc("Get Ceph Users", "Cluster"),
+ set_column={"caps": {"cellTemplate": "badgeDict"}},
+ get_all=CRUDCollectionMethod(
+ func=lambda **_: CephService.send_command('mon', 'auth ls')["auth_dump"],
+ doc=EndpointDoc("Get Ceph Users")
+ )
+)
+class CephUser(NamedTuple):
+ entity: str
+ caps: CephUserCaps
+ key: SecretStr
--- /dev/null
+import { UsersPageHelper } from './users.po';
+
+describe('Cluster Users', () => {
+ const users = new UsersPageHelper();
+
+ beforeEach(() => {
+ cy.login();
+ Cypress.Cookies.preserveOnce('token');
+ users.navigateTo();
+ });
+
+ describe('breadcrumb and tab tests', () => {
+ it('should open and show breadcrumb', () => {
+ users.expectBreadcrumbText('Users');
+ });
+ });
+
+ describe('Cluster users table', () => {
+ it('should verify the table is not empty', () => {
+ users.checkForUsers();
+ });
+
+ it('should verify the keys are hidden', () => {
+ users.verifyKeysAreHidden();
+ });
+ });
+});
--- /dev/null
+import { PageHelper } from '../page-helper.po';
+
+const pages = {
+ index: { url: '#/ceph-users', id: 'cd-crud-table' }
+};
+
+export class UsersPageHelper extends PageHelper {
+ pages = pages;
+
+ columnIndex = {
+ entity: 1,
+ capabilities: 2,
+ key: 3
+ };
+
+ checkForUsers() {
+ this.getTableCount('total').should('not.be.eq', 0);
+ }
+
+ verifyKeysAreHidden() {
+ this.getTableCell(this.columnIndex.entity, 'osd.0')
+ .parent()
+ .find(`datatable-body-cell:nth-child(${this.columnIndex.key}) span`)
+ .should(($ele) => {
+ const serviceInstances = $ele.toArray().map((v) => v.innerText);
+ expect(serviceInstances).not.contains(/^[a-z0-9]+$/i);
+ });
+ }
+}
{ menu: 'Configuration', component: 'cd-configuration' },
{ menu: 'CRUSH map', component: 'cd-crushmap' },
{ menu: 'Manager Modules', component: 'cd-mgr-module-list' },
+ { menu: 'Users', component: 'cd-crud-table' },
{ menu: 'Logs', component: 'cd-logs' },
{ menu: 'Monitoring', component: 'cd-prometheus-tabs' }
]
navs.forEach((nav: any) => {
cy.contains('.simplebar-content li.nav-item a', nav.menu).click();
if (nav.submenus) {
- this.checkNavigations(nav.submenus);
+ this.checkNavSubMenu(nav.menu, nav.submenus);
} else {
cy.get(nav.component).should('exist');
}
});
}
+
+ checkNavSubMenu(menu: any, submenu: any) {
+ submenu.forEach((nav: any) => {
+ cy.contains('.simplebar-content li.nav-item', menu).within(() => {
+ cy.contains(`ul.list-unstyled li a`, nav.menu).click();
+ });
+ });
+ }
}
import { WorkbenchLayoutComponent } from './core/layouts/workbench-layout/workbench-layout.component';
import { ApiDocsComponent } from './core/navigation/api-docs/api-docs.component';
import { ActionLabels, URLVerbs } from './shared/constants/app.constants';
+import { CRUDTableComponent } from './shared/datatable/crud-table/crud-table.component';
import { BreadcrumbsResolver, IBreadcrumb } from './shared/models/breadcrumbs';
import { AuthGuardService } from './shared/services/auth-guard.service';
import { ChangePasswordGuardService } from './shared/services/change-password-guard.service';
}
]
},
+ {
+ path: 'ceph-users',
+ component: CRUDTableComponent,
+ data: {
+ breadcrumbs: 'Cluster/Users',
+ resource: 'api.cluster.user@1.0'
+ }
+ },
{
path: 'monitor',
component: MonitorComponent,
<a i18n
routerLink="/mgr-modules">Manager Modules</a>
</li>
+ <li routerLinkActive="active"
+ class="tc_submenuitem tc_submenuitem_users"
+ *ngIf="permissions.configOpt.read">
+ <a i18n
+ routerLink="/ceph-users">Users</a>
+ </li>
<li routerLinkActive="active"
class="tc_submenuitem tc_submenuitem_log"
*ngIf="permissions.log.read">
],
[['monitor'], ['.tc_submenuitem_cluster_monitor']],
[['osd'], ['.tc_submenuitem_osds', '.tc_submenuitem_crush']],
- [['configOpt'], ['.tc_submenuitem_configuration', '.tc_submenuitem_modules']],
+ [
+ ['configOpt'],
+ ['.tc_submenuitem_configuration', '.tc_submenuitem_modules', '.tc_submenuitem_users']
+ ],
[['log'], ['.tc_submenuitem_log']],
[['prometheus'], ['.tc_submenuitem_monitoring']],
[['pool'], ['.tc_menuitem_pool']],
--- /dev/null
+<ng-container *ngIf="meta">
+ <cd-table
+ [data]="data$ | async"
+ [columns]="meta.table.columns"
+ [columnMode]="meta.table.columnMode"
+ [toolHeader]="meta.table.toolHeader"
+ ></cd-table>
+</ng-container>
+
+<ng-template #badgeDictTpl
+ let-value="value">
+ <span *ngFor="let instance of value | keyvalue; last as isLast">
+ <span class="badge badge-background-primary" >{{ instance.key }}: {{ instance.value }}</span>
+ <ng-container *ngIf="!isLast"> </ng-container>
+ </span>
+</ng-template>
--- /dev/null
+/* tslint:disable:no-unused-variable */
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { FormsModule } from '@angular/forms';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { NgbDropdownModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
+import { NgxDatatableModule } from '@swimlane/ngx-datatable';
+import { NgxPipeFunctionModule } from 'ngx-pipe-function';
+
+import { ComponentsModule } from '~/app/shared/components/components.module';
+import { PipesModule } from '~/app/shared/pipes/pipes.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { TableKeyValueComponent } from '../table-key-value/table-key-value.component';
+import { TableComponent } from '../table/table.component';
+import { CRUDTableComponent } from './crud-table.component';
+
+describe('CRUDTableComponent', () => {
+ let component: CRUDTableComponent;
+ let fixture: ComponentFixture<CRUDTableComponent>;
+
+ configureTestBed({
+ declarations: [CRUDTableComponent, TableComponent, TableKeyValueComponent],
+ imports: [
+ NgxDatatableModule,
+ FormsModule,
+ ComponentsModule,
+ NgbDropdownModule,
+ PipesModule,
+ NgbTooltipModule,
+ RouterTestingModule,
+ NgxPipeFunctionModule,
+ HttpClientTestingModule
+ ]
+ });
+ beforeEach(() => {
+ fixture = TestBed.createComponent(CRUDTableComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
--- /dev/null
+import { Component, OnInit, TemplateRef, ViewChild } from '@angular/core';
+import { ActivatedRoute } from '@angular/router';
+
+import _ from 'lodash';
+import { Observable } from 'rxjs';
+
+import { CrudMetadata } from '~/app/shared/models/crud-table-metadata';
+import { DataGatewayService } from '~/app/shared/services/data-gateway.service';
+import { TimerService } from '~/app/shared/services/timer.service';
+
+@Component({
+ selector: 'cd-crud-table',
+ templateUrl: './crud-table.component.html',
+ styleUrls: ['./crud-table.component.scss']
+})
+export class CRUDTableComponent implements OnInit {
+ @ViewChild('badgeDictTpl')
+ public badgeDictTpl: TemplateRef<any>;
+
+ data$: Observable<any>;
+ meta$: Observable<CrudMetadata>;
+ meta: CrudMetadata;
+
+ constructor(
+ private timerService: TimerService,
+ private dataGatewayService: DataGatewayService,
+ private activatedRoute: ActivatedRoute
+ ) {}
+
+ ngOnInit() {
+ /* The following should be simplified with a wrapper that
+ converts .data to @Input args. For example:
+ https://medium.com/@andrewcherepovskiy/passing-route-params-into-angular-components-input-properties-fc85c34c9aca
+ */
+ this.activatedRoute.data.subscribe((data) => {
+ const resource: string = data.resource;
+ this.dataGatewayService
+ .list(`ui-${resource}`)
+ .subscribe((response) => this.processMeta(response));
+ this.data$ = this.timerService.get(() => this.dataGatewayService.list(resource));
+ });
+ }
+
+ processMeta(meta: CrudMetadata) {
+ this.meta = meta;
+ const templates = {
+ badgeDict: this.badgeDictTpl
+ };
+ this.meta.table.columns.forEach((element, index) => {
+ if (element['cellTemplate'] !== undefined) {
+ this.meta.table.columns[index]['cellTemplate'] =
+ templates[element['cellTemplate'] as string];
+ }
+ });
+ }
+}
import { ComponentsModule } from '../components/components.module';
import { PipesModule } from '../pipes/pipes.module';
+import { CRUDTableComponent } from './crud-table/crud-table.component';
import { TableActionsComponent } from './table-actions/table-actions.component';
import { TableKeyValueComponent } from './table-key-value/table-key-value.component';
import { TablePaginationComponent } from './table-pagination/table-pagination.component';
ComponentsModule,
RouterModule
],
- declarations: [
- TableComponent,
- TableKeyValueComponent,
- TableActionsComponent,
- TablePaginationComponent
- ],
+ declarations: [TableComponent, TableKeyValueComponent, TableActionsComponent, CRUDTableComponent],
exports: [
TableComponent,
NgxDatatableModule,
TableKeyValueComponent,
TableActionsComponent,
- TablePaginationComponent
+ TablePaginationComponent,
+ CRUDTableComponent
]
})
export class DataTableModule {}
--- /dev/null
+import { CdTableColumn } from '~/app/shared/models/cd-table-column';
+
+class Table {
+ columns: CdTableColumn[];
+ columnMode: string;
+ toolHeader: boolean;
+}
+
+export class CrudMetadata {
+ table: Table;
+}
--- /dev/null
+/* tslint:disable:no-unused-variable */
+
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { inject, TestBed } from '@angular/core/testing';
+
+import { DataGatewayService } from './data-gateway.service';
+
+describe('Service: DataGateway', () => {
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ imports: [HttpClientTestingModule],
+ providers: [DataGatewayService]
+ });
+ });
+
+ it('should ...', inject([DataGatewayService], (service: DataGatewayService) => {
+ expect(service).toBeTruthy();
+ }));
+});
--- /dev/null
+import { HttpClient } from '@angular/common/http';
+import { Injectable } from '@angular/core';
+
+import { Observable } from 'rxjs';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class DataGatewayService {
+ cache: { [keys: string]: Observable<any> } = {};
+
+ constructor(private http: HttpClient) {}
+
+ list(dataPath: string): Observable<any> {
+ if (this.cache[dataPath] === undefined) {
+ const match = dataPath.match(/(?<url>[^@]+)(?:@(?<version>.+))?/);
+ const url = match.groups.url.split('.').join('/');
+ const version = match.groups.version || '1.0';
+
+ this.cache[dataPath] = this.http.get<any>(url, {
+ headers: { Accept: `application/vnd.ceph.api.v${version}+json` }
+ });
+ }
+
+ return this.cache[dataPath];
+ }
+}
summary: Update the cluster status
tags:
- Cluster
- /api/cluster/capacity:
+ /api/cluster/user:
get:
parameters: []
responses:
trace.
security:
- jwt: []
+ summary: Get Ceph Users
tags:
- Cluster
/api/cluster_conf:
cherrypy.tree.mount(None, config={
base_url: {'request.dispatch': mapper}})
+ @classmethod
+ def setup_crud_controllers(cls, crud_ctrl_classes, base_url='',
+ cp_config: Dict[str, Any] = None):
+ if crud_ctrl_classes and not isinstance(crud_ctrl_classes, list):
+ crud_ctrl_classes = [crud_ctrl_classes]
+ ctrl_classes = []
+ for ctrl in crud_ctrl_classes:
+ ctrl_classes.append(ctrl.CRUDClass)
+ ctrl_classes.append(ctrl.CRUDClassMetadata)
+
+ cls.setup_controllers(ctrl_classes, base_url=base_url, cp_config=cp_config)
+
_request_logging = False
@classmethod
--- /dev/null
+import unittest.mock as mock
+
+from ..controllers.ceph_users import CephUser
+from ..tests import ControllerTestCase
+
+auth_dump_mock = {"auth_dump": [
+ {"entity": "client.admin",
+ "key": "RANDOMFi7NwMARAA7RdGqdav+BEEFDEAD0x00g==",
+ "caps": {"mds": "allow *",
+ "mgr": "allow *",
+ "mon": "allow *",
+ "osd": "allow *"}},
+ {"entity": "client.bootstrap-mds",
+ "key": "2RANDOMi7NwMARAA7RdGqdav+BEEFDEAD0x00g==",
+ "caps": {"mds": "allow *",
+ "osd": "allow *"}}
+]}
+
+
+class CephUsersControllerTestCase(ControllerTestCase):
+ @classmethod
+ def setup_server(cls):
+ cls.setup_crud_controllers(CephUser)
+
+ @mock.patch('dashboard.services.ceph_service.CephService.send_command')
+ def test_get_all(self, send_command):
+ send_command.return_value = auth_dump_mock
+ self._get('/api/cluster/user')
+ self.assertStatus(200)
+ self.assertJsonBody([
+ {"entity": "client.admin",
+ "caps": {"mds": "allow *",
+ "mgr": "allow *",
+ "mon": "allow *",
+ "osd": "allow *"},
+ "key": "***********"
+ },
+ {"entity": "client.bootstrap-mds",
+ "caps": {"mds": "allow *",
+ "osd": "allow *"},
+ "key": "***********"
+ }
+ ])
--- /dev/null
+# pylint: disable=C0102
+
+import json
+from typing import NamedTuple
+
+import pytest
+
+from ..controllers._crud import SecretStr, serialize
+
+
+def assertObjectEquals(a, b):
+ assert json.dumps(a) == json.dumps(b)
+
+
+class NamedTupleMock(NamedTuple):
+ foo: int
+ var: str
+
+
+class NamedTupleSecretMock(NamedTuple):
+ foo: int
+ var: str
+ key: SecretStr
+
+
+@pytest.mark.parametrize("inp,out", [
+ (["foo", "var"], ["foo", "var"]),
+ (NamedTupleMock(1, "test"), {"foo": 1, "var": "test"}),
+ (NamedTupleSecretMock(1, "test", "supposethisisakey"), {"foo": 1, "var": "test",
+ "key": "***********"}),
+ ((1, 2, 3), [1, 2, 3]),
+ (set((1, 2, 3)), [1, 2, 3]),
+])
+def test_serialize(inp, out):
+ assertObjectEquals(serialize(inp), out)