]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: clean-up controllers
authorErnesto Puerta <epuertat@redhat.com>
Tue, 7 Sep 2021 15:07:48 +0000 (17:07 +0200)
committerNizamudeen A <nia@redhat.com>
Thu, 14 Oct 2021 17:32:36 +0000 (23:02 +0530)
Fixes: https://tracker.ceph.com/issues/52589
Signed-off-by: Ernesto Puerta <epuertat@redhat.com>
 Conflicts:
src/pybind/mgr/dashboard/CMakeLists.txt
   - Added some testts in the CephTest section

60 files changed:
src/pybind/mgr/dashboard/CMakeLists.txt
src/pybind/mgr/dashboard/controllers/__init__.py
src/pybind/mgr/dashboard/controllers/_api_router.py [new file with mode: 0644]
src/pybind/mgr/dashboard/controllers/_auth.py [new file with mode: 0644]
src/pybind/mgr/dashboard/controllers/_base_controller.py [new file with mode: 0644]
src/pybind/mgr/dashboard/controllers/_docs.py [new file with mode: 0644]
src/pybind/mgr/dashboard/controllers/_endpoint.py [new file with mode: 0644]
src/pybind/mgr/dashboard/controllers/_helpers.py [new file with mode: 0644]
src/pybind/mgr/dashboard/controllers/_permissions.py [new file with mode: 0644]
src/pybind/mgr/dashboard/controllers/_rest_controller.py [new file with mode: 0644]
src/pybind/mgr/dashboard/controllers/_router.py [new file with mode: 0644]
src/pybind/mgr/dashboard/controllers/_task.py [new file with mode: 0644]
src/pybind/mgr/dashboard/controllers/_ui_router.py [new file with mode: 0644]
src/pybind/mgr/dashboard/controllers/auth.py
src/pybind/mgr/dashboard/controllers/cephfs.py
src/pybind/mgr/dashboard/controllers/cluster_configuration.py
src/pybind/mgr/dashboard/controllers/crush_rule.py
src/pybind/mgr/dashboard/controllers/docs.py
src/pybind/mgr/dashboard/controllers/erasure_code_profile.py
src/pybind/mgr/dashboard/controllers/feedback.py [new file with mode: 0644]
src/pybind/mgr/dashboard/controllers/frontend_logging.py
src/pybind/mgr/dashboard/controllers/grafana.py
src/pybind/mgr/dashboard/controllers/health.py
src/pybind/mgr/dashboard/controllers/home.py
src/pybind/mgr/dashboard/controllers/host.py
src/pybind/mgr/dashboard/controllers/iscsi.py
src/pybind/mgr/dashboard/controllers/logs.py
src/pybind/mgr/dashboard/controllers/mgr_modules.py
src/pybind/mgr/dashboard/controllers/monitor.py
src/pybind/mgr/dashboard/controllers/nfsganesha.py
src/pybind/mgr/dashboard/controllers/orchestrator.py
src/pybind/mgr/dashboard/controllers/osd.py
src/pybind/mgr/dashboard/controllers/perf_counters.py
src/pybind/mgr/dashboard/controllers/pool.py
src/pybind/mgr/dashboard/controllers/prometheus.py
src/pybind/mgr/dashboard/controllers/rbd.py
src/pybind/mgr/dashboard/controllers/rbd_mirroring.py
src/pybind/mgr/dashboard/controllers/rgw.py
src/pybind/mgr/dashboard/controllers/role.py
src/pybind/mgr/dashboard/controllers/saml2.py
src/pybind/mgr/dashboard/controllers/service.py
src/pybind/mgr/dashboard/controllers/settings.py
src/pybind/mgr/dashboard/controllers/summary.py
src/pybind/mgr/dashboard/controllers/task.py
src/pybind/mgr/dashboard/controllers/telemetry.py
src/pybind/mgr/dashboard/controllers/user.py
src/pybind/mgr/dashboard/module.py
src/pybind/mgr/dashboard/openapi.yaml
src/pybind/mgr/dashboard/plugins/feature_toggles.py
src/pybind/mgr/dashboard/plugins/motd.py
src/pybind/mgr/dashboard/tests/__init__.py
src/pybind/mgr/dashboard/tests/test_api_auditing.py
src/pybind/mgr/dashboard/tests/test_controllers.py
src/pybind/mgr/dashboard/tests/test_docs.py
src/pybind/mgr/dashboard/tests/test_exceptions.py
src/pybind/mgr/dashboard/tests/test_feature_toggles.py
src/pybind/mgr/dashboard/tests/test_host.py
src/pybind/mgr/dashboard/tests/test_rest_tasks.py
src/pybind/mgr/dashboard/tests/test_tools.py
src/pybind/mgr/dashboard/tests/test_versioning.py

index 94c0a568277c8427c5ef9254d157b252503de3b3..9b3432213a016baf9327ed5132aef48e2b726710 100644 (file)
@@ -134,5 +134,8 @@ add_dependencies(tests mgr-dashboard-frontend-build)
 
 if(WITH_TESTS)
   include(AddCephTest)
-  add_tox_test(mgr-dashboard TOX_ENVS py3 lint check openapi-check)
+  add_tox_test(mgr-dashboard-py3 TOX_ENVS py3)
+  add_tox_test(mgr-dashboard-lint TOX_ENVS lint)
+  add_tox_test(mgr-dashboard-check TOX_ENVS check)
+  add_tox_test(mgr-dashboard-openapi TOX_ENVS openapi-check)
 endif()
