mgr/dashboard: create authx users

Signed-off-by: Pere Diaz Bou <pdiazbou@redhat.com>
Signed-off-by: Nizamudeen A <nia@redhat.com>
Co-authored-by: Nizamudeen A <nia@redhat.com>
This commit is contained in:
Pere Diaz Bou 2022-08-24 19:28:38 +02:00
parent cb17f28627
commit 10f17bd9eb
26 changed files with 738 additions and 40 deletions

View File

@ -1,5 +1,8 @@
from enum import Enum
from functools import wraps
from inspect import isclass
from typing import Any, Callable, Dict, Generator, Iterable, Iterator, List, \
NamedTuple, Optional, get_type_hints
NamedTuple, Optional, Union, get_type_hints
from ._api_router import APIRouter
from ._docs import APIDoc, EndpointDoc
@ -17,6 +20,7 @@ def isnamedtuple(o):
def serialize(o, expected_type=None):
# pylint: disable=R1705,W1116
print(o, expected_type)
if isnamedtuple(o):
hints = get_type_hints(o)
return {k: serialize(v, hints[k]) for k, v in zip(o._fields, o)}
@ -28,7 +32,7 @@ def serialize(o, expected_type=None):
return [serialize(i) for i in o]
elif isinstance(o, (Iterator, Generator)):
return [serialize(i) for i in o]
elif expected_type and issubclass(expected_type, SecretStr):
elif expected_type and isclass(expected_type) and issubclass(expected_type, SecretStr):
return "***********"
else:
return o
@ -41,14 +45,235 @@ class TableColumn(NamedTuple):
filterable: bool = True
class TableAction(NamedTuple):
name: str
permission: str
icon: str
routerLink: str # redirect to...
class TableComponent(NamedTuple):
columns: List[TableColumn] = []
columnMode: str = 'flex'
toolHeader: bool = True
class Icon(Enum):
add = 'fa fa-plus'
class FormField(NamedTuple):
"""
The key of a FromField is then used to send the data related to that key into the
POST and PUT endpoints. It is imperative for the developer to map keys of fields and containers
to the input of the POST and PUT endpoints.
"""
name: str
key: str
field_type: Any = str
default_value: Optional[Any] = None
optional: bool = False
html_class: str = ''
label_html_class: str = 'col-form-label'
field_html_class: str = 'col-form-input'
def get_type(self):
_type = ''
if self.field_type == str:
_type = 'string'
elif self.field_type == int:
_type = 'integer'
elif self.field_type == bool:
_type = 'boolean'
else:
raise NotImplementedError(f'Unimplemented type {self.field_type}')
return _type
class Container:
def __init__(self, name: str, key: str, fields: List[Union[FormField, "Container"]],
optional: bool = False, html_class: str = '', label_html_class: str = '',
field_html_class: str = ''):
self.name = name
self.key = key
self.fields = fields
self.optional = optional
self.html_class = html_class
self.label_html_class = label_html_class
self.field_html_class = field_html_class
def layout_type(self):
raise NotImplementedError
def _property_type(self):
raise NotImplementedError
def to_dict(self, key=''):
# intialize the schema of this container
ui_schemas = []
control_schema = {
'type': self._property_type(),
'title': self.name
}
items = None # layout items alias as it depends on the type of container
properties = None # control schema properties alias
required = None
if self._property_type() == 'array':
control_schema['items'] = {
'type': 'object',
'properties': {},
'required': []
}
properties = control_schema['items']['properties']
required = control_schema['items']['required']
ui_schemas.append({
'type': 'array',
'key': key,
'htmlClass': self.html_class,
'fieldHtmlClass': self.field_html_class,
'labelHtmlClass': self.label_html_class,
'items': [{
'type': 'div',
'flex-direction': self.layout_type(),
'displayFlex': True,
'items': []
}]
})
items = ui_schemas[-1]['items'][0]['items']
else:
control_schema['properties'] = {}
control_schema['required'] = []
required = control_schema['required']
properties = control_schema['properties']
ui_schemas.append({
'type': 'section',
'flex-direction': self.layout_type(),
'displayFlex': True,
'htmlClass': self.html_class,
'fieldHtmlClass': self.field_html_class,
'labelHtmlClass': self.label_html_class,
'key': key,
'items': []
})
if key:
items = ui_schemas[-1]['items']
else:
items = ui_schemas
assert items is not None
assert properties is not None
assert required is not None
# include fields in this container's schema
for field in self.fields:
field_ui_schema = {}
properties[field.key] = {}
field_key = field.key
if key:
if self._property_type() == 'array':
field_key = key + '[].' + field.key
else:
field_key = key + '.' + field.key
if isinstance(field, FormField):
_type = field.get_type()
properties[field.key]['type'] = _type
properties[field.key]['title'] = field.name
field_ui_schema['key'] = field_key
field_ui_schema['htmlClass'] = field.html_class
field_ui_schema['fieldHtmlClass'] = field.field_html_class
field_ui_schema['labelHtmlClass'] = field.label_html_class
items.append(field_ui_schema)
elif isinstance(field, Container):
container_schema = field.to_dict(key+'.'+field.key if key else field.key)
control_schema['properties'][field.key] = container_schema['control_schema']
ui_schemas.extend(container_schema['ui_schema'])
if not field.optional:
required.append(field.key)
return {
'ui_schema': ui_schemas,
'control_schema': control_schema,
}
class VerticalContainer(Container):
def layout_type(self):
return 'column'
def _property_type(self):
return 'object'
class HorizontalContainer(Container):
def layout_type(self):
return 'row'
def _property_type(self):
return 'object'
class ArrayVerticalContainer(Container):
def layout_type(self):
return 'column'
def _property_type(self):
return 'array'
class ArrayHorizontalContainer(Container):
def layout_type(self):
return 'row'
def _property_type(self):
return 'array'
class Form:
def __init__(self, path, root_container, action: str = '',
footer_html_class: str = 'card-footer position-absolute pb-0 mt-3',
submit_style: str = 'btn btn-primary', cancel_style: str = ''):
self.path = path
self.action = action
self.root_container = root_container
self.footer_html_class = footer_html_class
self.submit_style = submit_style
self.cancel_style = cancel_style
def to_dict(self):
container_schema = self.root_container.to_dict()
# root container style
container_schema['ui_schema'].append({
'type': 'flex',
'flex-flow': f'{self.root_container.layout_type()} wrap',
'displayFlex': True,
})
footer = {
"type": "flex",
"htmlClass": self.footer_html_class,
"items": [
{
'type': 'flex',
'flex-direction': 'row',
'displayFlex': True,
'htmlClass': 'd-flex justify-content-end mb-0',
'items': [
{"type": "cancel", "style": self.cancel_style, 'htmlClass': 'mr-2'},
{"type": "submit", "style": self.submit_style, "title": self.action},
]
}
]
}
container_schema['ui_schema'].append(footer)
return container_schema
class CRUDMeta(NamedTuple):
table: TableComponent = TableComponent()
permissions: List[str] = []
actions: List[Dict[str, Any]] = []
forms: List[Dict[str, Any]] = []
class CRUDCollectionMethod(NamedTuple):
@ -65,8 +290,12 @@ class CRUDEndpoint(NamedTuple):
router: APIRouter
doc: APIDoc
set_column: Optional[Dict[str, Dict[str, str]]] = None
actions: List[TableAction] = []
permissions: List[str] = []
forms: List[Form] = []
meta: CRUDMeta = CRUDMeta()
get_all: Optional[CRUDCollectionMethod] = None
create: Optional[CRUDCollectionMethod] = None
# for testing purposes
CRUDClass: Optional[RESTController] = None
@ -89,9 +318,19 @@ class CRUDEndpoint(NamedTuple):
if self.get_all:
@self.get_all.doc
def list(self):
return serialize(cls(**item)
for item in outer_self.get_all.func()) # type: ignore
@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
cls.CRUDClass = CRUDClass
def create_meta_class(self, cls):
@ -101,6 +340,9 @@ class CRUDEndpoint(NamedTuple):
class CRUDClassMetadata(RESTController):
def list(self):
self.update_columns()
self.generate_actions()
self.generate_forms()
self.set_permissions()
return serialize(outer_self.meta)
def update_columns(self):
@ -114,4 +356,20 @@ class CRUDEndpoint(NamedTuple):
column.filterable)
outer_self.meta.table.columns[i] = new_column
def generate_actions(self):
outer_self.meta.actions.clear()
for action in outer_self.actions:
outer_self.meta.actions.append(action._asdict())
def generate_forms(self):
outer_self.meta.forms.clear()
for form in outer_self.forms:
outer_self.meta.forms.append(form.to_dict())
def set_permissions(self):
if outer_self.permissions:
outer_self.meta.permissions.extend(outer_self.permissions)
cls.CRUDClassMetadata = CRUDClassMetadata

