From 7934ce335287a260af3ab3c77ecd92ca941910a4 Mon Sep 17 00:00:00 2001 From: Ernesto Puerta Date: Tue, 2 Nov 2021 13:03:19 +0100 Subject: [PATCH] mgr/dashboard: add backend-driven UI tables As an example this PR displays the list of Ceph Auth users, keys and caps. It tries to minimize the amount of UI code required by feeding a generic table-like component (RestTable) with backend-generated JSON data. This is just a proof of concept and there's lot of room for improvement. Fixes: https://tracker.ceph.com/issues/52701 Signed-off-by: Ernesto Puerta Signed-off-by: Pere Diaz Bou Signed-off-by: Nizamudeen A (cherry picked from commit f6c88f4c30a8de8b03aaa96b2e8916943eab5f80) --- .../mgr/dashboard/controllers/__init__.py | 7 +- src/pybind/mgr/dashboard/controllers/_crud.py | 117 ++++++++++++++++++ .../mgr/dashboard/controllers/ceph_users.py | 27 ++++ .../integration/cluster/users.e2e-spec.ts | 27 ++++ .../cypress/integration/cluster/users.po.ts | 29 +++++ .../cypress/integration/ui/navigation.po.ts | 11 +- .../frontend/src/app/app-routing.module.ts | 9 ++ .../navigation/navigation.component.html | 6 + .../navigation/navigation.component.spec.ts | 5 +- .../crud-table/crud-table.component.html | 16 +++ .../crud-table/crud-table.component.scss | 0 .../crud-table/crud-table.component.spec.ts | 45 +++++++ .../crud-table/crud-table.component.ts | 56 +++++++++ .../app/shared/datatable/datatable.module.ts | 11 +- .../app/shared/models/crud-table-metadata.ts | 11 ++ .../services/data-gateway.service.spec.ts | 19 +++ .../shared/services/data-gateway.service.ts | 27 ++++ src/pybind/mgr/dashboard/openapi.yaml | 3 +- src/pybind/mgr/dashboard/tests/__init__.py | 12 ++ .../mgr/dashboard/tests/test_ceph_users.py | 43 +++++++ src/pybind/mgr/dashboard/tests/test_crud.py | 35 ++++++ 21 files changed, 505 insertions(+), 11 deletions(-) create mode 100644 src/pybind/mgr/dashboard/controllers/_crud.py create mode 100644 src/pybind/mgr/dashboard/controllers/ceph_users.py create mode 100644 src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/users.e2e-spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/users.po.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/crud-table/crud-table.component.html create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/crud-table/crud-table.component.scss create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/crud-table/crud-table.component.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/crud-table/crud-table.component.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/models/crud-table-metadata.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/services/data-gateway.service.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/services/data-gateway.service.ts create mode 100644 src/pybind/mgr/dashboard/tests/test_ceph_users.py create mode 100644 src/pybind/mgr/dashboard/tests/test_crud.py diff --git a/src/pybind/mgr/dashboard/controllers/__init__.py b/src/pybind/mgr/dashboard/controllers/__init__.py index 7a5b090ee461e..af3f276ebfa2f 100755 --- a/src/pybind/mgr/dashboard/controllers/__init__.py +++ b/src/pybind/mgr/dashboard/controllers/__init__.py @@ -1,6 +1,7 @@ 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, \ @@ -31,5 +32,9 @@ __all__ = [ 'CreatePermission', 'ReadPermission', 'UpdatePermission', - 'DeletePermission' + 'DeletePermission', + 'CRUDEndpoint', + 'CRUDCollectionMethod', + 'CRUDResourceMethod', + 'SecretStr', ] diff --git a/src/pybind/mgr/dashboard/controllers/_crud.py b/src/pybind/mgr/dashboard/controllers/_crud.py new file mode 100644 index 0000000000000..907759f7c91d1 --- /dev/null +++ b/src/pybind/mgr/dashboard/controllers/_crud.py @@ -0,0 +1,117 @@ +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 diff --git a/src/pybind/mgr/dashboard/controllers/ceph_users.py b/src/pybind/mgr/dashboard/controllers/ceph_users.py new file mode 100644 index 0000000000000..65b8a0294b4f0 --- /dev/null +++ b/src/pybind/mgr/dashboard/controllers/ceph_users.py @@ -0,0 +1,27 @@ +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 diff --git a/src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/users.e2e-spec.ts b/src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/users.e2e-spec.ts new file mode 100644 index 0000000000000..8374e7c7500fd --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/users.e2e-spec.ts @@ -0,0 +1,27 @@ +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(); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/users.po.ts b/src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/users.po.ts new file mode 100644 index 0000000000000..8778384f46f80 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/users.po.ts @@ -0,0 +1,29 @@ +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); + }); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/cypress/integration/ui/navigation.po.ts b/src/pybind/mgr/dashboard/frontend/cypress/integration/ui/navigation.po.ts index a7ecf3af0e8d5..3713bd772c1a5 100644 --- a/src/pybind/mgr/dashboard/frontend/cypress/integration/ui/navigation.po.ts +++ b/src/pybind/mgr/dashboard/frontend/cypress/integration/ui/navigation.po.ts @@ -27,6 +27,7 @@ export class NavigationPageHelper extends PageHelper { { 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' } ] @@ -60,10 +61,18 @@ export class NavigationPageHelper extends PageHelper { 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(); + }); + }); + } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts index 4a490728b7cb4..333a8422ebebe 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts @@ -37,6 +37,7 @@ import { LoginLayoutComponent } from './core/layouts/login-layout/login-layout.c 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'; @@ -115,6 +116,14 @@ const routes: Routes = [ } ] }, + { + path: 'ceph-users', + component: CRUDTableComponent, + data: { + breadcrumbs: 'Cluster/Users', + resource: 'api.cluster.user@1.0' + } + }, { path: 'monitor', component: MonitorComponent, diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.html index aa9b70cc49b2c..dd72a2493ce1e 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.html @@ -151,6 +151,12 @@ Manager Modules +
  • + Users +
  • diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.spec.ts index 2dbce116315f2..64aaca65b5a4e 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.spec.ts @@ -109,7 +109,10 @@ describe('NavigationComponent', () => { ], [['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']], diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/crud-table/crud-table.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/crud-table/crud-table.component.html new file mode 100644 index 0000000000000..e8982a9254782 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/crud-table/crud-table.component.html @@ -0,0 +1,16 @@ + + + + + + + {{ instance.key }}: {{ instance.value }} +   + + diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/crud-table/crud-table.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/crud-table/crud-table.component.scss new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/crud-table/crud-table.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/crud-table/crud-table.component.spec.ts new file mode 100644 index 0000000000000..df10e2a97e435 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/crud-table/crud-table.component.spec.ts @@ -0,0 +1,45 @@ +/* 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; + + 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(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/crud-table/crud-table.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/crud-table/crud-table.component.ts new file mode 100644 index 0000000000000..7878a0661e3e3 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/crud-table/crud-table.component.ts @@ -0,0 +1,56 @@ +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; + + data$: Observable; + meta$: Observable; + 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]; + } + }); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/datatable.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/datatable.module.ts index fa6de840757f1..ed92e6b9d7d27 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/datatable.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/datatable.module.ts @@ -9,6 +9,7 @@ import { NgxPipeFunctionModule } from 'ngx-pipe-function'; 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'; @@ -26,18 +27,14 @@ import { TableComponent } from './table/table.component'; ComponentsModule, RouterModule ], - declarations: [ - TableComponent, - TableKeyValueComponent, - TableActionsComponent, - TablePaginationComponent - ], + declarations: [TableComponent, TableKeyValueComponent, TableActionsComponent, CRUDTableComponent], exports: [ TableComponent, NgxDatatableModule, TableKeyValueComponent, TableActionsComponent, - TablePaginationComponent + TablePaginationComponent, + CRUDTableComponent ] }) export class DataTableModule {} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/crud-table-metadata.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/crud-table-metadata.ts new file mode 100644 index 0000000000000..ba3e17621a9aa --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/crud-table-metadata.ts @@ -0,0 +1,11 @@ +import { CdTableColumn } from '~/app/shared/models/cd-table-column'; + +class Table { + columns: CdTableColumn[]; + columnMode: string; + toolHeader: boolean; +} + +export class CrudMetadata { + table: Table; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/data-gateway.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/data-gateway.service.spec.ts new file mode 100644 index 0000000000000..1eb7ccbc42492 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/data-gateway.service.spec.ts @@ -0,0 +1,19 @@ +/* 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(); + })); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/data-gateway.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/data-gateway.service.ts new file mode 100644 index 0000000000000..283d37bb6f8cb --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/data-gateway.service.ts @@ -0,0 +1,27 @@ +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 } = {}; + + constructor(private http: HttpClient) {} + + list(dataPath: string): Observable { + if (this.cache[dataPath] === undefined) { + const match = dataPath.match(/(?[^@]+)(?:@(?.+))?/); + const url = match.groups.url.split('.').join('/'); + const version = match.groups.version || '1.0'; + + this.cache[dataPath] = this.http.get(url, { + headers: { Accept: `application/vnd.ceph.api.v${version}+json` } + }); + } + + return this.cache[dataPath]; + } +} diff --git a/src/pybind/mgr/dashboard/openapi.yaml b/src/pybind/mgr/dashboard/openapi.yaml index 13f2fb4d32b1d..99be5131d444e 100644 --- a/src/pybind/mgr/dashboard/openapi.yaml +++ b/src/pybind/mgr/dashboard/openapi.yaml @@ -2143,7 +2143,7 @@ paths: summary: Update the cluster status tags: - Cluster - /api/cluster/capacity: + /api/cluster/user: get: parameters: [] responses: @@ -2163,6 +2163,7 @@ paths: trace. security: - jwt: [] + summary: Get Ceph Users tags: - Cluster /api/cluster_conf: diff --git a/src/pybind/mgr/dashboard/tests/__init__.py b/src/pybind/mgr/dashboard/tests/__init__.py index d262d27d7fc58..51d233208b94c 100644 --- a/src/pybind/mgr/dashboard/tests/__init__.py +++ b/src/pybind/mgr/dashboard/tests/__init__.py @@ -139,6 +139,18 @@ class ControllerTestCase(helper.CPWebCase): 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 diff --git a/src/pybind/mgr/dashboard/tests/test_ceph_users.py b/src/pybind/mgr/dashboard/tests/test_ceph_users.py new file mode 100644 index 0000000000000..04a03ca355d87 --- /dev/null +++ b/src/pybind/mgr/dashboard/tests/test_ceph_users.py @@ -0,0 +1,43 @@ +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": "***********" + } + ]) diff --git a/src/pybind/mgr/dashboard/tests/test_crud.py b/src/pybind/mgr/dashboard/tests/test_crud.py new file mode 100644 index 0000000000000..db8aa2990190d --- /dev/null +++ b/src/pybind/mgr/dashboard/tests/test_crud.py @@ -0,0 +1,35 @@ +# 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) -- 2.39.5