index 7daf2301d351f3b07de90d01bf44495d42ef8166..7a5b090ee461e71d7b90ff890db5beaad610c088 100644 (file)
-# -*- coding: utf-8 -*-
-# pylint: disable=protected-access,too-many-branches,too-many-lines
-from __future__ import absolute_import
-
-import collections
-import importlib
-import inspect
-import json
-import logging
-import os
-import pkgutil
-import re
-import sys
-from functools import wraps
-from urllib.parse import unquote
-
-# pylint: disable=wrong-import-position
-import cherrypy
-# pylint: disable=import-error
-from ceph_argparse import ArgumentFormat  # type: ignore
-
-from .. import DEFAULT_VERSION
-from ..api.doc import SchemaInput, SchemaType
-from ..exceptions import DashboardException, PermissionNotValid, ScopeNotValid
-from ..plugins import PLUGIN_MANAGER
-from ..security import Permission, Scope
-from ..services.auth import AuthManager, JwtManager
-from ..tools import TaskManager, get_request_body_params, getargspec
-
-try:
-    from typing import Any, List, Optional
-except ImportError:
-    pass  # For typing only
-
-
-def EndpointDoc(description="", group="", parameters=None, responses=None):  # noqa: N802
-    if not isinstance(description, str):
-        raise Exception("%s has been called with a description that is not a string: %s"
-                        % (EndpointDoc.__name__, description))
-    if not isinstance(group, str):
-        raise Exception("%s has been called with a groupname that is not a string: %s"
-                        % (EndpointDoc.__name__, group))
-    if parameters and not isinstance(parameters, dict):
-        raise Exception("%s has been called with parameters that is not a dict: %s"
-                        % (EndpointDoc.__name__, parameters))
-    if responses and not isinstance(responses, dict):
-        raise Exception("%s has been called with responses that is not a dict: %s"
-                        % (EndpointDoc.__name__, responses))
-
-    if not parameters:
-        parameters = {}
-
-    def _split_param(name, p_type, description, optional=False, default_value=None, nested=False):
-        param = {
-            'name': name,
-            'description': description,
-            'required': not optional,
-            'nested': nested,
-        }
-        if default_value:
-            param['default'] = default_value
-        if isinstance(p_type, type):
-            param['type'] = p_type
-        else:
-            nested_params = _split_parameters(p_type, nested=True)
-            if nested_params:
-                param['type'] = type(p_type)
-                param['nested_params'] = nested_params
-            else:
-                param['type'] = p_type
-        return param
-
-    #  Optional must be set to True in order to set default value and parameters format must be:
-    # 'name: (type or nested parameters, description, [optional], [default value])'
-    def _split_dict(data, nested):
-        splitted = []
-        for name, props in data.items():
-            if isinstance(name, str) and isinstance(props, tuple):
-                if len(props) == 2:
-                    param = _split_param(name, props[0], props[1], nested=nested)
-                elif len(props) == 3:
-                    param = _split_param(name, props[0], props[1], optional=props[2], nested=nested)
-                if len(props) == 4:
-                    param = _split_param(name, props[0], props[1], props[2], props[3], nested)
-                splitted.append(param)
-            else:
-                raise Exception(
-                    """Parameter %s in %s has not correct format. Valid formats are:
-                    <name>: (<type>, <description>, [optional], [default value])
-                    <name>: (<[type]>, <description>, [optional], [default value])
-                    <name>: (<[nested parameters]>, <description>, [optional], [default value])
-                    <name>: (<{nested parameters}>, <description>, [optional], [default value])"""
-                    % (name, EndpointDoc.__name__))
-        return splitted
-
-    def _split_list(data, nested):
-        splitted = []  # type: List[Any]
-        for item in data:
-            splitted.extend(_split_parameters(item, nested))
-        return splitted
-
-    # nested = True means parameters are inside a dict or array
-    def _split_parameters(data, nested=False):
-        param_list = []  # type: List[Any]
-        if isinstance(data, dict):
-            param_list.extend(_split_dict(data, nested))
-        elif isinstance(data, (list, tuple)):
-            param_list.extend(_split_list(data, True))
-        return param_list
-
-    resp = {}
-    if responses:
-        for status_code, response_body in responses.items():
-            schema_input = SchemaInput()
-            schema_input.type = SchemaType.ARRAY if \
-                isinstance(response_body, list) else SchemaType.OBJECT
-            schema_input.params = _split_parameters(response_body)
-
-            resp[str(status_code)] = schema_input
-
-    def _wrapper(func):
-        func.doc_info = {
-            'summary': description,
-            'tag': group,
-            'parameters': _split_parameters(parameters),
-            'response': resp
-        }
-        return func
-
-    return _wrapper
-
-
-class ControllerDoc(object):
-    def __init__(self, description="", group=""):
-        self.tag = group
-        self.tag_descr = description
-
-    def __call__(self, cls):
-        cls.doc_info = {
-            'tag': self.tag,
-            'tag_descr': self.tag_descr
-        }
-        return cls
-
-
-class Controller(object):
-    def __init__(self, path, base_url=None, security_scope=None, secure=True):
-        if security_scope and not Scope.valid_scope(security_scope):
-            raise ScopeNotValid(security_scope)
-        self.path = path
-        self.base_url = base_url
-        self.security_scope = security_scope
-        self.secure = secure
-
-        if self.path and self.path[0] != "/":
-            self.path = "/" + self.path
-
-        if self.base_url is None:
-            self.base_url = ""
-        elif self.base_url == "/":
-            self.base_url = ""
-
-        if self.base_url == "" and self.path == "":
-            self.base_url = "/"
-
-    def __call__(self, cls):
-        cls._cp_controller_ = True
-        cls._cp_path_ = "{}{}".format(self.base_url, self.path)
-        cls._security_scope = self.security_scope
-
-        config = {
-            'tools.dashboard_exception_handler.on': True,
-            'tools.authenticate.on': self.secure,
-        }
-        if not hasattr(cls, '_cp_config'):
-            cls._cp_config = {}
-        cls._cp_config.update(config)
-        return cls
-
-
-class ApiController(Controller):
-    def __init__(self, path, security_scope=None, secure=True):
-        super(ApiController, self).__init__(path, base_url="/api",
-                                            security_scope=security_scope,
-                                            secure=secure)
-
-    def __call__(self, cls):
-        cls = super(ApiController, self).__call__(cls)
-        cls._api_endpoint = True
-        return cls
-
-
-class UiApiController(Controller):
-    def __init__(self, path, security_scope=None, secure=True):
-        super(UiApiController, self).__init__(path, base_url="/ui-api",
-                                              security_scope=security_scope,
-                                              secure=secure)
-
-    def __call__(self, cls):
-        cls = super(UiApiController, self).__call__(cls)
-        cls._api_endpoint = False
-        return cls
-
-
-def Endpoint(method=None, path=None, path_params=None, query_params=None,  # noqa: N802
-             json_response=True, proxy=False, xml=False, version=DEFAULT_VERSION):
-
-    if method is None:
-        method = 'GET'
-    elif not isinstance(method, str) or \
-            method.upper() not in ['GET', 'POST', 'DELETE', 'PUT']:
-        raise TypeError("Possible values for method are: 'GET', 'POST', "
-                        "'DELETE', or 'PUT'")
-
-    method = method.upper()
-
-    if method in ['GET', 'DELETE']:
-        if path_params is not None:
-            raise TypeError("path_params should not be used for {} "
-                            "endpoints. All function params are considered"
-                            " path parameters by default".format(method))
-
-    if path_params is None:
-        if method in ['POST', 'PUT']:
-            path_params = []
-
-    if query_params is None:
-        query_params = []
-
-    def _wrapper(func):
-        if method in ['POST', 'PUT']:
-            func_params = _get_function_params(func)
-            for param in func_params:
-                if param['name'] in path_params and not param['required']:
-                    raise TypeError("path_params can only reference "
-                                    "non-optional function parameters")
-
-        if func.__name__ == '__call__' and path is None:
-            e_path = ""
-        else:
-            e_path = path
-
-        if e_path is not None:
-            e_path = e_path.strip()
-            if e_path and e_path[0] != "/":
-                e_path = "/" + e_path
-            elif e_path == "/":
-                e_path = ""
-
-        func._endpoint = {
-            'method': method,
-            'path': e_path,
-            'path_params': path_params,
-            'query_params': query_params,
-            'json_response': json_response,
-            'proxy': proxy,
-            'xml': xml,
-            'version': version
-        }
-        return func
-    return _wrapper
-
-
-def Proxy(path=None):  # noqa: N802
-    if path is None:
-        path = ""
-    elif path == "/":
-        path = ""
-    path += "/{path:.*}"
-    return Endpoint(path=path, proxy=True)
-
-
-def load_controllers():
-    logger = logging.getLogger('controller.load')
-    # setting sys.path properly when not running under the mgr
-    controllers_dir = os.path.dirname(os.path.realpath(__file__))
-    dashboard_dir = os.path.dirname(controllers_dir)
-    mgr_dir = os.path.dirname(dashboard_dir)
-    logger.debug("controllers_dir=%s", controllers_dir)
-    logger.debug("dashboard_dir=%s", dashboard_dir)
-    logger.debug("mgr_dir=%s", mgr_dir)
-    if mgr_dir not in sys.path:
-        sys.path.append(mgr_dir)
-
-    controllers = []
-    mods = [mod for _, mod, _ in pkgutil.iter_modules([controllers_dir])]
-    logger.debug("mods=%s", mods)
-    for mod_name in mods:
-        mod = importlib.import_module('.controllers.{}'.format(mod_name),
-                                      package='dashboard')
-        for _, cls in mod.__dict__.items():
-            # Controllers MUST be derived from the class BaseController.
-            if inspect.isclass(cls) and issubclass(cls, BaseController) and \
-                    hasattr(cls, '_cp_controller_'):
-                if cls._cp_path_.startswith(':'):
-                    # invalid _cp_path_ value
-                    logger.error("Invalid url prefix '%s' for controller '%s'",
-                                 cls._cp_path_, cls.__name__)
-                    continue
-                controllers.append(cls)
-
-    for clist in PLUGIN_MANAGER.hook.get_controllers() or []:
-        controllers.extend(clist)
-
-    return controllers
-
-
-ENDPOINT_MAP = collections.defaultdict(list)  # type: dict
-
-
-def generate_controller_routes(endpoint, mapper, base_url):
-    inst = endpoint.inst
-    ctrl_class = endpoint.ctrl
-
-    if endpoint.proxy:
-        conditions = None
-    else:
-        conditions = dict(method=[endpoint.method])
-
-    # base_url can be empty or a URL path that starts with "/"
-    # we will remove the trailing "/" if exists to help with the
-    # concatenation with the endpoint url below
-    if base_url.endswith("/"):
-        base_url = base_url[:-1]
-
-    endp_url = endpoint.url
-
-    if endp_url.find("/", 1) == -1:
-        parent_url = "{}{}".format(base_url, endp_url)
-    else:
-        parent_url = "{}{}".format(base_url, endp_url[:endp_url.find("/", 1)])
-
-    # parent_url might be of the form "/.../{...}" where "{...}" is a path parameter
-    # we need to remove the path parameter definition
-    parent_url = re.sub(r'(?:/\{[^}]+\})$', '', parent_url)
-    if not parent_url:  # root path case
-        parent_url = "/"
-
-    url = "{}{}".format(base_url, endp_url)
-
-    logger = logging.getLogger('controller')
-    logger.debug("Mapped [%s] to %s:%s restricted to %s",
-                 url, ctrl_class.__name__, endpoint.action,
-                 endpoint.method)
-
-    ENDPOINT_MAP[endpoint.url].append(endpoint)
-
-    name = ctrl_class.__name__ + ":" + endpoint.action
-    mapper.connect(name, url, controller=inst, action=endpoint.action,
-                   conditions=conditions)
-
-    # adding route with trailing slash
-    name += "/"
-    url += "/"
-    mapper.connect(name, url, controller=inst, action=endpoint.action,
-                   conditions=conditions)
-
-    return parent_url
-
-
-def generate_routes(url_prefix):
-    mapper = cherrypy.dispatch.RoutesDispatcher()
-    ctrls = load_controllers()
-
-    parent_urls = set()
-
-    endpoint_list = []
-    for ctrl in ctrls:
-        inst = ctrl()
-        for endpoint in ctrl.endpoints():
-            endpoint.inst = inst
-            endpoint_list.append(endpoint)
-
-    endpoint_list = sorted(endpoint_list, key=lambda e: e.url)
-    for endpoint in endpoint_list:
-        parent_urls.add(generate_controller_routes(endpoint, mapper,
-                                                   "{}".format(url_prefix)))
-
-    logger = logging.getLogger('controller')
-    logger.debug("list of parent paths: %s", parent_urls)
-    return mapper, parent_urls
-
-
-def json_error_page(status, message, traceback, version):
-    cherrypy.response.headers['Content-Type'] = 'application/json'
-    return json.dumps(dict(status=status, detail=message, traceback=traceback,
-                           version=version))
-
-
-def _get_function_params(func):
-    """
-    Retrieves the list of parameters declared in function.
-    Each parameter is represented as dict with keys:
-      * name (str): the name of the parameter
-      * required (bool): whether the parameter is required or not
-      * default (obj): the parameter's default value
-    """
-    fspec = getargspec(func)
-
-    func_params = []
-    nd = len(fspec.args) if not fspec.defaults else -len(fspec.defaults)
-    for param in fspec.args[1:nd]:
-        func_params.append({'name': param, 'required': True})
-
-    if fspec.defaults:
-        for param, val in zip(fspec.args[nd:], fspec.defaults):
-            func_params.append({
-                'name': param,
-                'required': False,
-                'default': val
-            })
-
-    return func_params
-
-
-class Task(object):
-    def __init__(self, name, metadata, wait_for=5.0, exception_handler=None):
-        self.name = name
-        if isinstance(metadata, list):
-            self.metadata = {e[1:-1]: e for e in metadata}
-        else:
-            self.metadata = metadata
-        self.wait_for = wait_for
-        self.exception_handler = exception_handler
-
-    def _gen_arg_map(self, func, args, kwargs):
-        arg_map = {}
-        params = _get_function_params(func)
-
-        args = args[1:]  # exclude self
-        for idx, param in enumerate(params):
-            if idx < len(args):
-                arg_map[param['name']] = args[idx]
-            else:
-                if param['name'] in kwargs:
-                    arg_map[param['name']] = kwargs[param['name']]
-                else:
-                    assert not param['required'], "{0} is required".format(param['name'])
-                    arg_map[param['name']] = param['default']
-
-            if param['name'] in arg_map:
-                # This is not a type error. We are using the index here.
-                arg_map[idx+1] = arg_map[param['name']]
-
-        return arg_map
-
-    def __call__(self, func):
-        @wraps(func)
-        def wrapper(*args, **kwargs):
-            arg_map = self._gen_arg_map(func, args, kwargs)
-            metadata = {}
-            for k, v in self.metadata.items():
-                if isinstance(v, str) and v and v[0] == '{' and v[-1] == '}':
-                    param = v[1:-1]
-                    try:
-                        pos = int(param)
-                        metadata[k] = arg_map[pos]
-                    except ValueError:
-                        if param.find('.') == -1:
-                            metadata[k] = arg_map[param]
-                        else:
-                            path = param.split('.')
-                            metadata[k] = arg_map[path[0]]
-                            for i in range(1, len(path)):
-                                metadata[k] = metadata[k][path[i]]
-                else:
-                    metadata[k] = v
-            task = TaskManager.run(self.name, metadata, func, args, kwargs,
-                                   exception_handler=self.exception_handler)
-            try:
-                status, value = task.wait(self.wait_for)
-            except Exception as ex:
-                if task.ret_value:
-                    # exception was handled by task.exception_handler
-                    if 'status' in task.ret_value:
-                        status = task.ret_value['status']
-                    else:
-                        status = getattr(ex, 'status', 500)
-                    cherrypy.response.status = status
-                    return task.ret_value
-                raise ex
-            if status == TaskManager.VALUE_EXECUTING:
-                cherrypy.response.status = 202
-                return {'name': self.name, 'metadata': metadata}
-            return value
-        return wrapper
-
-
-class BaseController(object):
-    """
-    Base class for all controllers providing API endpoints.
-    """
-
-    class Endpoint(object):
-        """
-        An instance of this class represents an endpoint.
-        """
-
-        def __init__(self, ctrl, func):
-            self.ctrl = ctrl
-            self.inst = None
-            self.func = func
-
-            if not self.config['proxy']:
-                setattr(self.ctrl, func.__name__, self.function)
-
-        @property
-        def config(self):
-            func = self.func
-            while not hasattr(func, '_endpoint'):
-                if hasattr(func, "__wrapped__"):
-                    func = func.__wrapped__
-                else:
-                    return None
-            return func._endpoint
-
-        @property
-        def function(self):
-            return self.ctrl._request_wrapper(self.func, self.method,
-                                              self.config['json_response'],
-                                              self.config['xml'],
-                                              self.config['version'])
-
-        @property
-        def method(self):
-            return self.config['method']
-
-        @property
-        def proxy(self):
-            return self.config['proxy']
-
-        @property
-        def url(self):
-            ctrl_path = self.ctrl.get_path()
-            if ctrl_path == "/":
-                ctrl_path = ""
-            if self.config['path'] is not None:
-                url = "{}{}".format(ctrl_path, self.config['path'])
-            else:
-                url = "{}/{}".format(ctrl_path, self.func.__name__)
-
-            ctrl_path_params = self.ctrl.get_path_param_names(
-                self.config['path'])
-            path_params = [p['name'] for p in self.path_params
-                           if p['name'] not in ctrl_path_params]
-            path_params = ["{{{}}}".format(p) for p in path_params]
-            if path_params:
-                url += "/{}".format("/".join(path_params))
-
-            return url
-
-        @property
-        def action(self):
-            return self.func.__name__
-
-        @property
-        def path_params(self):
-            ctrl_path_params = self.ctrl.get_path_param_names(
-                self.config['path'])
-            func_params = _get_function_params(self.func)
-
-            if self.method in ['GET', 'DELETE']:
-                assert self.config['path_params'] is None
-
-                return [p for p in func_params if p['name'] in ctrl_path_params
-                        or (p['name'] not in self.config['query_params']
-                            and p['required'])]
-
-            # elif self.method in ['POST', 'PUT']:
-            return [p for p in func_params if p['name'] in ctrl_path_params
-                    or p['name'] in self.config['path_params']]
-
-        @property
-        def query_params(self):
-            if self.method in ['GET', 'DELETE']:
-                func_params = _get_function_params(self.func)
-                path_params = [p['name'] for p in self.path_params]
-                return [p for p in func_params if p['name'] not in path_params]
-
-            # elif self.method in ['POST', 'PUT']:
-            func_params = _get_function_params(self.func)
-            return [p for p in func_params
-                    if p['name'] in self.config['query_params']]
-
-        @property
-        def body_params(self):
-            func_params = _get_function_params(self.func)
-            path_params = [p['name'] for p in self.path_params]
-            query_params = [p['name'] for p in self.query_params]
-            return [p for p in func_params
-                    if p['name'] not in path_params
-                    and p['name'] not in query_params]
-
-        @property
-        def group(self):
-            return self.ctrl.__name__
-
-        @property
-        def is_api(self):
-            # changed from hasattr to getattr: some ui-based api inherit _api_endpoint
-            return getattr(self.ctrl, '_api_endpoint', False)
-
-        @property
-        def is_secure(self):
-            return self.ctrl._cp_config['tools.authenticate.on']
-
-        def __repr__(self):
-            return "Endpoint({}, {}, {})".format(self.url, self.method,
-                                                 self.action)
-
-    def __init__(self):
-        logger = logging.getLogger('controller')
-        logger.info('Initializing controller: %s -> %s',
-                    self.__class__.__name__, self._cp_path_)  # type: ignore
-        super(BaseController, self).__init__()
-
-    def _has_permissions(self, permissions, scope=None):
-        if not self._cp_config['tools.authenticate.on']:  # type: ignore
-            raise Exception("Cannot verify permission in non secured "
-                            "controllers")
-
-        if not isinstance(permissions, list):
-            permissions = [permissions]
-
-        if scope is None:
-            scope = getattr(self, '_security_scope', None)
-        if scope is None:
-            raise Exception("Cannot verify permissions without scope security"
-                            " defined")
-        username = JwtManager.LOCAL_USER.username
-        return AuthManager.authorize(username, scope, permissions)
-
-    @classmethod
-    def get_path_param_names(cls, path_extension=None):
-        if path_extension is None:
-            path_extension = ""
-        full_path = cls._cp_path_[1:] + path_extension  # type: ignore
-        path_params = []
-        for step in full_path.split('/'):
-            param = None
-            if not step:
-                continue
-            if step[0] == ':':
-                param = step[1:]
-            elif step[0] == '{' and step[-1] == '}':
-                param, _, _ = step[1:-1].partition(':')
-            if param:
-                path_params.append(param)
-        return path_params
-
-    @classmethod
-    def get_path(cls):
-        return cls._cp_path_  # type: ignore
-
-    @classmethod
-    def endpoints(cls):
-        """
-        This method iterates over all the methods decorated with ``@endpoint``
-        and creates an Endpoint object for each one of the methods.
-
-        :return: A list of endpoint objects
-        :rtype: list[BaseController.Endpoint]
-        """
-        result = []
-        for _, func in inspect.getmembers(cls, predicate=callable):
-            if hasattr(func, '_endpoint'):
-                result.append(cls.Endpoint(cls, func))
-        return result
-
-    @staticmethod
-    def _request_wrapper(func, method, json_response, xml,  # pylint: disable=unused-argument
-                         version):
-        @wraps(func)
-        def inner(*args, **kwargs):
-            req_version = None
-            for key, value in kwargs.items():
-                if isinstance(value, str):
-                    kwargs[key] = unquote(value)
-
-            # Process method arguments.
-            params = get_request_body_params(cherrypy.request)
-            kwargs.update(params)
-
-            if version is not None:
-                accept_header = cherrypy.request.headers.get('Accept')
-                if accept_header and accept_header.startswith('application/vnd.ceph.api.v'):
-                    req_match = re.search(r"\d\.\d", accept_header)
-                    if req_match:
-                        req_version = req_match[0]
-                else:
-                    raise cherrypy.HTTPError(415, "Unable to find version in request header")
-
-                if req_version and req_version == version:
-                    ret = func(*args, **kwargs)
-                else:
-                    raise cherrypy.HTTPError(415,
-                                             "Incorrect version: "
-                                             "{} requested but {} is expected"
-                                             "".format(req_version, version))
-            else:
-                ret = func(*args, **kwargs)
-            if isinstance(ret, bytes):
-                ret = ret.decode('utf-8')
-            if xml:
-                if version:
-                    cherrypy.response.headers['Content-Type'] = \
-                        'application/vnd.ceph.api.v{}+xml'.format(version)
-                else:
-                    cherrypy.response.headers['Content-Type'] = 'application/xml'
-                return ret.encode('utf8')
-            if json_response:
-                if version:
-                    cherrypy.response.headers['Content-Type'] = \
-                        'application/vnd.ceph.api.v{}+json'.format(version)
-                else:
-                    cherrypy.response.headers['Content-Type'] = 'application/json'
-                ret = json.dumps(ret).encode('utf8')
-            return ret
-        return inner
-
-    @property
-    def _request(self):
-        return self.Request(cherrypy.request)
-
-    class Request(object):
-        def __init__(self, cherrypy_req):
-            self._creq = cherrypy_req
-
-        @property
-        def scheme(self):
-            return self._creq.scheme
-
-        @property
-        def host(self):
-            base = self._creq.base
-            base = base[len(self.scheme)+3:]
-            return base[:base.find(":")] if ":" in base else base
-
-        @property
-        def port(self):
-            base = self._creq.base
-            base = base[len(self.scheme)+3:]
-            default_port = 443 if self.scheme == 'https' else 80
-            return int(base[base.find(":")+1:]) if ":" in base else default_port
-
-        @property
-        def path_info(self):
-            return self._creq.path_info
-
-
-class RESTController(BaseController):
-    """
-    Base class for providing a RESTful interface to a resource.
-
-    To use this class, simply derive a class from it and implement the methods
-    you want to support.  The list of possible methods are:
-
-    * list()
-    * bulk_set(data)
-    * create(data)
-    * bulk_delete()
-    * get(key)
-    * set(data, key)
-    * singleton_set(data)
-    * delete(key)
-
-    Test with curl:
-
-    curl -H "Content-Type: application/json" -X POST \
-         -d '{"username":"xyz","password":"xyz"}'  https://127.0.0.1:8443/foo
-    curl https://127.0.0.1:8443/foo
-    curl https://127.0.0.1:8443/foo/0
-
-    """
-
-    # resource id parameter for using in get, set, and delete methods
-    # should be overridden by subclasses.
-    # to specify a composite id (two parameters) use '/'. e.g., "param1/param2".
-    # If subclasses don't override this property we try to infer the structure
-    # of the resource ID.
-    RESOURCE_ID = None  # type: Optional[str]
-
-    _permission_map = {
-        'GET': Permission.READ,
-        'POST': Permission.CREATE,
-        'PUT': Permission.UPDATE,
-        'DELETE': Permission.DELETE
-    }
-
-    _method_mapping = collections.OrderedDict([
-        ('list', {'method': 'GET', 'resource': False, 'status': 200, 'version': DEFAULT_VERSION}),
-        ('create', {'method': 'POST', 'resource': False, 'status': 201, 'version': DEFAULT_VERSION}),  # noqa E501 #pylint: disable=line-too-long
-        ('bulk_set', {'method': 'PUT', 'resource': False, 'status': 200, 'version': DEFAULT_VERSION}),  # noqa E501 #pylint: disable=line-too-long
-        ('bulk_delete', {'method': 'DELETE', 'resource': False, 'status': 204, 'version': DEFAULT_VERSION}),  # noqa E501 #pylint: disable=line-too-long
-        ('get', {'method': 'GET', 'resource': True, 'status': 200, 'version': DEFAULT_VERSION}),
-        ('delete', {'method': 'DELETE', 'resource': True, 'status': 204, 'version': DEFAULT_VERSION}),  # noqa E501 #pylint: disable=line-too-long
-        ('set', {'method': 'PUT', 'resource': True, 'status': 200, 'version': DEFAULT_VERSION}),
-        ('singleton_set', {'method': 'PUT', 'resource': False, 'status': 200, 'version': DEFAULT_VERSION})  # noqa E501 #pylint: disable=line-too-long
-    ])
-
-    @classmethod
-    def infer_resource_id(cls):
-        if cls.RESOURCE_ID is not None:
-            return cls.RESOURCE_ID.split('/')
-        for k, v in cls._method_mapping.items():
-            func = getattr(cls, k, None)
-            while hasattr(func, "__wrapped__"):
-                func = func.__wrapped__
-            if v['resource'] and func:
-                path_params = cls.get_path_param_names()
-                params = _get_function_params(func)
-                return [p['name'] for p in params
-                        if p['required'] and p['name'] not in path_params]
-        return None
-
-    @classmethod
-    def endpoints(cls):
-        result = super(RESTController, cls).endpoints()
-        res_id_params = cls.infer_resource_id()
-
-        for _, func in inspect.getmembers(cls, predicate=callable):
-            no_resource_id_params = False
-            status = 200
-            method = None
-            query_params = None
-            path = ""
-            version = DEFAULT_VERSION
-            sec_permissions = hasattr(func, '_security_permissions')
-            permission = None
-
-            if func.__name__ in cls._method_mapping:
-                meth = cls._method_mapping[func.__name__]  # type: dict
-
-                if meth['resource']:
-                    if not res_id_params:
-                        no_resource_id_params = True
-                    else:
-                        path_params = ["{{{}}}".format(p) for p in res_id_params]
-                        path += "/{}".format("/".join(path_params))
-
-                status = meth['status']
-                method = meth['method']
-                if hasattr(func, "__method_map_method__"):
-                    version = func.__method_map_method__['version']
-                if not sec_permissions:
-                    permission = cls._permission_map[method]
-
-            elif hasattr(func, "__collection_method__"):
-                if func.__collection_method__['path']:
-                    path = func.__collection_method__['path']
-                else:
-                    path = "/{}".format(func.__name__)
-                status = func.__collection_method__['status']
-                method = func.__collection_method__['method']
-                query_params = func.__collection_method__['query_params']
-                version = func.__collection_method__['version']
-                if not sec_permissions:
-                    permission = cls._permission_map[method]
-
-            elif hasattr(func, "__resource_method__"):
-                if not res_id_params:
-                    no_resource_id_params = True
-                else:
-                    path_params = ["{{{}}}".format(p) for p in res_id_params]
-                    path += "/{}".format("/".join(path_params))
-                    if func.__resource_method__['path']:
-                        path += func.__resource_method__['path']
-                    else:
-                        path += "/{}".format(func.__name__)
-                status = func.__resource_method__['status']
-                method = func.__resource_method__['method']
-                version = func.__resource_method__['version']
-                query_params = func.__resource_method__['query_params']
-                if not sec_permissions:
-                    permission = cls._permission_map[method]
-
-            else:
-                continue
-
-            if no_resource_id_params:
-                raise TypeError("Could not infer the resource ID parameters for"
-                                " method {} of controller {}. "
-                                "Please specify the resource ID parameters "
-                                "using the RESOURCE_ID class property"
-                                .format(func.__name__, cls.__name__))
-
-            if method in ['GET', 'DELETE']:
-                params = _get_function_params(func)
-                if res_id_params is None:
-                    res_id_params = []
-                if query_params is None:
-                    query_params = [p['name'] for p in params
-                                    if p['name'] not in res_id_params]
-
-            func = cls._status_code_wrapper(func, status)
-            endp_func = Endpoint(method, path=path,
-                                 query_params=query_params, version=version)(func)
-            if permission:
-                _set_func_permissions(endp_func, [permission])
-            result.append(cls.Endpoint(cls, endp_func))
-
-        return result
-
-    @classmethod
-    def _status_code_wrapper(cls, func, status_code):
-        @wraps(func)
-        def wrapper(*vpath, **params):
-            cherrypy.response.status = status_code
-            return func(*vpath, **params)
-
-        return wrapper
-
-    @staticmethod
-    def Resource(method=None, path=None, status=None, query_params=None,  # noqa: N802
-                 version=DEFAULT_VERSION):
-        if not method:
-            method = 'GET'
-
-        if status is None:
-            status = 200
-
-        def _wrapper(func):
-            func.__resource_method__ = {
-                'method': method,
-                'path': path,
-                'status': status,
-                'query_params': query_params,
-                'version': version
-            }
-            return func
-        return _wrapper
-
-    @staticmethod
-    def MethodMap(resource=False, status=None, version=DEFAULT_VERSION):  # noqa: N802
-
-        if status is None:
-            status = 200
-
-        def _wrapper(func):
-            func.__method_map_method__ = {
-                'resource': resource,
-                'status': status,
-                'version': version
-            }
-            return func
-        return _wrapper
-
-    @staticmethod
-    def Collection(method=None, path=None, status=None, query_params=None,  # noqa: N802
-                   version=DEFAULT_VERSION):
-        if not method:
-            method = 'GET'
-
-        if status is None:
-            status = 200
-
-        def _wrapper(func):
-            func.__collection_method__ = {
-                'method': method,
-                'path': path,
-                'status': status,
-                'query_params': query_params,
-                'version': version
-            }
-            return func
-        return _wrapper
-
-
-class ControllerAuthMixin(object):
-    @staticmethod
-    def _delete_token_cookie(token):
-        cherrypy.response.cookie['token'] = token
-        cherrypy.response.cookie['token']['expires'] = 0
-        cherrypy.response.cookie['token']['max-age'] = 0
-
-    @staticmethod
-    def _set_token_cookie(url_prefix, token):
-        cherrypy.response.cookie['token'] = token
-        if url_prefix == 'https':
-            cherrypy.response.cookie['token']['secure'] = True
-        cherrypy.response.cookie['token']['HttpOnly'] = True
-        cherrypy.response.cookie['token']['path'] = '/'
-        cherrypy.response.cookie['token']['SameSite'] = 'Strict'
-
-
-# Role-based access permissions decorators
-
-def _set_func_permissions(func, permissions):
-    if not isinstance(permissions, list):
-        permissions = [permissions]
-
-    for perm in permissions:
-        if not Permission.valid_permission(perm):
-            logger = logging.getLogger('controller.set_func_perms')
-            logger.debug("Invalid security permission: %s\n "
-                         "Possible values: %s", perm,
-                         Permission.all_permissions())
-            raise PermissionNotValid(perm)
-
-    if not hasattr(func, '_security_permissions'):
-        func._security_permissions = permissions
-    else:
-        permissions.extend(func._security_permissions)
-        func._security_permissions = list(set(permissions))
-
-
-def ReadPermission(func):  # noqa: N802
-    """
-    :raises PermissionNotValid: If the permission is missing.
-    """
-    _set_func_permissions(func, Permission.READ)
-    return func
-
-
-def CreatePermission(func):  # noqa: N802
-    """
-    :raises PermissionNotValid: If the permission is missing.
-    """
-    _set_func_permissions(func, Permission.CREATE)
-    return func
-
-
-def DeletePermission(func):  # noqa: N802
-    """
-    :raises PermissionNotValid: If the permission is missing.
-    """
-    _set_func_permissions(func, Permission.DELETE)
-    return func
-
-
-def UpdatePermission(func):  # noqa: N802
-    """
-    :raises PermissionNotValid: If the permission is missing.
-    """
-    _set_func_permissions(func, Permission.UPDATE)
-    return func
-
-
-# Empty request body decorator
-
-def allow_empty_body(func):  # noqa: N802
-    """
-    The POST/PUT request methods decorated with ``@allow_empty_body``
-    are allowed to send empty request body.
-    """
-    try:
-        func._cp_config['tools.json_in.force'] = False
-    except (AttributeError, KeyError):
-        func._cp_config = {'tools.json_in.force': False}
-    return func
-
-
-def validate_ceph_type(validations, component=''):
-    def decorator(func):
-        @wraps(func)
-        def validate_args(*args, **kwargs):
-            input_values = kwargs
-            for key, ceph_type in validations:
-                try:
-                    ceph_type.valid(input_values[key])
-                except ArgumentFormat as e:
-                    raise DashboardException(msg=e,
-                                             code='ceph_type_not_valid',
-                                             component=component)
-            return func(*args, **kwargs)
-        return validate_args
-    return decorator
+from ._api_router import APIRouter
+from ._auth import ControllerAuthMixin
+from ._base_controller import BaseController
+from ._docs import APIDoc, EndpointDoc
+from ._endpoint import Endpoint, Proxy
+from ._helpers import ENDPOINT_MAP, allow_empty_body, \
+    generate_controller_routes, json_error_page, validate_ceph_type
+from ._permissions import CreatePermission, DeletePermission, ReadPermission, UpdatePermission
+from ._rest_controller import RESTController
+from ._router import Router
+from ._task import Task
+from ._ui_router import UIRouter
+
+__all__ = [
+    'BaseController',
+    'RESTController',
+    'Router',
+    'UIRouter',
+    'APIRouter',
+    'Endpoint',
+    'Proxy',
+    'Task',
+    'ControllerAuthMixin',
+    'EndpointDoc',
+    'APIDoc',
+    'allow_empty_body',
+    'ENDPOINT_MAP',
+    'generate_controller_routes',
+    'json_error_page',
+    'validate_ceph_type',
+    'CreatePermission',
+    'ReadPermission',
+    'UpdatePermission',
+    'DeletePermission'
+]
diff --git a/src/pybind/mgr/dashboard/controllers/_api_router.py b/src/pybind/mgr/dashboard/controllers/_api_router.py
new file mode 100644 (file)
index 0000000..dbd45ac
--- /dev/null
@@ -0,0 +1,13 @@
+from ._router import Router
+
+
+class APIRouter(Router):
+    def __init__(self, path, security_scope=None, secure=True):
+        super().__init__(path, base_url="/api",
+                         security_scope=security_scope,
+                         secure=secure)
+
+    def __call__(self, cls):
+        cls = super().__call__(cls)
+        cls._api_endpoint = True
+        return cls
diff --git a/src/pybind/mgr/dashboard/controllers/_auth.py b/src/pybind/mgr/dashboard/controllers/_auth.py
new file mode 100644 (file)
index 0000000..0015a75
--- /dev/null
@@ -0,0 +1,18 @@
+import cherrypy
+
+
+class ControllerAuthMixin:
+    @staticmethod
+    def _delete_token_cookie(token):
+        cherrypy.response.cookie['token'] = token
+        cherrypy.response.cookie['token']['expires'] = 0
+        cherrypy.response.cookie['token']['max-age'] = 0
+
+    @staticmethod
+    def _set_token_cookie(url_prefix, token):
+        cherrypy.response.cookie['token'] = token
+        if url_prefix == 'https':
+            cherrypy.response.cookie['token']['secure'] = True
+        cherrypy.response.cookie['token']['HttpOnly'] = True
+        cherrypy.response.cookie['token']['path'] = '/'
+        cherrypy.response.cookie['token']['SameSite'] = 'Strict'
diff --git a/src/pybind/mgr/dashboard/controllers/_base_controller.py b/src/pybind/mgr/dashboard/controllers/_base_controller.py
new file mode 100644 (file)
index 0000000..4fcc844
--- /dev/null
@@ -0,0 +1,314 @@
+import inspect
+import json
+import logging
+from functools import wraps
+from typing import ClassVar, List, Optional, Type
+from urllib.parse import unquote
+
+import cherrypy
+
+from ..plugins import PLUGIN_MANAGER
+from ..services.auth import AuthManager, JwtManager
+from ..tools import get_request_body_params
+from ._helpers import _get_function_params
+from ._version import APIVersion
+
+logger = logging.getLogger(__name__)
+
+
+class BaseController:
+    """
+    Base class for all controllers providing API endpoints.
+    """
+
+    _registry: ClassVar[List[Type['BaseController']]] = []
+    _routed = False
+
+    def __init_subclass__(cls, skip_registry: bool = False, **kwargs) -> None:
+        super().__init_subclass__(**kwargs)  # type: ignore
+        if not skip_registry:
+            BaseController._registry.append(cls)
+
+    @classmethod
+    def load_controllers(cls):
+        import importlib
+        from pathlib import Path
+
+        path = Path(__file__).parent
+        logger.debug('Controller import path: %s', path)
+        modules = [
+            f.stem for f in path.glob('*.py') if
+            not f.name.startswith('_') and f.is_file() and not f.is_symlink()]
+        logger.debug('Controller files found: %r', modules)
+
+        for module in modules:
+            importlib.import_module(f'{__package__}.{module}')
+
+        # pylint: disable=protected-access
+        controllers = [
+            controller for controller in BaseController._registry if
+            controller._routed
+        ]
+
+        for clist in PLUGIN_MANAGER.hook.get_controllers() or []:
+            controllers.extend(clist)
+
+        return controllers
+
+    class Endpoint:
+        """
+        An instance of this class represents an endpoint.
+        """
+
+        def __init__(self, ctrl, func):
+            self.ctrl = ctrl
+            self.inst = None
+            self.func = func
+
+            if not self.config['proxy']:
+                setattr(self.ctrl, func.__name__, self.function)
+
+        @property
+        def config(self):
+            func = self.func
+            while not hasattr(func, '_endpoint'):
+                if hasattr(func, "__wrapped__"):
+                    func = func.__wrapped__
+                else:
+                    return None
+            return func._endpoint  # pylint: disable=protected-access
+
+        @property
+        def function(self):
+            # pylint: disable=protected-access
+            return self.ctrl._request_wrapper(self.func, self.method,
+                                              self.config['json_response'],
+                                              self.config['xml'],
+                                              self.config['version'])
+
+        @property
+        def method(self):
+            return self.config['method']
+
+        @property
+        def proxy(self):
+            return self.config['proxy']
+
+        @property
+        def url(self):
+            ctrl_path = self.ctrl.get_path()
+            if ctrl_path == "/":
+                ctrl_path = ""
+            if self.config['path'] is not None:
+                url = "{}{}".format(ctrl_path, self.config['path'])
+            else:
+                url = "{}/{}".format(ctrl_path, self.func.__name__)
+
+            ctrl_path_params = self.ctrl.get_path_param_names(
+                self.config['path'])
+            path_params = [p['name'] for p in self.path_params
+                           if p['name'] not in ctrl_path_params]
+            path_params = ["{{{}}}".format(p) for p in path_params]
+            if path_params:
+                url += "/{}".format("/".join(path_params))
+
+            return url
+
+        @property
+        def action(self):
+            return self.func.__name__
+
+        @property
+        def path_params(self):
+            ctrl_path_params = self.ctrl.get_path_param_names(
+                self.config['path'])
+            func_params = _get_function_params(self.func)
+
+            if self.method in ['GET', 'DELETE']:
+                assert self.config['path_params'] is None
+
+                return [p for p in func_params if p['name'] in ctrl_path_params
+                        or (p['name'] not in self.config['query_params']
+                            and p['required'])]
+
+            # elif self.method in ['POST', 'PUT']:
+            return [p for p in func_params if p['name'] in ctrl_path_params
+                    or p['name'] in self.config['path_params']]
+
+        @property
+        def query_params(self):
+            if self.method in ['GET', 'DELETE']:
+                func_params = _get_function_params(self.func)
+                path_params = [p['name'] for p in self.path_params]
+                return [p for p in func_params if p['name'] not in path_params]
+
+            # elif self.method in ['POST', 'PUT']:
+            func_params = _get_function_params(self.func)
+            return [p for p in func_params
+                    if p['name'] in self.config['query_params']]
+
+        @property
+        def body_params(self):
+            func_params = _get_function_params(self.func)
+            path_params = [p['name'] for p in self.path_params]
+            query_params = [p['name'] for p in self.query_params]
+            return [p for p in func_params
+                    if p['name'] not in path_params
+                    and p['name'] not in query_params]
+
+        @property
+        def group(self):
+            return self.ctrl.__name__
+
+        @property
+        def is_api(self):
+            # changed from hasattr to getattr: some ui-based api inherit _api_endpoint
+            return getattr(self.ctrl, '_api_endpoint', False)
+
+        @property
+        def is_secure(self):
+            return self.ctrl._cp_config['tools.authenticate.on']  # pylint: disable=protected-access
+
+        def __repr__(self):
+            return "Endpoint({}, {}, {})".format(self.url, self.method,
+                                                 self.action)
+
+    def __init__(self):
+        logger.info('Initializing controller: %s -> %s',
+                    self.__class__.__name__, self._cp_path_)  # type: ignore
+        super().__init__()
+
+    def _has_permissions(self, permissions, scope=None):
+        if not self._cp_config['tools.authenticate.on']:  # type: ignore
+            raise Exception("Cannot verify permission in non secured "
+                            "controllers")
+
+        if not isinstance(permissions, list):
+            permissions = [permissions]
+
+        if scope is None:
+            scope = getattr(self, '_security_scope', None)
+        if scope is None:
+            raise Exception("Cannot verify permissions without scope security"
+                            " defined")
+        username = JwtManager.LOCAL_USER.username
+        return AuthManager.authorize(username, scope, permissions)
+
+    @classmethod
+    def get_path_param_names(cls, path_extension=None):
+        if path_extension is None:
+            path_extension = ""
+        full_path = cls._cp_path_[1:] + path_extension  # type: ignore
+        path_params = []
+        for step in full_path.split('/'):
+            param = None
+            if not step:
+                continue
+            if step[0] == ':':
+                param = step[1:]
+            elif step[0] == '{' and step[-1] == '}':
+                param, _, _ = step[1:-1].partition(':')
+            if param:
+                path_params.append(param)
+        return path_params
+
+    @classmethod
+    def get_path(cls):
+        return cls._cp_path_  # type: ignore
+
+    @classmethod
+    def endpoints(cls):
+        """
+        This method iterates over all the methods decorated with ``@endpoint``
+        and creates an Endpoint object for each one of the methods.
+
+        :return: A list of endpoint objects
+        :rtype: list[BaseController.Endpoint]
+        """
+        result = []
+        for _, func in inspect.getmembers(cls, predicate=callable):
+            if hasattr(func, '_endpoint'):
+                result.append(cls.Endpoint(cls, func))
+        return result
+
+    @staticmethod
+    def _request_wrapper(func, method, json_response, xml,  # pylint: disable=unused-argument
+                         version: Optional[APIVersion]):
+        # pylint: disable=too-many-branches
+        @wraps(func)
+        def inner(*args, **kwargs):
+            client_version = None
+            for key, value in kwargs.items():
+                if isinstance(value, str):
+                    kwargs[key] = unquote(value)
+
+            # Process method arguments.
+            params = get_request_body_params(cherrypy.request)
+            kwargs.update(params)
+
+            if version is not None:
+                try:
+                    client_version = APIVersion.from_mime_type(
+                        cherrypy.request.headers['Accept'])
+                except Exception:
+                    raise cherrypy.HTTPError(
+                        415, "Unable to find version in request header")
+
+                if version.supports(client_version):
+                    ret = func(*args, **kwargs)
+                else:
+                    raise cherrypy.HTTPError(
+                        415,
+                        f"Incorrect version: endpoint is '{version!s}', "
+                        f"client requested '{client_version!s}'"
+                    )
+
+            else:
+                ret = func(*args, **kwargs)
+            if isinstance(ret, bytes):
+                ret = ret.decode('utf-8')
+            if xml:
+                if version:
+                    cherrypy.response.headers['Content-Type'] = \
+                        'application/vnd.ceph.api.v{}+xml'.format(version)
+                else:
+                    cherrypy.response.headers['Content-Type'] = 'application/xml'
+                return ret.encode('utf8')
+            if json_response:
+                if version:
+                    cherrypy.response.headers['Content-Type'] = \
+                        'application/vnd.ceph.api.v{}+json'.format(version)
+                else:
+                    cherrypy.response.headers['Content-Type'] = 'application/json'
+                ret = json.dumps(ret).encode('utf8')
+            return ret
+        return inner
+
+    @property
+    def _request(self):
+        return self.Request(cherrypy.request)
+
+    class Request(object):
+        def __init__(self, cherrypy_req):
+            self._creq = cherrypy_req
+
+        @property
+        def scheme(self):
+            return self._creq.scheme
+
+        @property
+        def host(self):
+            base = self._creq.base
+            base = base[len(self.scheme)+3:]
+            return base[:base.find(":")] if ":" in base else base
+
+        @property
+        def port(self):
+            base = self._creq.base
+            base = base[len(self.scheme)+3:]
+            default_port = 443 if self.scheme == 'https' else 80
+            return int(base[base.find(":")+1:]) if ":" in base else default_port
+
+        @property
+        def path_info(self):
+            return self._creq.path_info
diff --git a/src/pybind/mgr/dashboard/controllers/_docs.py b/src/pybind/mgr/dashboard/controllers/_docs.py
new file mode 100644 (file)
index 0000000..5bd7a5a
--- /dev/null
@@ -0,0 +1,128 @@
+from typing import Any, Dict, List, Optional, Tuple, Union
+
+from ..api.doc import SchemaInput, SchemaType
+
+
+class EndpointDoc:  # noqa: N802
+    DICT_TYPE = Union[Dict[str, Any], Dict[int, Any]]
+
+    def __init__(self, description: str = "", group: str = "",
+                 parameters: Optional[Union[DICT_TYPE, List[Any], Tuple[Any, ...]]] = None,
+                 responses: Optional[DICT_TYPE] = None) -> None:
+        self.description = description
+        self.group = group
+        self.parameters = parameters
+        self.responses = responses
+
+        self.validate_args()
+
+        if not self.parameters:
+            self.parameters = {}  # type: ignore
+
+        self.resp = {}
+        if self.responses:
+            for status_code, response_body in self.responses.items():
+                schema_input = SchemaInput()
+                schema_input.type = SchemaType.ARRAY if \
+                    isinstance(response_body, list) else SchemaType.OBJECT
+                schema_input.params = self._split_parameters(response_body)
+
+                self.resp[str(status_code)] = schema_input
+
+    def validate_args(self) -> None:
+        if not isinstance(self.description, str):
+            raise Exception("%s has been called with a description that is not a string: %s"
+                            % (EndpointDoc.__name__, self.description))
+        if not isinstance(self.group, str):
+            raise Exception("%s has been called with a groupname that is not a string: %s"
+                            % (EndpointDoc.__name__, self.group))
+        if self.parameters and not isinstance(self.parameters, dict):
+            raise Exception("%s has been called with parameters that is not a dict: %s"
+                            % (EndpointDoc.__name__, self.parameters))
+        if self.responses and not isinstance(self.responses, dict):
+            raise Exception("%s has been called with responses that is not a dict: %s"
+                            % (EndpointDoc.__name__, self.responses))
+
+    def _split_param(self, name: str, p_type: Union[type, DICT_TYPE, List[Any], Tuple[Any, ...]],
+                     description: str, optional: bool = False, default_value: Any = None,
+                     nested: bool = False) -> Dict[str, Any]:
+        param = {
+            'name': name,
+            'description': description,
+            'required': not optional,
+            'nested': nested,
+        }
+        if default_value:
+            param['default'] = default_value
+        if isinstance(p_type, type):
+            param['type'] = p_type
+        else:
+            nested_params = self._split_parameters(p_type, nested=True)
+            if nested_params:
+                param['type'] = type(p_type)
+                param['nested_params'] = nested_params
+            else:
+                param['type'] = p_type
+        return param
+
+    #  Optional must be set to True in order to set default value and parameters format must be:
+    # 'name: (type or nested parameters, description, [optional], [default value])'
+    def _split_dict(self, data: DICT_TYPE, nested: bool) -> List[Any]:
+        splitted = []
+        for name, props in data.items():
+            if isinstance(name, str) and isinstance(props, tuple):
+                if len(props) == 2:
+                    param = self._split_param(name, props[0], props[1], nested=nested)
+                elif len(props) == 3:
+                    param = self._split_param(
+                        name, props[0], props[1], optional=props[2], nested=nested)
+                if len(props) == 4:
+                    param = self._split_param(name, props[0], props[1], props[2], props[3], nested)
+                splitted.append(param)
+            else:
+                raise Exception(
+                    """Parameter %s in %s has not correct format. Valid formats are:
+                    <name>: (<type>, <description>, [optional], [default value])
+                    <name>: (<[type]>, <description>, [optional], [default value])
+                    <name>: (<[nested parameters]>, <description>, [optional], [default value])
+                    <name>: (<{nested parameters}>, <description>, [optional], [default value])"""
+                    % (name, EndpointDoc.__name__))
+        return splitted
+
+    def _split_list(self, data: Union[List[Any], Tuple[Any, ...]], nested: bool) -> List[Any]:
+        splitted = []  # type: List[Any]
+        for item in data:
+            splitted.extend(self._split_parameters(item, nested))
+        return splitted
+
+    # nested = True means parameters are inside a dict or array
+    def _split_parameters(self, data: Optional[Union[DICT_TYPE, List[Any], Tuple[Any, ...]]],
+                          nested: bool = False) -> List[Any]:
+        param_list = []  # type: List[Any]
+        if isinstance(data, dict):
+            param_list.extend(self._split_dict(data, nested))
+        elif isinstance(data, (list, tuple)):
+            param_list.extend(self._split_list(data, True))
+        return param_list
+
+    def __call__(self, func: Any) -> Any:
+        func.doc_info = {
+            'summary': self.description,
+            'tag': self.group,
+            'parameters': self._split_parameters(self.parameters),
+            'response': self.resp
+        }
+        return func
+
+
+class APIDoc(object):
+    def __init__(self, description="", group=""):
+        self.tag = group
+        self.tag_descr = description
+
+    def __call__(self, cls):
+        cls.doc_info = {
+            'tag': self.tag,
+            'tag_descr': self.tag_descr
+        }
+        return cls
diff --git a/src/pybind/mgr/dashboard/controllers/_endpoint.py b/src/pybind/mgr/dashboard/controllers/_endpoint.py
new file mode 100644 (file)
index 0000000..fccab89
--- /dev/null
@@ -0,0 +1,82 @@
+from typing import Optional
+
+from ._helpers import _get_function_params
+from ._version import APIVersion
+
+
+class Endpoint:
+
+    def __init__(self, method=None, path=None, path_params=None, query_params=None,  # noqa: N802
+                 json_response=True, proxy=False, xml=False,
+                 version: Optional[APIVersion] = APIVersion.DEFAULT):
+        if method is None:
+            method = 'GET'
+        elif not isinstance(method, str) or \
+                method.upper() not in ['GET', 'POST', 'DELETE', 'PUT']:
+            raise TypeError("Possible values for method are: 'GET', 'POST', "
+                            "'DELETE', or 'PUT'")
+
+        method = method.upper()
+
+        if method in ['GET', 'DELETE']:
+            if path_params is not None:
+                raise TypeError("path_params should not be used for {} "
+                                "endpoints. All function params are considered"
+                                " path parameters by default".format(method))
+
+        if path_params is None:
+            if method in ['POST', 'PUT']:
+                path_params = []
+
+        if query_params is None:
+            query_params = []
+
+        self.method = method
+        self.path = path
+        self.path_params = path_params
+        self.query_params = query_params
+        self.json_response = json_response
+        self.proxy = proxy
+        self.xml = xml
+        self.version = version
+
+    def __call__(self, func):
+        if self.method in ['POST', 'PUT']:
+            func_params = _get_function_params(func)
+            for param in func_params:
+                if param['name'] in self.path_params and not param['required']:
+                    raise TypeError("path_params can only reference "
+                                    "non-optional function parameters")
+
+        if func.__name__ == '__call__' and self.path is None:
+            e_path = ""
+        else:
+            e_path = self.path
+
+        if e_path is not None:
+            e_path = e_path.strip()
+            if e_path and e_path[0] != "/":
+                e_path = "/" + e_path
+            elif e_path == "/":
+                e_path = ""
+
+        func._endpoint = {
+            'method': self.method,
+            'path': e_path,
+            'path_params': self.path_params,
+            'query_params': self.query_params,
+            'json_response': self.json_response,
+            'proxy': self.proxy,
+            'xml': self.xml,
+            'version': self.version
+        }
+        return func
+
+
+def Proxy(path=None):  # noqa: N802
+    if path is None:
+        path = ""
+    elif path == "/":
+        path = ""
+    path += "/{path:.*}"
+    return Endpoint(path=path, proxy=True)
diff --git a/src/pybind/mgr/dashboard/controllers/_helpers.py b/src/pybind/mgr/dashboard/controllers/_helpers.py
new file mode 100644 (file)
index 0000000..5ec49ee
--- /dev/null
@@ -0,0 +1,127 @@
+import collections
+import json
+import logging
+import re
+from functools import wraps
+
+import cherrypy
+from ceph_argparse import ArgumentFormat  # pylint: disable=import-error
+
+from ..exceptions import DashboardException
+from ..tools import getargspec
+
+logger = logging.getLogger(__name__)
+
+
+ENDPOINT_MAP = collections.defaultdict(list)  # type: dict
+
+
+def _get_function_params(func):
+    """
+    Retrieves the list of parameters declared in function.
+    Each parameter is represented as dict with keys:
+      * name (str): the name of the parameter
+      * required (bool): whether the parameter is required or not
+      * default (obj): the parameter's default value
+    """
+    fspec = getargspec(func)
+
+    func_params = []
+    nd = len(fspec.args) if not fspec.defaults else -len(fspec.defaults)
+    for param in fspec.args[1:nd]:
+        func_params.append({'name': param, 'required': True})
+
+    if fspec.defaults:
+        for param, val in zip(fspec.args[nd:], fspec.defaults):
+            func_params.append({
+                'name': param,
+                'required': False,
+                'default': val
+            })
+
+    return func_params
+
+
+def generate_controller_routes(endpoint, mapper, base_url):
+    inst = endpoint.inst
+    ctrl_class = endpoint.ctrl
+
+    if endpoint.proxy:
+        conditions = None
+    else:
+        conditions = dict(method=[endpoint.method])
+
+    # base_url can be empty or a URL path that starts with "/"
+    # we will remove the trailing "/" if exists to help with the
+    # concatenation with the endpoint url below
+    if base_url.endswith("/"):
+        base_url = base_url[:-1]
+
+    endp_url = endpoint.url
+
+    if endp_url.find("/", 1) == -1:
+        parent_url = "{}{}".format(base_url, endp_url)
+    else:
+        parent_url = "{}{}".format(base_url, endp_url[:endp_url.find("/", 1)])
+
+    # parent_url might be of the form "/.../{...}" where "{...}" is a path parameter
+    # we need to remove the path parameter definition
+    parent_url = re.sub(r'(?:/\{[^}]+\})$', '', parent_url)
+    if not parent_url:  # root path case
+        parent_url = "/"
+
+    url = "{}{}".format(base_url, endp_url)
+
+    logger.debug("Mapped [%s] to %s:%s restricted to %s",
+                 url, ctrl_class.__name__, endpoint.action,
+                 endpoint.method)
+
+    ENDPOINT_MAP[endpoint.url].append(endpoint)
+
+    name = ctrl_class.__name__ + ":" + endpoint.action
+    mapper.connect(name, url, controller=inst, action=endpoint.action,
+                   conditions=conditions)
+
+    # adding route with trailing slash
+    name += "/"
+    url += "/"
+    mapper.connect(name, url, controller=inst, action=endpoint.action,
+                   conditions=conditions)
+
+    return parent_url
+
+
+def json_error_page(status, message, traceback, version):
+    cherrypy.response.headers['Content-Type'] = 'application/json'
+    return json.dumps(dict(status=status, detail=message, traceback=traceback,
+                           version=version))
+
+
+def allow_empty_body(func):  # noqa: N802
+    """
+    The POST/PUT request methods decorated with ``@allow_empty_body``
+    are allowed to send empty request body.
+    """
+    # pylint: disable=protected-access
+    try:
+        func._cp_config['tools.json_in.force'] = False
+    except (AttributeError, KeyError):
+        func._cp_config = {'tools.json_in.force': False}
+    return func
+
+
+def validate_ceph_type(validations, component=''):
+    def decorator(func):
+        @wraps(func)
+        def validate_args(*args, **kwargs):
+            input_values = kwargs
+            for key, ceph_type in validations:
+                try:
+                    ceph_type.valid(input_values[key])
+                except ArgumentFormat as e:
+                    raise DashboardException(msg=e,
+                                             code='ceph_type_not_valid',
+                                             component=component)
+            return func(*args, **kwargs)
+        return validate_args
+    return decorator
diff --git a/src/pybind/mgr/dashboard/controllers/_permissions.py b/src/pybind/mgr/dashboard/controllers/_permissions.py
new file mode 100644 (file)
index 0000000..eb190c9
--- /dev/null
@@ -0,0 +1,60 @@
+"""
+Role-based access permissions decorators
+"""
+import logging
+
+from ..exceptions import PermissionNotValid
+from ..security import Permission
+
+logger = logging.getLogger(__name__)
+
+
+def _set_func_permissions(func, permissions):
+    if not isinstance(permissions, list):
+        permissions = [permissions]
+
+    for perm in permissions:
+        if not Permission.valid_permission(perm):
+            logger.debug("Invalid security permission: %s\n "
+                         "Possible values: %s", perm,
+                         Permission.all_permissions())
+            raise PermissionNotValid(perm)
+
+    # pylint: disable=protected-access
+    if not hasattr(func, '_security_permissions'):
+        func._security_permissions = permissions
+    else:
+        permissions.extend(func._security_permissions)
+        func._security_permissions = list(set(permissions))
+
+
+def ReadPermission(func):  # noqa: N802
+    """
+    :raises PermissionNotValid: If the permission is missing.
+    """
+    _set_func_permissions(func, Permission.READ)
+    return func
+
+
+def CreatePermission(func):  # noqa: N802
+    """
+    :raises PermissionNotValid: If the permission is missing.
+    """
+    _set_func_permissions(func, Permission.CREATE)
+    return func
+
+
+def DeletePermission(func):  # noqa: N802
+    """
+    :raises PermissionNotValid: If the permission is missing.
+    """
+    _set_func_permissions(func, Permission.DELETE)
+    return func
+
+
+def UpdatePermission(func):  # noqa: N802
+    """
+    :raises PermissionNotValid: If the permission is missing.
+    """
+    _set_func_permissions(func, Permission.UPDATE)
+    return func
diff --git a/src/pybind/mgr/dashboard/controllers/_rest_controller.py b/src/pybind/mgr/dashboard/controllers/_rest_controller.py
new file mode 100644 (file)
index 0000000..03e124f
--- /dev/null
@@ -0,0 +1,249 @@
+import collections
+import inspect
+from functools import wraps
+from typing import Optional
+
+import cherrypy
+
+from ..security import Permission
+from ._base_controller import BaseController
+from ._endpoint import Endpoint
+from ._helpers import _get_function_params
+from ._permissions import _set_func_permissions
+from ._version import APIVersion
+
+
+class RESTController(BaseController, skip_registry=True):
+    """
+    Base class for providing a RESTful interface to a resource.
+
+    To use this class, simply derive a class from it and implement the methods
+    you want to support.  The list of possible methods are:
+
+    * list()
+    * bulk_set(data)
+    * create(data)
+    * bulk_delete()
+    * get(key)
+    * set(data, key)
+    * singleton_set(data)
+    * delete(key)
+
+    Test with curl:
+
+    curl -H "Content-Type: application/json" -X POST \
+         -d '{"username":"xyz","password":"xyz"}'  https://127.0.0.1:8443/foo
+    curl https://127.0.0.1:8443/foo
+    curl https://127.0.0.1:8443/foo/0
+
+    """
+
+    # resource id parameter for using in get, set, and delete methods
+    # should be overridden by subclasses.
+    # to specify a composite id (two parameters) use '/'. e.g., "param1/param2".
+    # If subclasses don't override this property we try to infer the structure
+    # of the resource ID.
+    RESOURCE_ID: Optional[str] = None
+
+    _permission_map = {
+        'GET': Permission.READ,
+        'POST': Permission.CREATE,
+        'PUT': Permission.UPDATE,
+        'DELETE': Permission.DELETE
+    }
+
+    _method_mapping = collections.OrderedDict([
+        ('list', {'method': 'GET', 'resource': False, 'status': 200, 'version': APIVersion.DEFAULT}),  # noqa E501 #pylint: disable=line-too-long
+        ('create', {'method': 'POST', 'resource': False, 'status': 201, 'version': APIVersion.DEFAULT}),  # noqa E501 #pylint: disable=line-too-long
+        ('bulk_set', {'method': 'PUT', 'resource': False, 'status': 200, 'version': APIVersion.DEFAULT}),  # noqa E501 #pylint: disable=line-too-long
+        ('bulk_delete', {'method': 'DELETE', 'resource': False, 'status': 204, 'version': APIVersion.DEFAULT}),  # noqa E501 #pylint: disable=line-too-long
+        ('get', {'method': 'GET', 'resource': True, 'status': 200, 'version': APIVersion.DEFAULT}),
+        ('delete', {'method': 'DELETE', 'resource': True, 'status': 204, 'version': APIVersion.DEFAULT}),  # noqa E501 #pylint: disable=line-too-long
+        ('set', {'method': 'PUT', 'resource': True, 'status': 200, 'version': APIVersion.DEFAULT}),
+        ('singleton_set', {'method': 'PUT', 'resource': False, 'status': 200, 'version': APIVersion.DEFAULT})  # noqa E501 #pylint: disable=line-too-long
+    ])
+
+    @classmethod
+    def infer_resource_id(cls):
+        if cls.RESOURCE_ID is not None:
+            return cls.RESOURCE_ID.split('/')
+        for k, v in cls._method_mapping.items():
+            func = getattr(cls, k, None)
+            while hasattr(func, "__wrapped__"):
+                func = func.__wrapped__
+            if v['resource'] and func:
+                path_params = cls.get_path_param_names()
+                params = _get_function_params(func)
+                return [p['name'] for p in params
+                        if p['required'] and p['name'] not in path_params]
+        return None
+
+    @classmethod
+    def endpoints(cls):
+        result = super().endpoints()
+        res_id_params = cls.infer_resource_id()
+
+        for _, func in inspect.getmembers(cls, predicate=callable):
+            endpoint_params = {
+                'no_resource_id_params': False,
+                'status': 200,
+                'method': None,
+                'query_params': None,
+                'path': '',
+                'version': APIVersion.DEFAULT,
+                'sec_permissions': hasattr(func, '_security_permissions'),
+                'permission': None,
+            }
+
+            if func.__name__ in cls._method_mapping:
+                cls._update_endpoint_params_method_map(
+                    func, res_id_params, endpoint_params)
+
+            elif hasattr(func, "__collection_method__"):
+                cls._update_endpoint_params_collection_map(func, endpoint_params)
+
+            elif hasattr(func, "__resource_method__"):
+                cls._update_endpoint_params_resource_method(
+                    res_id_params, endpoint_params, func)
+
+            else:
+                continue
+
+            if endpoint_params['no_resource_id_params']:
+                raise TypeError("Could not infer the resource ID parameters for"
+                                " method {} of controller {}. "
+                                "Please specify the resource ID parameters "
+                                "using the RESOURCE_ID class property"
+                                .format(func.__name__, cls.__name__))
+
+            if endpoint_params['method'] in ['GET', 'DELETE']:
+                params = _get_function_params(func)
+                if res_id_params is None:
+                    res_id_params = []
+                if endpoint_params['query_params'] is None:
+                    endpoint_params['query_params'] = [p['name'] for p in params  # type: ignore
+                                                       if p['name'] not in res_id_params]
+
+            func = cls._status_code_wrapper(func, endpoint_params['status'])
+            endp_func = Endpoint(endpoint_params['method'], path=endpoint_params['path'],
+                                 query_params=endpoint_params['query_params'],
+                                 version=endpoint_params['version'])(func)  # type: ignore
+            if endpoint_params['permission']:
+                _set_func_permissions(endp_func, [endpoint_params['permission']])
+            result.append(cls.Endpoint(cls, endp_func))
+
+        return result
+
+    @classmethod
+    def _update_endpoint_params_resource_method(cls, res_id_params, endpoint_params, func):
+        if not res_id_params:
+            endpoint_params['no_resource_id_params'] = True
+        else:
+            path_params = ["{{{}}}".format(p) for p in res_id_params]
+            endpoint_params['path'] += "/{}".format("/".join(path_params))
+            if func.__resource_method__['path']:
+                endpoint_params['path'] += func.__resource_method__['path']
+            else:
+                endpoint_params['path'] += "/{}".format(func.__name__)
+        endpoint_params['status'] = func.__resource_method__['status']
+        endpoint_params['method'] = func.__resource_method__['method']
+        endpoint_params['version'] = func.__resource_method__['version']
+        endpoint_params['query_params'] = func.__resource_method__['query_params']
+        if not endpoint_params['sec_permissions']:
+            endpoint_params['permission'] = cls._permission_map[endpoint_params['method']]
+
+    @classmethod
+    def _update_endpoint_params_collection_map(cls, func, endpoint_params):
+        if func.__collection_method__['path']:
+            endpoint_params['path'] = func.__collection_method__['path']
+        else:
+            endpoint_params['path'] = "/{}".format(func.__name__)
+        endpoint_params['status'] = func.__collection_method__['status']
+        endpoint_params['method'] = func.__collection_method__['method']
+        endpoint_params['query_params'] = func.__collection_method__['query_params']
+        endpoint_params['version'] = func.__collection_method__['version']
+        if not endpoint_params['sec_permissions']:
+            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
+
+        if meth['resource']:
+            if not res_id_params:
+                endpoint_params['no_resource_id_params'] = True
+            else:
+                path_params = ["{{{}}}".format(p) for p in res_id_params]
+                endpoint_params['path'] += "/{}".format("/".join(path_params))
+
+        endpoint_params['status'] = meth['status']
+        endpoint_params['method'] = meth['method']
+        if hasattr(func, "__method_map_method__"):
+            endpoint_params['version'] = func.__method_map_method__['version']
+        if not endpoint_params['sec_permissions']:
+            endpoint_params['permission'] = cls._permission_map[endpoint_params['method']]
+
+    @classmethod
+    def _status_code_wrapper(cls, func, status_code):
+        @wraps(func)
+        def wrapper(*vpath, **params):
+            cherrypy.response.status = status_code
+            return func(*vpath, **params)
+
+        return wrapper
+
+    @staticmethod
+    def Resource(method=None, path=None, status=None, query_params=None,  # noqa: N802
+                 version: Optional[APIVersion] = APIVersion.DEFAULT):
+        if not method:
+            method = 'GET'
+
+        if status is None:
+            status = 200
+
+        def _wrapper(func):
+            func.__resource_method__ = {
+                'method': method,
+                'path': path,
+                'status': status,
+                'query_params': query_params,
+                'version': version
+            }
+            return func
+        return _wrapper
+
+    @staticmethod
+    def MethodMap(resource=False, status=None,
+                  version: Optional[APIVersion] = APIVersion.DEFAULT):  # noqa: N802
+
+        if status is None:
+            status = 200
+
+        def _wrapper(func):
+            func.__method_map_method__ = {
+                'resource': resource,
+                'status': status,
+                'version': version
+            }
+            return func
+        return _wrapper
+
+    @staticmethod
+    def Collection(method=None, path=None, status=None, query_params=None,  # noqa: N802
+                   version: Optional[APIVersion] = APIVersion.DEFAULT):
+        if not method:
+            method = 'GET'
+
+        if status is None:
+            status = 200
+
+        def _wrapper(func):
+            func.__collection_method__ = {
+                'method': method,
+                'path': path,
+                'status': status,
+                'query_params': query_params,
+                'version': version
+            }
+            return func
+        return _wrapper
diff --git a/src/pybind/mgr/dashboard/controllers/_router.py b/src/pybind/mgr/dashboard/controllers/_router.py
new file mode 100644 (file)
index 0000000..ad67532
--- /dev/null
@@ -0,0 +1,69 @@
+import logging
+
+import cherrypy
+
+from ..exceptions import ScopeNotValid
+from ..security import Scope
+from ._base_controller import BaseController
+from ._helpers import generate_controller_routes
+
+logger = logging.getLogger(__name__)
+
+
+class Router(object):
+    def __init__(self, path, base_url=None, security_scope=None, secure=True):
+        if security_scope and not Scope.valid_scope(security_scope):
+            raise ScopeNotValid(security_scope)
+        self.path = path
+        self.base_url = base_url
+        self.security_scope = security_scope
+        self.secure = secure
+
+        if self.path and self.path[0] != "/":
+            self.path = "/" + self.path
+
+        if self.base_url is None:
+            self.base_url = ""
+        elif self.base_url == "/":
+            self.base_url = ""
+
+        if self.base_url == "" and self.path == "":
+            self.base_url = "/"
+
+    def __call__(self, cls):
+        cls._routed = True
+        cls._cp_path_ = "{}{}".format(self.base_url, self.path)
+        cls._security_scope = self.security_scope
+
+        config = {
+            'tools.dashboard_exception_handler.on': True,
+            'tools.authenticate.on': self.secure,
+        }
+        if not hasattr(cls, '_cp_config'):
+            cls._cp_config = {}
+        cls._cp_config.update(config)
+        return cls
+
+    @classmethod
+    def generate_routes(cls, url_prefix):
+        controllers = BaseController.load_controllers()
+        logger.debug("controllers=%r", controllers)
+
+        mapper = cherrypy.dispatch.RoutesDispatcher()
+
+        parent_urls = set()
+
+        endpoint_list = []
+        for ctrl in controllers:
+            inst = ctrl()
+            for endpoint in ctrl.endpoints():
+                endpoint.inst = inst
+                endpoint_list.append(endpoint)
+
+        endpoint_list = sorted(endpoint_list, key=lambda e: e.url)
+        for endpoint in endpoint_list:
+            parent_urls.add(generate_controller_routes(endpoint, mapper,
+                                                       "{}".format(url_prefix)))
+
+        logger.debug("list of parent paths: %s", parent_urls)
+        return mapper, parent_urls
diff --git a/src/pybind/mgr/dashboard/controllers/_task.py b/src/pybind/mgr/dashboard/controllers/_task.py
new file mode 100644 (file)
index 0000000..33399e8
--- /dev/null
@@ -0,0 +1,79 @@
+from functools import wraps
+
+import cherrypy
+
+from ..tools import TaskManager
+from ._helpers import _get_function_params
+
+
+class Task:
+    def __init__(self, name, metadata, wait_for=5.0, exception_handler=None):
+        self.name = name
+        if isinstance(metadata, list):
+            self.metadata = {e[1:-1]: e for e in metadata}
+        else:
+            self.metadata = metadata
+        self.wait_for = wait_for
+        self.exception_handler = exception_handler
+
+    def _gen_arg_map(self, func, args, kwargs):
+        arg_map = {}
+        params = _get_function_params(func)
+
+        args = args[1:]  # exclude self
+        for idx, param in enumerate(params):
+            if idx < len(args):
+                arg_map[param['name']] = args[idx]
+            else:
+                if param['name'] in kwargs:
+                    arg_map[param['name']] = kwargs[param['name']]
+                else:
+                    assert not param['required'], "{0} is required".format(param['name'])
+                    arg_map[param['name']] = param['default']
+
+            if param['name'] in arg_map:
+                # This is not a type error. We are using the index here.
+                arg_map[idx+1] = arg_map[param['name']]
+
+        return arg_map
+
+    def __call__(self, func):
+        @wraps(func)
+        def wrapper(*args, **kwargs):
+            arg_map = self._gen_arg_map(func, args, kwargs)
+            metadata = {}
+            for k, v in self.metadata.items():
+                if isinstance(v, str) and v and v[0] == '{' and v[-1] == '}':
+                    param = v[1:-1]
+                    try:
+                        pos = int(param)
+                        metadata[k] = arg_map[pos]
+                    except ValueError:
+                        if param.find('.') == -1:
+                            metadata[k] = arg_map[param]
+                        else:
+                            path = param.split('.')
+                            metadata[k] = arg_map[path[0]]
+                            for i in range(1, len(path)):
+                                metadata[k] = metadata[k][path[i]]
+                else:
+                    metadata[k] = v
+            task = TaskManager.run(self.name, metadata, func, args, kwargs,
+                                   exception_handler=self.exception_handler)
+            try:
+                status, value = task.wait(self.wait_for)
+            except Exception as ex:
+                if task.ret_value:
+                    # exception was handled by task.exception_handler
+                    if 'status' in task.ret_value:
+                        status = task.ret_value['status']
+                    else:
+                        status = getattr(ex, 'status', 500)
+                    cherrypy.response.status = status
+                    return task.ret_value
+                raise ex
+            if status == TaskManager.VALUE_EXECUTING:
+                cherrypy.response.status = 202
+                return {'name': self.name, 'metadata': metadata}
+            return value
+        return wrapper
diff --git a/src/pybind/mgr/dashboard/controllers/_ui_router.py b/src/pybind/mgr/dashboard/controllers/_ui_router.py
new file mode 100644 (file)
index 0000000..7454afa
--- /dev/null
@@ -0,0 +1,13 @@
+from ._router import Router
+
+
+class UIRouter(Router):
+    def __init__(self, path, security_scope=None, secure=True):
+        super().__init__(path, base_url="/ui-api",
+                         security_scope=security_scope,
+                         secure=secure)
+
+    def __call__(self, cls):
+        cls = super().__call__(cls)
+        cls._api_endpoint = False
+        return cls
index 2e45b7c92e1840c23392c5dd37d22c9ff1350f8f..f0190c68bdde29f71d73e953474893500f0c0f1c 100644 (file)
@@ -9,8 +9,7 @@ from .. import mgr
 from ..exceptions import InvalidCredentialsError, UserDoesNotExist
 from ..services.auth import AuthManager, JwtManager
 from ..settings import Settings