View File

@ -83,7 +83,7 @@ class RESTController(BaseController, skip_registry=True):
result = super().endpoints()
res_id_params = cls.infer_resource_id()
for _, func in inspect.getmembers(cls, predicate=callable):
for name, func in inspect.getmembers(cls, predicate=callable):
endpoint_params = {
'no_resource_id_params': False,
'status': 200,
@ -94,10 +94,9 @@ class RESTController(BaseController, skip_registry=True):
'sec_permissions': hasattr(func, '_security_permissions'),
'permission': None,
}
if func.__name__ in cls._method_mapping:
if name in cls._method_mapping:
cls._update_endpoint_params_method_map(
func, res_id_params, endpoint_params)
func, res_id_params, endpoint_params, name=name)
elif hasattr(func, "__collection_method__"):
cls._update_endpoint_params_collection_map(func, endpoint_params)
@ -166,8 +165,8 @@ class RESTController(BaseController, skip_registry=True):
endpoint_params['permission'] = cls._permission_map[endpoint_params['method']]
@classmethod
def _update_endpoint_params_method_map(cls, func, res_id_params, endpoint_params):
meth = cls._method_mapping[func.__name__] # type: dict
def _update_endpoint_params_method_map(cls, func, res_id_params, endpoint_params, name=None):
meth = cls._method_mapping[func.__name__ if not name else name] # type: dict
if meth['resource']:
if not res_id_params:

