From 07e07b8d8a39e8cec3cce30e9cdd5439cc9b2906 Mon Sep 17 00:00:00 2001 From: Pere Diaz Bou Date: Mon, 23 Jan 2023 12:34:12 +0100 Subject: [PATCH] mgr/dashboard: rgw role listing Listing is performed using the radosgw-admin command api we have with the mgr for now until the S3 api is fixed: https://tracker.ceph.com/issues/58547. This commit fixes and issue with regards to the _crud.py controller where redefining `CRUDClassMetadata` caused the users table and the roles table to share columns. We fixed this by creating CRUDClassMetadata dynamically for each endpoint. The issue described above is linked to an issue with NamedTuple were default nested lists are not a great move because it can cause unexpected issues when 2 or more classes are created. Moreover, NamedTuples are read-only making initialization even harder without factory methods as with dataclasses. Therefore, let's move to the good old __init__ :). Signed-off-by: Pere Diaz Bou Fixes: https://tracker.ceph.com/issues/58699 --- src/pybind/mgr/dashboard/controllers/_crud.py | 170 +++++++++++------- .../mgr/dashboard/controllers/ceph_users.py | 6 +- src/pybind/mgr/dashboard/controllers/rgw.py | 46 ++++- .../rgw-user-list.component.html | 2 + .../rgw-user-tabs.component.html | 18 ++ .../rgw-user-tabs.component.scss | 0 .../rgw-user-tabs.component.spec.ts | 24 +++ .../rgw-user-tabs/rgw-user-tabs.component.ts | 8 + .../frontend/src/app/ceph/rgw/rgw.module.ts | 23 ++- .../crud-table/crud-table.component.html | 23 +++ .../crud-table/crud-table.component.ts | 25 ++- src/pybind/mgr/dashboard/openapi.yaml | 25 +++ .../mgr/dashboard/services/rgw_client.py | 9 + 13 files changed, 301 insertions(+), 78 deletions(-) create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-tabs/rgw-user-tabs.component.html create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-tabs/rgw-user-tabs.component.scss create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-tabs/rgw-user-tabs.component.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-tabs/rgw-user-tabs.component.ts diff --git a/src/pybind/mgr/dashboard/controllers/_crud.py b/src/pybind/mgr/dashboard/controllers/_crud.py index 94b1a3146d7..0ac299db6e4 100644 --- a/src/pybind/mgr/dashboard/controllers/_crud.py +++ b/src/pybind/mgr/dashboard/controllers/_crud.py @@ -18,9 +18,21 @@ def isnamedtuple(o): return isinstance(o, tuple) and hasattr(o, '_asdict') and hasattr(o, '_fields') +class SerializableClass: + def __iter__(self): + for attr in self.__dict__: + if not attr.startswith("__"): + yield attr, getattr(self, attr) + + def __contains__(self, value): + return value in self.__dict__ + + def __len__(self): + return len(self.__dict__) + + def serialize(o, expected_type=None): # pylint: disable=R1705,W1116 - print(o, expected_type) if isnamedtuple(o): hints = get_type_hints(o) return {k: serialize(v, hints[k]) for k, v in zip(o._fields, o)} @@ -30,6 +42,8 @@ def serialize(o, expected_type=None): # 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, SerializableClass): + return {serialize(k): serialize(v) for k, v in o} elif isinstance(o, (Iterator, Generator)): return [serialize(i) for i in o] elif expected_type and isclass(expected_type) and issubclass(expected_type, SecretStr): @@ -43,6 +57,7 @@ class TableColumn(NamedTuple): cellTemplate: str = '' isHidden: bool = False filterable: bool = True + flexGrow: int = 1 class TableAction(NamedTuple): @@ -52,10 +67,11 @@ class TableAction(NamedTuple): routerLink: str # redirect to... -class TableComponent(NamedTuple): - columns: List[TableColumn] = [] - columnMode: str = 'flex' - toolHeader: bool = True +class TableComponent(SerializableClass): + def __init__(self) -> None: + self.columns: List[TableColumn] = [] + self.columnMode: str = 'flex' + self.toolHeader: bool = True class Icon(Enum): @@ -269,11 +285,12 @@ class Form: return container_schema -class CRUDMeta(NamedTuple): - table: TableComponent = TableComponent() - permissions: List[str] = [] - actions: List[Dict[str, Any]] = [] - forms: List[Dict[str, Any]] = [] +class CRUDMeta(SerializableClass): + def __init__(self): + self.table = TableComponent() + self.permissions = [] + self.actions = [] + self.forms = [] class CRUDCollectionMethod(NamedTuple): @@ -286,23 +303,39 @@ class CRUDResourceMethod(NamedTuple): doc: EndpointDoc -class CRUDEndpoint(NamedTuple): - router: APIRouter - doc: APIDoc - set_column: Optional[Dict[str, Dict[str, str]]] = None - actions: List[TableAction] = [] - permissions: List[str] = [] - forms: List[Form] = [] - meta: CRUDMeta = CRUDMeta() - get_all: Optional[CRUDCollectionMethod] = None - create: Optional[CRUDCollectionMethod] = None - +class CRUDEndpoint: # for testing purposes CRUDClass: Optional[RESTController] = None CRUDClassMetadata: Optional[RESTController] = None - # --------------------- - def __call__(self, cls: NamedTuple): + # pylint: disable=R0902 + def __init__(self, router: APIRouter, doc: APIDoc, + set_column: Optional[Dict[str, Dict[str, str]]] = None, + actions: Optional[List[TableAction]] = None, + permissions: Optional[List[str]] = None, forms: Optional[List[Form]] = None, + meta: CRUDMeta = CRUDMeta(), get_all: Optional[CRUDCollectionMethod] = None, + create: Optional[CRUDCollectionMethod] = None): + self.router = router + self.doc = doc + self.set_column = set_column + if actions: + self.actions = actions + else: + self.actions = [] + + if forms: + self.forms = forms + else: + self.forms = [] + self.meta = meta + self.get_all = get_all + self.create = create + if permissions: + self.permissions = permissions + else: + self.permissions = [] + + def __call__(self, cls: Any): self.create_crud_class(cls) self.meta.table.columns.extend(TableColumn(prop=field) for field in cls._fields) @@ -334,42 +367,55 @@ class CRUDEndpoint(NamedTuple): 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() - self.generate_actions() - self.generate_forms() - self.set_permissions() - 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 - - def generate_actions(self): - outer_self.meta.actions.clear() - - for action in outer_self.actions: - outer_self.meta.actions.append(action._asdict()) - - def generate_forms(self): - outer_self.meta.forms.clear() - - for form in outer_self.forms: - outer_self.meta.forms.append(form.to_dict()) - - def set_permissions(self): - if outer_self.permissions: - outer_self.meta.permissions.extend(outer_self.permissions) - - cls.CRUDClassMetadata = CRUDClassMetadata + def _list(self): + self.update_columns() + self.generate_actions() + self.generate_forms() + self.set_permissions() + return serialize(self.__class__.outer_self.meta) + + def update_columns(self): + if self.__class__.outer_self.set_column: + for i, column in enumerate(self.__class__.outer_self.meta.table.columns): + if column.prop in dict(self.__class__.outer_self.set_column): + prop = self.__class__.outer_self.set_column[column.prop] + new_template = "" + if "cellTemplate" in prop: + new_template = prop["cellTemplate"] + hidden = prop['isHidden'] if 'isHidden' in prop else False + flex_grow = prop['flexGrow'] if 'flexGrow' in prop else column.flexGrow + new_column = TableColumn(column.prop, + new_template, + hidden, + column.filterable, + flex_grow) + self.__class__.outer_self.meta.table.columns[i] = new_column + + def generate_actions(self): + self.__class__.outer_self.meta.actions.clear() + + for action in self.__class__.outer_self.actions: + self.__class__.outer_self.meta.actions.append(action._asdict()) + + def generate_forms(self): + self.__class__.outer_self.meta.forms.clear() + + for form in self.__class__.outer_self.forms: + self.__class__.outer_self.meta.forms.append(form.to_dict()) + + def set_permissions(self): + if self.__class__.outer_self.permissions: + self.outer_self.meta.permissions.extend(self.__class__.outer_self.permissions) + class_name = self.router.path.replace('/', '') + meta_class = type(f'{class_name}_CRUDClassMetadata', + (RESTController,), + { + 'list': _list, + 'update_columns': update_columns, + 'generate_actions': generate_actions, + 'generate_forms': generate_forms, + 'set_permissions': set_permissions, + 'outer_self': self, + }) + UIRouter(self.router.path, self.router.security_scope)(meta_class) + cls.CRUDClassMetadata = meta_class diff --git a/src/pybind/mgr/dashboard/controllers/ceph_users.py b/src/pybind/mgr/dashboard/controllers/ceph_users.py index 0c65ce1a912..3562f33a227 100644 --- a/src/pybind/mgr/dashboard/controllers/ceph_users.py +++ b/src/pybind/mgr/dashboard/controllers/ceph_users.py @@ -6,7 +6,8 @@ from ..exceptions import DashboardException from ..security import Scope from ..services.ceph_service import CephService, SendCommandError from . import APIDoc, APIRouter, CRUDCollectionMethod, CRUDEndpoint, EndpointDoc, SecretStr -from ._crud import ArrayHorizontalContainer, Form, FormField, Icon, TableAction, VerticalContainer +from ._crud import ArrayHorizontalContainer, CRUDMeta, Form, FormField, Icon, \ + TableAction, VerticalContainer logger = logging.getLogger("controllers.ceph_users") @@ -92,7 +93,8 @@ create_form = Form(path='/cluster/user/create', create=CRUDCollectionMethod( func=CephUserEndpoints.user_create, doc=EndpointDoc("Create Ceph User") - ) + ), + meta=CRUDMeta() ) class CephUser(NamedTuple): entity: str diff --git a/src/pybind/mgr/dashboard/controllers/rgw.py b/src/pybind/mgr/dashboard/controllers/rgw.py index f120b4a5ee3..710a4980f7c 100644 --- a/src/pybind/mgr/dashboard/controllers/rgw.py +++ b/src/pybind/mgr/dashboard/controllers/rgw.py @@ -2,6 +2,7 @@ import json import logging +from typing import Any, Dict, List, NamedTuple, Optional, Union import cherrypy @@ -12,15 +13,12 @@ from ..services.auth import AuthManager, JwtManager from ..services.ceph_service import CephService from ..services.rgw_client import NoRgwDaemonsException, RgwClient from ..tools import json_str_to_object, str_to_bool -from . import APIDoc, APIRouter, BaseController, Endpoint, EndpointDoc, \ - ReadPermission, RESTController, UIRouter, allow_empty_body +from . import APIDoc, APIRouter, BaseController, CRUDCollectionMethod, \ + CRUDEndpoint, Endpoint, EndpointDoc, ReadPermission, RESTController, \ + UIRouter, allow_empty_body +from ._crud import CRUDMeta from ._version import APIVersion -try: - from typing import Any, Dict, List, Optional, Union -except ImportError: # pragma: no cover - pass # Just for type checking - logger = logging.getLogger("controllers.rgw") RGW_SCHEMA = { @@ -607,3 +605,37 @@ class RgwUser(RgwRESTController): 'subuser': subuser, 'purge-keys': purge_keys }, json_response=False) + + +class RGWRoleEndpoints: + @staticmethod + def role_list(_): + rgw_client = RgwClient.admin_instance() + roles = rgw_client.list_roles() + return roles + + +@CRUDEndpoint( + router=APIRouter('/rgw/user/roles', Scope.RGW), + doc=APIDoc("List of RGW roles", "RGW"), + actions=[], + permissions=[Scope.CONFIG_OPT], + get_all=CRUDCollectionMethod( + func=RGWRoleEndpoints.role_list, + doc=EndpointDoc("List RGW roles") + ), + set_column={ + "CreateDate": {'cellTemplate': 'date'}, + "MaxSessionDuration": {'cellTemplate': 'duration'}, + "AssumeRolePolicyDocument": {'isHidden': True} + }, + meta=CRUDMeta() +) +class RgwUserRole(NamedTuple): + RoleId: int + RoleName: str + Path: str + Arn: str + CreateDate: str + MaxSessionDuration: int + AssumeRolePolicyDocument: str diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-list/rgw-user-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-list/rgw-user-list.component.html index 6c6d7677ec7..8f50e4abcb2 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-list/rgw-user-list.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-list/rgw-user-list.component.html @@ -1,3 +1,5 @@ + + + + + diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-tabs/rgw-user-tabs.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-tabs/rgw-user-tabs.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-tabs/rgw-user-tabs.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-tabs/rgw-user-tabs.component.spec.ts new file mode 100644 index 00000000000..3b81e718dc4 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-tabs/rgw-user-tabs.component.spec.ts @@ -0,0 +1,24 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { RgwUserTabsComponent } from './rgw-user-tabs.component'; + +describe('RgwUserTabsComponent', () => { + let component: RgwUserTabsComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [RgwUserTabsComponent] + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(RgwUserTabsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-tabs/rgw-user-tabs.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-tabs/rgw-user-tabs.component.ts new file mode 100644 index 00000000000..95625ca6218 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-tabs/rgw-user-tabs.component.ts @@ -0,0 +1,8 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'cd-rgw-user-tabs', + templateUrl: './rgw-user-tabs.component.html', + styleUrls: ['./rgw-user-tabs.component.scss'] +}) +export class RgwUserTabsComponent {} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw.module.ts index 7ecaddddfef..edffa8e856b 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw.module.ts @@ -7,6 +7,7 @@ import { NgbNavModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; import { NgxPipeFunctionModule } from 'ngx-pipe-function'; import { ActionLabels, URLVerbs } from '~/app/shared/constants/app.constants'; +import { CRUDTableComponent } from '~/app/shared/datatable/crud-table/crud-table.component'; import { SharedModule } from '~/app/shared/shared.module'; import { PerformanceCounterModule } from '../performance-counter/performance-counter.module'; import { RgwBucketDetailsComponent } from './rgw-bucket-details/rgw-bucket-details.component'; @@ -22,6 +23,7 @@ import { RgwUserListComponent } from './rgw-user-list/rgw-user-list.component'; import { RgwUserS3KeyModalComponent } from './rgw-user-s3-key-modal/rgw-user-s3-key-modal.component'; import { RgwUserSubuserModalComponent } from './rgw-user-subuser-modal/rgw-user-subuser-modal.component'; import { RgwUserSwiftKeyModalComponent } from './rgw-user-swift-key-modal/rgw-user-swift-key-modal.component'; +import { RgwUserTabsComponent } from './rgw-user-tabs/rgw-user-tabs.component'; @NgModule({ imports: [ @@ -58,7 +60,8 @@ import { RgwUserSwiftKeyModalComponent } from './rgw-user-swift-key-modal/rgw-us RgwUserS3KeyModalComponent, RgwUserCapabilityModalComponent, RgwUserSubuserModalComponent, - RgwConfigModalComponent + RgwConfigModalComponent, + RgwUserTabsComponent ] }) export class RgwModule {} @@ -82,6 +85,24 @@ const routes: Routes = [ path: `${URLVerbs.EDIT}/:uid`, component: RgwUserFormComponent, data: { breadcrumbs: ActionLabels.EDIT } + }, + { + path: 'roles', + component: CRUDTableComponent, + data: { + breadcrumbs: 'Roles', + resource: 'api.rgw.user.roles@1.0', + tabs: [ + { + name: 'Users', + url: '/rgw/user' + }, + { + name: 'Roles', + url: '/rgw/user/roles' + } + ] + } } ] }, 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 index 87a3c586b4a..b9b4ae62b34 100644 --- 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 @@ -1,3 +1,16 @@ + +   + + + {{ value | cdDate }} + + + + {{ value | duration }} + 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 index 7e82c523e78..96346ea7a1b 100644 --- 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 @@ -19,6 +19,10 @@ import { AuthStorageService } from '../../services/auth-storage.service'; export class CRUDTableComponent implements OnInit { @ViewChild('badgeDictTpl') public badgeDictTpl: TemplateRef; + @ViewChild('dateTpl') + public dateTpl: TemplateRef; + @ViewChild('durationTpl') + public durationTpl: TemplateRef; data$: Observable; meta$: Observable; @@ -26,6 +30,7 @@ export class CRUDTableComponent implements OnInit { permissions: Permissions; permission: Permission; selection = new CdTableSelection(); + tabs = {}; constructor( private authStorageService: AuthStorageService, @@ -43,9 +48,10 @@ export class CRUDTableComponent implements OnInit { */ this.activatedRoute.data.subscribe((data: any) => { const resource: string = data.resource; + this.tabs = data.tabs; this.dataGatewayService .list(`ui-${resource}`) - .subscribe((response: any) => this.processMeta(response)); + .subscribe((response: CrudMetadata) => this.processMeta(response)); this.data$ = this.timerService.get(() => this.dataGatewayService.list(resource)); }); } @@ -62,15 +68,22 @@ export class CRUDTableComponent implements OnInit { '' ); this.permission = this.permissions[toCamelCase(meta.permissions[0])]; - this.meta = meta; const templates = { - badgeDict: this.badgeDictTpl + badgeDict: this.badgeDictTpl, + date: this.dateTpl, + duration: this.durationTpl }; - this.meta.table.columns.forEach((element, index) => { + meta.table.columns.forEach((element, index) => { if (element['cellTemplate'] !== undefined) { - this.meta.table.columns[index]['cellTemplate'] = - templates[element['cellTemplate'] as string]; + meta.table.columns[index]['cellTemplate'] = templates[element['cellTemplate'] as string]; } }); + // isHidden flag does not work as expected somehow so the best ways to enforce isHidden is + // to filter the columns manually instead of letting isHidden flag inside table.component to + // work. + meta.table.columns = meta.table.columns.filter((col: any) => { + return !col['isHidden']; + }); + this.meta = meta; } } diff --git a/src/pybind/mgr/dashboard/openapi.yaml b/src/pybind/mgr/dashboard/openapi.yaml index d607cfb0668..16143a8794c 100644 --- a/src/pybind/mgr/dashboard/openapi.yaml +++ b/src/pybind/mgr/dashboard/openapi.yaml @@ -8269,6 +8269,29 @@ paths: - jwt: [] tags: - RgwUser + /api/rgw/user/roles: + get: + parameters: [] + responses: + '200': + content: + application/vnd.ceph.api.v1.0+json: + type: object + description: OK + '400': + description: Operation exception. Please check the response body for details. + '401': + description: Unauthenticated access. Please login first. + '403': + description: Unauthorized access. Please check your permissions. + '500': + description: Unexpected error. Please check the response body for the stack + trace. + security: + - jwt: [] + summary: List RGW roles + tags: + - RGW /api/rgw/user/{uid}: delete: parameters: @@ -10787,6 +10810,8 @@ tags: name: Prometheus - description: Prometheus Notifications Management API name: PrometheusNotifications +- description: List of RGW roles + name: RGW - description: RBD Management API name: Rbd - description: RBD Mirroring Management API diff --git a/src/pybind/mgr/dashboard/services/rgw_client.py b/src/pybind/mgr/dashboard/services/rgw_client.py index 5ef32044d6c..bf65dd2efe4 100644 --- a/src/pybind/mgr/dashboard/services/rgw_client.py +++ b/src/pybind/mgr/dashboard/services/rgw_client.py @@ -787,6 +787,15 @@ class RgwClient(RestClient): except RequestException as e: raise DashboardException(msg=str(e), component='rgw') + def list_roles(self) -> List[Dict[str, Any]]: + rgw_list_roles_command = ['role', 'list'] + code, roles, err = mgr.send_rgwadmin_command(rgw_list_roles_command) + if code < 0: + logger.warning('Error listing roles with code %d: %s', code, err) + return [] + + return roles + def perform_validations(self, retention_period_days, retention_period_years, mode): try: retention_period_days = int(retention_period_days) if retention_period_days else 0 -- 2.39.5