-from . import ApiController, ControllerAuthMixin, ControllerDoc, EndpointDoc, \
-    RESTController, allow_empty_body
+from . import APIDoc, APIRouter, ControllerAuthMixin, EndpointDoc, RESTController, allow_empty_body
 
 # Python 3.8 introduced `samesite` attribute:
 # https://docs.python.org/3/library/http.cookies.html#morsel-objects
@@ -29,8 +28,8 @@ AUTH_CHECK_SCHEMA = {
 }
 
 
-@ApiController('/auth', secure=False)
-@ControllerDoc("Initiate a session with Ceph", "Auth")
+@APIRouter('/auth', secure=False)
+@APIDoc("Initiate a session with Ceph", "Auth")
 class Auth(RESTController, ControllerAuthMixin):
     """
     Provide authenticates and returns JWT token.
index 0a196913c2fe7415e4d15d18ae082fb096d5a464..d32cbe4bcfab6b307b285c5e865126b5f685f884 100644 (file)
@@ -11,8 +11,7 @@ from ..security import Scope
 from ..services.ceph_service import CephService
 from ..services.cephfs import CephFS as CephFS_
 from ..tools import ViewCache
-from . import ApiController, ControllerDoc, EndpointDoc, RESTController, \
-    UiApiController, allow_empty_body
+from . import APIDoc, APIRouter, EndpointDoc, RESTController, UIRouter, allow_empty_body
 
 GET_QUOTAS_SCHEMA = {
     'max_bytes': (int, ''),
@@ -20,11 +19,11 @@ GET_QUOTAS_SCHEMA = {
 }
 
 
-@ApiController('/cephfs', Scope.CEPHFS)
-@ControllerDoc("Cephfs Management API", "Cephfs")
+@APIRouter('/cephfs', Scope.CEPHFS)
+@APIDoc("Cephfs Management API", "Cephfs")
 class CephFS(RESTController):
     def __init__(self):  # pragma: no cover
-        super(CephFS, self).__init__()
+        super().__init__()
 
         # Stateful instances of CephFSClients, hold cached results.  Key to
         # dict is FSCID
@@ -490,8 +489,8 @@ class CephFSClients(object):
         return CephService.send_command('mds', 'session ls', srv_spec='{0}:0'.format(self.fscid))
 
 
-@UiApiController('/cephfs', Scope.CEPHFS)
-@ControllerDoc("Dashboard UI helper function; not part of the public API", "CephFSUi")
+@UIRouter('/cephfs', Scope.CEPHFS)
+@APIDoc("Dashboard UI helper function; not part of the public API", "CephFSUi")
 class CephFsUi(CephFS):
     RESOURCE_ID = 'fs_id'
 
index 8b0829b8402646fc36a80982ac2f789f192679b8..1cfd06059e6051bddde714e3dd0b0c9191fe139f 100644 (file)
@@ -7,7 +7,7 @@ from .. import mgr
 from ..exceptions import DashboardException
 from ..security import Scope
 from ..services.ceph_service import CephService
-from . import ApiController, ControllerDoc, EndpointDoc, RESTController
+from . import APIDoc, APIRouter, EndpointDoc, RESTController
 
 FILTER_SCHEMA = [{
     "name": (str, 'Name of the config option'),
@@ -28,8 +28,8 @@ FILTER_SCHEMA = [{
 }]
 
 
-@ApiController('/cluster_conf', Scope.CONFIG_OPT)
-@ControllerDoc("Manage Cluster Configurations", "ClusterConfiguration")
+@APIRouter('/cluster_conf', Scope.CONFIG_OPT)
+@APIDoc("Manage Cluster Configurations", "ClusterConfiguration")
 class ClusterConfiguration(RESTController):
 
     def _append_config_option_values(self, options):
index 73485ac2df1d11e0d11246166d14338714d3162d..bd00d8feaf408b3ae6e3f57106789ed3f40ced2d 100644 (file)
@@ -1,3 +1,4 @@
+
 # -*- coding: utf-8 -*-
 from __future__ import absolute_import
 
@@ -6,8 +7,8 @@ from cherrypy import NotFound
 from .. import mgr
 from ..security import Scope
 from ..services.ceph_service import CephService
-from . import ApiController, ControllerDoc, Endpoint, EndpointDoc, \
-    ReadPermission, RESTController, UiApiController
+from . import APIDoc, APIRouter, Endpoint, EndpointDoc, ReadPermission, RESTController, UIRouter
+from ._version import APIVersion
 
 LIST_SCHEMA = {
     "rule_id": (int, 'Rule ID'),
@@ -20,8 +21,8 @@ LIST_SCHEMA = {
 }
 
 
-@ApiController('/crush_rule', Scope.POOL)
-@ControllerDoc("Crush Rule Management API", "CrushRule")
+@APIRouter('/crush_rule', Scope.POOL)
+@APIDoc("Crush Rule Management API", "CrushRule")
 class CrushRule(RESTController):
     @EndpointDoc("List Crush Rule Configuration",
                  responses={200: LIST_SCHEMA})
@@ -48,8 +49,8 @@ class CrushRule(RESTController):
         CephService.send_command('mon', 'osd crush rule rm', name=name)
 
 
-@UiApiController('/crush_rule', Scope.POOL)
-@ControllerDoc("Dashboard UI helper function; not part of the public API", "CrushRuleUi")
+@UIRouter('/crush_rule', Scope.POOL)
+@APIDoc("Dashboard UI helper function; not part of the public API", "CrushRuleUi")
 class CrushRuleUi(CrushRule):
     @Endpoint()
     @ReadPermission
index 331f2479a69b1d34c0e884c7108c8a04b0bcfb13..7ab6946aadbd1d1183e7b140fe93fdcf401d1d4c 100644 (file)
@@ -8,14 +8,15 @@ import cherrypy
 
 from .. import DEFAULT_VERSION, mgr
 from ..api.doc import Schema, SchemaInput, SchemaType
-from . import ENDPOINT_MAP, BaseController, Controller, Endpoint
+from . import ENDPOINT_MAP, BaseController, Endpoint, Router
+from ._version import APIVersion
 
 NO_DESCRIPTION_AVAILABLE = "*No description available*"
 
 logger = logging.getLogger('controllers.docs')
 
 
-@Controller('/docs', secure=False)
+@Router('/docs', secure=False)
 class Docs(BaseController):
 
     @classmethod
@@ -465,8 +466,6 @@ if __name__ == "__main__":
 
     import yaml
 
-    from . import generate_routes
-
     def fix_null_descr(obj):
         """
         A hot fix for errors caused by null description values when generating