View File

@ -1,8 +1,14 @@
from typing import NamedTuple
import logging
from errno import EINVAL
from typing import List, NamedTuple
from ..exceptions import DashboardException
from ..security import Scope
from ..services.ceph_service import CephService
from ..services.ceph_service import CephService, SendCommandError
from . import APIDoc, APIRouter, CRUDCollectionMethod, CRUDEndpoint, EndpointDoc, SecretStr
from ._crud import ArrayHorizontalContainer, Form, FormField, Icon, TableAction, VerticalContainer
logger = logging.getLogger("controllers.ceph_users")
class CephUserCaps(NamedTuple):
@ -12,16 +18,83 @@ class CephUserCaps(NamedTuple):
mds: str
class Cap(NamedTuple):
entity: str
cap: str
class CephUserEndpoints:
@staticmethod
def user_list(_):
"""
Get list of ceph users and its respective data
"""
return CephService.send_command('mon', 'auth ls')["auth_dump"]
@staticmethod
def user_create(_, user_entity: str, capabilities: List[Cap]):
"""
Add a ceph user with its defined capabilities.
:param user_entity: Entity to change
:param capabilities: List of capabilities to add to user_entity
"""
# Caps are represented as a vector in mon auth add commands.
# Look at AuthMonitor.cc::valid_caps for reference.
caps = []
for cap in capabilities:
caps.append(cap['entity'])
caps.append(cap['cap'])
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)
return f"Successfully created user '{user_entity}'"
create_cap_container = ArrayHorizontalContainer('Capabilities', 'capabilities',
label_html_class='hidden cd-header mt-1', fields=[
FormField('Entity', 'entity',
field_type=str, html_class='mr-3'),
FormField('Entity Capabilities',
'cap', field_type=str)
])
create_container = VerticalContainer('Create User', 'create_user',
html_class='d-none', fields=[
FormField('User entity', 'user_entity',
field_type=str),
create_cap_container,
])
create_form = Form(path='/cluster/user/create',
root_container=create_container, action='Create User')
@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,
routerLink='/cluster/user/create')
],
permissions=[Scope.CONFIG_OPT],
forms=[create_form],
get_all=CRUDCollectionMethod(
func=lambda **_: CephService.send_command('mon', 'auth ls')["auth_dump"],
func=CephUserEndpoints.user_list,
doc=EndpointDoc("Get Ceph Users")
),
create=CRUDCollectionMethod(
func=CephUserEndpoints.user_create,
doc=EndpointDoc("Create Ceph User")
)
)
class CephUser(NamedTuple):
entity: str
caps: CephUserCaps
caps: List[CephUserCaps]
key: SecretStr

