From b36766ebd85ba967bac80c56ab671775754b2ea4 Mon Sep 17 00:00:00 2001 From: Ernesto Puerta Date: Tue, 7 Sep 2021 17:07:48 +0200 Subject: [PATCH] mgr/dashboard: clean-up controllers Fixes: https://tracker.ceph.com/issues/52589 Signed-off-by: Ernesto Puerta --- src/pybind/mgr/dashboard/CMakeLists.txt | 5 +- .../mgr/dashboard/controllers/__init__.py | 1144 +---------------- .../mgr/dashboard/controllers/_api_router.py | 13 + src/pybind/mgr/dashboard/controllers/_auth.py | 18 + .../dashboard/controllers/_base_controller.py | 314 +++++ src/pybind/mgr/dashboard/controllers/_docs.py | 128 ++ .../mgr/dashboard/controllers/_endpoint.py | 82 ++ .../mgr/dashboard/controllers/_helpers.py | 127 ++ .../mgr/dashboard/controllers/_permissions.py | 60 + .../dashboard/controllers/_rest_controller.py | 249 ++++ .../mgr/dashboard/controllers/_router.py | 69 + src/pybind/mgr/dashboard/controllers/_task.py | 79 ++ .../mgr/dashboard/controllers/_ui_router.py | 13 + src/pybind/mgr/dashboard/controllers/auth.py | 7 +- .../mgr/dashboard/controllers/cephfs.py | 13 +- .../controllers/cluster_configuration.py | 6 +- .../mgr/dashboard/controllers/crush_rule.py | 17 +- src/pybind/mgr/dashboard/controllers/docs.py | 9 +- .../controllers/erasure_code_profile.py | 11 +- .../mgr/dashboard/controllers/feedback.py | 8 +- .../dashboard/controllers/frontend_logging.py | 4 +- .../mgr/dashboard/controllers/grafana.py | 8 +- .../mgr/dashboard/controllers/health.py | 8 +- src/pybind/mgr/dashboard/controllers/home.py | 8 +- src/pybind/mgr/dashboard/controllers/host.py | 13 +- src/pybind/mgr/dashboard/controllers/iscsi.py | 15 +- src/pybind/mgr/dashboard/controllers/logs.py | 8 +- .../mgr/dashboard/controllers/mgr_modules.py | 6 +- .../mgr/dashboard/controllers/monitor.py | 6 +- .../mgr/dashboard/controllers/nfsganesha.py | 18 +- .../mgr/dashboard/controllers/orchestrator.py | 6 +- src/pybind/mgr/dashboard/controllers/osd.py | 14 +- .../dashboard/controllers/perf_counters.py | 34 +- src/pybind/mgr/dashboard/controllers/pool.py | 12 +- .../mgr/dashboard/controllers/prometheus.py | 12 +- src/pybind/mgr/dashboard/controllers/rbd.py | 21 +- .../dashboard/controllers/rbd_mirroring.py | 26 +- src/pybind/mgr/dashboard/controllers/rgw.py | 24 +- src/pybind/mgr/dashboard/controllers/role.py | 9 +- src/pybind/mgr/dashboard/controllers/saml2.py | 4 +- .../mgr/dashboard/controllers/service.py | 8 +- .../mgr/dashboard/controllers/settings.py | 8 +- .../mgr/dashboard/controllers/summary.py | 6 +- src/pybind/mgr/dashboard/controllers/task.py | 6 +- .../mgr/dashboard/controllers/telemetry.py | 6 +- src/pybind/mgr/dashboard/controllers/user.py | 16 +- src/pybind/mgr/dashboard/module.py | 4 +- src/pybind/mgr/dashboard/openapi.yaml | 93 ++ .../mgr/dashboard/plugins/feature_toggles.py | 6 +- src/pybind/mgr/dashboard/plugins/motd.py | 4 +- src/pybind/mgr/dashboard/tests/__init__.py | 3 +- .../mgr/dashboard/tests/test_api_auditing.py | 4 +- .../mgr/dashboard/tests/test_controllers.py | 8 +- src/pybind/mgr/dashboard/tests/test_docs.py | 8 +- .../mgr/dashboard/tests/test_exceptions.py | 4 +- .../dashboard/tests/test_feature_toggles.py | 4 +- src/pybind/mgr/dashboard/tests/test_host.py | 2 +- .../mgr/dashboard/tests/test_rest_tasks.py | 4 +- src/pybind/mgr/dashboard/tests/test_tools.py | 12 +- .../mgr/dashboard/tests/test_versioning.py | 6 +- 60 files changed, 1507 insertions(+), 1333 deletions(-) create mode 100644 src/pybind/mgr/dashboard/controllers/_api_router.py create mode 100644 src/pybind/mgr/dashboard/controllers/_auth.py create mode 100644 src/pybind/mgr/dashboard/controllers/_base_controller.py create mode 100644 src/pybind/mgr/dashboard/controllers/_docs.py create mode 100644 src/pybind/mgr/dashboard/controllers/_endpoint.py create mode 100644 src/pybind/mgr/dashboard/controllers/_helpers.py create mode 100644 src/pybind/mgr/dashboard/controllers/_permissions.py create mode 100644 src/pybind/mgr/dashboard/controllers/_rest_controller.py create mode 100644 src/pybind/mgr/dashboard/controllers/_router.py create mode 100644 src/pybind/mgr/dashboard/controllers/_task.py create mode 100644 src/pybind/mgr/dashboard/controllers/_ui_router.py diff --git a/src/pybind/mgr/dashboard/CMakeLists.txt b/src/pybind/mgr/dashboard/CMakeLists.txt index 40bb66650d244..81bb9dd1bb23a 100644 --- a/src/pybind/mgr/dashboard/CMakeLists.txt +++ b/src/pybind/mgr/dashboard/CMakeLists.txt @@ -9,7 +9,10 @@ if(WITH_MGR_DASHBOARD_FRONTEND) add_subdirectory(frontend) 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() else() # prebuilt diff --git a/src/pybind/mgr/dashboard/controllers/__init__.py b/src/pybind/mgr/dashboard/controllers/__init__.py index 25fd383fc1574..7a5b090ee461e 100755 --- a/src/pybind/mgr/dashboard/controllers/__init__.py +++ b/src/pybind/mgr/dashboard/controllers/__init__.py @@ -1,1109 +1,35 @@ -# -*- coding: utf-8 -*- -# pylint: disable=protected-access,too-many-branches,too-many-lines - -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 - -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 -from ._version import APIVersion - -try: - from typing import Any, Dict, List, Optional, Tuple, Union -except ImportError: - pass # For typing only - - -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: - : (, , [optional], [default value]) - : (<[type]>, , [optional], [default value]) - : (<[nested parameters]>, , [optional], [default value]) - : (<{nested parameters}>, , [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 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 - - -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) - - -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: Optional[APIVersion]): - @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'] = \ - version.to_mime_type(subtype='xml') - else: - cherrypy.response.headers['Content-Type'] = 'application/xml' - return ret.encode('utf8') - if json_response: - if version: - cherrypy.response.headers['Content-Type'] = \ - version.to_mime_type() - 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': 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(RESTController, cls).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 - - -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 index 0000000000000..dbd45ac0e674c --- /dev/null +++ b/src/pybind/mgr/dashboard/controllers/_api_router.py @@ -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 index 0000000000000..0015a75e40b58 --- /dev/null +++ b/src/pybind/mgr/dashboard/controllers/_auth.py @@ -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 index 0000000000000..4fcc8442f1bc8 --- /dev/null +++ b/src/pybind/mgr/dashboard/controllers/_base_controller.py @@ -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 index 0000000000000..5bd7a5a7a6ea5 --- /dev/null +++ b/src/pybind/mgr/dashboard/controllers/_docs.py @@ -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: + : (, , [optional], [default value]) + : (<[type]>, , [optional], [default value]) + : (<[nested parameters]>, , [optional], [default value]) + : (<{nested parameters}>, , [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 index 0000000000000..fccab89c34979 --- /dev/null +++ b/src/pybind/mgr/dashboard/controllers/_endpoint.py @@ -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 index 0000000000000..5ec49ee97e166 --- /dev/null +++ b/src/pybind/mgr/dashboard/controllers/_helpers.py @@ -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 index 0000000000000..eb190c9a9e07a --- /dev/null +++ b/src/pybind/mgr/dashboard/controllers/_permissions.py @@ -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 index 0000000000000..03e124f9e4fb1 --- /dev/null +++ b/src/pybind/mgr/dashboard/controllers/_rest_controller.py @@ -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 index 0000000000000..ad67532e31549 --- /dev/null +++ b/src/pybind/mgr/dashboard/controllers/_router.py @@ -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 index 0000000000000..33399e8e0f6ed --- /dev/null +++ b/src/pybind/mgr/dashboard/controllers/_task.py @@ -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 index 0000000000000..7454afaeb5740 --- /dev/null +++ b/src/pybind/mgr/dashboard/controllers/_ui_router.py @@ -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 diff --git a/src/pybind/mgr/dashboard/controllers/auth.py b/src/pybind/mgr/dashboard/controllers/auth.py index ed219dc72b0b2..353d5d72bb9e8 100644 --- a/src/pybind/mgr/dashboard/controllers/auth.py +++ b/src/pybind/mgr/dashboard/controllers/auth.py @@ -8,8 +8,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 @@ -28,8 +27,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. diff --git a/src/pybind/mgr/dashboard/controllers/cephfs.py b/src/pybind/mgr/dashboard/controllers/cephfs.py index 0a196913c2fe7..d32cbe4bcfab6 100644 --- a/src/pybind/mgr/dashboard/controllers/cephfs.py +++ b/src/pybind/mgr/dashboard/controllers/cephfs.py @@ -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' diff --git a/src/pybind/mgr/dashboard/controllers/cluster_configuration.py b/src/pybind/mgr/dashboard/controllers/cluster_configuration.py index 871b629ee4c2e..da5be2cc81d6a 100644 --- a/src/pybind/mgr/dashboard/controllers/cluster_configuration.py +++ b/src/pybind/mgr/dashboard/controllers/cluster_configuration.py @@ -6,7 +6,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'), @@ -27,8 +27,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): diff --git a/src/pybind/mgr/dashboard/controllers/crush_rule.py b/src/pybind/mgr/dashboard/controllers/crush_rule.py index f57f5d4e3ee1a..250f657b2bae6 100644 --- a/src/pybind/mgr/dashboard/controllers/crush_rule.py +++ b/src/pybind/mgr/dashboard/controllers/crush_rule.py @@ -1,23 +1,28 @@ + # -*- coding: utf-8 -*- +from __future__ import absolute_import from cherrypy import NotFound from .. import mgr from ..security import Scope from ..services.ceph_service import CephService -from . import ApiController, APIVersion, 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'), "rule_name": (str, 'Rule Name'), + "ruleset": (int, 'RuleSet related to the rule'), "type": (int, 'Type of Rule'), + "min_size": (int, 'Minimum size of Rule'), + "max_size": (int, 'Maximum size of Rule'), 'steps': ([{str}], 'Steps included in the rule') } -@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}) @@ -46,8 +51,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 diff --git a/src/pybind/mgr/dashboard/controllers/docs.py b/src/pybind/mgr/dashboard/controllers/docs.py index 87326aaa0a258..ab9f5687b532b 100644 --- a/src/pybind/mgr/dashboard/controllers/docs.py +++ b/src/pybind/mgr/dashboard/controllers/docs.py @@ -6,14 +6,15 @@ import cherrypy from .. import mgr from ..api.doc import Schema, SchemaInput, SchemaType -from . import ENDPOINT_MAP, APIVersion, 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 @@ -404,8 +405,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 @@ -415,7 +414,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 diff --git a/src/pybind/mgr/dashboard/controllers/erasure_code_profile.py b/src/pybind/mgr/dashboard/controllers/erasure_code_profile.py index a62f60c1a9052..d0966025aa650 100644 --- a/src/pybind/mgr/dashboard/controllers/erasure_code_profile.py +++ b/src/pybind/mgr/dashboard/controllers/erasure_code_profile.py @@ -5,8 +5,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, ''), @@ -18,8 +17,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 @@ -46,8 +45,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 index 1ae8c15c6b06b..e1a9eb31d6c20 100644 --- a/src/pybind/mgr/dashboard/controllers/feedback.py +++ b/src/pybind/mgr/dashboard/controllers/feedback.py @@ -5,16 +5,16 @@ from ..model.feedback import Feedback from ..rest_client import RequestException from ..security import Scope from ..services import feedback -from . import ControllerDoc, RESTController, UiApiController +from . import APIDoc, APIRouter, RESTController -@UiApiController('/feedback', Scope.CONFIG_OPT) -@ControllerDoc("Feedback API", "Report") +@APIRouter('/feedback', Scope.CONFIG_OPT) +@APIDoc("Feedback API", "Report") class FeedbackController(RESTController): issueAPIkey = None def __init__(self): # pragma: no cover - super(FeedbackController, self).__init__() + super().__init__() self.tracker_client = feedback.CephTrackerClient() def create(self, project, tracker, subject, description): diff --git a/src/pybind/mgr/dashboard/controllers/frontend_logging.py b/src/pybind/mgr/dashboard/controllers/frontend_logging.py index 1d3e3cdd6255d..df9ca19ccfe08 100644 --- a/src/pybind/mgr/dashboard/controllers/frontend_logging.py +++ b/src/pybind/mgr/dashboard/controllers/frontend_logging.py @@ -1,11 +1,11 @@ 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') diff --git a/src/pybind/mgr/dashboard/controllers/grafana.py b/src/pybind/mgr/dashboard/controllers/grafana.py index a92a390adee6f..d5c9b19f80878 100644 --- a/src/pybind/mgr/dashboard/controllers/grafana.py +++ b/src/pybind/mgr/dashboard/controllers/grafana.py @@ -4,16 +4,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 diff --git a/src/pybind/mgr/dashboard/controllers/health.py b/src/pybind/mgr/dashboard/controllers/health.py index 4e402ee65bd60..f0964ba19239d 100644 --- a/src/pybind/mgr/dashboard/controllers/health.py +++ b/src/pybind/mgr/dashboard/controllers/health.py @@ -9,7 +9,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 = ({ @@ -274,11 +274,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) diff --git a/src/pybind/mgr/dashboard/controllers/home.py b/src/pybind/mgr/dashboard/controllers/home.py index 809b50617b102..32b0071342aca 100644 --- a/src/pybind/mgr/dashboard/controllers/home.py +++ b/src/pybind/mgr/dashboard/controllers/home.py @@ -14,7 +14,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") @@ -51,10 +51,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( @@ -134,7 +134,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): diff --git a/src/pybind/mgr/dashboard/controllers/host.py b/src/pybind/mgr/dashboard/controllers/host.py index dc1aef3aebc5e..a72c4b34052d5 100644 --- a/src/pybind/mgr/dashboard/controllers/host.py +++ b/src/pybind/mgr/dashboard/controllers/host.py @@ -16,9 +16,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, APIVersion, 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 = { @@ -266,8 +267,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={ @@ -448,7 +449,7 @@ class Host(RESTController): orch.hosts.add_label(hostname, label) -@UiApiController('/host', Scope.HOSTS) +@UIRouter('/host', Scope.HOSTS) class HostUi(BaseController): @Endpoint('GET') @ReadPermission diff --git a/src/pybind/mgr/dashboard/controllers/iscsi.py b/src/pybind/mgr/dashboard/controllers/iscsi.py index 35de24c9b1424..4754c1fab460a 100644 --- a/src/pybind/mgr/dashboard/controllers/iscsi.py +++ b/src/pybind/mgr/dashboard/controllers/iscsi.py @@ -23,9 +23,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 ISCSI_SCHEMA = { 'user': (str, 'username'), @@ -35,7 +34,7 @@ ISCSI_SCHEMA = { } -@UiApiController('/iscsi', Scope.ISCSI) +@UIRouter('/iscsi', Scope.ISCSI) class IscsiUi(BaseController): REQUIRED_CEPH_ISCSI_CONFIG_MIN_VERSION = 10 @@ -199,8 +198,8 @@ class IscsiUi(BaseController): return result_gateways -@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 @@ -256,8 +255,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): diff --git a/src/pybind/mgr/dashboard/controllers/logs.py b/src/pybind/mgr/dashboard/controllers/logs.py index 0efd37cfefc5c..133c33477b585 100644 --- a/src/pybind/mgr/dashboard/controllers/logs.py +++ b/src/pybind/mgr/dashboard/controllers/logs.py @@ -5,7 +5,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 @@ -30,11 +30,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) diff --git a/src/pybind/mgr/dashboard/controllers/mgr_modules.py b/src/pybind/mgr/dashboard/controllers/mgr_modules.py index e6095841ff952..57bb9b5ffb8f0 100644 --- a/src/pybind/mgr/dashboard/controllers/mgr_modules.py +++ b/src/pybind/mgr/dashboard/controllers/mgr_modules.py @@ -5,7 +5,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"), @@ -30,8 +30,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'] diff --git a/src/pybind/mgr/dashboard/controllers/monitor.py b/src/pybind/mgr/dashboard/controllers/monitor.py index 986fddc7f7308..288b6977acea5 100644 --- a/src/pybind/mgr/dashboard/controllers/monitor.py +++ b/src/pybind/mgr/dashboard/controllers/monitor.py @@ -4,7 +4,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": ({ @@ -98,8 +98,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 diff --git a/src/pybind/mgr/dashboard/controllers/nfsganesha.py b/src/pybind/mgr/dashboard/controllers/nfsganesha.py index b2d9ae5505f00..7c740587381b6 100644 --- a/src/pybind/mgr/dashboard/controllers/nfsganesha.py +++ b/src/pybind/mgr/dashboard/controllers/nfsganesha.py @@ -14,8 +14,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') @@ -86,8 +86,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", @@ -108,8 +108,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" @@ -233,8 +233,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", @@ -252,7 +252,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 diff --git a/src/pybind/mgr/dashboard/controllers/orchestrator.py b/src/pybind/mgr/dashboard/controllers/orchestrator.py index a0c1d171d5409..b93b850e022ce 100644 --- a/src/pybind/mgr/dashboard/controllers/orchestrator.py +++ b/src/pybind/mgr/dashboard/controllers/orchestrator.py @@ -4,7 +4,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"), @@ -35,8 +35,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() diff --git a/src/pybind/mgr/dashboard/controllers/osd.py b/src/pybind/mgr/dashboard/controllers/osd.py index ea5ccd0f809bc..67db1ad1d9d3d 100644 --- a/src/pybind/mgr/dashboard/controllers/osd.py +++ b/src/pybind/mgr/dashboard/controllers/osd.py @@ -15,9 +15,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') @@ -50,8 +50,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() @@ -395,8 +395,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(): diff --git a/src/pybind/mgr/dashboard/controllers/perf_counters.py b/src/pybind/mgr/dashboard/controllers/perf_counters.py index 803c273ec1a4b..0bd8833669371 100644 --- a/src/pybind/mgr/dashboard/controllers/perf_counters.py +++ b/src/pybind/mgr/dashboard/controllers/perf_counters.py @@ -5,7 +5,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": ({ @@ -31,50 +31,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}) diff --git a/src/pybind/mgr/dashboard/controllers/pool.py b/src/pybind/mgr/dashboard/controllers/pool.py index 4fef2b975d4aa..e2717e6fd4773 100644 --- a/src/pybind/mgr/dashboard/controllers/pool.py +++ b/src/pybind/mgr/dashboard/controllers/pool.py @@ -11,8 +11,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"), @@ -89,8 +89,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 @@ -283,8 +283,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 diff --git a/src/pybind/mgr/dashboard/controllers/prometheus.py b/src/pybind/mgr/dashboard/controllers/prometheus.py index 21bd151682bb0..f1dae7f1a2fa2 100644 --- a/src/pybind/mgr/dashboard/controllers/prometheus.py +++ b/src/pybind/mgr/dashboard/controllers/prometheus.py @@ -8,10 +8,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) @@ -59,8 +59,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) @@ -82,8 +82,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): diff --git a/src/pybind/mgr/dashboard/controllers/rbd.py b/src/pybind/mgr/dashboard/controllers/rbd.py index a1fa9be3bdb8b..252ddfc61c1ba 100644 --- a/src/pybind/mgr/dashboard/controllers/rbd.py +++ b/src/pybind/mgr/dashboard/controllers/rbd.py @@ -18,9 +18,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__) @@ -66,8 +65,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 @@ -264,8 +263,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" @@ -355,8 +354,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" @@ -447,8 +446,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): diff --git a/src/pybind/mgr/dashboard/controllers/rbd_mirroring.py b/src/pybind/mgr/dashboard/controllers/rbd_mirroring.py index b2bc86b4adc1b..f5bda8c8926f2 100644 --- a/src/pybind/mgr/dashboard/controllers/rbd_mirroring.py +++ b/src/pybind/mgr/dashboard/controllers/rbd_mirroring.py @@ -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 logger = logging.getLogger('controllers.rbd_mirror') @@ -367,8 +366,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') @@ -390,8 +389,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() @@ -408,8 +407,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" @@ -450,9 +449,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') @@ -484,8 +482,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" diff --git a/src/pybind/mgr/dashboard/controllers/rgw.py b/src/pybind/mgr/dashboard/controllers/rgw.py index 86694b92b0d94..640a531f749b2 100644 --- a/src/pybind/mgr/dashboard/controllers/rgw.py +++ b/src/pybind/mgr/dashboard/controllers/rgw.py @@ -12,8 +12,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 @@ -40,8 +40,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 @@ -78,8 +78,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]}) @@ -149,8 +149,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': @@ -162,8 +162,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): """ @@ -324,8 +324,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): """ diff --git a/src/pybind/mgr/dashboard/controllers/role.py b/src/pybind/mgr/dashboard/controllers/role.py index 44dcef75c765e..cdd73ddf1be07 100644 --- a/src/pybind/mgr/dashboard/controllers/role.py +++ b/src/pybind/mgr/dashboard/controllers/role.py @@ -8,8 +8,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"), @@ -21,8 +20,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): @@ -138,7 +137,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() diff --git a/src/pybind/mgr/dashboard/controllers/saml2.py b/src/pybind/mgr/dashboard/controllers/saml2.py index 8939f6827243b..55445bc94d7b2 100644 --- a/src/pybind/mgr/dashboard/controllers/saml2.py +++ b/src/pybind/mgr/dashboard/controllers/saml2.py @@ -15,10 +15,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 diff --git a/src/pybind/mgr/dashboard/controllers/service.py b/src/pybind/mgr/dashboard/controllers/service.py index b3c3ab94798bf..d3ba882a1d926 100644 --- a/src/pybind/mgr/dashboard/controllers/service.py +++ b/src/pybind/mgr/dashboard/controllers/service.py @@ -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() diff --git a/src/pybind/mgr/dashboard/controllers/settings.py b/src/pybind/mgr/dashboard/controllers/settings.py index 77d8eeb0ad7d7..7e81a0822d1d7 100644 --- a/src/pybind/mgr/dashboard/controllers/settings.py +++ b/src/pybind/mgr/dashboard/controllers/settings.py @@ -6,7 +6,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'), @@ -16,8 +16,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). @@ -102,7 +102,7 @@ class Settings(RESTController): setattr(SettingsModule, self._to_native(name), value) -@UiApiController('/standard_settings') +@UIRouter('/standard_settings') class StandardSettings(RESTController): def list(self): """ diff --git a/src/pybind/mgr/dashboard/controllers/summary.py b/src/pybind/mgr/dashboard/controllers/summary.py index becb91295f6a0..9da48220808dd 100644 --- a/src/pybind/mgr/dashboard/controllers/summary.py +++ b/src/pybind/mgr/dashboard/controllers/summary.py @@ -8,7 +8,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, ""), @@ -37,8 +37,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") diff --git a/src/pybind/mgr/dashboard/controllers/task.py b/src/pybind/mgr/dashboard/controllers/task.py index 9b477a659d200..d5fbc34a766ec 100644 --- a/src/pybind/mgr/dashboard/controllers/task.py +++ b/src/pybind/mgr/dashboard/controllers/task.py @@ -2,7 +2,7 @@ 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"), @@ -22,8 +22,8 @@ TASK_SCHEMA = { } -@ApiController('/task') -@ControllerDoc("Task Management API", "Task") +@APIRouter('/task') +@APIDoc("Task Management API", "Task") class Task(RESTController): @EndpointDoc("Display Tasks", parameters={ diff --git a/src/pybind/mgr/dashboard/controllers/telemetry.py b/src/pybind/mgr/dashboard/controllers/telemetry.py index 37ab98f1a56c8..03f34592b1cf2 100644 --- a/src/pybind/mgr/dashboard/controllers/telemetry.py +++ b/src/pybind/mgr/dashboard/controllers/telemetry.py @@ -3,7 +3,7 @@ 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": ({ @@ -200,8 +200,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') diff --git a/src/pybind/mgr/dashboard/controllers/user.py b/src/pybind/mgr/dashboard/controllers/user.py index dbd240f7c5410..9141cfe68756d 100644 --- a/src/pybind/mgr/dashboard/controllers/user.py +++ b/src/pybind/mgr/dashboard/controllers/user.py @@ -12,8 +12,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'), @@ -46,8 +46,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 @@ -157,8 +157,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') @@ -190,8 +190,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') diff --git a/src/pybind/mgr/dashboard/module.py b/src/pybind/mgr/dashboard/module.py index 71551ed14e0ba..9b9e028d6947c 100644 --- a/src/pybind/mgr/dashboard/module.py +++ b/src/pybind/mgr/dashboard/module.py @@ -25,7 +25,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 .model.feedback import Feedback from .rest_client import RequestException @@ -339,7 +339,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: diff --git a/src/pybind/mgr/dashboard/openapi.yaml b/src/pybind/mgr/dashboard/openapi.yaml index ff2dea0e99a38..844457b0a72d3 100644 --- a/src/pybind/mgr/dashboard/openapi.yaml +++ b/src/pybind/mgr/dashboard/openapi.yaml @@ -2311,12 +2311,21 @@ paths: application/vnd.ceph.api.v2.0+json: schema: properties: + max_size: + description: Maximum size of Rule + type: integer + min_size: + description: Minimum size of Rule + type: integer rule_id: description: Rule ID type: integer rule_name: description: Rule Name type: string + ruleset: + description: RuleSet related to the rule + type: integer steps: description: Steps included in the rule items: @@ -2328,7 +2337,10 @@ paths: required: - rule_id - rule_name + - ruleset - type + - min_size + - max_size - steps type: object description: OK @@ -2644,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: [] @@ -10361,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 diff --git a/src/pybind/mgr/dashboard/plugins/feature_toggles.py b/src/pybind/mgr/dashboard/plugins/feature_toggles.py index 1811be258a788..48d77e6cf1fd0 100644 --- a/src/pybind/mgr/dashboard/plugins/feature_toggles.py +++ b/src/pybind/mgr/dashboard/plugins/feature_toggles.py @@ -132,7 +132,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, ''), @@ -143,8 +143,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}) diff --git a/src/pybind/mgr/dashboard/plugins/motd.py b/src/pybind/mgr/dashboard/plugins/motd.py index 0600135ac231c..22d6a294a3944 100644 --- a/src/pybind/mgr/dashboard/plugins/motd.py +++ b/src/pybind/mgr/dashboard/plugins/motd.py @@ -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) diff --git a/src/pybind/mgr/dashboard/tests/__init__.py b/src/pybind/mgr/dashboard/tests/__init__.py index 198e5502f488a..9e73b85d608b9 100644 --- a/src/pybind/mgr/dashboard/tests/__init__.py +++ b/src/pybind/mgr/dashboard/tests/__init__.py @@ -14,7 +14,8 @@ from mgr_module import HandleCommandResult from pyfakefs import fake_filesystem from .. import mgr -from ..controllers import APIVersion, generate_controller_routes, json_error_page +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 diff --git a/src/pybind/mgr/dashboard/tests/test_api_auditing.py b/src/pybind/mgr/dashboard/tests/test_api_auditing.py index 71121a06bcaf1..de6e7cdc5f784 100644 --- a/src/pybind/mgr/dashboard/tests/test_api_auditing.py +++ b/src/pybind/mgr/dashboard/tests/test_api_auditing.py @@ -9,12 +9,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 diff --git a/src/pybind/mgr/dashboard/tests/test_controllers.py b/src/pybind/mgr/dashboard/tests/test_controllers.py index 7bd0b1955a061..8daa2dd0ea51d 100644 --- a/src/pybind/mgr/dashboard/tests/test_controllers.py +++ b/src/pybind/mgr/dashboard/tests/test_controllers.py @@ -1,10 +1,10 @@ # -*- coding: utf-8 -*- -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): @@ -36,7 +36,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' @@ -70,7 +70,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): diff --git a/src/pybind/mgr/dashboard/tests/test_docs.py b/src/pybind/mgr/dashboard/tests/test_docs.py index a49305fd29eda..3610144b2cc3e 100644 --- a/src/pybind/mgr/dashboard/tests/test_docs.py +++ b/src/pybind/mgr/dashboard/tests/test_docs.py @@ -3,15 +3,15 @@ import unittest from ..api.doc import SchemaType -from ..controllers import ApiController, APIVersion, 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' diff --git a/src/pybind/mgr/dashboard/tests/test_exceptions.py b/src/pybind/mgr/dashboard/tests/test_exceptions.py index f28e2cd55d4df..8e20bfb237363 100644 --- a/src/pybind/mgr/dashboard/tests/test_exceptions.py +++ b/src/pybind/mgr/dashboard/tests/test_exceptions.py @@ -4,7 +4,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 @@ -13,7 +13,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() diff --git a/src/pybind/mgr/dashboard/tests/test_feature_toggles.py b/src/pybind/mgr/dashboard/tests/test_feature_toggles.py index 7a36269d87bec..fce7c60720ea5 100644 --- a/src/pybind/mgr/dashboard/tests/test_feature_toggles.py +++ b/src/pybind/mgr/dashboard/tests/test_feature_toggles.py @@ -22,8 +22,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() diff --git a/src/pybind/mgr/dashboard/tests/test_host.py b/src/pybind/mgr/dashboard/tests/test_host.py index 56a117f048d03..8b1e03b0ce317 100644 --- a/src/pybind/mgr/dashboard/tests/test_host.py +++ b/src/pybind/mgr/dashboard/tests/test_host.py @@ -6,7 +6,7 @@ from unittest import mock from orchestrator import HostSpec, InventoryHost from .. import mgr -from ..controllers import APIVersion +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 diff --git a/src/pybind/mgr/dashboard/tests/test_rest_tasks.py b/src/pybind/mgr/dashboard/tests/test_rest_tasks.py index b4abbdea36b97..b2bf7091f87b3 100644 --- a/src/pybind/mgr/dashboard/tests/test_rest_tasks.py +++ b/src/pybind/mgr/dashboard/tests/test_rest_tasks.py @@ -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 diff --git a/src/pybind/mgr/dashboard/tests/test_tools.py b/src/pybind/mgr/dashboard/tests/test_tools.py index fec42d6678335..51f9216a19c4d 100644 --- a/src/pybind/mgr/dashboard/tests/test_tools.py +++ b/src/pybind/mgr/dashboard/tests/test_tools.py @@ -10,15 +10,15 @@ try: except ImportError: from unittest.mock import patch -from ..controllers import ApiController, APIVersion, 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 = [] @@ -43,20 +43,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} diff --git a/src/pybind/mgr/dashboard/tests/test_versioning.py b/src/pybind/mgr/dashboard/tests/test_versioning.py index b6d6d0c2ef502..85804b00ba21b 100644 --- a/src/pybind/mgr/dashboard/tests/test_versioning.py +++ b/src/pybind/mgr/dashboard/tests/test_versioning.py @@ -2,11 +2,13 @@ import unittest -from ..controllers import ApiController, APIVersion, 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" -- 2.39.5