@@ -476,7 +475,7 @@ if __name__ == "__main__":
         return {k: fix_null_descr(v) for k, v in obj.items() if v is not None} \
             if isinstance(obj, dict) else obj
 
-    generate_routes("/api")
+    Router.generate_routes("/api")
     try:
         with open(sys.argv[1], 'w') as f:
             # pylint: disable=protected-access
index 4b8264ef0bd5c7e78c7f378eb114384b9939c84a..c5fb449d61e256eeb93bd46d93b770e2c8f01406 100644 (file)
@@ -6,8 +6,7 @@ from cherrypy import NotFound
 from .. import mgr
 from ..security import Scope
 from ..services.ceph_service import CephService
-from . import ApiController, ControllerDoc, Endpoint, EndpointDoc, \
-    ReadPermission, RESTController, UiApiController
+from . import APIDoc, APIRouter, Endpoint, EndpointDoc, ReadPermission, RESTController, UIRouter
 
 LIST_CODE__SCHEMA = {
     "crush-failure-domain": (str, ''),
@@ -19,8 +18,8 @@ LIST_CODE__SCHEMA = {
 }
 
 
-@ApiController('/erasure_code_profile', Scope.POOL)
-@ControllerDoc("Erasure Code Profile Management API", "ErasureCodeProfile")
+@APIRouter('/erasure_code_profile', Scope.POOL)
+@APIDoc("Erasure Code Profile Management API", "ErasureCodeProfile")
 class ErasureCodeProfile(RESTController):
     """
     create() supports additional key-value arguments that are passed to the
@@ -47,8 +46,8 @@ class ErasureCodeProfile(RESTController):
         CephService.send_command('mon', 'osd erasure-code-profile rm', name=name)
 
 
-@UiApiController('/erasure_code_profile', Scope.POOL)
-@ControllerDoc("Dashboard UI helper function; not part of the public API", "ErasureCodeProfileUi")
+@UIRouter('/erasure_code_profile', Scope.POOL)
+@APIDoc("Dashboard UI helper function; not part of the public API", "ErasureCodeProfileUi")
 class ErasureCodeProfileUi(ErasureCodeProfile):
     @Endpoint()
     @ReadPermission
diff --git a/src/pybind/mgr/dashboard/controllers/feedback.py b/src/pybind/mgr/dashboard/controllers/feedback.py
new file mode 100644 (file)
index 0000000..e1a9eb3
--- /dev/null
@@ -0,0 +1,58 @@
+# # -*- coding: utf-8 -*-
+
+from ..exceptions import DashboardException
+from ..model.feedback import Feedback
+from ..rest_client import RequestException
+from ..security import Scope
+from ..services import feedback
+from . import APIDoc, APIRouter, RESTController
+
+
+@APIRouter('/feedback', Scope.CONFIG_OPT)
+@APIDoc("Feedback API", "Report")
+class FeedbackController(RESTController):
+    issueAPIkey = None
+
+    def __init__(self):  # pragma: no cover
+        super().__init__()
+        self.tracker_client = feedback.CephTrackerClient()
+
+    def create(self, project, tracker, subject, description):
+        """
+        Create an issue.
+        :param project: The affected ceph component.
+        :param tracker: The tracker type.
+        :param subject: The title of the issue.
+        :param description: The description of the issue.
+        """
+        try:
+            new_issue = Feedback(Feedback.Project[project].value,
+                                 Feedback.TrackerType[tracker].value, subject, description)
+        except KeyError:
+            raise DashboardException(msg=f'{"Invalid arguments"}', component='feedback')
+        try:
+            return self.tracker_client.create_issue(new_issue)
+        except RequestException as error:
+            if error.status_code == 401:
+                raise DashboardException(msg=f'{"Invalid API key"}',
+                                         http_status_code=error.status_code,
+                                         component='feedback')
+            raise error
+        except Exception:
+            raise DashboardException(msg=f'{"API key not set"}',
+                                     http_status_code=401,
+                                     component='feedback')
+
+    def get(self, issue_number):
+        """
+        Fetch issue details.
+        :param issueAPI: The issue tracker API access key.
+        """
+        try:
+            return self.tracker_client.get_issues(issue_number)
+        except RequestException as error:
+            if error.status_code == 404:
+                raise DashboardException(msg=f'Issue {issue_number} not found',
+                                         http_status_code=error.status_code,
+                                         component='feedback')
+            raise error
index 421e36c02e744cb5a1c74b85691f539e94bba7f4..77372c6556b2bbec09711f83dd640c472a31bea9 100644 (file)
@@ -2,12 +2,12 @@ from __future__ import absolute_import
 
 import logging
 
-from . import BaseController, Endpoint, UiApiController
+from . import BaseController, Endpoint, UIRouter
 
 logger = logging.getLogger('frontend.error')
 
 
-@UiApiController('/logging', secure=False)
+@UIRouter('/logging', secure=False)
 class FrontendLogging(BaseController):
 
     @Endpoint('POST', path='js-error')
index 94fe53d0f9774ef65838a364b6e00b6dbb61cf76..e200984ea4abbf70ae6d232863c6d57f297a38c7 100644 (file)
@@ -6,16 +6,16 @@ from ..exceptions import DashboardException
 from ..grafana import GrafanaRestClient, push_local_dashboards
 from ..security import Scope
 from ..settings import Settings
-from . import ApiController, BaseController, ControllerDoc, Endpoint, \
-    EndpointDoc, ReadPermission, UpdatePermission
+from . import APIDoc, APIRouter, BaseController, Endpoint, EndpointDoc, \
+    ReadPermission, UpdatePermission
 
 URL_SCHEMA = {
     "instance": (str, "grafana instance")
 }
 
 
-@ApiController('/grafana', Scope.GRAFANA)
-@ControllerDoc("Grafana Management API", "Grafana")
+@APIRouter('/grafana', Scope.GRAFANA)
+@APIDoc("Grafana Management API", "Grafana")
 class Grafana(BaseController):
     @Endpoint()
     @ReadPermission
index 0ae0a5bf08b17d875dc6bece78ed819fbf488a9b..ed7e575de63c2ae80999a2ae74ec7bfc980b0212 100644 (file)
@@ -10,7 +10,7 @@ from ..services.ceph_service import CephService
 from ..services.iscsi_cli import IscsiGatewaysConfig
 from ..services.iscsi_client import IscsiClient
 from ..tools import partial_dict
-from . import ApiController, BaseController, ControllerDoc, Endpoint, EndpointDoc
+from . import APIDoc, APIRouter, BaseController, Endpoint, EndpointDoc
 from .host import get_hosts
 
 HEALTH_MINIMAL_SCHEMA = ({
@@ -275,11 +275,11 @@ class HealthData(object):
         return CephService.get_scrub_status()
 
 
-@ApiController('/health')
-@ControllerDoc("Display Detailed Cluster health Status", "Health")
+@APIRouter('/health')
+@APIDoc("Display Detailed Cluster health Status", "Health")
 class Health(BaseController):
     def __init__(self):
-        super(Health, self).__init__()
+        super().__init__()
         self.health_full = HealthData(self._has_permissions, minimal=False)
         self.health_minimal = HealthData(self._has_permissions, minimal=True)
 
index cb4a4a5a18533188387c292e2bb648a5a468ddc2..eec811495e707fc8abbc0698e09c3011798ef9d8 100644 (file)
@@ -15,7 +15,7 @@ import cherrypy
 from cherrypy.lib.static import serve_file
 
 from .. import mgr
-from . import BaseController, Controller, Endpoint, Proxy, UiApiController
+from . import BaseController, Endpoint, Proxy, Router, UIRouter
 
 logger = logging.getLogger("controllers.home")
 
@@ -52,10 +52,10 @@ class LanguageMixin(object):
         self.DEFAULT_LANGUAGE = config['config']['locale']
         self.DEFAULT_LANGUAGE_PATH = os.path.join(mgr.get_frontend_path(),
                                                   self.DEFAULT_LANGUAGE)
-        super(LanguageMixin, self).__init__()
+        super().__init__()
 
 
-@Controller("/", secure=False)
+@Router("/", secure=False)
 class HomeController(BaseController, LanguageMixin):
     LANG_TAG_SEQ_RE = re.compile(r'\s*([^,]+)\s*,?\s*')
     LANG_TAG_RE = re.compile(
@@ -135,7 +135,7 @@ class HomeController(BaseController, LanguageMixin):
         return serve_file(full_path)
 
 
-@UiApiController("/langs", secure=False)
+@UIRouter("/langs", secure=False)
 class LangsController(BaseController, LanguageMixin):
     @Endpoint('GET')
     def __call__(self):
index a13b1eb20f6a3754c141c5c8eb56a10344331729..b4d12c0b9312ad5da1a1bd6bde54d04b53c71bb5 100644 (file)
@@ -17,9 +17,10 @@ from ..services.ceph_service import CephService
 from ..services.exception import handle_orchestrator_error
 from ..services.orchestrator import OrchClient, OrchFeature
 from ..tools import TaskManager, str_to_bool
-from . import ApiController, BaseController, ControllerDoc, Endpoint, \
-    EndpointDoc, ReadPermission, RESTController, Task, UiApiController, \
-    UpdatePermission, allow_empty_body
+from . import APIDoc, APIRouter, BaseController, Endpoint, EndpointDoc, \
+    ReadPermission, RESTController, Task, UIRouter, UpdatePermission, \
+    allow_empty_body
+from ._version import APIVersion
 from .orchestrator import raise_if_no_orchestrator
 
 LIST_HOST_SCHEMA = {
@@ -267,8 +268,8 @@ def add_host(hostname: str, addr: Optional[str] = None,
         orch_client.hosts.enter_maintenance(hostname)
 
 
-@ApiController('/host', Scope.HOSTS)
-@ControllerDoc("Get Host Details", "Host")
+@APIRouter('/host', Scope.HOSTS)
+@APIDoc("Get Host Details", "Host")
 class Host(RESTController):
     @EndpointDoc("List Host Specifications",
                  parameters={
@@ -449,7 +450,7 @@ class Host(RESTController):
                 orch.hosts.add_label(hostname, label)
 
 
-@UiApiController('/host', Scope.HOSTS)
+@UIRouter('/host', Scope.HOSTS)
 class HostUi(BaseController):
     @Endpoint('GET')
     @ReadPermission
index 687daa91966f7e1db9894cc62c34d061a3f5adaf..9135f5abc8f44a802133c713a40ed394ebe4409c 100644 (file)
@@ -22,9 +22,8 @@ from ..services.iscsi_config import IscsiGatewayDoesNotExist
 from ..services.rbd import format_bitmask
 from ..services.tcmu_service import TcmuService
 from ..tools import TaskManager, str_to_bool
-from . import ApiController, BaseController, ControllerDoc, Endpoint, \
-    EndpointDoc, ReadPermission, RESTController, Task, UiApiController, \
-    UpdatePermission
+from . import APIDoc, APIRouter, BaseController, Endpoint, EndpointDoc, \
+    ReadPermission, RESTController, Task, UIRouter, UpdatePermission
 
 try:
     from typing import Any, Dict, List, no_type_check
@@ -39,7 +38,7 @@ ISCSI_SCHEMA = {
 }
 
 
-@UiApiController('/iscsi', Scope.ISCSI)
+@UIRouter('/iscsi', Scope.ISCSI)
 class IscsiUi(BaseController):
 
     REQUIRED_CEPH_ISCSI_CONFIG_MIN_VERSION = 10
@@ -196,8 +195,8 @@ class IscsiUi(BaseController):
         }
 
 
-@ApiController('/iscsi', Scope.ISCSI)
-@ControllerDoc("Iscsi Management API", "Iscsi")
+@APIRouter('/iscsi', Scope.ISCSI)
+@APIDoc("Iscsi Management API", "Iscsi")
 class Iscsi(BaseController):
     @Endpoint('GET', 'discoveryauth')
     @ReadPermission
@@ -253,8 +252,8 @@ def iscsi_target_task(name, metadata, wait_for=2.0):
     return Task("iscsi/target/{}".format(name), metadata, wait_for)
 
 
-@ApiController('/iscsi/target', Scope.ISCSI)
-@ControllerDoc("Get Iscsi Target Details", "IscsiTarget")
+@APIRouter('/iscsi/target', Scope.ISCSI)
+@APIDoc("Get Iscsi Target Details", "IscsiTarget")
 class IscsiTarget(RESTController):
 
     def list(self):
index 97995110746f347433200f8b1cb2aeda4bb68f03..cc217ce0d0bbf19a36ef10d34acd8d21295a812f 100644 (file)
@@ -6,7 +6,7 @@ import collections
 from ..security import Scope
 from ..services.ceph_service import CephService
 from ..tools import NotificationQueue
-from . import ApiController, BaseController, ControllerDoc, Endpoint, EndpointDoc, ReadPermission
+from . import APIDoc, APIRouter, BaseController, Endpoint, EndpointDoc, ReadPermission
 
 LOG_BUFFER_SIZE = 30
 
@@ -31,11 +31,11 @@ LOGS_SCHEMA = {
 }
 
 
-@ApiController('/logs', Scope.LOG)
-@ControllerDoc("Logs Management API", "Logs")
+@APIRouter('/logs', Scope.LOG)
+@APIDoc("Logs Management API", "Logs")
 class Logs(BaseController):
     def __init__(self):
-        super(Logs, self).__init__()
+        super().__init__()
         self._log_initialized = False
         self.log_buffer = collections.deque(maxlen=LOG_BUFFER_SIZE)
         self.audit_buffer = collections.deque(maxlen=LOG_BUFFER_SIZE)
index b614612d0b72dd056662d575f8cd477ac237f3d7..50a4c71e8eccd8502f66c78154771e7891424c23 100644 (file)
@@ -6,7 +6,7 @@ from ..security import Scope
 from ..services.ceph_service import CephService
 from ..services.exception import handle_send_command_error
 from ..tools import find_object_in_list, str_to_bool
-from . import ApiController, ControllerDoc, EndpointDoc, RESTController, allow_empty_body
+from . import APIDoc, APIRouter, EndpointDoc, RESTController, allow_empty_body
 
 MGR_MODULE_SCHEMA = ([{
     "name": (str, "Module Name"),
@@ -31,8 +31,8 @@ MGR_MODULE_SCHEMA = ([{
 }])
 
 
-@ApiController('/mgr/module', Scope.CONFIG_OPT)
-@ControllerDoc("Get details of MGR Module", "MgrModule")
+@APIRouter('/mgr/module', Scope.CONFIG_OPT)
+@APIDoc("Get details of MGR Module", "MgrModule")
 class MgrModules(RESTController):
     ignore_modules = ['selftest']
 
index b61fcd4f393e26e45039e107f1395fc50caf01f1..c880859d2470648a2bf4c9c25e108e674a8238ac 100644 (file)
@@ -5,7 +5,7 @@ import json
 
 from .. import mgr
 from ..security import Scope
-from . import ApiController, BaseController, ControllerDoc, Endpoint, EndpointDoc, ReadPermission
+from . import APIDoc, APIRouter, BaseController, Endpoint, EndpointDoc, ReadPermission
 
 MONITOR_SCHEMA = {
     "mon_status": ({
@@ -99,8 +99,8 @@ MONITOR_SCHEMA = {
 }
 
 
-@ApiController('/monitor', Scope.MONITOR)
-@ControllerDoc("Get Monitor Details", "Monitor")
+@APIRouter('/monitor', Scope.MONITOR)
+@APIDoc("Get Monitor Details", "Monitor")
 class Monitor(BaseController):
     @Endpoint()
     @ReadPermission
index 91d079234e4608b3c9dc7b6a550471ed7812e29d..f749c7998508d8ba563a75ba7fea3eaa2bb1c896 100644 (file)
@@ -15,8 +15,8 @@ from ..services.exception import DashboardException, serialize_dashboard_excepti
 from ..services.ganesha import Ganesha, GaneshaConf, NFSException
 from ..services.rgw_client import NoCredentialsException, \
     NoRgwDaemonsException, RequestException, RgwClient
-from . import ApiController, BaseController, ControllerDoc, Endpoint, \
-    EndpointDoc, ReadPermission, RESTController, Task, UiApiController
+from . import APIDoc, APIRouter, BaseController, Endpoint, EndpointDoc, \
+    ReadPermission, RESTController, Task, UIRouter
 
 logger = logging.getLogger('controllers.ganesha')
 
@@ -87,8 +87,8 @@ def NfsTask(name, metadata, wait_for):  # noqa: N802
     return composed_decorator
 
 
-@ApiController('/nfs-ganesha', Scope.NFS_GANESHA)
-@ControllerDoc("NFS-Ganesha Management API", "NFS-Ganesha")
+@APIRouter('/nfs-ganesha', Scope.NFS_GANESHA)
+@APIDoc("NFS-Ganesha Management API", "NFS-Ganesha")
 class NFSGanesha(RESTController):
 
     @EndpointDoc("Status of NFS-Ganesha management feature",
@@ -109,8 +109,8 @@ class NFSGanesha(RESTController):
         return status
 
 
-@ApiController('/nfs-ganesha/export', Scope.NFS_GANESHA)
-@ControllerDoc(group="NFS-Ganesha")
+@APIRouter('/nfs-ganesha/export', Scope.NFS_GANESHA)
+@APIDoc(group="NFS-Ganesha")
 class NFSGaneshaExports(RESTController):
     RESOURCE_ID = "cluster_id/export_id"
 
@@ -234,8 +234,8 @@ class NFSGaneshaExports(RESTController):
             ganesha_conf.reload_daemons(export.daemons)
 
 
-@ApiController('/nfs-ganesha/daemon', Scope.NFS_GANESHA)
-@ControllerDoc(group="NFS-Ganesha")
+@APIRouter('/nfs-ganesha/daemon', Scope.NFS_GANESHA)
+@APIDoc(group="NFS-Ganesha")
 class NFSGaneshaService(RESTController):
 
     @EndpointDoc("List NFS-Ganesha daemons information",
@@ -253,7 +253,7 @@ class NFSGaneshaService(RESTController):
         return result
 
 
-@UiApiController('/nfs-ganesha', Scope.NFS_GANESHA)
+@UIRouter('/nfs-ganesha', Scope.NFS_GANESHA)
 class NFSGaneshaUi(BaseController):
     @Endpoint('GET', '/cephx/clients')
     @ReadPermission
index 085870a0f4aac7bbbda82e87dc3d71e6c530bf18..03eeaadbc0904346a6df27c3ec24cf72cd8307be 100644 (file)
@@ -5,7 +5,7 @@ from functools import wraps
 
 from ..exceptions import DashboardException
 from ..services.orchestrator import OrchClient
-from . import ApiController, ControllerDoc, Endpoint, EndpointDoc, ReadPermission, RESTController
+from . import APIDoc, APIRouter, Endpoint, EndpointDoc, ReadPermission, RESTController
 
 STATUS_SCHEMA = {
     "available": (bool, "Orchestrator status"),
@@ -36,8 +36,8 @@ def raise_if_no_orchestrator(features=None):
     return inner
 
 
-@ApiController('/orchestrator')
-@ControllerDoc("Orchestrator Management API", "Orchestrator")
+@APIRouter('/orchestrator')
+@APIDoc("Orchestrator Management API", "Orchestrator")
 class Orchestrator(RESTController):
 
     @Endpoint()
index 1c6b2d26aeba11494cba796ec668492f809b94b9..95b6e7a04dc7517c18b6f76d4fc98c2a7d8dfa2c 100644 (file)
@@ -16,9 +16,9 @@ from ..services.ceph_service import CephService, SendCommandError
 from ..services.exception import handle_orchestrator_error, handle_send_command_error
 from ..services.orchestrator import OrchClient, OrchFeature
 from ..tools import str_to_bool
-from . import ApiController, ControllerDoc, CreatePermission, \
-    DeletePermission, Endpoint, EndpointDoc, ReadPermission, RESTController, \
-    Task, UpdatePermission, allow_empty_body
+from . import APIDoc, APIRouter, CreatePermission, DeletePermission, Endpoint, \
+    EndpointDoc, ReadPermission, RESTController, Task, UpdatePermission, \
+    allow_empty_body
 from .orchestrator import raise_if_no_orchestrator
 
 logger = logging.getLogger('controllers.osd')
@@ -51,8 +51,8 @@ def osd_task(name, metadata, wait_for=2.0):
     return Task("osd/{}".format(name), metadata, wait_for)
 
 
-@ApiController('/osd', Scope.OSD)
-@ControllerDoc('OSD management API', 'OSD')
+@APIRouter('/osd', Scope.OSD)
+@APIDoc('OSD management API', 'OSD')
 class Osd(RESTController):
     def list(self):
         osds = self.get_osd_map()
@@ -396,8 +396,8 @@ class Osd(RESTController):
         return CephService.send_command('mon', 'device ls-by-daemon', who='osd.{}'.format(svc_id))
 
 
-@ApiController('/osd/flags', Scope.OSD)
-@ControllerDoc(group='OSD')
+@APIRouter('/osd/flags', Scope.OSD)
+@APIDoc(group='OSD')
 class OsdFlagsController(RESTController):
     @staticmethod
     def _osd_flags():
index dff109dff256ae0663af1cc80f058192142c8881..7c272c6a801b5c3f6bb347bcaf86165e98545b1c 100644 (file)
@@ -6,7 +6,7 @@ import cherrypy
 from .. import mgr
 from ..security import Scope
 from ..services.ceph_service import CephService
-from . import ApiController, ControllerDoc, EndpointDoc, RESTController
+from . import APIDoc, APIRouter, EndpointDoc, RESTController
 
 PERF_SCHEMA = {
     "mon.a": ({
@@ -32,50 +32,50 @@ class PerfCounter(RESTController):
             raise cherrypy.HTTPError(404, "{0} not found".format(error))
 
 
-@ApiController('perf_counters/mds', Scope.CEPHFS)
-@ControllerDoc("Mds Perf Counters Management API", "MdsPerfCounter")
+@APIRouter('perf_counters/mds', Scope.CEPHFS)
+@APIDoc("Mds Perf Counters Management API", "MdsPerfCounter")
 class MdsPerfCounter(PerfCounter):
     service_type = 'mds'
 
 
-@ApiController('perf_counters/mon', Scope.MONITOR)
-@ControllerDoc("Mon Perf Counters Management API", "MonPerfCounter")
+@APIRouter('perf_counters/mon', Scope.MONITOR)
+@APIDoc("Mon Perf Counters Management API", "MonPerfCounter")
 class MonPerfCounter(PerfCounter):
     service_type = 'mon'
 
 
-@ApiController('perf_counters/osd', Scope.OSD)
-@ControllerDoc("OSD Perf Counters Management API", "OsdPerfCounter")
+@APIRouter('perf_counters/osd', Scope.OSD)
+@APIDoc("OSD Perf Counters Management API", "OsdPerfCounter")
 class OsdPerfCounter(PerfCounter):
     service_type = 'osd'
 
 
-@ApiController('perf_counters/rgw', Scope.RGW)
-@ControllerDoc("Rgw Perf Counters Management API", "RgwPerfCounter")
+@APIRouter('perf_counters/rgw', Scope.RGW)
+@APIDoc("Rgw Perf Counters Management API", "RgwPerfCounter")
 class RgwPerfCounter(PerfCounter):
     service_type = 'rgw'
 
 
-@ApiController('perf_counters/rbd-mirror', Scope.RBD_MIRRORING)
-@ControllerDoc("Rgw Mirroring Perf Counters Management API", "RgwMirrorPerfCounter")
+@APIRouter('perf_counters/rbd-mirror', Scope.RBD_MIRRORING)
+@APIDoc("Rgw Mirroring Perf Counters Management API", "RgwMirrorPerfCounter")
 class RbdMirrorPerfCounter(PerfCounter):
     service_type = 'rbd-mirror'
 
 
-@ApiController('perf_counters/mgr', Scope.MANAGER)
-@ControllerDoc("Mgr Perf Counters Management API", "MgrPerfCounter")
+@APIRouter('perf_counters/mgr', Scope.MANAGER)
+@APIDoc("Mgr Perf Counters Management API", "MgrPerfCounter")
 class MgrPerfCounter(PerfCounter):
     service_type = 'mgr'
 
 
-@ApiController('perf_counters/tcmu-runner', Scope.ISCSI)
-@ControllerDoc("Tcmu Runner Perf Counters Management API", "TcmuRunnerPerfCounter")
+@APIRouter('perf_counters/tcmu-runner', Scope.ISCSI)
+@APIDoc("Tcmu Runner Perf Counters Management API", "TcmuRunnerPerfCounter")
 class TcmuRunnerPerfCounter(PerfCounter):
     service_type = 'tcmu-runner'
 
 
-@ApiController('perf_counters')
-@ControllerDoc("Perf Counters Management API", "PerfCounters")
+@APIRouter('perf_counters')
+@APIDoc("Perf Counters Management API", "PerfCounters")
 class PerfCounters(RESTController):
     @EndpointDoc("Display Perf Counters",
                  responses={200: PERF_SCHEMA})
index 32ec6c198f8799476cefca9ff9331f7dcb2e90cb..3ad1aa95f6d6841515a0a2801c61ae862001b926 100644 (file)
@@ -12,8 +12,8 @@ from ..services.ceph_service import CephService
 from ..services.exception import handle_send_command_error
 from ..services.rbd import RbdConfiguration
 from ..tools import TaskManager, str_to_bool
-from . import ApiController, ControllerDoc, Endpoint, EndpointDoc, \
-    ReadPermission, RESTController, Task, UiApiController
+from . import APIDoc, APIRouter, Endpoint, EndpointDoc, ReadPermission, \
+    RESTController, Task, UIRouter
 
 POOL_SCHEMA = ([{
     "pool": (int, "pool id"),
@@ -90,8 +90,8 @@ def pool_task(name, metadata, wait_for=2.0):
     return Task("pool/{}".format(name), metadata, wait_for)
 
 
-@ApiController('/pool', Scope.POOL)
-@ControllerDoc("Get pool details by pool name", "Pool")
+@APIRouter('/pool', Scope.POOL)
+@APIDoc("Get pool details by pool name", "Pool")
 class Pool(RESTController):
 
     @staticmethod
@@ -284,8 +284,8 @@ class Pool(RESTController):
         return RbdConfiguration(pool_name).list()
 
 
-@UiApiController('/pool', Scope.POOL)
-@ControllerDoc("Dashboard UI helper function; not part of the public API", "PoolUi")
+@UIRouter('/pool', Scope.POOL)
+@APIDoc("Dashboard UI helper function; not part of the public API", "PoolUi")
 class PoolUi(Pool):
     @Endpoint()
     @ReadPermission
index a06778f9a595a2ab0c9ce8546b5299d00d43c31a..057c92dd8f9cd4fdf020f02e9d3cd90e9a54934d 100644 (file)
@@ -9,10 +9,10 @@ import requests
 from ..exceptions import DashboardException
 from ..security import Scope
 from ..settings import Settings
-from . import ApiController, BaseController, Controller, ControllerDoc, Endpoint, RESTController
+from . import APIDoc, APIRouter, BaseController, Endpoint, RESTController, Router
 
 
-@Controller('/api/prometheus_receiver', secure=False)
+@Router('/api/prometheus_receiver', secure=False)
 class PrometheusReceiver(BaseController):
     """
     The receiver is needed in order to receive alert notifications (reports)
@@ -60,8 +60,8 @@ class PrometheusRESTController(RESTController):
         raise DashboardException(content, http_status_code=400, component='prometheus')
 
 
-@ApiController('/prometheus', Scope.PROMETHEUS)
-@ControllerDoc("Prometheus Management API", "Prometheus")
+@APIRouter('/prometheus', Scope.PROMETHEUS)
+@APIDoc("Prometheus Management API", "Prometheus")
 class Prometheus(PrometheusRESTController):
     def list(self, **params):
         return self.alert_proxy('GET', '/alerts', params)
@@ -83,8 +83,8 @@ class Prometheus(PrometheusRESTController):
         return self.alert_proxy('DELETE', '/silence/' + s_id) if s_id else None
 
 
-@ApiController('/prometheus/notifications', Scope.PROMETHEUS)
-@ControllerDoc("Prometheus Notifications Management API", "PrometheusNotifications")
+@APIRouter('/prometheus/notifications', Scope.PROMETHEUS)
+@APIDoc("Prometheus Notifications Management API", "PrometheusNotifications")
 class PrometheusNotifications(RESTController):
 
     def list(self, **params):
index 57fe06a00a65a5538ea63dcddd72f07646384992..39c5b59edc16e5788cbe401e34780301bb5cc0df 100644 (file)
@@ -19,9 +19,8 @@ from ..services.rbd import RbdConfiguration, RbdService, RbdSnapshotService, \
     format_bitmask, format_features, parse_image_spec, rbd_call, \
     rbd_image_call
 from ..tools import ViewCache, str_to_bool
-from . import ApiController, ControllerDoc, CreatePermission, \
-    DeletePermission, EndpointDoc, RESTController, Task, UpdatePermission, \
-    allow_empty_body
+from . import APIDoc, APIRouter, CreatePermission, DeletePermission, \
+    EndpointDoc, RESTController, Task, UpdatePermission, allow_empty_body
 
 logger = logging.getLogger(__name__)
 
@@ -67,8 +66,8 @@ def _sort_features(features, enable=True):
     features.sort(key=key_func, reverse=not enable)
 
 
-@ApiController('/block/image', Scope.RBD_IMAGE)
-@ControllerDoc("RBD Management API", "Rbd")
+@APIRouter('/block/image', Scope.RBD_IMAGE)
+@APIDoc("RBD Management API", "Rbd")
 class Rbd(RESTController):
 
     # set of image features that can be enable on existing images
@@ -265,8 +264,8 @@ class Rbd(RESTController):
         return rbd_call(pool_name, namespace, rbd_inst.trash_move, image_name, delay)
 
 
-@ApiController('/block/image/{image_spec}/snap', Scope.RBD_IMAGE)
-@ControllerDoc("RBD Snapshot Management API", "RbdSnapshot")
+@APIRouter('/block/image/{image_spec}/snap', Scope.RBD_IMAGE)
+@APIDoc("RBD Snapshot Management API", "RbdSnapshot")
 class RbdSnapshot(RESTController):
 
     RESOURCE_ID = "snapshot_name"
@@ -356,8 +355,8 @@ class RbdSnapshot(RESTController):
         rbd_call(pool_name, namespace, _parent_clone)
 
 
-@ApiController('/block/image/trash', Scope.RBD_IMAGE)
-@ControllerDoc("RBD Trash Management API", "RbdTrash")
+@APIRouter('/block/image/trash', Scope.RBD_IMAGE)
+@APIDoc("RBD Trash Management API", "RbdTrash")
 class RbdTrash(RESTController):
     RESOURCE_ID = "image_id_spec"
 
@@ -448,8 +447,8 @@ class RbdTrash(RESTController):
                         int(str_to_bool(force)))
 
 