View File

@ -10,6 +10,44 @@
"integrity": "sha512-20Pk2Z98fbPLkECcrZSJszKos/OgtvJJR3NcbVfgCJ6EQjDNzW2P1BKqImOz3tJ952dvO2DWEhcLhQ1Wz1e9ng==",
"dev": true
},
"@ajsf/bootstrap4": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/@ajsf/bootstrap4/-/bootstrap4-0.7.0.tgz",
"integrity": "sha512-wn6wIQeWknmn/t96XZgihfFq/jjr9GkV9P5dHEU+i9wQbxPNL1MS+x4tLWj9LH3Mx5RiC0Dr4gPgbkDd/bzLxg==",
"requires": {
"@ajsf/core": "~0.7.0",
"lodash-es": "~4.17.21",
"tslib": "^2.0.0"
}
},
"@ajsf/core": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/@ajsf/core/-/core-0.7.0.tgz",
"integrity": "sha512-mysKftZAxT0bHYoia7LzbSinK7Z55wINS63zeK/rqSs9r2dF01Vxtzlx2ITViiok3TQ0UV+1OYce/piozEf4aw==",
"requires": {
"ajv": "^6.10.0",
"lodash-es": "~4.17.21",
"tslib": "^2.0.0"
},
"dependencies": {
"ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"requires": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
"json-schema-traverse": "^0.4.1",
"uri-js": "^4.2.2"
}
},
"json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="
}
}
},
"@ampproject/remapping": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.0.tgz",
@ -11011,8 +11049,7 @@
"fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
},
"fast-glob": {
"version": "3.2.12",
@ -11046,8 +11083,7 @@
"fast-json-stable-stringify": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
"dev": true
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="
},
"fast-levenshtein": {
"version": "2.0.6",
@ -24050,7 +24086,6 @@
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
"dev": true,
"requires": {
"punycode": "^2.1.0"
},
@ -24058,8 +24093,7 @@
"punycode": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
"integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==",
"dev": true
"integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A=="
}
}
},

View File

