]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: import/export authx users
authorPere Diaz Bou <pere-altea@hotmail.com>
Thu, 6 Apr 2023 14:24:03 +0000 (16:24 +0200)
committerPedro Gonzalez Gomez <pegonzal@redhat.com>
Fri, 5 May 2023 10:45:05 +0000 (12:45 +0200)
Signed-off-by: Pere Diaz Bou <pdiazbou@redhat.com>
Fixes: https://tracker.ceph.com/issues/59486
(cherry picked from commit 62d762f6965c5b8585d223c06cb23071a856cfcb)

26 files changed:
src/pybind/mgr/dashboard/controllers/_crud.py
src/pybind/mgr/dashboard/controllers/ceph_users.py
src/pybind/mgr/dashboard/controllers/rgw.py
src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/ceph-user.service.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/confirmation-modal/confirmation-modal.component.html
src/pybind/mgr/dashboard/frontend/src/app/shared/components/confirmation-modal/confirmation-modal.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-button-panel/form-button-panel.component.html
src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-button-panel/form-button-panel.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/crud-table/crud-table.component.html
src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/crud-table/crud-table.component.scss
src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/crud-table/crud-table.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/datatable.module.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/forms/crud-form/crud-form.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/forms/crud-form/formly-file-type/formly-file-type-accessor.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/forms/crud-form/formly-file-type/formly-file-type.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/forms/crud-form/formly-file-type/formly-file-type.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/forms/crud-form/formly-file-type/formly-file-type.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/forms/crud-form/formly-file-type/formly-file-type.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/forms/crud-form/helpers.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/forms/crud-form/validators/file-validator.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/models/crud-table-metadata.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/crud-form-adapter.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/data-gateway.service.ts
src/pybind/mgr/dashboard/openapi.yaml
src/pybind/mgr/dashboard/services/ceph_service.py

index bf86773116fd7638b48454501e170ac1a218165a..7b2f13defac3cb8bb71aaff70ed8f66ccb6e46b1 100644 (file)
@@ -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):
index e66aa18fa2e9446066b0fbc964abebd228ec888d..77e67dc7ff8404679d0549dc0a3cc82203da1e4f 100644 (file)
@@ -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 <a '  # noqa: E501
+    'href="https://docs.ceph.com/en/latest/rados/operations/user-management/#authorization-capabilities"'  # noqa: E501
+    'target="_blank">here.</a>'
+)
+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):
index 85af1ae7fbaee089dcc86e6ac9cdeaf9dc501144..b7680c7860a9cf4357d52375492014d3dcb9796c 100644 (file)
@@ -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],
index badaedd996a360f2358b9af327c99e549a30f00f..c053d51ef0bf9dacdbed68435102705b5a7a928f 100644 (file)
@@ -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 (file)
index 0000000..c41c70d
--- /dev/null
@@ -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 });
+  }
+}
index 294d43f775b21c1b184294323901bf0e309b13ea..1c80dc4dd4d6dab2d60a9d5240229d5b36eb4238 100644 (file)
@@ -20,6 +20,7 @@
                               (backActionEvent)="boundCancel()"
                               [form]="confirmationForm"
                               [submitText]="buttonText"
+                              [showCancel]="showCancel"
                               [showSubmit]="showSubmit"></cd-form-button-panel>
       </div>
     </form>
index fe56249816a01fb794725badaf8612f026fb3876..b98cea93846f6f01c65082448ee1c66bf13ec8a1 100644 (file)
@@ -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);
index 476ed96096c122f717a1bc6289c61c57ce3d031d..944541f2e9229795dcb126e3314ff7b7a4491be0 100644 (file)
@@ -1,5 +1,6 @@
 <div [class]="wrappingClass">
-  <cd-back-button class="m-2"
+  <cd-back-button *ngIf="showCancel"
+                  class="m-2"
                   (backAction)="backAction()"
                   [name]="cancelText"></cd-back-button>
   <cd-submit-button *ngIf="showSubmit"