-@ApiController('/block/pool/{pool_name}/namespace', Scope.RBD_IMAGE)
-@ControllerDoc("RBD Namespace Management API", "RbdNamespace")
+@APIRouter('/block/pool/{pool_name}/namespace', Scope.RBD_IMAGE)
+@APIDoc("RBD Namespace Management API", "RbdNamespace")
 class RbdNamespace(RESTController):
 
     def __init__(self):
index 0c41d71375cfbec591f6484c385ac9b555145fab..a472fbd49e499e440af933a3887757095ec7f84e 100644 (file)
@@ -15,9 +15,8 @@ from ..services.ceph_service import CephService
 from ..services.exception import handle_rados_error, handle_rbd_error, serialize_dashboard_exception
 from ..services.rbd import rbd_call
 from ..tools import ViewCache
-from . import ApiController, BaseController, ControllerDoc, Endpoint, \
-    EndpointDoc, ReadPermission, RESTController, Task, UpdatePermission, \
-    allow_empty_body
+from . import APIDoc, APIRouter, BaseController, Endpoint, EndpointDoc, \
+    ReadPermission, RESTController, Task, UpdatePermission, allow_empty_body
 
 try:
     from typing import no_type_check
@@ -359,8 +358,8 @@ RBDM_SUMMARY_SCHEMA = {
 }
 
 
