From 18a397bbbaa36a2aa75400f97b4b7debdb1c793f Mon Sep 17 00:00:00 2001 From: Pere Diaz Bou Date: Thu, 6 Apr 2023 16:24:03 +0200 Subject: [PATCH] mgr/dashboard: import/export authx users Signed-off-by: Pere Diaz Bou Fixes: https://tracker.ceph.com/issues/59486 (cherry picked from commit 62d762f6965c5b8585d223c06cb23071a856cfcb) --- src/pybind/mgr/dashboard/controllers/_crud.py | 94 +++++++++++++------ .../mgr/dashboard/controllers/ceph_users.py | 87 +++++++++++++---- src/pybind/mgr/dashboard/controllers/rgw.py | 2 +- .../frontend/src/app/app-routing.module.ts | 8 ++ .../src/app/shared/api/ceph-user.service.ts | 13 +++ .../confirmation-modal.component.html | 1 + .../confirmation-modal.component.ts | 1 + .../form-button-panel.component.html | 3 +- .../form-button-panel.component.ts | 2 + .../crud-table/crud-table.component.html | 18 +++- .../crud-table/crud-table.component.scss | 3 + .../crud-table/crud-table.component.ts | 41 +++++++- .../app/shared/datatable/datatable.module.ts | 12 ++- .../forms/crud-form/crud-form.component.ts | 29 +++++- .../formly-file-type-accessor.ts | 29 ++++++ .../formly-file-type.component.html | 4 + .../formly-file-type.component.scss | 0 .../formly-file-type.component.spec.ts | 40 ++++++++ .../formly-file-type.component.ts | 9 ++ .../src/app/shared/forms/crud-form/helpers.ts | 5 + .../crud-form/validators/file-validator.ts | 15 +++ .../app/shared/models/crud-table-metadata.ts | 1 + .../services/crud-form-adapter.service.ts | 20 ++-- .../shared/services/data-gateway.service.ts | 4 +- src/pybind/mgr/dashboard/openapi.yaml | 47 +++++++++- .../mgr/dashboard/services/ceph_service.py | 10 +- 26 files changed, 418 insertions(+), 80 deletions(-) create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/api/ceph-user.service.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/forms/crud-form/formly-file-type/formly-file-type-accessor.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/forms/crud-form/formly-file-type/formly-file-type.component.html create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/forms/crud-form/formly-file-type/formly-file-type.component.scss create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/forms/crud-form/formly-file-type/formly-file-type.component.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/forms/crud-form/formly-file-type/formly-file-type.component.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/forms/crud-form/validators/file-validator.ts diff --git a/src/pybind/mgr/dashboard/controllers/_crud.py b/src/pybind/mgr/dashboard/controllers/_crud.py index bf86773116fd7..7b2f13defac3c 100644 --- a/src/pybind/mgr/dashboard/controllers/_crud.py +++ b/src/pybind/mgr/dashboard/controllers/_crud.py @@ -2,7 +2,7 @@ from enum import Enum from functools import wraps from inspect import isclass from typing import Any, Callable, Dict, Generator, Iterable, Iterator, List, \ - NamedTuple, Optional, Union, get_type_hints + NamedTuple, Optional, Tuple, Union, get_type_hints from ._api_router import APIRouter from ._docs import APIDoc, EndpointDoc @@ -66,6 +66,13 @@ class TableAction(NamedTuple): icon: str routerLink: str = '' # redirect to... click: str = '' + disable: bool = False # disable without selection + + +class SelectionType(Enum): + NONE = '' + SINGLE = 'single' + MULTI = 'multiClick' class TableComponent(SerializableClass): @@ -73,17 +80,24 @@ class TableComponent(SerializableClass): self.columns: List[TableColumn] = [] self.columnMode: str = 'flex' self.toolHeader: bool = True + self.selectionType: str = SelectionType.SINGLE.value + + def set_selection_type(self, type_: SelectionType): + self.selectionType = type_.value class Icon(Enum): - add = 'fa fa-plus' - destroy = 'fa fa-times' + ADD = 'fa fa-plus' + DESTROY = 'fa fa-times' + IMPORT = 'fa fa-upload' + EXPORT = 'fa fa-download' class Validator(Enum): JSON = 'json' RGW_ROLE_NAME = 'rgwRoleName' RGW_ROLE_PATH = 'rgwRolePath' + FILE = 'file' class FormField(NamedTuple): @@ -110,6 +124,8 @@ class FormField(NamedTuple): _type = 'boolean' elif self.field_type == 'textarea': _type = 'textarea' + elif self.field_type == "file": + _type = 'file' else: raise NotImplementedError(f'Unimplemented type {self.field_type}') return _type @@ -265,6 +281,7 @@ class Form: def to_dict(self): res = self.root_container.to_dict() res['task_info'] = self.task_info.to_dict() + res['path'] = self.path return res @@ -302,7 +319,9 @@ class CRUDEndpoint: meta: CRUDMeta = CRUDMeta(), get_all: Optional[CRUDCollectionMethod] = None, create: Optional[CRUDCollectionMethod] = None, delete: Optional[CRUDCollectionMethod] = None, - detail_columns: Optional[List[str]] = None): + detail_columns: Optional[List[str]] = None, + selection_type: SelectionType = SelectionType.SINGLE, + extra_endpoints: Optional[List[Tuple[str, CRUDCollectionMethod]]] = None): self.router = router self.doc = doc self.set_column = set_column @@ -315,6 +334,8 @@ class CRUDEndpoint: self.permissions = permissions if permissions is not None else [] self.column_key = column_key if column_key is not None else '' self.detail_columns = detail_columns if detail_columns is not None else [] + self.extra_endpoints = extra_endpoints if extra_endpoints is not None else [] + self.selection_type = selection_type def __call__(self, cls: Any): self.create_crud_class(cls) @@ -326,32 +347,43 @@ class CRUDEndpoint: 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 - @wraps(self.get_all.func) - def list(self, *args, **kwargs): - items = [] - for item in outer_self.get_all.func(self, *args, **kwargs): # type: ignore - items.append(serialize(cls(**item))) - return items - - if self.create: - @self.create.doc - @wraps(self.create.func) - def create(self, *args, **kwargs): - return outer_self.create.func(self, *args, **kwargs) # type: ignore - - if self.delete: - @self.delete.doc - @wraps(self.delete.func) - def delete(self, *args, **kwargs): - return outer_self.delete.func(self, *args, **kwargs) # type: ignore - - cls.CRUDClass = CRUDClass + funcs = {} + if self.get_all: + @self.get_all.doc + @wraps(self.get_all.func) + def _list(self, *args, **kwargs): + items = [] + for item in outer_self.get_all.func(self, *args, **kwargs): # type: ignore + items.append(serialize(cls(**item))) + return items + funcs['list'] = _list + + if self.create: + @self.create.doc + @wraps(self.create.func) + def _create(self, *args, **kwargs): + return outer_self.create.func(self, *args, **kwargs) # type: ignore + funcs['create'] = _create + + if self.delete: + @self.delete.doc + @wraps(self.delete.func) + def delete(self, *args, **kwargs): + return outer_self.delete.func(self, *args, **kwargs) # type: ignore + funcs['delete'] = delete + + for extra_endpoint in self.extra_endpoints: + funcs[extra_endpoint[0]] = extra_endpoint[1].doc(extra_endpoint[1].func) + + class_name = self.router.path.replace('/', '') + crud_class = type(f'{class_name}_CRUDClass', + (RESTController,), + { + **funcs, + 'outer_self': self, + }) + self.router(self.doc(crud_class)) + cls.CRUDClass = crud_class def create_meta_class(self, cls): def _list(self): @@ -361,6 +393,8 @@ class CRUDEndpoint: self.set_permissions() self.set_column_key() self.get_detail_columns() + selection_type = self.__class__.outer_self.selection_type + self.__class__.outer_self.meta.table.set_selection_type(selection_type) return serialize(self.__class__.outer_self.meta) def get_detail_columns(self): diff --git a/src/pybind/mgr/dashboard/controllers/ceph_users.py b/src/pybind/mgr/dashboard/controllers/ceph_users.py index e66aa18fa2e94..77e67dc7ff840 100644 --- a/src/pybind/mgr/dashboard/controllers/ceph_users.py +++ b/src/pybind/mgr/dashboard/controllers/ceph_users.py @@ -1,13 +1,15 @@ import logging from errno import EINVAL -from typing import List, NamedTuple +from typing import List, NamedTuple, Optional from ..exceptions import DashboardException from ..security import Scope from ..services.ceph_service import CephService, SendCommandError -from . import APIDoc, APIRouter, CRUDCollectionMethod, CRUDEndpoint, EndpointDoc, SecretStr +from . import APIDoc, APIRouter, CRUDCollectionMethod, CRUDEndpoint, \ + EndpointDoc, RESTController, SecretStr from ._crud import ArrayHorizontalContainer, CRUDMeta, Form, FormField, \ - FormTaskInfo, Icon, TableAction, VerticalContainer + FormTaskInfo, Icon, SelectionType, TableAction, Validator, \ + VerticalContainer logger = logging.getLogger("controllers.ceph_users") @@ -25,15 +27,26 @@ class Cap(NamedTuple): class CephUserEndpoints: + @staticmethod + def _run_auth_command(command: str, *args, **kwargs): + try: + return CephService.send_command('mon', command, *args, **kwargs) + except SendCommandError as ex: + msg = f'{ex} in command {ex.prefix}' + if ex.errno == -EINVAL: + raise DashboardException(msg, code=400) + raise DashboardException(msg, code=500) + @staticmethod def user_list(_): """ Get list of ceph users and its respective data """ - return CephService.send_command('mon', 'auth ls')["auth_dump"] + return CephUserEndpoints._run_auth_command('auth ls')["auth_dump"] @staticmethod - def user_create(_, user_entity: str, capabilities: List[Cap]): + def user_create(_, user_entity: str = '', capabilities: Optional[List[Cap]] = None, + import_data: str = ''): """ Add a ceph user with its defined capabilities. :param user_entity: Entity to change @@ -41,6 +54,12 @@ class CephUserEndpoints: """ # Caps are represented as a vector in mon auth add commands. # Look at AuthMonitor.cc::valid_caps for reference. + if import_data: + logger.debug("Sending import command 'auth import' \n%s", import_data) + CephUserEndpoints._run_auth_command('auth import', inbuf=import_data) + return "Successfully imported user" + + assert user_entity caps = [] for cap in capabilities: caps.append(cap['entity']) @@ -48,13 +67,8 @@ class CephUserEndpoints: logger.debug("Sending command 'auth add' of entity '%s' with caps '%s'", user_entity, str(caps)) - try: - CephService.send_command('mon', 'auth add', entity=user_entity, caps=caps) - except SendCommandError as ex: - msg = f'{ex} in command {ex.prefix}' - if ex.errno == -EINVAL: - raise DashboardException(msg, code=400) - raise DashboardException(msg, code=500) + CephUserEndpoints._run_auth_command('auth add', entity=user_entity, caps=caps) + return f"Successfully created user '{user_entity}'" @staticmethod @@ -65,13 +79,21 @@ class CephUserEndpoints: """ logger.debug("Sending command 'auth del' of entity '%s'", user_entity) try: - CephService.send_command('mon', 'auth del', entity=user_entity) + CephUserEndpoints._run_auth_command('auth del', entity=user_entity) except SendCommandError as ex: msg = f'{ex} in command {ex.prefix}' if ex.errno == -EINVAL: raise DashboardException(msg, code=400) raise DashboardException(msg, code=500) - return f"Successfully eleted user '{user_entity}'" + return f"Successfully deleted user '{user_entity}'" + + @staticmethod + def export(_, entities: List[str]): + export_string = "" + for entity in entities: + out = CephUserEndpoints._run_auth_command('auth export', entity=entity, to_json=False) + export_string += f'{out}\n' + return export_string create_cap_container = ArrayHorizontalContainer('Capabilities', 'capabilities', fields=[ @@ -91,20 +113,40 @@ create_form = Form(path='/cluster/user/create', task_info=FormTaskInfo("Ceph user '{user_entity}' created successfully", ['user_entity'])) +# pylint: disable=C0301 +import_user_help = ( + 'The imported file should be a keyring file and it must follow the schema described here.' +) +import_container = VerticalContainer('Import User', 'import_user', fields=[ + FormField('User file import', 'import_data', + field_type="file", validators=[Validator.FILE], + help=import_user_help), +]) + +import_user_form = Form(path='/cluster/user/import', + root_container=import_container, + task_info=FormTaskInfo("User imported successfully", [])) + @CRUDEndpoint( router=APIRouter('/cluster/user', Scope.CONFIG_OPT), doc=APIDoc("Get Ceph Users", "Cluster"), set_column={"caps": {"cellTemplate": "badgeDict"}}, actions=[ - TableAction(name='create', permission='create', icon=Icon.add.value, + TableAction(name='Create', permission='create', icon=Icon.ADD.value, routerLink='/cluster/user/create'), - TableAction(name='Delete', permission='delete', icon=Icon.destroy.value, - click='delete') + TableAction(name='Delete', permission='delete', icon=Icon.DESTROY.value, + click='delete', disable=True), + TableAction(name='Import', permission='create', icon=Icon.IMPORT.value, + routerLink='/cluster/user/import'), + TableAction(name='Export', permission='read', icon=Icon.EXPORT.value, + click='authExport', disable=True), ], - permissions=[Scope.CONFIG_OPT], - forms=[create_form], column_key='entity', + permissions=[Scope.CONFIG_OPT], + forms=[create_form, import_user_form], get_all=CRUDCollectionMethod( func=CephUserEndpoints.user_list, doc=EndpointDoc("Get Ceph Users") @@ -117,6 +159,13 @@ create_form = Form(path='/cluster/user/create', func=CephUserEndpoints.user_delete, doc=EndpointDoc("Delete Ceph User") ), + extra_endpoints=[ + ('export', CRUDCollectionMethod( + func=RESTController.Collection('POST', 'export')(CephUserEndpoints.export), + doc=EndpointDoc("Export Ceph Users") + )) + ], + selection_type=SelectionType.MULTI, meta=CRUDMeta() ) class CephUser(NamedTuple): diff --git a/src/pybind/mgr/dashboard/controllers/rgw.py b/src/pybind/mgr/dashboard/controllers/rgw.py index 85af1ae7fbaee..b7680c7860a9c 100644 --- a/src/pybind/mgr/dashboard/controllers/rgw.py +++ b/src/pybind/mgr/dashboard/controllers/rgw.py @@ -650,7 +650,7 @@ create_role_form = Form(path='/rgw/user/roles/create', router=APIRouter('/rgw/user/roles', Scope.RGW), doc=APIDoc("List of RGW roles", "RGW"), actions=[ - TableAction(name='Create', permission='create', icon=Icon.add.value, + TableAction(name='Create', permission='create', icon=Icon.ADD.value, routerLink='/rgw/user/roles/create') ], forms=[create_role_form], 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 badaedd996a36..c053d51ef0bf9 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 @@ -133,6 +133,14 @@ const routes: Routes = [ resource: 'api.cluster.user@1.0' } }, + { + path: 'cluster/user/import', + component: CrudFormComponent, + data: { + breadcrumbs: 'Cluster/Users', + resource: 'api.cluster.user@1.0' + } + }, { path: 'monitor', component: MonitorComponent, diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/ceph-user.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/ceph-user.service.ts new file mode 100644 index 0000000000000..c41c70dc70c60 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/ceph-user.service.ts @@ -0,0 +1,13 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; + +@Injectable({ + providedIn: 'root' +}) +export class CephUserService { + constructor(private http: HttpClient) {} + + export(entities: string[]) { + return this.http.post('api/cluster/user/export', { entities: entities }); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/confirmation-modal/confirmation-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/confirmation-modal/confirmation-modal.component.html index 294d43f775b21..1c80dc4dd4d6d 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/confirmation-modal/confirmation-modal.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/confirmation-modal/confirmation-modal.component.html @@ -20,6 +20,7 @@ (backActionEvent)="boundCancel()" [form]="confirmationForm" [submitText]="buttonText" + [showCancel]="showCancel" [showSubmit]="showSubmit"> diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/confirmation-modal/confirmation-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/confirmation-modal/confirmation-modal.component.ts index fe56249816a01..b98cea93846f6 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/confirmation-modal/confirmation-modal.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/confirmation-modal/confirmation-modal.component.ts @@ -24,6 +24,7 @@ export class ConfirmationModalComponent implements OnInit, OnDestroy { onCancel?: Function; bodyContext?: object; showSubmit = true; + showCancel = true; // Component only boundCancel = this.cancel.bind(this); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-button-panel/form-button-panel.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-button-panel/form-button-panel.component.html index 476ed96096c12..944541f2e9229 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-button-panel/form-button-panel.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-button-panel/form-button-panel.component.html @@ -1,5 +1,6 @@
- + [toolHeader]="meta.table.toolHeader">
{{ value | duration }} + + +
+ + + + +
+
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 index e69de29bb2d1d..b9eb698d02541 100644 --- 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 @@ -0,0 +1,3 @@ +.height-400 { + height: 400px; +} 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 75ff5341c08cc..bdb3b44bd73b1 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 @@ -1,5 +1,6 @@ import { Component, OnInit, TemplateRef, ViewChild } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; +import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; import _ from 'lodash'; import { Observable } from 'rxjs'; @@ -7,13 +8,14 @@ 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'; +import { CephUserService } from '../../api/ceph-user.service'; +import { ConfirmationModalComponent } from '../../components/confirmation-modal/confirmation-modal.component'; import { CdTableSelection } from '../../models/cd-table-selection'; import { FinishedTask } from '../../models/finished-task'; import { Permission, Permissions } from '../../models/permissions'; import { AuthStorageService } from '../../services/auth-storage.service'; import { TaskWrapperService } from '../../services/task-wrapper.service'; import { ModalService } from '../../services/modal.service'; -import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; import { CriticalConfirmationModalComponent } from '../../components/critical-confirmation-modal/critical-confirmation-modal.component'; @Component({ @@ -28,6 +30,8 @@ export class CRUDTableComponent implements OnInit { public dateTpl: TemplateRef; @ViewChild('durationTpl') public durationTpl: TemplateRef; + @ViewChild('exportDataModalTpl') + public authxEportTpl: TemplateRef; data$: Observable; meta$: Observable; @@ -36,16 +40,18 @@ export class CRUDTableComponent implements OnInit { permission: Permission; selection = new CdTableSelection(); expandedRow: any = null; + modalRef: NgbModalRef; tabs = {}; resource: string; - modalRef: NgbModalRef; + modalState = {}; constructor( private authStorageService: AuthStorageService, private timerService: TimerService, private dataGatewayService: DataGatewayService, - private activatedRoute: ActivatedRoute, private taskWrapper: TaskWrapperService, + private cephUserService: CephUserService, + private activatedRoute: ActivatedRoute, private modalService: ModalService ) { this.permissions = this.authStorageService.getPermissions(); @@ -97,10 +103,15 @@ export class CRUDTableComponent implements OnInit { meta.table.columns = meta.table.columns.filter((col: any) => { return !col['isHidden']; }); + this.meta = meta; for (let i = 0; i < this.meta.actions.length; i++) { - if (this.meta.actions[i].click.toString() !== '') { - this.meta.actions[i].click = this[this.meta.actions[i].click.toString()].bind(this); + let action = this.meta.actions[i]; + if (action.disable) { + action.disable = (selection) => !selection.hasSelection; + } + if (action.click.toString() !== '') { + action.click = this[this.meta.actions[i].click.toString()].bind(this); } } } @@ -131,7 +142,27 @@ export class CRUDTableComponent implements OnInit { updateSelection(selection: CdTableSelection) { this.selection = selection; } + setExpandedRow(event: any) { this.expandedRow = event; } + + authExport() { + let entities: string[] = []; + this.selection.selected.forEach((row) => entities.push(row.entity)); + this.cephUserService.export(entities).subscribe((data: string) => { + const modalVariables = { + titleText: $localize`Ceph user export data`, + buttonText: $localize`Close`, + bodyTpl: this.authxEportTpl, + showSubmit: true, + showCancel: false, + onSubmit: () => { + this.modalRef.close(); + } + }; + this.modalState['authExportData'] = data.trim(); + this.modalRef = this.modalService.show(ConfirmationModalComponent, modalVariables); + }); + } } 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 8b091c0dd31e1..71c5d8f72e12d 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 @@ -22,6 +22,8 @@ import { FormlyInputTypeComponent } from '../forms/crud-form/formly-input-type/f import { FormlyObjectTypeComponent } from '../forms/crud-form/formly-object-type/formly-object-type.component'; import { FormlyTextareaTypeComponent } from '../forms/crud-form/formly-textarea-type/formly-textarea-type.component'; import { FormlyInputWrapperComponent } from '../forms/crud-form/formly-input-wrapper/formly-input-wrapper.component'; +import { FormlyFileTypeComponent } from '../forms/crud-form/formly-file-type/formly-file-type.component'; +import { FormlyFileValueAccessorDirective } from '../forms/crud-form/formly-file-type/formly-file-type-accessor'; @NgModule({ imports: [ @@ -40,7 +42,8 @@ import { FormlyInputWrapperComponent } from '../forms/crud-form/formly-input-wra { name: 'array', component: FormlyArrayTypeComponent }, { name: 'object', component: FormlyObjectTypeComponent }, { name: 'input', component: FormlyInputTypeComponent, wrappers: ['input-wrapper'] }, - { name: 'textarea', component: FormlyTextareaTypeComponent, wrappers: ['input-wrapper'] } + { name: 'textarea', component: FormlyTextareaTypeComponent, wrappers: ['input-wrapper'] }, + { name: 'file', component: FormlyFileTypeComponent, wrappers: ['input-wrapper'] } ], validationMessages: [ { name: 'required', message: 'This field is required' }, @@ -56,7 +59,8 @@ import { FormlyInputWrapperComponent } from '../forms/crud-form/formly-input-wra message: 'Role path must start and finish with a slash "/".' + ' (pattern: (\u002F)|(\u002F[\u0021-\u007E]+\u002F))' - } + }, + { name: 'file_size', message: 'File size must not exceed 4KiB' } ], wrappers: [{ name: 'input-wrapper', component: FormlyInputWrapperComponent }] }), @@ -72,7 +76,9 @@ import { FormlyInputWrapperComponent } from '../forms/crud-form/formly-input-wra FormlyArrayTypeComponent, FormlyInputTypeComponent, FormlyObjectTypeComponent, - FormlyInputWrapperComponent + FormlyInputWrapperComponent, + FormlyFileTypeComponent, + FormlyFileValueAccessorDirective ], exports: [ TableComponent, diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/crud-form/crud-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/crud-form/crud-form.component.ts index 596740fdcd975..14ddccbc19579 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/crud-form/crud-form.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/crud-form/crud-form.component.ts @@ -33,18 +33,43 @@ export class CrudFormComponent implements OnInit { this.formUISchema$ = this.activatedRoute.data.pipe( mergeMap((data: any) => { this.resource = data.resource; - return this.dataGatewayService.form(`ui-${this.resource}`); + const url = '/' + this.activatedRoute.snapshot.url.join('/'); + return this.dataGatewayService.form(`ui-${this.resource}`, url); }) ); } - submit(data: any, taskInfo: CrudTaskInfo) { + async readFileAsText(file: File): Promise { + let fileReader = new FileReader(); + let text: string = ''; + await new Promise((resolve) => { + fileReader.onload = (_) => { + text = fileReader.result.toString(); + resolve(true); + }; + fileReader.readAsText(file); + }); + return text; + } + + async preSubmit(data: { [name: string]: any }) { + for (const [key, value] of Object.entries(data)) { + if (value instanceof FileList) { + let file = value[0]; + let text = await this.readFileAsText(file); + data[key] = text; + } + } + } + + async submit(data: { [name: string]: any }, taskInfo: CrudTaskInfo) { if (data) { let taskMetadata = {}; _.forEach(taskInfo.metadataFields, (field) => { taskMetadata[field] = data[field]; }); taskMetadata['__message'] = taskInfo.message; + await this.preSubmit(data); this.taskWrapper .wrapTaskAroundCall({ task: new FinishedTask('crud-component', taskMetadata), diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/crud-form/formly-file-type/formly-file-type-accessor.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/crud-form/formly-file-type/formly-file-type-accessor.ts new file mode 100644 index 0000000000000..2b35113b8f863 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/crud-form/formly-file-type/formly-file-type-accessor.ts @@ -0,0 +1,29 @@ +import { Directive } from '@angular/core'; +import { NG_VALUE_ACCESSOR, ControlValueAccessor } from '@angular/forms'; +@Directive({ + // eslint-disable-next-line + selector: 'input[type=file]', + // eslint-disable-next-line + host: { + '(change)': 'onChange($event.target.files)', + '(input)': 'onChange($event.target.files)', + '(blur)': 'onTouched()' + }, + providers: [ + { provide: NG_VALUE_ACCESSOR, useExisting: FormlyFileValueAccessorDirective, multi: true } + ] +}) +// https://github.com/angular/angular/issues/7341 +export class FormlyFileValueAccessorDirective implements ControlValueAccessor { + value: any; + onChange = (_: any) => {}; + onTouched = () => {}; + + writeValue(_value: any) {} + registerOnChange(fn: any) { + this.onChange = fn; + } + registerOnTouched(fn: any) { + this.onTouched = fn; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/crud-form/formly-file-type/formly-file-type.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/crud-form/formly-file-type/formly-file-type.component.html new file mode 100644 index 0000000000000..bf6dc6e89d952 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/crud-form/formly-file-type/formly-file-type.component.html @@ -0,0 +1,4 @@ + diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/crud-form/formly-file-type/formly-file-type.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/crud-form/formly-file-type/formly-file-type.component.scss new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/crud-form/formly-file-type/formly-file-type.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/crud-form/formly-file-type/formly-file-type.component.spec.ts new file mode 100644 index 0000000000000..cd8b3a243ee41 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/crud-form/formly-file-type/formly-file-type.component.spec.ts @@ -0,0 +1,40 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormControl } from '@angular/forms'; +import { FormlyModule } from '@ngx-formly/core'; + +import { FormlyFileTypeComponent } from './formly-file-type.component'; + +describe('FormlyFileTypeComponent', () => { + let component: FormlyFileTypeComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [FormlyModule.forRoot()], + declarations: [FormlyFileTypeComponent] + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(FormlyFileTypeComponent); + component = fixture.componentInstance; + + const formControl = new FormControl(); + const field = { + key: 'file', + type: 'file', + templateOptions: {}, + get formControl() { + return formControl; + } + }; + + component.field = field; + + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/crud-form/formly-file-type/formly-file-type.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/crud-form/formly-file-type/formly-file-type.component.ts new file mode 100644 index 0000000000000..742376bd878cd --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/crud-form/formly-file-type/formly-file-type.component.ts @@ -0,0 +1,9 @@ +import { Component } from '@angular/core'; +import { FieldType } from '@ngx-formly/core'; + +@Component({ + selector: 'cd-formly-file-type', + templateUrl: './formly-file-type.component.html', + styleUrls: ['./formly-file-type.component.scss'] +}) +export class FormlyFileTypeComponent extends FieldType {} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/crud-form/helpers.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/crud-form/helpers.ts index 2af3d91d9763d..1ea21b71081cc 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/crud-form/helpers.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/crud-form/helpers.ts @@ -1,6 +1,7 @@ import { ValidatorFn } from '@angular/forms'; import { FormlyFieldConfig } from '@ngx-formly/core'; import { forEach } from 'lodash'; +import { formlyAsyncFileValidator } from './validators/file-validator'; import { formlyAsyncJsonValidator } from './validators/json-validator'; import { formlyRgwRoleNameValidator, formlyRgwRolePath } from './validators/rgw-role-validator'; @@ -29,6 +30,10 @@ export function setupValidators(field: FormlyFieldConfig, uiSchema: any[]) { validators.push(formlyRgwRolePath); break; } + case 'file': { + validators.push(formlyAsyncFileValidator); + break; + } } }); field.asyncValidators = { validation: validators }; diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/crud-form/validators/file-validator.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/crud-form/validators/file-validator.ts new file mode 100644 index 0000000000000..672610422b35e --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/crud-form/validators/file-validator.ts @@ -0,0 +1,15 @@ +import { AbstractControl } from '@angular/forms'; + +export function formlyAsyncFileValidator(control: AbstractControl): Promise { + return new Promise((resolve, _reject) => { + if (control.value instanceof FileList) { + control.value; + let file = control.value[0]; + if (file.size > 4096) { + resolve({ file_size: true }); + } + resolve(null); + } + resolve({ not_a_file: true }); + }); +} 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 index 2f8a9bc9e29a1..140fa5b5f8ea4 100644 --- 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 @@ -5,6 +5,7 @@ class Table { columns: CdTableColumn[]; columnMode: string; toolHeader: boolean; + selectionType: string; } export class CrudMetadata { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/crud-form-adapter.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/crud-form-adapter.service.ts index 21e26d19dabda..ab0b7a6d6e64f 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/crud-form-adapter.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/crud-form-adapter.service.ts @@ -9,10 +9,18 @@ import { setupValidators } from '../forms/crud-form/helpers'; export class CrudFormAdapterService { constructor(private formlyJsonschema: FormlyJsonschema) {} - processJsonSchemaForm(response: any): JsonFormUISchema { - const title = response.forms[0].control_schema.title; - const uiSchema = response.forms[0].ui_schema; - const cSchema = response.forms[0].control_schema; + processJsonSchemaForm(response: any, path: string): JsonFormUISchema { + let form = 0; + while (form < response.forms.length) { + if (response.forms[form].path == path) { + break; + } + form++; + } + form %= response.forms.length; + const title = response.forms[form].control_schema.title; + const uiSchema = response.forms[form].ui_schema; + const cSchema = response.forms[form].control_schema; let controlSchema = this.formlyJsonschema.toFieldConfig(cSchema).fieldGroup; for (let i = 0; i < controlSchema.length; i++) { for (let j = 0; j < uiSchema.length; j++) { @@ -23,8 +31,8 @@ export class CrudFormAdapterService { } } let taskInfo: CrudTaskInfo = { - metadataFields: response.forms[0].task_info.metadataFields, - message: response.forms[0].task_info.message + metadataFields: response.forms[form].task_info.metadataFields, + message: response.forms[form].task_info.message }; return { title, uiSchema, controlSchema, taskInfo }; } 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 index 3424ca1b3c44f..15045d6d83198 100644 --- 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 @@ -44,7 +44,7 @@ export class DataGatewayService { }); } - form(dataPath: string): Observable { + form(dataPath: string, formPath: string): Observable { const cacheable = this.getCacheable(dataPath, 'get'); if (this.cache[cacheable] === undefined) { const { url, version } = this.getUrlAndVersion(dataPath); @@ -55,7 +55,7 @@ export class DataGatewayService { } return this.cache[cacheable].pipe( map((response) => { - return this.crudFormAdapater.processJsonSchemaForm(response); + return this.crudFormAdapater.processJsonSchemaForm(response, formPath); }) ); } diff --git a/src/pybind/mgr/dashboard/openapi.yaml b/src/pybind/mgr/dashboard/openapi.yaml index 7e2688ae94f2e..42532082801aa 100644 --- a/src/pybind/mgr/dashboard/openapi.yaml +++ b/src/pybind/mgr/dashboard/openapi.yaml @@ -2207,11 +2207,12 @@ paths: properties: capabilities: type: string + import_data: + default: '' + type: string user_entity: + default: '' type: string - required: - - user_entity - - capabilities type: object responses: '201': @@ -2238,6 +2239,44 @@ paths: summary: Create Ceph User tags: - Cluster + /api/cluster/user/export: + post: + parameters: [] + requestBody: + content: + application/json: + schema: + properties: + entities: + type: string + required: + - entities + type: object + responses: + '201': + content: + application/vnd.ceph.api.v1.0+json: + type: object + description: Resource created. + '202': + content: + application/vnd.ceph.api.v1.0+json: + type: object + description: Operation is still executing. Please check the task queue. + '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: Export Ceph Users + tags: + - Cluster /api/cluster/user/{user_entity}: delete: description: "\n Delete a ceph user and it's defined capabilities.\n\ @@ -10906,7 +10945,7 @@ tags: name: Auth - description: Cephfs Management API name: Cephfs -- description: Get Ceph Users +- description: Get Cluster Details name: Cluster - description: Manage Cluster Configurations name: ClusterConfiguration diff --git a/src/pybind/mgr/dashboard/services/ceph_service.py b/src/pybind/mgr/dashboard/services/ceph_service.py index 730556e1d2b84..f0e21c598989d 100644 --- a/src/pybind/mgr/dashboard/services/ceph_service.py +++ b/src/pybind/mgr/dashboard/services/ceph_service.py @@ -302,13 +302,14 @@ class CephService(object): return mgr.get("pg_summary")['by_pool'][pool['pool'].__str__()] @staticmethod - def send_command(srv_type, prefix, srv_spec='', **kwargs): - # type: (str, str, Optional[str], Any) -> Any + def send_command(srv_type, prefix, srv_spec='', to_json=True, inbuf='', **kwargs): + # type: (str, str, Optional[str], bool, str, Any) -> Any """ :type prefix: str :param srv_type: mon | :param kwargs: will be added to argdict :param srv_spec: typically empty. or something like ":0" + :param to_json: if true return as json format :raises PermissionError: See rados.make_ex :raises ObjectNotFound: See rados.make_ex @@ -323,11 +324,12 @@ class CephService(object): """ argdict = { "prefix": prefix, - "format": "json", } + if to_json: + argdict["format"] = "json" argdict.update({k: v for k, v in kwargs.items() if v is not None}) result = CommandResult("") - mgr.send_command(result, srv_type, srv_spec, json.dumps(argdict), "") + mgr.send_command(result, srv_type, srv_spec, json.dumps(argdict), "", inbuf=inbuf) r, outb, outs = result.wait() if r != 0: logger.error("send_command '%s' failed. (r=%s, outs=\"%s\", kwargs=%s)", prefix, r, -- 2.39.5