]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: rgw role creation form 50426/head
authorPere Diaz Bou <pdiazbou@redhat.com>
Thu, 2 Mar 2023 12:17:25 +0000 (13:17 +0100)
committerPere Diaz Bou <pdiazbou@redhat.com>
Tue, 28 Mar 2023 09:19:10 +0000 (11:19 +0200)
Fixes: https://tracker.ceph.com/issues/59187
Signed-off-by: Pere Diaz Bou <pdiazbou@redhat.com>
Signed-off-by: Nizamudeen A <nia@redhat.com>
28 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/ceph/rgw/rgw.module.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.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.html
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/crud-form.model.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/forms/crud-form/formly-input-type/formly-input-type.component.html
src/pybind/mgr/dashboard/frontend/src/app/shared/forms/crud-form/formly-input-wrapper/formly-input-wrapper.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/forms/crud-form/formly-input-wrapper/formly-input-wrapper.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/forms/crud-form/formly-input-wrapper/formly-input-wrapper.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/forms/crud-form/formly-input-wrapper/formly-input-wrapper.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/forms/crud-form/formly-textarea-type/formly-textarea-type.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/forms/crud-form/formly-textarea-type/formly-textarea-type.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/forms/crud-form/formly-textarea-type/formly-textarea-type.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/forms/crud-form/formly-textarea-type/formly-textarea-type.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/forms/crud-form/helpers.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/forms/crud-form/validators/json-validator.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/forms/crud-form/validators/rgw-role-validator.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/services/crud-form-adapter.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/shared.module.ts
src/pybind/mgr/dashboard/frontend/src/styles.scss
src/pybind/mgr/dashboard/openapi.yaml
src/pybind/mgr/dashboard/services/rgw_client.py

index cbf87e54be3754b55eaa0a1137916db1dffb2432..63224928b1bbd1016bde825a9bdb566aae4d41ee 100644 (file)
@@ -78,6 +78,12 @@ class Icon(Enum):
     add = 'fa fa-plus'
 
 