-@ApiController('/block/mirroring', Scope.RBD_MIRRORING)
-@ControllerDoc("RBD Mirroring Management API", "RbdMirroring")
+@APIRouter('/block/mirroring', Scope.RBD_MIRRORING)
+@APIDoc("RBD Mirroring Management API", "RbdMirroring")
 class RbdMirroring(BaseController):
 
     @Endpoint(method='GET', path='site_name')
@@ -382,8 +381,8 @@ class RbdMirroring(BaseController):
         return {'site_name': rbd.RBD().mirror_site_name_get(mgr.rados)}
 
 
-@ApiController('/block/mirroring/summary', Scope.RBD_MIRRORING)
-@ControllerDoc("RBD Mirroring Summary Management API", "RbdMirroringSummary")
+@APIRouter('/block/mirroring/summary', Scope.RBD_MIRRORING)
+@APIDoc("RBD Mirroring Summary Management API", "RbdMirroringSummary")
 class RbdMirroringSummary(BaseController):
 
     @Endpoint()
@@ -400,8 +399,8 @@ class RbdMirroringSummary(BaseController):
                 'content_data': content_data}
 
 
-@ApiController('/block/mirroring/pool', Scope.RBD_MIRRORING)
-@ControllerDoc("RBD Mirroring Pool Mode Management API", "RbdMirroringPoolMode")
+@APIRouter('/block/mirroring/pool', Scope.RBD_MIRRORING)
+@APIDoc("RBD Mirroring Pool Mode Management API", "RbdMirroringPoolMode")
 class RbdMirroringPoolMode(RESTController):
 
     RESOURCE_ID = "pool_name"