@ -44,6 +44,8 @@
},
"private": true,
"dependencies": {
"@ajsf/bootstrap4": "0.7.0",
"@ajsf/core": "0.7.0",
"@angular/animations": "13.3.11",
"@angular/common": "13.3.11",
"@angular/compiler": "13.3.11",

View File

@ -37,6 +37,7 @@ import { LoginLayoutComponent } from './core/layouts/login-layout/login-layout.c
import { WorkbenchLayoutComponent } from './core/layouts/workbench-layout/workbench-layout.component';
import { ApiDocsComponent } from './core/navigation/api-docs/api-docs.component';
import { ActionLabels, URLVerbs } from './shared/constants/app.constants';
import { CrudFormComponent } from './shared/datatable/crud-table/crud-form/crud-form.component';
import { CRUDTableComponent } from './shared/datatable/crud-table/crud-table.component';
import { BreadcrumbsResolver, IBreadcrumb } from './shared/models/breadcrumbs';
import { AuthGuardService } from './shared/services/auth-guard.service';
@ -124,6 +125,14 @@ const routes: Routes = [
resource: 'api.cluster.user@1.0'
}
},
{
path: 'cluster/user/create',
component: CrudFormComponent,
data: {
breadcrumbs: 'Cluster/Users',
resource: 'api.cluster.user@1.0'
}
},
{
path: 'monitor',
component: MonitorComponent,

View File

@ -54,7 +54,7 @@ export class SubmitButtonComponent implements OnInit {
constructor(private elRef: ElementRef) {}
ngOnInit() {
this.form.statusChanges.subscribe(() => {
this.form?.statusChanges.subscribe(() => {
if (_.has(this.form.errors, 'cdSubmitButton')) {
this.loading = false;
_.unset(this.form.errors, 'cdSubmitButton');

View File

@ -0,0 +1,20 @@
<div class="cd-col-form">
<div class="card pb-0">
<div i18n="form title"
class="card-header">{{ title }}</div>
<div class="card-body position-relative">
<json-schema-form
*ngIf="controlSchema && uiSchema"
[schema]="controlSchema"
[layout]="uiSchema"
[data]="data"
[widgets]="widgets"
(onSubmit)="submit($event)"
[options]="formOptions"
framework="bootstrap-4">
</json-schema-form>
</div>
</div>
</div>

View File

@ -0,0 +1,22 @@
@use './src/styles/vendor/variables' as vv;
::ng-deep json-schema-form {
label.control-label.hidden {
display: none;
}
.form-group.schema-form-submit p {
display: none;
}
legend {
font-weight: 100 !important;
}
.card-footer {
border: 1px solid rgba(0, 0, 0, 0.125);
left: -1px;
width: -webkit-fill-available;
width: -moz-available;
}
}

View File

@ -0,0 +1,42 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { ToastrModule, ToastrService } from 'ngx-toastr';
import { configureTestBed } from '~/testing/unit-test-helper';
import { CdDatePipe } from '~/app/shared/pipes/cd-date.pipe';
import { CrudFormComponent } from './crud-form.component';
import { RouterTestingModule } from '@angular/router/testing';
describe('CrudFormComponent', () => {
let component: CrudFormComponent;
let fixture: ComponentFixture<CrudFormComponent>;
const toastFakeService = {
error: () => true,
info: () => true,
success: () => true
};
configureTestBed({
imports: [ToastrModule.forRoot(), RouterTestingModule, HttpClientTestingModule],
providers: [
{ provide: ToastrService, useValue: toastFakeService },
{ provide: CdDatePipe, useValue: { transform: (d: any) => d } }
]
});
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [CrudFormComponent]
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(CrudFormComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,65 @@
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { DataGatewayService } from '~/app/shared/services/data-gateway.service';
import { BackButtonComponent } from '~/app/shared/components/back-button/back-button.component';
import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
import { FinishedTask } from '~/app/shared/models/finished-task';
import { Location } from '@angular/common';
@Component({
selector: 'cd-crud-form',
templateUrl: './crud-form.component.html',
styleUrls: ['./crud-form.component.scss']
})
export class CrudFormComponent implements OnInit {
uiSchema: any;
controlSchema: any;
data: any;
widgets: any = {
cancel: BackButtonComponent
};
resource: string;
title: string;
formOptions = {
defautWidgetOptions: {
validationMessages: {
required: 'This field is required'
}
}
};
constructor(
private dataGatewayService: DataGatewayService,
private activatedRoute: ActivatedRoute,
private taskWrapper: TaskWrapperService,
private location: Location
) {}
ngOnInit(): void {
this.activatedRoute.data.subscribe((data: any) => {
this.resource = data.resource;
this.dataGatewayService.list(`ui-${this.resource}`).subscribe((response: any) => {
this.title = response.forms[0].control_schema.title;
this.uiSchema = response.forms[0].ui_schema;
this.controlSchema = response.forms[0].control_schema;
});
});
}
submit(data: any) {
if (data) {
this.taskWrapper
.wrapTaskAroundCall({
task: new FinishedTask('ceph-user/create', {
user_entity: data.user_entity
}),
call: this.dataGatewayService.create(this.resource, data)
})
.subscribe({
complete: () => {
this.location.back();
}
});
}
}
}

View File

@ -1,10 +1,19 @@
<ng-container *ngIf="meta">
<cd-table
[data]="data$ | async"
[columns]="meta.table.columns"
[columnMode]="meta.table.columnMode"
[toolHeader]="meta.table.toolHeader"
></cd-table>
[data]="data$ | async"
[columns]="meta.table.columns"
[columnMode]="meta.table.columnMode"
[toolHeader]="meta.table.toolHeader">
<div class="table-actions btn-toolbar">
<cd-table-actions [permission]="permission"
[selection]="selection"
class="btn-group"
id="crud-table-actions"
[tableActions]="meta.actions">
</cd-table-actions>
</div>
</cd-table>
</ng-container>
<ng-template #badgeDictTpl

View File

@ -7,6 +7,9 @@ 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 { CdTableSelection } from '../../models/cd-table-selection';
import { Permission, Permissions } from '../../models/permissions';
import { AuthStorageService } from '../../services/auth-storage.service';
@Component({
selector: 'cd-crud-table',
@ -20,28 +23,45 @@ export class CRUDTableComponent implements OnInit {
data$: Observable<any>;
meta$: Observable<CrudMetadata>;
meta: CrudMetadata;
permissions: Permissions;
permission: Permission;
selection = new CdTableSelection();
constructor(
private authStorageService: AuthStorageService,
private timerService: TimerService,
private dataGatewayService: DataGatewayService,
private activatedRoute: ActivatedRoute
) {}
) {
this.permissions = this.authStorageService.getPermissions();
}
ngOnInit() {
/* The following should be simplified with a wrapper that
converts .data to @Input args. For example:
https://medium.com/@andrewcherepovskiy/passing-route-params-into-angular-components-input-properties-fc85c34c9aca
*/
this.activatedRoute.data.subscribe((data) => {
this.activatedRoute.data.subscribe((data: any) => {
const resource: string = data.resource;
this.dataGatewayService
.list(`ui-${resource}`)
.subscribe((response) => this.processMeta(response));
.subscribe((response: any) => this.processMeta(response));
this.data$ = this.timerService.get(() => this.dataGatewayService.list(resource));
});
}
processMeta(meta: CrudMetadata) {
const toCamelCase = (test: string) =>
test
.split('-')
.reduce(
(res: string, word: string, i: number) =>
i === 0
? word.toLowerCase()
: `${res}${word.charAt(0).toUpperCase()}${word.substr(1).toLowerCase()}`,
''
);
this.permission = this.permissions[toCamelCase(meta.permissions[0])];
this.meta = meta;
const templates = {
badgeDict: this.badgeDictTpl

View File

@ -14,6 +14,8 @@ import { TableActionsComponent } from './table-actions/table-actions.component';
import { TableKeyValueComponent } from './table-key-value/table-key-value.component';
import { TablePaginationComponent } from './table-pagination/table-pagination.component';
import { TableComponent } from './table/table.component';
import { Bootstrap4FrameworkModule } from '@ajsf/bootstrap4';
import { CrudFormComponent } from './crud-table/crud-form/crud-form.component';
@NgModule({
imports: [
@ -25,14 +27,16 @@ import { TableComponent } from './table/table.component';
NgbTooltipModule,
PipesModule,
ComponentsModule,
RouterModule
RouterModule,
Bootstrap4FrameworkModule
],
declarations: [
TableComponent,
TableKeyValueComponent,
TableActionsComponent,
CRUDTableComponent,
TablePaginationComponent
TablePaginationComponent,
CrudFormComponent
],
exports: [
TableComponent,

View File

@ -1,4 +1,5 @@
import { CdTableColumn } from '~/app/shared/models/cd-table-column';
import { CdTableAction } from './cd-table-action';
class Table {
columns: CdTableColumn[];
@ -8,4 +9,7 @@ class Table {
export class CrudMetadata {
table: Table;
permissions: string[];
actions: CdTableAction[];
forms: any;
}

View File

@ -12,16 +12,31 @@ export class DataGatewayService {
constructor(private http: HttpClient) {}
list(dataPath: string): Observable<any> {
if (this.cache[dataPath] === undefined) {
const cacheable = this.getCacheable(dataPath, 'get');
if (this.cache[cacheable] === undefined) {
const match = dataPath.match(/(?<url>[^@]+)(?:@(?<version>.+))?/);
const url = match.groups.url.split('.').join('/');
const version = match.groups.version || '1.0';
this.cache[dataPath] = this.http.get<any>(url, {
this.cache[cacheable] = this.http.get<any>(url, {
headers: { Accept: `application/vnd.ceph.api.v${version}+json` }
});
}
return this.cache[dataPath];
return this.cache[cacheable];
}
create(dataPath: string, data: any): Observable<any> {
const match = dataPath.match(/(?<url>[^@]+)(?:@(?<version>.+))?/);
const url = match.groups.url.split('.').join('/');
const version = match.groups.version || '1.0';
return this.http.post<any>(url, data, {
headers: { Accept: `application/vnd.ceph.api.v${version}+json` }
});
}
getCacheable(dataPath: string, method: string) {
return dataPath + method;
}
}

View File

@ -339,6 +339,9 @@ 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)
)
};
@ -384,6 +387,10 @@ export class TaskMessageService {
return $localize`Service '${metadata.service_name}'`;
}
cephUser(metadata: any) {
return $localize`Ceph User '${metadata.user_entity}'`;
}
_getTaskTitle(task: Task) {
if (task.name && task.name.startsWith('progress/')) {
// we don't fill the failure string because, at least for now, all

View File

@ -1,5 +1,6 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { Bootstrap4FrameworkModule } from '@ajsf/bootstrap4';
import { CssHelper } from '~/app/shared/classes/css-helper';
import { ComponentsModule } from './components/components.module';
@ -11,7 +12,14 @@ import { AuthStorageService } from './services/auth-storage.service';
import { FormatterService } from './services/formatter.service';
@NgModule({
imports: [CommonModule, PipesModule, ComponentsModule, DataTableModule, DirectivesModule],
imports: [
CommonModule,
PipesModule,
ComponentsModule,
DataTableModule,
DirectivesModule,
Bootstrap4FrameworkModule
],
declarations: [],
exports: [ComponentsModule, PipesModule, DataTableModule, DirectivesModule],
providers: [AuthStorageService, AuthGuardService, FormatterService, CssHelper]

View File

@ -217,3 +217,17 @@ a.btn-light {
.badge-dark {
@extend .badge, .bg-dark;
}
json-schema-form {
.help-block {
@extend .invalid-feedback;
}
.ng-touched.ng-invalid {
@extend .is-invalid;
}
.ng-touched.ng-valid {
@extend .is-valid;
}
}

View File

@ -80,6 +80,10 @@
}
}
.btn-default {
@extend .btn-light;
}
.btn-primary .badge {
background-color: vv.$gray-200;
color: vv.$primary;

View File

@ -2147,6 +2147,8 @@ paths:
- Cluster
/api/cluster/user:
get:
description: "\n Get list of ceph users and its respective data\n \
\ "
parameters: []
responses:
'200':
@ -2168,6 +2170,49 @@ paths:
summary: Get Ceph Users
tags:
- Cluster
post:
description: "\n Add a ceph user with its defined capabilities.\n \
\ :param user_entity: Entity to change\n :param capabilities: List\
\ of capabilities to add to user_entity\n "
parameters: []
requestBody:
content:
application/json:
schema:
properties:
capabilities:
type: string
user_entity:
type: string
required:
- user_entity
- capabilities
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:
- Cluster
/api/cluster_conf:
get:
parameters: []

View File

@ -8,3 +8,4 @@ rstcheck==3.3.1
autopep8==1.5.7
pyfakefs==4.5.0
isort==5.5.3
jsonschema==4.16.0

View File

@ -1,3 +1,4 @@
pytest-cov
pytest-instafail
pyfakefs==4.5.0
jsonschema==4.16.0

View File

@ -11,3 +11,4 @@ pytest
pyyaml
natsort
setuptools
jsonpatch

View File

@ -1,6 +1,8 @@
import unittest.mock as mock
from ..controllers.ceph_users import CephUser
from jsonschema import validate
from ..controllers.ceph_users import CephUser, create_form
from ..tests import ControllerTestCase
auth_dump_mock = {"auth_dump": [
@ -41,3 +43,8 @@ class CephUsersControllerTestCase(ControllerTestCase):
"key": "***********"
}
])
def test_create_form(self):
form_dict = create_form.to_dict()
schema = {'schema': form_dict['control_schema'], 'layout': form_dict['ui_schema']}
validate(instance={'user_entity': 'foo', 'capabilities': []}, schema=schema['schema'])

View File

@ -4,8 +4,11 @@ import json
from typing import NamedTuple
import pytest
from jsonschema import validate
from ..controllers._crud import SecretStr, serialize
from ..controllers._crud import ArrayHorizontalContainer, \
ArrayVerticalContainer, Form, FormField, HorizontalContainer, SecretStr, \
VerticalContainer, serialize
def assertObjectEquals(a, b):
@ -26,10 +29,41 @@ class NamedTupleSecretMock(NamedTuple):
@pytest.mark.parametrize("inp,out", [
(["foo", "var"], ["foo", "var"]),
(NamedTupleMock(1, "test"), {"foo": 1, "var": "test"}),
(NamedTupleSecretMock(1, "test", "supposethisisakey"), {"foo": 1, "var": "test",
(NamedTupleSecretMock(1, "test", "imaginethisisakey"), {"foo": 1, "var": "test",
"key": "***********"}),
((1, 2, 3), [1, 2, 3]),
(set((1, 2, 3)), [1, 2, 3]),
])
def test_serialize(inp, out):
assertObjectEquals(serialize(inp), out)
def test_schema():
form = Form(path='/cluster/user/create',
root_container=VerticalContainer('Create user', key='create_user', fields=[
FormField('User entity', key='user_entity', field_type=str),
ArrayHorizontalContainer('Capabilities', key='caps', fields=[
FormField('left', field_type=str, key='left',
html_class='cd-col-form-input'),
FormField('right', key='right', field_type=str)
]),
ArrayVerticalContainer('ah', key='ah', fields=[
FormField('top', key='top', field_type=str, label_html_class='d-none'),
FormField('bottom', key='bottom', field_type=str)
]),
HorizontalContainer('oh', key='oh', fields=[
FormField('left', key='left', field_type=str, label_html_class='d-none'),
FormField('right', key='right', field_type=str)
]),
VerticalContainer('ov', key='ov', fields=[
FormField('top', key='top', field_type=str, label_html_class='d-none'),
FormField('bottom', key='bottom', field_type=bool)
]),
]))
form_dict = form.to_dict()
schema = {'schema': form_dict['control_schema'], 'layout': form_dict['ui_schema']}
validate(instance={'user_entity': 'foo',
'caps': [{'left': 'foo', 'right': 'foo2'}],
'ah': [{'top': 'foo', 'bottom': 'foo2'}],
'oh': {'left': 'foo', 'right': 'foo2'},
'ov': {'top': 'foo', 'bottom': True}}, schema=schema['schema'])