+class Validator(Enum):
+    JSON = 'json'
+    RGW_ROLE_NAME = 'rgwRoleName'
+    RGW_ROLE_PATH = 'rgwRolePath'
+
+
 class FormField(NamedTuple):
     """
     The key of a FromField is then used to send the data related to that key into the
@@ -89,6 +95,8 @@ class FormField(NamedTuple):
     field_type: Any = str
     default_value: Optional[Any] = None
     optional: bool = False
+    help: str = ''
+    validators: List[Validator] = []
 
     def get_type(self):
         _type = ''
@@ -98,6 +106,8 @@ class FormField(NamedTuple):
             _type = 'int'
         elif self.field_type == bool:
             _type = 'boolean'
+        elif self.field_type == 'textarea':
+            _type = 'textarea'
         else:
             raise NotImplementedError(f'Unimplemented type {self.field_type}')
         return _type
@@ -173,7 +183,7 @@ class Container:
 
         # include fields in this container's schema
         for field in self.fields:
-            field_ui_schema = {}
+            field_ui_schema: Dict[str, Any] = {}
             properties[field.key] = {}
             field_key = field.key
             if key:
@@ -187,6 +197,8 @@ class Container:
                 properties[field.key]['type'] = _type
                 properties[field.key]['title'] = field.name
                 field_ui_schema['key'] = field_key
+                field_ui_schema['help'] = f'{field.help}'
+                field_ui_schema['validators'] = [i.value for i in field.validators]
                 items.append(field_ui_schema)
             elif isinstance(field, Container):
                 container_schema = field.to_dict(key+'.'+field.key if key else field.key)
@@ -232,13 +244,26 @@ class ArrayHorizontalContainer(Container):
         return 'array'
 
 
+class FormTaskInfo:
+    def __init__(self, message: str, metadata_fields: List[str]) -> None:
+        self.message = message
+        self.metadata_fields = metadata_fields
+
+    def to_dict(self):
+        return {'message': self.message, 'metadataFields': self.metadata_fields}
+
+
 class Form:
-    def __init__(self, path, root_container):
+    def __init__(self, path, root_container,
+                 task_info: FormTaskInfo = FormTaskInfo("Unknown task", [])):
         self.path = path
-        self.root_container = root_container
+        self.root_container: Container = root_container
+        self.task_info = task_info
 
     def to_dict(self):
-        return self.root_container.to_dict()
+        res = self.root_container.to_dict()
+        res['task_info'] = self.task_info.to_dict()
+        return res
 
 
 class CRUDMeta(SerializableClass):
@@ -247,6 +272,7 @@ class CRUDMeta(SerializableClass):
         self.permissions = []
         self.actions = []
         self.forms = []
+        self.detail_columns = []
 
 
 class CRUDCollectionMethod(NamedTuple):
@@ -270,26 +296,18 @@ class CRUDEndpoint:
                  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):
+                 create: Optional[CRUDCollectionMethod] = None,
+                 detail_columns: Optional[List[str]] = 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.actions = actions if actions is not None else []
+        self.forms = forms if forms is not None else []
         self.meta = meta
         self.get_all = get_all
         self.create = create
-        if permissions:
-            self.permissions = permissions
-        else:
-            self.permissions = []
+        self.permissions = permissions if permissions is not None else []
+        self.detail_columns = detail_columns if detail_columns is not None else []
 
     def __call__(self, cls: Any):
         self.create_crud_class(cls)
@@ -328,8 +346,13 @@ class CRUDEndpoint:
             self.generate_actions()
             self.generate_forms()
             self.set_permissions()
+            self.get_detail_columns()
             return serialize(self.__class__.outer_self.meta)
 
+        def get_detail_columns(self):
+            columns = self.__class__.outer_self.detail_columns
+            self.__class__.outer_self.meta.detail_columns = columns
+
         def update_columns(self):
             if self.__class__.outer_self.set_column:
                 for i, column in enumerate(self.__class__.outer_self.meta.table.columns):
@@ -371,6 +394,7 @@ class CRUDEndpoint:
                               'generate_actions': generate_actions,
                               'generate_forms': generate_forms,
                               'set_permissions': set_permissions,
+                              'get_detail_columns': get_detail_columns,
                               'outer_self': self,
                           })
         UIRouter(self.router.path, self.router.security_scope)(meta_class)
index 344ad4c04c1f5039bb57548022ad1f70ff4b7e92..2de1d36c78b5cbe10b63a58ab833303668280416 100644 (file)
@@ -6,8 +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, CRUDMeta, Form, FormField, Icon, \
-    TableAction, VerticalContainer
+from ._crud import ArrayHorizontalContainer, CRUDMeta, Form, FormField, \
+    FormTaskInfo, Icon, TableAction, VerticalContainer
 
 logger = logging.getLogger("controllers.ceph_users")
 
@@ -71,7 +71,9 @@ create_container = VerticalContainer('Create User', 'create_user', fields=[
 ])
 
 create_form = Form(path='/cluster/user/create',
-                   root_container=create_container)
+                   root_container=create_container,
+                   task_info=FormTaskInfo("Ceph user '{user_entity}' created successfully",
+                                          ['user_entity']))
 
 
 @CRUDEndpoint(
index 909bdecba5ad053ec2623ec55b4e4dfb0265bb79..93e8af5706eb47cf5243faa44502faf1d2c86c1a 100644 (file)
@@ -16,7 +16,8 @@ from ..tools import json_str_to_object, str_to_bool
 from . import APIDoc, APIRouter, BaseController, CRUDCollectionMethod, \
     CRUDEndpoint, Endpoint, EndpointDoc, ReadPermission, RESTController, \
     UIRouter, allow_empty_body
-from ._crud import CRUDMeta
+from ._crud import CRUDMeta, Form, FormField, FormTaskInfo, Icon, TableAction, \
+    Validator, VerticalContainer
 from ._version import APIVersion
 
 logger = logging.getLogger("controllers.rgw")
@@ -633,21 +634,61 @@ class RGWRoleEndpoints:
         roles = rgw_client.list_roles()
         return roles
 
+    @staticmethod
+    def role_create(_, role_name: str = '', role_path: str = '', role_assume_policy_doc: str = ''):
+        assert role_name
+        assert role_path
+        rgw_client = RgwClient.admin_instance()
+        rgw_client.create_role(role_name, role_path, role_assume_policy_doc)
+        return f'Role {role_name} created successfully'
+
+
+# pylint: disable=C0301
+assume_role_policy_help = (
+    'Paste a json assume role policy document, to find more information on how to get this document, <a '  # noqa: E501
+    'href="https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-iam-role.html#cfn-iam-role-assumerolepolicydocument"'  # noqa: E501
+    'target="_blank">click here.</a>'
+)
+
+create_container = VerticalContainer('Create Role', 'create_role', fields=[
+    FormField('Role name', 'role_name', validators=[Validator.RGW_ROLE_NAME]),
+    FormField('Path', 'role_path', validators=[Validator.RGW_ROLE_PATH]),
+    FormField('Assume Role Policy Document',
+              'role_assume_policy_doc',
+              help=assume_role_policy_help,
+              field_type='textarea',
+              validators=[Validator.JSON]),
+])
+create_role_form = Form(path='/rgw/user/roles/create',
+                        root_container=create_container,
+                        task_info=FormTaskInfo("IAM RGW Role '{role_name}' created successfully",
+                                               ['role_name']))
+
 
 @CRUDEndpoint(
     router=APIRouter('/rgw/user/roles', Scope.RGW),
     doc=APIDoc("List of RGW roles", "RGW"),
-    actions=[],
+    actions=[
+        TableAction(name='Create', permission='create', icon=Icon.add.value,
+                    routerLink='/rgw/user/roles/create')
+    ],
+    forms=[create_role_form],
     permissions=[Scope.CONFIG_OPT],
     get_all=CRUDCollectionMethod(
         func=RGWRoleEndpoints.role_list,
         doc=EndpointDoc("List RGW roles")
     ),
+    create=CRUDCollectionMethod(
+        func=RGWRoleEndpoints.role_create,
+        doc=EndpointDoc("Create Ceph User")
+    ),
     set_column={
         "CreateDate": {'cellTemplate': 'date'},
         "MaxSessionDuration": {'cellTemplate': 'duration'},
+        "RoleId": {'isHidden': True},
         "AssumeRolePolicyDocument": {'isHidden': True}
     },
+    detail_columns=['RoleId', 'AssumeRolePolicyDocument'],
     meta=CRUDMeta()
 )
 class RgwUserRole(NamedTuple):
index 6bd374c3baebb75d29ad96ef700d4da08ec1e41d..d7074b690dec5c628564a67fc373ae732bc5ff2c 100644 (file)
@@ -33,6 +33,7 @@ import { ModuleStatusGuardService } from '~/app/shared/services/module-status-gu
 import { RgwMultisiteRealmFormComponent } from './rgw-multisite-realm-form/rgw-multisite-realm-form.component';
 import { RgwMultisiteZonegroupFormComponent } from './rgw-multisite-zonegroup-form/rgw-multisite-zonegroup-form.component';
 import { RgwMultisiteZoneFormComponent } from './rgw-multisite-zone-form/rgw-multisite-zone-form.component';
+import { CrudFormComponent } from '~/app/shared/forms/crud-form/crud-form.component';
 
 @NgModule({
   imports: [
@@ -103,7 +104,6 @@ const routes: Routes = [
       },
       {
         path: 'roles',
-        component: CRUDTableComponent,
         data: {
           breadcrumbs: 'Roles',
           resource: 'api.rgw.user.roles@1.0',
@@ -117,7 +117,20 @@ const routes: Routes = [
               url: '/rgw/user/roles'
             }
           ]
-        }
+        },
+        children: [
+          {
+            path: '',
+            component: CRUDTableComponent
+          },
+          {
+            path: URLVerbs.CREATE,
+            component: CrudFormComponent,
+            data: {
+              breadcrumbs: ActionLabels.CREATE
+            }
+          }
+        ]
       }
     ]
   },
index d1aed4462d7f7ec949fd49ad33db3964ad7f2f84..5b7c58bc1c7c01c086b1b5be4727d3f782cf2d7d 100644 (file)
       [data]="data$ | async"
       [columns]="meta.table.columns"
       [columnMode]="meta.table.columnMode"
-      [toolHeader]="meta.table.toolHeader">
+      [toolHeader]="meta.table.toolHeader"
+      selectionType="single"
+      (setExpandedRow)="setExpandedRow($event)"
+      (updateSelection)="updateSelection($event)"
+      [hasDetails]="meta.detail_columns.length > 0">
     <div class="table-actions btn-toolbar">
       <cd-table-actions [permission]="permission"
                         [selection]="selection"
index 96346ea7a1b7e8f567b51e99df706ae82669ba2a..7241a08b63894cf03b462b76d94ed160b938065c 100644 (file)
@@ -30,6 +30,7 @@ export class CRUDTableComponent implements OnInit {
   permissions: Permissions;
   permission: Permission;
   selection = new CdTableSelection();
+  expandedRow: any = null;
   tabs = {};
 
   constructor(
@@ -86,4 +87,11 @@ export class CRUDTableComponent implements OnInit {
     });
     this.meta = meta;
   }
+
+  updateSelection(selection: CdTableSelection) {
+    this.selection = selection;
+  }
+  setExpandedRow(event: any) {
+    this.expandedRow = event;
+  }
 }
index 3d15a250b007135a58191e991fc148733bbac2aa..8b091c0dd31e12e551c2436967ffb6bc31792943 100644 (file)
@@ -20,6 +20,8 @@ import { CrudFormComponent } from '../forms/crud-form/crud-form.component';
 import { FormlyArrayTypeComponent } from '../forms/crud-form/formly-array-type/formly-array-type.component';
 import { FormlyInputTypeComponent } from '../forms/crud-form/formly-input-type/formly-input-type.component';
 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';
 
 @NgModule({
   imports: [
@@ -37,9 +39,26 @@ import { FormlyObjectTypeComponent } from '../forms/crud-form/formly-object-type
       types: [
         { name: 'array', component: FormlyArrayTypeComponent },
         { name: 'object', component: FormlyObjectTypeComponent },
-        { name: 'input', component: FormlyInputTypeComponent }
+        { name: 'input', component: FormlyInputTypeComponent, wrappers: ['input-wrapper'] },
+        { name: 'textarea', component: FormlyTextareaTypeComponent, wrappers: ['input-wrapper'] }
       ],
-      validationMessages: [{ name: 'required', message: 'This field is required' }]
+      validationMessages: [
+        { name: 'required', message: 'This field is required' },
+        { name: 'json', message: 'This field is not a valid json document' },
+        {
+          name: 'rgwRoleName',
+          message:
+            'Role name must contain letters, numbers or the ' +
+            'following valid special characters "_+=,.@-]+" (pattern: [0-9a-zA-Z_+=,.@-]+)'
+        },
+        {
+          name: 'rgwRolePath',
+          message:
+            'Role path must start and finish with a slash "/".' +
+            ' (pattern: (\u002F)|(\u002F[\u0021-\u007E]+\u002F))'
+        }
+      ],
+      wrappers: [{ name: 'input-wrapper', component: FormlyInputWrapperComponent }]
     }),
     FormlyBootstrapModule
   ],
@@ -52,7 +71,8 @@ import { FormlyObjectTypeComponent } from '../forms/crud-form/formly-object-type
     CrudFormComponent,
     FormlyArrayTypeComponent,
     FormlyInputTypeComponent,
-    FormlyObjectTypeComponent
+    FormlyObjectTypeComponent,
+    FormlyInputWrapperComponent
   ],
   exports: [
     TableComponent,
index 9102b892ff5572f2d4d5b8e9b17204a37769a804..002acb51e77b25b3dc1f16385a4625c75e023699 100644 (file)
@@ -5,16 +5,16 @@
          class="card-header">{{ formUISchema.title }}</div>
     <form *ngIf="formUISchema.uiSchema"
           [formGroup]="form"
-          (ngSubmit)="submit(model)">
+          (ngSubmit)="submit(model, formUISchema.taskInfo)">
 
       <div class="card-body position-relative">
         <formly-form [form]="form"
                      [fields]="formUISchema.controlSchema"
                      [model]="model"
-                     [options]="options"></formly-form>
+                     [options]="{formState: formUISchema.uiSchema}"></formly-form>
       </div>
       <div class="card-footer">
-        <cd-form-button-panel (submitActionEvent)="submit(model)"
+        <cd-form-button-panel (submitActionEvent)="submit(model, formUISchema.taskInfo)"
                               [form]="formDir"
                               [submitText]="formUISchema.title"
                               [disabled]="!form.valid"
index 3734bc84c499f0024d2122328315cf561704571a..596740fdcd9757c2891b3f5cb1149763460b2368 100644 (file)
@@ -5,10 +5,10 @@ import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
 import { FinishedTask } from '~/app/shared/models/finished-task';
 import { Location } from '@angular/common';
 import { FormGroup } from '@angular/forms';
-import { FormlyFormOptions } from '@ngx-formly/core';
 import { mergeMap } from 'rxjs/operators';
-import { JsonFormUISchema } from './crud-form.model';
+import { CrudTaskInfo, JsonFormUISchema } from './crud-form.model';
 import { Observable } from 'rxjs';
+import _ from 'lodash';
 
 @Component({
   selector: 'cd-crud-form',
@@ -17,8 +17,8 @@ import { Observable } from 'rxjs';
 })
 export class CrudFormComponent implements OnInit {
   model: any = {};
-  options: FormlyFormOptions = {};
   resource: string;
+  task: { message: string; id: string } = { message: '', id: '' };
   form = new FormGroup({});
   formUISchema$: Observable<JsonFormUISchema>;
 
@@ -38,13 +38,16 @@ export class CrudFormComponent implements OnInit {
     );
   }
 
-  submit(data: any) {
+  submit(data: any, taskInfo: CrudTaskInfo) {
     if (data) {
+      let taskMetadata = {};
+      _.forEach(taskInfo.metadataFields, (field) => {
+        taskMetadata[field] = data[field];
+      });
+      taskMetadata['__message'] = taskInfo.message;
       this.taskWrapper
         .wrapTaskAroundCall({
-          task: new FinishedTask('ceph-user/create', {
-            user_entity: data.user_entity
-          }),
+          task: new FinishedTask('crud-component', taskMetadata),
           call: this.dataGatewayService.create(this.resource, data)
         })
         .subscribe({
index 01574edd86405aed73d76b8c51859e19df461099..fbded8f24bc222cde8c20746872c97ac4846cad7 100644 (file)
@@ -1,7 +1,13 @@
 import { FormlyFieldConfig } from '@ngx-formly/core';
 
+export interface CrudTaskInfo {
+  metadataFields: string[];
+  message: string;
+}
+
 export interface JsonFormUISchema {
   title: string;
   controlSchema: FormlyFieldConfig[];
   uiSchema: any;
+  taskInfo: CrudTaskInfo;
 }
index e090e7d6066ace1ca2eb5a3b2af87a9c3249ccea..2d807d02b82f3959fd2a00d631009384a4f26304 100644 (file)
@@ -1,3 +1,3 @@
 <input [formControl]="formControl"
-       class="form-control cd-col-form-input"
-       [formlyAttributes]="field">
+       [formlyAttributes]="field"
+       class="form-control col-form-input"/>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/crud-form/formly-input-wrapper/formly-input-wrapper.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/crud-form/formly-input-wrapper/formly-input-wrapper.component.html
new file mode 100644 (file)
index 0000000..b8f1a47
--- /dev/null
@@ -0,0 +1,37 @@
+<ng-template #labelTemplate>
+  <div class="d-flex align-items-center">
+    <label *ngIf="props.label && props.hideLabel !== true"
+           [attr.for]="id"
+           class="form-label">
+      {{ props.label }}
+      <span *ngIf="props.required && props.hideRequiredMarker !== true"
+            aria-hidden="true">*</span>
+      <cd-helper *ngIf="helper">
+        <span [innerHTML]="helper"></span>
+      </cd-helper>
+    </label>
+  </div>
+</ng-template>
+
+<div class="mb-3"
+     [class.form-floating]="props.labelPosition === 'floating'"
+     [class.has-error]="showError">
+  <ng-container *ngIf="props.labelPosition !== 'floating'">
+    <ng-container [ngTemplateOutlet]="labelTemplate"></ng-container>
+  </ng-container>
+
+  <ng-container #fieldComponent></ng-container>
+
+  <ng-container *ngIf="props.labelPosition === 'floating'">
+    <ng-container [ngTemplateOutlet]="labelTemplate"></ng-container>
+  </ng-container>
+
+  <div *ngIf="showError"
+       class="invalid-feedback"
+       [style.display]="'block'">
+    <formly-validation-message [field]="field"></formly-validation-message>
+  </div>
+
+  <small *ngIf="props.description"
+         class="form-text text-muted">{{ props.description }}</small>
+</div>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/crud-form/formly-input-wrapper/formly-input-wrapper.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/crud-form/formly-input-wrapper/formly-input-wrapper.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-input-wrapper/formly-input-wrapper.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/crud-form/formly-input-wrapper/formly-input-wrapper.component.spec.ts
new file mode 100644 (file)
index 0000000..db93615
--- /dev/null
@@ -0,0 +1,47 @@
+import { Component } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { FormGroup } from '@angular/forms';
+import { FormlyFieldConfig, FormlyModule } from '@ngx-formly/core';
+
+import { FormlyInputWrapperComponent } from './formly-input-wrapper.component';
+
+@Component({
+  template: ` <form [formGroup]="form">
+    <formly-form [model]="{}" [fields]="fields" [options]="{}" [form]="form"></formly-form>
+  </form>`
+})
+class MockFormComponent {
+  form = new FormGroup({});
+  fields: FormlyFieldConfig[] = [
+    {
+      wrappers: ['input'],
+      defaultValue: {}
+    }
+  ];
+}
+
+describe('FormlyInputWrapperComponent', () => {
+  let component: MockFormComponent;
+  let fixture: ComponentFixture<MockFormComponent>;
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      declarations: [FormlyInputWrapperComponent],
+      imports: [
+        FormlyModule.forRoot({
+          types: [{ name: 'input', component: FormlyInputWrapperComponent }]
+        })
+      ]
+    }).compileComponents();
+  });
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(MockFormComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/crud-form/formly-input-wrapper/formly-input-wrapper.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/crud-form/formly-input-wrapper/formly-input-wrapper.component.ts
new file mode 100644 (file)
index 0000000..baed44a
--- /dev/null
@@ -0,0 +1,15 @@
+import { Component } from '@angular/core';
+import { FieldWrapper } from '@ngx-formly/core';
+import { getFieldState } from '../helpers';
+
+@Component({
+  selector: 'cd-formly-input-wrapper',
+  templateUrl: './formly-input-wrapper.component.html',
+  styleUrls: ['./formly-input-wrapper.component.scss']
+})
+export class FormlyInputWrapperComponent extends FieldWrapper {
+  get helper(): string {
+    const fieldState = getFieldState(this.field);
+    return fieldState?.help || '';
+  }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/crud-form/formly-textarea-type/formly-textarea-type.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/crud-form/formly-textarea-type/formly-textarea-type.component.html
new file mode 100644 (file)
index 0000000..603a3dd
--- /dev/null
@@ -0,0 +1,9 @@
+<textarea #textArea
+          [formControl]="formControl"
+          [cols]="props.cols"
+          [rows]="props.rows"
+          class="form-control"
+          [class.is-invalid]="showError"
+          [formlyAttributes]="field"
+          (change)="onChange()">
+</textarea>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/crud-form/formly-textarea-type/formly-textarea-type.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/crud-form/formly-textarea-type/formly-textarea-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-textarea-type/formly-textarea-type.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/crud-form/formly-textarea-type/formly-textarea-type.component.spec.ts
new file mode 100644 (file)
index 0000000..f327178
--- /dev/null
@@ -0,0 +1,47 @@
+import { Component } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { FormGroup } from '@angular/forms';
+import { FormlyFieldConfig, FormlyModule } from '@ngx-formly/core';
+
+import { FormlyTextareaTypeComponent } from './formly-textarea-type.component';
+
+@Component({
+  template: ` <form [formGroup]="form">
+    <formly-form [model]="{}" [fields]="fields" [options]="{}" [form]="form"></formly-form>
+  </form>`
+})
+class MockFormComponent {
+  options = {};
+  form = new FormGroup({});
+  fields: FormlyFieldConfig[] = [
+    {
+      wrappers: ['input'],
+      defaultValue: {}
+    }
+  ];
+}
+describe('FormlyTextareaTypeComponent', () => {
+  let component: MockFormComponent;
+  let fixture: ComponentFixture<MockFormComponent>;
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      declarations: [FormlyTextareaTypeComponent],
+      imports: [
+        FormlyModule.forRoot({
+          types: [{ name: 'input', component: FormlyTextareaTypeComponent }]
+        })
+      ]
+    }).compileComponents();
+  });
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(MockFormComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/crud-form/formly-textarea-type/formly-textarea-type.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/crud-form/formly-textarea-type/formly-textarea-type.component.ts
new file mode 100644 (file)
index 0000000..a3139f0
--- /dev/null
@@ -0,0 +1,25 @@
+import { Component, ViewChild, ElementRef } from '@angular/core';
+import { FieldType, FieldTypeConfig } from '@ngx-formly/core';
+
+@Component({
+  selector: 'cd-formly-textarea-type',
+  templateUrl: './formly-textarea-type.component.html',
+  styleUrls: ['./formly-textarea-type.component.scss']
+})
+export class FormlyTextareaTypeComponent extends FieldType<FieldTypeConfig> {
+  @ViewChild('textArea')
+  public textArea: ElementRef<any>;
+
+  onChange() {
+    const value = this.textArea.nativeElement.value;
+    try {
+      const formatted = JSON.stringify(JSON.parse(value), null, 2);
+      this.textArea.nativeElement.value = formatted;
+      this.textArea.nativeElement.style.height = 'auto';
+      const lineNumber = formatted.split('\n').length;
+      const pixelPerLine = 25;
+      const pixels = lineNumber * pixelPerLine;
+      this.textArea.nativeElement.style.height = pixels + 'px';
+    } catch (e) {}
+  }
+}
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
new file mode 100644 (file)
index 0000000..2af3d91
--- /dev/null
@@ -0,0 +1,35 @@
+import { ValidatorFn } from '@angular/forms';
+import { FormlyFieldConfig } from '@ngx-formly/core';
+import { forEach } from 'lodash';
+import { formlyAsyncJsonValidator } from './validators/json-validator';
+import { formlyRgwRoleNameValidator, formlyRgwRolePath } from './validators/rgw-role-validator';
+
+export function getFieldState(field: FormlyFieldConfig, uiSchema: any[] = undefined) {
+  const formState: any[] = uiSchema || field.options?.formState;
+  if (formState) {
+    return formState.find((element) => element.key == field.key);
+  }
+  return {};
+}
+
+export function setupValidators(field: FormlyFieldConfig, uiSchema: any[]) {
+  const fieldState = getFieldState(field, uiSchema);
+  let validators: ValidatorFn[] = [];
+  forEach(fieldState.validators, (validatorStr) => {
+    switch (validatorStr) {
+      case 'json': {
+        validators.push(formlyAsyncJsonValidator);
+        break;
+      }
+      case 'rgwRoleName': {
+        validators.push(formlyRgwRoleNameValidator);
+        break;
+      }
+      case 'rgwRolePath': {
+        validators.push(formlyRgwRolePath);
+        break;
+      }
+    }
+  });
+  field.asyncValidators = { validation: validators };
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/crud-form/validators/json-validator.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/crud-form/validators/json-validator.ts
new file mode 100644 (file)
index 0000000..8ffea04
--- /dev/null
@@ -0,0 +1,12 @@
+import { AbstractControl } from '@angular/forms';
+
+export function formlyAsyncJsonValidator(control: AbstractControl): Promise<any> {
+  return new Promise((resolve, _reject) => {
+    try {
+      JSON.parse(control.value);
+      resolve(null);
+    } catch (e) {
+      resolve({ json: true });
+    }
+  });
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/crud-form/validators/rgw-role-validator.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/crud-form/validators/rgw-role-validator.ts
new file mode 100644 (file)
index 0000000..a100f27
--- /dev/null
@@ -0,0 +1,19 @@
+import { AbstractControl } from '@angular/forms';
+
+export function formlyRgwRolePath(control: AbstractControl): Promise<any> {
+  return new Promise((resolve, _reject) => {
+    if (control.value.match('^((\u002F)|(\u002F[\u0021-\u007E]+\u002F))$')) {
+      resolve(null);
+    }
+    resolve({ rgwRolePath: true });
+  });
+}
+
+export function formlyRgwRoleNameValidator(control: AbstractControl): Promise<any> {
+  return new Promise((resolve, _reject) => {
+    if (control.value.match('^[0-9a-zA-Z_+=,.@-]+$')) {
+      resolve(null);
+    }
+    resolve({ rgwRoleName: true });
+  });
+}
index f9cd35ffdeeb9d3842ff09144b5f529854f9ad7d..21e26d19dabda48399105c807c293261ef8a67ad 100644 (file)
@@ -1,6 +1,7 @@
 import { Injectable } from '@angular/core';
 import { FormlyJsonschema } from '@ngx-formly/core/json-schema';
-import { JsonFormUISchema } from '../forms/crud-form/crud-form.model';
+import { CrudTaskInfo, JsonFormUISchema } from '../forms/crud-form/crud-form.model';
+import { setupValidators } from '../forms/crud-form/helpers';
 
 @Injectable({
   providedIn: 'root'
@@ -16,11 +17,15 @@ export class CrudFormAdapterService {
     for (let i = 0; i < controlSchema.length; i++) {
       for (let j = 0; j < uiSchema.length; j++) {
         if (controlSchema[i].key == uiSchema[j].key) {
-          controlSchema[i].className = uiSchema[j].className;
           controlSchema[i].props.templateOptions = uiSchema[j].templateOptions;
+          setupValidators(controlSchema[i], uiSchema);
         }
       }
     }
-    return { title, uiSchema, controlSchema };
+    let taskInfo: CrudTaskInfo = {
+      metadataFields: response.forms[0].task_info.metadataFields,
+      message: response.forms[0].task_info.message
+    };
+    return { title, uiSchema, controlSchema, taskInfo };
   }
 }
index 595e4fc96f8cabc1a57dea79d9f1d4f9baa776ce..42c07ff0471be6a9be7f59e237ca615cb451c226 100644 (file)
@@ -1,4 +1,5 @@
 import { Injectable } from '@angular/core';
+import _ from 'lodash';
 
 import { Components } from '../enum/components.enum';
 import { FinishedTask } from '../models/finished-task';
@@ -340,8 +341,8 @@ export class TaskMessageService {
     'service/delete': this.newTaskMessage(this.commonOperations.delete, (metadata) =>
       this.service(metadata)
     ),
-    'ceph-users/create': this.newTaskMessage(this.commonOperations.create, (metadata) =>
-      this.cephUser(metadata)
+    'crud-component': this.newTaskMessage(this.commonOperations.create, (metadata) =>
+      this.crudMessage(metadata)
     )
   };
 
@@ -387,8 +388,15 @@ export class TaskMessageService {
     return $localize`Service '${metadata.service_name}'`;
   }
 
-  cephUser(metadata: any) {
-    return $localize`Ceph User '${metadata.user_entity}'`;
+  crudMessage(metadata: any) {
+    let message = metadata.__message;
+    _.forEach(metadata, (value, key) => {
+      if (key != '__message') {
+        let regex = '{' + key + '}';
+        message = message.replace(regex, value);
+      }
+    });
+    return $localize`${message}`;
   }
 
   _getTaskTitle(task: Task) {
index 7756bed2d943af722aebd59de85e8d8bedd0f8dd..9b119c700ca3788333c48f28abd9f73fac59e4e2 100644 (file)
@@ -15,6 +15,7 @@ import { FormatterService } from './services/formatter.service';
 import { FormlyArrayTypeComponent } from './forms/crud-form/formly-array-type/formly-array-type.component';
 import { FormlyObjectTypeComponent } from './forms/crud-form/formly-object-type/formly-object-type.component';
 import { FormlyInputTypeComponent } from './forms/crud-form/formly-input-type/formly-input-type.component';
+import { FormlyTextareaTypeComponent } from './forms/crud-form/formly-textarea-type/formly-textarea-type.component';
 
 @NgModule({
   imports: [
@@ -35,7 +36,7 @@ import { FormlyInputTypeComponent } from './forms/crud-form/formly-input-type/fo
     }),
     FormlyBootstrapModule
   ],
-  declarations: [],
+  declarations: [FormlyTextareaTypeComponent],
   exports: [ComponentsModule, PipesModule, DataTableModule, DirectivesModule],
   providers: [AuthStorageService, AuthGuardService, FormatterService, CssHelper]
 })
index 4fdcb1fe3780bdf594869798244dffd5d56eccb2..fc6a9a25446780f6e8b53f3866b3a1a53ba0a494 100644 (file)
@@ -230,7 +230,8 @@ formly-form {
   .form-label {
     @extend .cd-col-form-label;
     text-align: start;
-    width: 50%;
+    white-space: nowrap;
+    width: fit-content;
 
     span[aria-hidden='true'] {
       color: $danger;
index 606f2444bbe4419a8f7b5c91f5518dd0cbbf34c6..7434ec8b02c28ce163ef01533241e42689319e54 100644 (file)
@@ -8478,6 +8478,48 @@ paths:
       summary: List RGW roles
       tags:
       - RGW
+    post:
+      parameters: []
+      requestBody:
+        content:
+          application/json:
+            schema:
+              properties:
+                role_assume_policy_doc:
+                  default: ''
+                  type: string
+                role_name:
+                  default: ''
+                  type: string
+                role_path:
+                  default: ''
+                  type: string
+              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: Create Ceph User
+      tags:
+      - RGW
   /api/rgw/user/{uid}:
     delete:
       parameters:
index d4008faacb33c187a974bd03cb7218b797cb055c..a9a55d96ea97ade0cc382c8b0260c4b8af6e128a 100644 (file)
@@ -6,6 +6,7 @@
 import ipaddress
 import json
 import logging
+import os
 import re
 import xml.etree.ElementTree as ET  # noqa: N814
 from subprocess import SubprocessError
@@ -1026,6 +1027,50 @@ class RgwClient(RestClient):
 
         return roles
 
+    def create_role(self, role_name: str, role_path: str, role_assume_policy_doc: str) -> None:
+        try:
+            json.loads(role_assume_policy_doc)
+        except:  # noqa: E722
+            raise DashboardException('Assume role policy document is not a valid json')
+
+        # valid values:
+        # pylint: disable=C0301
+        # https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-iam-role.html#cfn-iam-role-path # noqa: E501
+        if len(role_name) > 64:
+            raise DashboardException(
+                f'Role name "{role_name}" is invalid. Should be 64 characters or less')
+
+        role_name_regex = '[0-9a-zA-Z_+=,.@-]+'
+        if not re.fullmatch(role_name_regex, role_name):
+            raise DashboardException(
+                f'Role name "{role_name}" is invalid. Valid characters are "{role_name_regex}"')
+
+        if not os.path.isabs(role_path):
+            raise DashboardException(
+                f'Role path "{role_path}" is invalid. It should be an absolute path')
+        if role_path[-1] != '/':
+            raise DashboardException(
+                f'Role path "{role_path}" is invalid. It should start and end with a slash')
+        path_regex = '(\u002F)|(\u002F[\u0021-\u007E]+\u002F)'
+        if not re.fullmatch(path_regex, role_path):
+            raise DashboardException(
+                (f'Role path "{role_path}" is invalid.'
+                 f'Role path should follow the pattern "{path_regex}"'))
+
+        rgw_create_role_command = ['role', 'create', '--role-name', role_name, '--path', role_path]
+        if role_assume_policy_doc:
+            rgw_create_role_command += ['--assume-role-policy-doc', f"{role_assume_policy_doc}"]
+
+        code, _roles, _err = mgr.send_rgwadmin_command(rgw_create_role_command,
+                                                       stdout_as_json=False)
+        if code != 0:
+            # pylint: disable=C0301
+            link = 'https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-iam-role.html#cfn-iam-role-path'  # noqa: E501
+            msg = (f'Error creating role with code {code}: '
+                   'Looks like the document has a wrong format.'
+                   f' For more information about the format look at {link}')
+            raise DashboardException(msg=msg, component='rgw')
+
     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