@@ -442,9 +441,8 @@ class RbdMirroringPoolMode(RESTController):
         return rbd_call(pool_name, None, _edit, mirror_mode)
 
 
-@ApiController('/block/mirroring/pool/{pool_name}/bootstrap',
-               Scope.RBD_MIRRORING)
-@ControllerDoc("RBD Mirroring Pool Bootstrap Management API", "RbdMirroringPoolBootstrap")
+@APIRouter('/block/mirroring/pool/{pool_name}/bootstrap', Scope.RBD_MIRRORING)
+@APIDoc("RBD Mirroring Pool Bootstrap Management API", "RbdMirroringPoolBootstrap")
 class RbdMirroringPoolBootstrap(BaseController):
 
     @Endpoint(method='POST', path='token')
@@ -476,8 +474,8 @@ class RbdMirroringPoolBootstrap(BaseController):
         return {}
 
 
-@ApiController('/block/mirroring/pool/{pool_name}/peer', Scope.RBD_MIRRORING)
-@ControllerDoc("RBD Mirroring Pool Peer Management API", "RbdMirroringPoolPeer")
+@APIRouter('/block/mirroring/pool/{pool_name}/peer', Scope.RBD_MIRRORING)
+@APIDoc("RBD Mirroring Pool Peer Management API", "RbdMirroringPoolPeer")
 class RbdMirroringPoolPeer(RESTController):
 
     RESOURCE_ID = "peer_uuid"
index c5ba6384fa2eda7c7dfce880812230515dc171cd..32885dc537f186417d7f270f7100c11ff70b95e2 100644 (file)
@@ -13,8 +13,8 @@ from ..services.auth import AuthManager, JwtManager
 from ..services.ceph_service import CephService
 from ..services.rgw_client import NoRgwDaemonsException, RgwClient
 from ..tools import json_str_to_object, str_to_bool
-from . import ApiController, BaseController, ControllerDoc, Endpoint, \
-    EndpointDoc, ReadPermission, RESTController, allow_empty_body
+from . import APIDoc, APIRouter, BaseController, Endpoint, EndpointDoc, \
+    ReadPermission, RESTController, allow_empty_body
 
 try:
     from typing import Any, List, Optional
@@ -41,8 +41,8 @@ RGW_USER_SCHEMA = {
 }
 
 
-@ApiController('/rgw', Scope.RGW)
-@ControllerDoc("RGW Management API", "Rgw")
+@APIRouter('/rgw', Scope.RGW)
+@APIDoc("RGW Management API", "Rgw")
 class Rgw(BaseController):
     @Endpoint()
     @ReadPermission
@@ -79,8 +79,8 @@ class Rgw(BaseController):
         return status
 
 
-@ApiController('/rgw/daemon', Scope.RGW)
-@ControllerDoc("RGW Daemon Management API", "RgwDaemon")
+@APIRouter('/rgw/daemon', Scope.RGW)
+@APIDoc("RGW Daemon Management API", "RgwDaemon")
 class RgwDaemon(RESTController):
     @EndpointDoc("Display RGW Daemons",
                  responses={200: [RGW_DAEMON_SCHEMA]})
@@ -150,8 +150,8 @@ class RgwRESTController(RESTController):
             raise DashboardException(e, http_status_code=http_status_code, component='rgw')
 
 
-@ApiController('/rgw/site', Scope.RGW)
-@ControllerDoc("RGW Site Management API", "RgwSite")
+@APIRouter('/rgw/site', Scope.RGW)
+@APIDoc("RGW Site Management API", "RgwSite")
 class RgwSite(RgwRESTController):
     def list(self, query=None, daemon_name=None):
         if query == 'placement-targets':
@@ -163,8 +163,8 @@ class RgwSite(RgwRESTController):
         raise DashboardException(http_status_code=501, component='rgw', msg='Not Implemented')
 
 
-@ApiController('/rgw/bucket', Scope.RGW)
-@ControllerDoc("RGW Bucket Management API", "RgwBucket")
+@APIRouter('/rgw/bucket', Scope.RGW)
+@APIDoc("RGW Bucket Management API", "RgwBucket")
 class RgwBucket(RgwRESTController):
     def _append_bid(self, bucket):
         """
@@ -325,8 +325,8 @@ class RgwBucket(RgwRESTController):
         }, json_response=False)
 
 
-@ApiController('/rgw/user', Scope.RGW)
-@ControllerDoc("RGW User Management API", "RgwUser")
+@APIRouter('/rgw/user', Scope.RGW)
+@APIDoc("RGW User Management API", "RgwUser")
 class RgwUser(RgwRESTController):
     def _append_uid(self, user):
         """
index 32404237c4a7c17bf3dafdc5632df53f7de3f7b3..197c037647387b7663d08ed2e79a1bf7ad13e44c 100644 (file)
@@ -9,8 +9,7 @@ from ..exceptions import DashboardException, RoleAlreadyExists, \
 from ..security import Permission
 from ..security import Scope as SecurityScope
 from ..services.access_control import SYSTEM_ROLES