index 0d48f63c05b65e4bc4bec4886b80dd9fee4a262e..fa0483b182d8c60caf8c8c74f08219b1eaeee3f5 100644 (file)
@@ -25,6 +25,8 @@ export class FormButtonPanelComponent {
   @Input()
   showSubmit = true;
   @Input()
+  showCancel = true;
+  @Input()
   wrappingClass = '';
   @Input()
   btnClass = '';
index 5b7c58bc1c7c01c086b1b5be4727d3f782cf2d7d..7e1a7f2b341862dc90dc90db80daabefb042a6a2 100644 (file)
       [data]="data$ | async"
       [columns]="meta.table.columns"
       [columnMode]="meta.table.columnMode"
-      [toolHeader]="meta.table.toolHeader"
-      selectionType="single"
       (setExpandedRow)="setExpandedRow($event)"
+      [hasDetails]="meta.detail_columns.length > 0"
+      [selectionType]="meta.table.selectionType"
       (updateSelection)="updateSelection($event)"
-      [hasDetails]="meta.detail_columns.length > 0">
+      [toolHeader]="meta.table.toolHeader">
     <div class="table-actions btn-toolbar">
       <cd-table-actions [permission]="permission"
                         [selection]="selection"
              let-value="value">
   <span>{{ value | duration }}</span>
 </ng-template>
+
+<ng-template #exportDataModalTpl>
+  <div class="d-flex flex-column align-items-center w-100 gap-3">
+    <textarea readonly
+              class="form-control w-100 bg-light height-400"
+              id="authExportArea">{{ modalState.authExportData }}</textarea>
+    <cd-copy-2-clipboard-button class="align-self-end"
+                                source="authExportArea">
+
+    </cd-copy-2-clipboard-button>
+  </div>
+</ng-template>
index 75ff5341c08cc6ae061f6fa1a28c425db713c522..bdb3b44bd73b1312581ab259b2367e41df2b1489 100644 (file)
@@ -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<any>;
   @ViewChild('durationTpl')
   public durationTpl: TemplateRef<any>;
+  @ViewChild('exportDataModalTpl')
+  public authxEportTpl: TemplateRef<any>;
 
   data$: Observable<any>;
   meta$: Observable<CrudMetadata>;
@@ -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);
+    });
+  }
 }
index 8b091c0dd31e12e551c2436967ffb6bc31792943..71c5d8f72e12d658b89441b97ba4b45d3aa4cf72 100644 (file)
@@ -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,
index 596740fdcd9757c2891b3f5cb1149763460b2368..14ddccbc1957943d09c3deede32c3805aa62dc33 100644 (file)
@@ -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<string> {
+    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 (file)
index 0000000..2b35113
--- /dev/null
@@ -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 (file)
index 0000000..bf6dc6e
--- /dev/null
@@ -0,0 +1,4 @@
+<input type="file"
+       [formControl]="formControl"
+       [formlyAttributes]="field"
+       />
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 (file)
index 0000000..e69de29
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 (file)
index 0000000..cd8b3a2
--- /dev/null
@@ -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<FormlyFileTypeComponent>;
+
+  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 (file)
index 0000000..742376b
--- /dev/null
@@ -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 {}
index 2af3d91d9763d145580186a8a5128dccb21a2f0d..1ea21b71081cc638628f5911fc986a7f2283a1af 100644 (file)
@@ -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 (file)
index 0000000..6726104
--- /dev/null
@@ -0,0 +1,15 @@
+import { AbstractControl } from '@angular/forms';
+
+export function formlyAsyncFileValidator(control: AbstractControl): Promise<any> {
+  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 });
+  });
+}
index 2f8a9bc9e29a13411e71ac4996f2d1aa44a7e627..140fa5b5f8ea40fca2d05e371b38fd570c665a9c 100644 (file)
@@ -5,6 +5,7 @@ class Table {
   columns: CdTableColumn[];
   columnMode: string;
   toolHeader: boolean;
+  selectionType: string;
 }
 
 export class CrudMetadata {
index 21e26d19dabda48399105c807c293261ef8a67ad..ab0b7a6d6e64f58ff167b9fd42b9c9656c1add81 100644 (file)
@@ -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 };
   }
index 3424ca1b3c44f145c83c4d4f7742cb16b1af7a1b..15045d6d831984191b064a8d38eae3ba22176bab 100644 (file)
@@ -44,7 +44,7 @@ export class DataGatewayService {
     });
   }
 
-  form(dataPath: string): Observable<JsonFormUISchema> {
+  form(dataPath: string, formPath: string): Observable<JsonFormUISchema> {
     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);
       })
     );
   }
index 7e2688ae94f2e62e4de8978a838894d62ed0f0c1..42532082801aac520ce16b8db273574e349bba67 100644 (file)
@@ -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
index 730556e1d2b844af741768d5d44fddac0136beef..f0e21c598989d692c11a525f82adc7ab4a070049 100644 (file)
@@ -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 "<fs_id>: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,