-from . import ApiController, ControllerDoc, CreatePermission, EndpointDoc, \
-    RESTController, UiApiController
+from . import APIDoc, APIRouter, CreatePermission, EndpointDoc, RESTController, UIRouter
 
 ROLE_SCHEMA = [{
     "name": (str, "Role Name"),
@@ -22,8 +21,8 @@ ROLE_SCHEMA = [{
 }]
 
 
-@ApiController('/role', SecurityScope.USER)
-@ControllerDoc("Role Management API", "Role")
+@APIRouter('/role', SecurityScope.USER)
+@APIDoc("Role Management API", "Role")
 class Role(RESTController):
     @staticmethod
     def _role_to_dict(role):
@@ -139,7 +138,7 @@ class Role(RESTController):
                             role.get('scopes_permissions'))
 
 
-@UiApiController('/scope', SecurityScope.USER)
+@UIRouter('/scope', SecurityScope.USER)
 class Scope(RESTController):
     def list(self):
         return SecurityScope.all_scopes()
index fb8278068b1a61a43bca095180cbb5e84b5ebe31..6de8cf0df73d12179dde435d2fe14316fce5c0f8 100644 (file)
@@ -16,10 +16,10 @@ from .. import mgr
 from ..exceptions import UserDoesNotExist
 from ..services.auth import JwtManager
 from ..tools import prepare_url_prefix
-from . import BaseController, Controller, ControllerAuthMixin, Endpoint, allow_empty_body
+from . import BaseController, ControllerAuthMixin, Endpoint, Router, allow_empty_body
 
 
-@Controller('/auth/saml2', secure=False)
+@Router('/auth/saml2', secure=False)
 class Saml2(BaseController, ControllerAuthMixin):
 
     @staticmethod
index b3c3ab94798bf4c4788ec29cb68057c7823fb74b..d3ba882a1d92633ab143fc86df0008f0751e6657 100644 (file)
@@ -7,8 +7,8 @@ from ..exceptions import DashboardException
 from ..security import Scope
 from ..services.exception import handle_orchestrator_error
 from ..services.orchestrator import OrchClient, OrchFeature
-from . import ApiController, ControllerDoc, CreatePermission, \
-    DeletePermission, Endpoint, ReadPermission, RESTController, Task
+from . import APIDoc, APIRouter, CreatePermission, DeletePermission, Endpoint, \
+    ReadPermission, RESTController, Task
 from .orchestrator import raise_if_no_orchestrator
 
 
@@ -16,8 +16,8 @@ def service_task(name, metadata, wait_for=2.0):
     return Task("service/{}".format(name), metadata, wait_for)
 
 
-@ApiController('/service', Scope.HOSTS)
-@ControllerDoc("Service Management API", "Service")
+@APIRouter('/service', Scope.HOSTS)
+@APIDoc("Service Management API", "Service")
 class Service(RESTController):
 
     @Endpoint()
index 7d9ca9fb316a60e279347c94d49f4bfbe8c6e2a4..5df9ab1c90fdedac1b928c6f54395c9f452da73c 100644 (file)
@@ -8,7 +8,7 @@ import cherrypy
 from ..security import Scope
 from ..settings import Options
 from ..settings import Settings as SettingsModule
-from . import ApiController, ControllerDoc, EndpointDoc, RESTController, UiApiController
+from . import APIDoc, APIRouter, EndpointDoc, RESTController, UIRouter
 
 SETTINGS_SCHEMA = [{
     "name": (str, 'Settings Name'),
@@ -18,8 +18,8 @@ SETTINGS_SCHEMA = [{
 }]
 
 
-@ApiController('/settings', Scope.CONFIG_OPT)
-@ControllerDoc("Settings Management API", "Settings")
+@APIRouter('/settings', Scope.CONFIG_OPT)
+@APIDoc("Settings Management API", "Settings")
 class Settings(RESTController):
     """
     Enables to manage the settings of the dashboard (not the Ceph cluster).
@@ -104,7 +104,7 @@ class Settings(RESTController):
                 setattr(SettingsModule, self._to_native(name), value)
 
 
-@UiApiController('/standard_settings')
+@UIRouter('/standard_settings')
 class StandardSettings(RESTController):
     def list(self):
         """
index 4e240b9770bd022be848b35919f89aaf1c7202e5..25e4669965323f93396efb7382b7baf765b91c86 100644 (file)
@@ -9,7 +9,7 @@ from ..exceptions import ViewCacheNoDataException
 from ..security import Permission, Scope
 from ..services import progress
 from ..tools import TaskManager
-from . import ApiController, BaseController, ControllerDoc, Endpoint, EndpointDoc
+from . import APIDoc, APIRouter, BaseController, Endpoint, EndpointDoc
 
 SUMMARY_SCHEMA = {
     "health_status": (str, ""),
@@ -38,8 +38,8 @@ SUMMARY_SCHEMA = {
 }
 
 
-@ApiController('/summary')
-@ControllerDoc("Get Ceph Summary Details", "Summary")
+@APIRouter('/summary')
+@APIDoc("Get Ceph Summary Details", "Summary")
 class Summary(BaseController):
     def _health_status(self):
         health_data = mgr.get("health")
index 16c5017822e308c4adb40de4c365b159c0036863..14e763b752aa84df665a456d0bca8a20393c95ec 100644 (file)
@@ -3,7 +3,7 @@ from __future__ import absolute_import
 
 from ..services import progress
 from ..tools import TaskManager
-from . import ApiController, ControllerDoc, EndpointDoc, RESTController
+from . import APIDoc, APIRouter, EndpointDoc, RESTController
 
 TASK_SCHEMA = {
     "executing_tasks": (str, "ongoing executing tasks"),
@@ -23,8 +23,8 @@ TASK_SCHEMA = {
 }
 
 
-@ApiController('/task')
-@ControllerDoc("Task Management API", "Task")
+@APIRouter('/task')
+@APIDoc("Task Management API", "Task")
 class Task(RESTController):
     @EndpointDoc("Display Tasks",
                  parameters={
index 9e3d3e1cbb06c283812c5002ebe8df0053b1a661..b1f714bc38e0ad5915add11ed01ab29448324e2f 100644 (file)
@@ -4,7 +4,7 @@ from __future__ import absolute_import
 from .. import mgr
 from ..exceptions import DashboardException
 from ..security import Scope
-from . import ApiController, ControllerDoc, EndpointDoc, RESTController
+from . import APIDoc, APIRouter, EndpointDoc, RESTController
 
 REPORT_SCHEMA = {
     "report": ({
@@ -201,8 +201,8 @@ REPORT_SCHEMA = {
 }
 
 
-@ApiController('/telemetry', Scope.CONFIG_OPT)
-@ControllerDoc("Display Telemetry Report", "Telemetry")
+@APIRouter('/telemetry', Scope.CONFIG_OPT)
+@APIDoc("Display Telemetry Report", "Telemetry")
 class Telemetry(RESTController):
 
     @RESTController.Collection('GET')
index 8d340fedccb4a3fd9861c0d021546873741c3daf..53df8eab90b1f645054acf6b1a1dcd6682f632ae 100644 (file)
@@ -13,8 +13,8 @@ from ..exceptions import DashboardException, PasswordPolicyException, \
 from ..security import Scope
 from ..services.access_control import SYSTEM_ROLES, PasswordPolicy
 from ..services.auth import JwtManager
-from . import ApiController, BaseController, ControllerDoc, Endpoint, \
-    EndpointDoc, RESTController, allow_empty_body, validate_ceph_type
+from . import APIDoc, APIRouter, BaseController, Endpoint, EndpointDoc, \
+    RESTController, allow_empty_body, validate_ceph_type
 
 USER_SCHEMA = ([{
     "username": (str, 'Username of the user'),
@@ -47,8 +47,8 @@ def validate_password_policy(password, username=None, old_password=None):
                                  component='user')
 
 
-@ApiController('/user', Scope.USER)
-@ControllerDoc("Display User Details", "User")
+@APIRouter('/user', Scope.USER)
+@APIDoc("Display User Details", "User")
 class User(RESTController):
 
     @staticmethod
@@ -158,8 +158,8 @@ class User(RESTController):
         return User._user_to_dict(user)
 
 
-@ApiController('/user')
-@ControllerDoc("Get User Password Policy Details", "UserPasswordPolicy")
+@APIRouter('/user')
+@APIDoc("Get User Password Policy Details", "UserPasswordPolicy")
 class UserPasswordPolicy(RESTController):
 
     @Endpoint('POST')
@@ -191,8 +191,8 @@ class UserPasswordPolicy(RESTController):
         return result
 
 
-@ApiController('/user/{username}')
-@ControllerDoc("Change User Password", "UserChangePassword")
+@APIRouter('/user/{username}')
+@APIDoc("Change User Password", "UserChangePassword")
 class UserChangePassword(BaseController):
 
     @Endpoint('POST')
index de9b536bfc88fb6a96ba4e1ae693a61cb502500d..9173305d3514c1fc53d5a1f6d558e968e0bcf4de 100644 (file)
@@ -27,7 +27,7 @@ from mgr_util import ServerConfigException, build_url, \
     create_self_signed_cert, get_default_addr, verify_tls_files
 
 from . import mgr
-from .controllers import generate_routes, json_error_page
+from .controllers import Router, json_error_page
 from .grafana import push_local_dashboards
 from .services.auth import AuthManager, AuthManagerTool, JwtManager
 from .services.exception import dashboard_exception_handler
@@ -330,7 +330,7 @@ class Module(MgrModule, CherryPyConfig):
         # about to start serving
         self.set_uri(uri)
 
-        mapper, parent_urls = generate_routes(self.url_prefix)
+        mapper, parent_urls = Router.generate_routes(self.url_prefix)
 
         config = {}
         for purl in parent_urls:
index 921dadb0471d01011c3647591d0eb1d12aa587d3..c923b3441a6c3cb79c4e772cec7729ede2eeb405 100644 (file)
@@ -2656,6 +2656,85 @@ paths:
       summary: Get List Of Features
       tags:
       - FeatureTogglesEndpoint
+  /api/feedback:
+    post:
+      description: "\n        Create an issue.\n        :param project: The affected\
+        \ ceph component.\n        :param tracker: The tracker type.\n        :param\
+        \ subject: The title of the issue.\n        :param description: The description\
+        \ of the issue.\n        "
+      parameters: []
+      requestBody:
+        content:
+          application/json:
+            schema:
+              properties:
+                description:
+                  type: string
+                project:
+                  type: string
+                subject:
+                  type: string
+                tracker:
+                  type: string
+              required:
+              - project
+              - tracker
+              - subject
+              - description
+              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: []
+      tags:
+      - Report
+  /api/feedback/{issue_number}:
+    get:
+      description: "\n        Fetch issue details.\n        :param issueAPI: The issue\
+        \ tracker API access key.\n        "
+      parameters:
+      - in: path
+        name: issue_number
+        required: true
+        schema:
+          type: integer
+      responses:
+        '200':
+          content:
+            application/vnd.ceph.api.v1.0+json:
+              type: object
+          description: OK
+        '400':
+          description: Operation exception. Please check the response body for details.
+        '401':
+          description: Unauthenticated access. Please login first.
+        '403':
+          description: Unauthorized access. Please check your permissions.
+        '500':
+          description: Unexpected error. Please check the response body for the stack
+            trace.
+      security:
+      - jwt: []
+      tags:
+      - Report
   /api/grafana/dashboards:
     post:
       parameters: []
@@ -10373,6 +10452,8 @@ tags:
   name: RbdSnapshot
 - description: RBD Trash Management API
   name: RbdTrash
+- description: Feedback API
+  name: Report
 - description: RGW Management API
   name: Rgw
 - description: RGW Bucket Management API
index ea50652346a4a678147d0c68f8b9d2f985f1deec..a45aa67ff917fb803a7880ea51a6fb8b1dcacd5f 100644 (file)
@@ -138,7 +138,7 @@ class FeatureToggles(I.CanMgr, I.Setupable, I.HasOptions,
 
     @PM.add_hook
     def get_controllers(self):
-        from ..controllers import ApiController, ControllerDoc, EndpointDoc, RESTController
+        from ..controllers import APIDoc, APIRouter, EndpointDoc, RESTController
 
         FEATURES_SCHEMA = {
             "rbd": (bool, ''),
@@ -149,8 +149,8 @@ class FeatureToggles(I.CanMgr, I.Setupable, I.HasOptions,
             "nfs": (bool, '')
         }
 
-        @ApiController('/feature_toggles')
-        @ControllerDoc("Manage Features API", "FeatureTogglesEndpoint")
+        @APIRouter('/feature_toggles')
+        @APIDoc("Manage Features API", "FeatureTogglesEndpoint")
         class FeatureTogglesEndpoint(RESTController):
             @EndpointDoc("Get List Of Features",
                          responses={200: FEATURES_SCHEMA})
index 0600135ac231cc7ad0489c1cee4fb25df7122920..22d6a294a39444550191b427c3863467985644aa 100644 (file)
@@ -79,9 +79,9 @@ class Motd(SP):
 
     @PM.add_hook
     def get_controllers(self):
-        from ..controllers import RESTController, UiApiController
+        from ..controllers import RESTController, UIRouter
 
-        @UiApiController('/motd')
+        @UIRouter('/motd')
         class MessageOfTheDay(RESTController):
             def list(_) -> Optional[Dict]:  # pylint: disable=no-self-argument
                 value: str = self.get_option(self.NAME)
index 9075cdc65fc857d27e14e73bdb1cd89b126d0c78..742ac371864eb5f1751168fdec6e39ec17a2ae99 100644 (file)
@@ -14,8 +14,9 @@ from cherrypy.test import helper
 from mgr_module import HandleCommandResult
 from pyfakefs import fake_filesystem
 
-from .. import DEFAULT_VERSION, mgr
+from .. import mgr
 from ..controllers import generate_controller_routes, json_error_page
+from ..controllers._version import APIVersion
 from ..module import Module
 from ..plugins import PLUGIN_MANAGER, debug, feature_toggles  # noqa
 from ..services.auth import AuthManagerTool
index b2e83379efc8de3da51b70af3ab58427a59fde23..d54e33ee02cd169f1a18da819605c48a7fdc3b44 100644 (file)
@@ -10,12 +10,12 @@ except ImportError:
     import unittest.mock as mock
 
 from .. import mgr
-from ..controllers import Controller, RESTController
+from ..controllers import RESTController, Router
 from . import ControllerTestCase, KVStoreMockMixin  # pylint: disable=no-name-in-module
 
 
 # pylint: disable=W0613
-@Controller('/foo', secure=False)
+@Router('/foo', secure=False)
 class FooResource(RESTController):
     def create(self, password):
         pass
index bacef3677d787af894f69dee7730262fe5f14e1f..4fde5452a6bd280d231b5863458eae0b482524aa 100644 (file)
@@ -1,11 +1,11 @@
 # -*- coding: utf-8 -*-
 from __future__ import absolute_import
 
-from ..controllers import ApiController, BaseController, Controller, Endpoint, RESTController
+from ..controllers import APIRouter, BaseController, Endpoint, RESTController, Router
 from . import ControllerTestCase  # pylint: disable=no-name-in-module
 
 
-@Controller("/btest/{key}", base_url="/ui", secure=False)
+@Router("/btest/{key}", base_url="/ui", secure=False)
 class BTest(BaseController):
     @Endpoint()
     def test1(self, key, opt=1):
@@ -37,7 +37,7 @@ class BTest(BaseController):
         return {'key': key, 'opt': opt}
 
 
-@ApiController("/rtest/{key}", secure=False)
+@APIRouter("/rtest/{key}", secure=False)
 class RTest(RESTController):
     RESOURCE_ID = 'skey/ekey'
 
@@ -71,7 +71,7 @@ class RTest(RESTController):
         return {'key': key, 'skey': skey, 'ekey': ekey, 'opt': opt}
 
 
-@Controller("/", secure=False)
+@Router("/", secure=False)
 class Root(BaseController):
     @Endpoint(json_response=False)
     def __call__(self):
index 072985e8bf89416420810376cd25419a2f867cdd..1e6abbc0c8cda054c7fa43b090bd45c679b55f19 100644 (file)
@@ -2,14 +2,15 @@
 from __future__ import absolute_import
 
 from ..api.doc import SchemaType
-from ..controllers import ApiController, ControllerDoc, Endpoint, EndpointDoc, RESTController
+from ..controllers import APIDoc, APIRouter, Endpoint, EndpointDoc, RESTController
+from ..controllers._version import APIVersion
 from ..controllers.docs import Docs
 from . import ControllerTestCase  # pylint: disable=no-name-in-module
 
 
 # Dummy controller and endpoint that can be assigned with @EndpointDoc and @GroupDoc
-@ControllerDoc("Group description", group="FooGroup")
-@ApiController("/doctest/", secure=False)
+@APIDoc("Group description", group="FooGroup")
+@APIRouter("/doctest/", secure=False)
 class DecoratedController(RESTController):
     RESOURCE_ID = 'doctest'
 
index bbf9090957204ea2e386b5c2513a9ad5fe50721f..0805e8c7bd1a5bf448db967da1ce82a9aa82cd80 100644 (file)
@@ -5,7 +5,7 @@ import time
 
 import rados
 
-from ..controllers import Controller, Endpoint, RESTController, Task
+from ..controllers import Endpoint, RESTController, Router, Task
 from ..services.ceph_service import SendCommandError
 from ..services.exception import handle_rados_error, \
     handle_send_command_error, serialize_dashboard_exception
@@ -14,7 +14,7 @@ from . import ControllerTestCase  # pylint: disable=no-name-in-module
 
 
 # pylint: disable=W0613
-@Controller('foo', secure=False)
+@Router('foo', secure=False)
 class FooResource(RESTController):
 
     @Endpoint()
index 571b8c286c859893c2b058e894fa4016dd39fe8f..d908c0e577da47a976e66ed7e039c052fc3997d0 100644 (file)
@@ -23,8 +23,8 @@ class SettingsTest(unittest.TestCase, KVStoreMockMixin):
         cls.mgr = mgr
 
         # Populate real endpoint map
-        from ..controllers import load_controllers
-        cls.controllers = load_controllers()
+        from ..controllers import BaseController
+        cls.controllers = BaseController.load_controllers()
 
         # Initialize FeatureToggles plugin
         cls.plugin = FeatureToggles()
index 6d719a0fc92218052c64f36e9a1927ef5da95932..003d955adc95e2b92468d7947f7b2c4262dfc8b1 100644 (file)
@@ -6,6 +6,7 @@ from unittest import mock
 from orchestrator import HostSpec, InventoryHost
 
 from .. import mgr
+from ..controllers._version import APIVersion
 from ..controllers.host import Host, HostUi, get_device_osd_map, get_hosts, get_inventories
 from ..tools import NotificationQueue, TaskManager
 from . import ControllerTestCase  # pylint: disable=no-name-in-module
index b4abbdea36b97cde32509d8c8532acc597bf4586..b2bf7091f87b3c3185435f7b493df0562e949073 100644 (file)
@@ -7,14 +7,14 @@ try:
 except ImportError:
     import unittest.mock as mock
 
-from ..controllers import Controller, RESTController, Task
+from ..controllers import RESTController, Router, Task
 from ..controllers.task import Task as TaskController
 from ..services import progress
 from ..tools import NotificationQueue, TaskManager
 from . import ControllerTestCase  # pylint: disable=no-name-in-module
 
 
-@Controller('/test/task', secure=False)
+@Router('/test/task', secure=False)
 class TaskTest(RESTController):
     sleep_time = 0.0
 
index c9cd11bafc8333b457284f8827f00ba23e0aa00f..0313e10ddcc3526cca347af475b6aa7b83e49b0f 100644 (file)
@@ -11,15 +11,15 @@ try:
 except ImportError:
     from unittest.mock import patch
 
-from .. import DEFAULT_VERSION
-from ..controllers import ApiController, BaseController, Controller, Proxy, RESTController
+from ..controllers import APIRouter, BaseController, Proxy, RESTController, Router
+from ..controllers._version import APIVersion
 from ..services.exception import handle_rados_error
 from ..tools import dict_contains_path, dict_get, json_str_to_object, partial_dict
 from . import ControllerTestCase  # pylint: disable=no-name-in-module
 
 
 # pylint: disable=W0613
-@Controller('/foo', secure=False)
+@Router('/foo', secure=False)
 class FooResource(RESTController):
     elems = []
 
@@ -44,20 +44,20 @@ class FooResource(RESTController):
         return dict(key=key, newdata=newdata)
 
 
-@Controller('/foo/:key/:method', secure=False)
+@Router('/foo/:key/:method', secure=False)
 class FooResourceDetail(RESTController):
     def list(self, key, method):
         return {'detail': (key, [method])}
 
 
-@ApiController('/rgw/proxy', secure=False)
+@APIRouter('/rgw/proxy', secure=False)
 class GenerateControllerRoutesController(BaseController):
     @Proxy()
     def __call__(self, path, **params):
         pass
 
 
-@ApiController('/fooargs', secure=False)
+@APIRouter('/fooargs', secure=False)
 class FooArgs(RESTController):
     def set(self, code, name=None, opt1=None, opt2=None):
         return {'code': code, 'name': name, 'opt1': opt1, 'opt2': opt2}
index 666b388bf28844731b3f61c021b2f345be6d532f..b3e6f6f8a6fa2ef843cab3f9974604891d338479 100644 (file)
@@ -3,11 +3,13 @@ from __future__ import absolute_import
 
 import unittest
 
-from ..controllers import ApiController, RESTController
+from ..controllers._api_router import APIRouter
+from ..controllers._rest_controller import RESTController
+from ..controllers._version import APIVersion
 from . import ControllerTestCase  # pylint: disable=no-name-in-module
 
 
-@ApiController("/vtest", secure=False)
+@APIRouter("/vtest", secure=False)
 class VTest(RESTController):
     RESOURCE_ID = "vid"