summaryrefslogtreecommitdiffstats
path: root/src/pybind/mgr/dashboard/controllers
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-21 11:54:28 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-21 11:54:28 +0000
commite6918187568dbd01842d8d1d2c808ce16a894239 (patch)
tree64f88b554b444a49f656b6c656111a145cbbaa28 /src/pybind/mgr/dashboard/controllers
parentInitial commit. (diff)
downloadceph-upstream/18.2.2.tar.xz
ceph-upstream/18.2.2.zip
Adding upstream version 18.2.2.upstream/18.2.2
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'src/pybind/mgr/dashboard/controllers')
-rwxr-xr-xsrc/pybind/mgr/dashboard/controllers/__init__.py40
-rw-r--r--src/pybind/mgr/dashboard/controllers/_api_router.py13
-rw-r--r--src/pybind/mgr/dashboard/controllers/_auth.py18
-rw-r--r--src/pybind/mgr/dashboard/controllers/_base_controller.py315
-rw-r--r--src/pybind/mgr/dashboard/controllers/_crud.py485
-rw-r--r--src/pybind/mgr/dashboard/controllers/_docs.py128
-rw-r--r--src/pybind/mgr/dashboard/controllers/_endpoint.py82
-rw-r--r--src/pybind/mgr/dashboard/controllers/_helpers.py127
-rw-r--r--src/pybind/mgr/dashboard/controllers/_paginate.py0
-rw-r--r--src/pybind/mgr/dashboard/controllers/_permissions.py60
-rw-r--r--src/pybind/mgr/dashboard/controllers/_rest_controller.py249
-rw-r--r--src/pybind/mgr/dashboard/controllers/_router.py69
-rw-r--r--src/pybind/mgr/dashboard/controllers/_task.py84
-rw-r--r--src/pybind/mgr/dashboard/controllers/_ui_router.py13
-rw-r--r--src/pybind/mgr/dashboard/controllers/_version.py75
-rw-r--r--src/pybind/mgr/dashboard/controllers/auth.py122
-rw-r--r--src/pybind/mgr/dashboard/controllers/ceph_users.py216
-rw-r--r--src/pybind/mgr/dashboard/controllers/cephfs.py765
-rw-r--r--src/pybind/mgr/dashboard/controllers/cluster.py101
-rw-r--r--src/pybind/mgr/dashboard/controllers/cluster_configuration.py132
-rw-r--r--src/pybind/mgr/dashboard/controllers/crush_rule.py68
-rw-r--r--src/pybind/mgr/dashboard/controllers/daemon.py49
-rw-r--r--src/pybind/mgr/dashboard/controllers/docs.py435
-rw-r--r--src/pybind/mgr/dashboard/controllers/erasure_code_profile.py65
-rw-r--r--src/pybind/mgr/dashboard/controllers/feedback.py120
-rw-r--r--src/pybind/mgr/dashboard/controllers/frontend_logging.py13
-rw-r--r--src/pybind/mgr/dashboard/controllers/grafana.py49
-rw-r--r--src/pybind/mgr/dashboard/controllers/health.py302
-rw-r--r--src/pybind/mgr/dashboard/controllers/home.py148
-rw-r--r--src/pybind/mgr/dashboard/controllers/host.py514
-rw-r--r--src/pybind/mgr/dashboard/controllers/iscsi.py1140
-rw-r--r--src/pybind/mgr/dashboard/controllers/logs.py72
-rw-r--r--src/pybind/mgr/dashboard/controllers/mgr_modules.py196
-rw-r--r--src/pybind/mgr/dashboard/controllers/monitor.py133
-rw-r--r--src/pybind/mgr/dashboard/controllers/nfs.py279
-rw-r--r--src/pybind/mgr/dashboard/controllers/orchestrator.py52
-rw-r--r--src/pybind/mgr/dashboard/controllers/osd.py658
-rw-r--r--src/pybind/mgr/dashboard/controllers/perf_counters.py82
-rw-r--r--src/pybind/mgr/dashboard/controllers/pool.py353
-rw-r--r--src/pybind/mgr/dashboard/controllers/prometheus.py173
-rw-r--r--src/pybind/mgr/dashboard/controllers/rbd.py435
-rw-r--r--src/pybind/mgr/dashboard/controllers/rbd_mirroring.py687
-rw-r--r--src/pybind/mgr/dashboard/controllers/rgw.py970
-rw-r--r--src/pybind/mgr/dashboard/controllers/role.py143
-rw-r--r--src/pybind/mgr/dashboard/controllers/saml2.py113
-rw-r--r--src/pybind/mgr/dashboard/controllers/service.py95
-rw-r--r--src/pybind/mgr/dashboard/controllers/settings.py113
-rw-r--r--src/pybind/mgr/dashboard/controllers/summary.py123
-rw-r--r--src/pybind/mgr/dashboard/controllers/task.py46
-rw-r--r--src/pybind/mgr/dashboard/controllers/telemetry.py239
-rw-r--r--src/pybind/mgr/dashboard/controllers/user.py214
51 files changed, 11173 insertions, 0 deletions
diff --git a/src/pybind/mgr/dashboard/controllers/__init__.py b/src/pybind/mgr/dashboard/controllers/__init__.py
new file mode 100755
index 000000000..af3f276eb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/controllers/__init__.py
@@ -0,0 +1,40 @@
+from ._api_router import APIRouter
+from ._auth import ControllerAuthMixin
+from ._base_controller import BaseController
+from ._crud import CRUDCollectionMethod, CRUDEndpoint, CRUDResourceMethod, SecretStr
+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',
+ 'CRUDEndpoint',
+ 'CRUDCollectionMethod',
+ 'CRUDResourceMethod',
+ 'SecretStr',
+]
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 000000000..dbd45ac0e
--- /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 000000000..0015a75e4
--- /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 000000000..ac7bc4a6b
--- /dev/null
+++ b/src/pybind/mgr/dashboard/controllers/_base_controller.py
@@ -0,0 +1,315 @@
+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 get_client_version():
+ try:
+ client_version = APIVersion.from_mime_type(
+ cherrypy.request.headers['Accept'])
+ except Exception:
+ raise cherrypy.HTTPError(
+ 415, "Unable to find version in request header")
+ return client_version
+
+ @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:
+ client_version = BaseController.get_client_version()
+
+ 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:
+ cherrypy.response.headers['Content-Type'] = (version.to_mime_type(subtype='xml')
+ if version else 'application/xml')
+ return ret.encode('utf8')
+ if json_response:
+ cherrypy.response.headers['Content-Type'] = (version.to_mime_type(subtype='json')
+ if version else '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/_crud.py b/src/pybind/mgr/dashboard/controllers/_crud.py
new file mode 100644
index 000000000..4a57ac06c
--- /dev/null
+++ b/src/pybind/mgr/dashboard/controllers/_crud.py
@@ -0,0 +1,485 @@
+from enum import Enum
+from functools import wraps
+from inspect import isclass
+from typing import Any, Callable, Dict, Generator, Iterable, Iterator, List, \
+ NamedTuple, Optional, Tuple, Union, get_type_hints
+
+from ._api_router import APIRouter
+from ._docs import APIDoc, EndpointDoc
+from ._rest_controller import RESTController
+from ._ui_router import UIRouter
+
+
+class SecretStr(str):
+ pass
+
+
+class MethodType(Enum):
+ POST = 'post'
+ PUT = 'put'
+
+
+def isnamedtuple(o):
+ return isinstance(o, tuple) and hasattr(o, '_asdict') and hasattr(o, '_fields')
+
+
+class SerializableClass:
+ def __iter__(self):
+ for attr in self.__dict__:
+ if not attr.startswith("__"):
+ yield attr, getattr(self, attr)
+
+ def __contains__(self, value):
+ return value in self.__dict__
+
+ def __len__(self):
+ return len(self.__dict__)
+
+
+def serialize(o, expected_type=None):
+ # pylint: disable=R1705,W1116
+ if isnamedtuple(o):
+ hints = get_type_hints(o)
+ return {k: serialize(v, hints[k]) for k, v in zip(o._fields, o)}
+ elif isinstance(o, (list, tuple, set)):
+ # json serializes list and tuples to arrays, hence we also serialize
+ # sets to lists.
+ # NOTE: we could add a metadata value in a list to indentify tuples and,
+ # sets if we wanted but for now let's go for lists.
+ return [serialize(i) for i in o]
+ elif isinstance(o, SerializableClass):
+ return {serialize(k): serialize(v) for k, v in o}
+ elif isinstance(o, (Iterator, Generator)):
+ return [serialize(i) for i in o]
+ elif expected_type and isclass(expected_type) and issubclass(expected_type, SecretStr):
+ return "***********"
+ else:
+ return o
+
+
+class TableColumn(NamedTuple):
+ prop: str
+ cellTemplate: str = ''
+ isHidden: bool = False
+ filterable: bool = True
+ flexGrow: int = 1
+
+
+class TableAction(NamedTuple):
+ name: str
+ permission: str
+ icon: str
+ routerLink: str = '' # redirect to...
+ click: str = ''
+ disable: bool = False # disable without selection
+
+
+class SelectionType(Enum):
+ NONE = ''
+ SINGLE = 'single'
+ MULTI = 'multiClick'
+
+
+class TableComponent(SerializableClass):
+ def __init__(self) -> None:
+ self.columns: List[TableColumn] = []
+ self.columnMode: str = 'flex'
+ self.toolHeader: bool = True
+ self.selectionType: str = SelectionType.SINGLE.value
+
+ def set_selection_type(self, type_: SelectionType):
+ self.selectionType = type_.value
+
+
+class Icon(Enum):
+ ADD = 'fa fa-plus'
+ DESTROY = 'fa fa-times'
+ IMPORT = 'fa fa-upload'
+ EXPORT = 'fa fa-download'
+ EDIT = 'fa fa-pencil'
+
+
+class Validator(Enum):
+ JSON = 'json'
+ RGW_ROLE_NAME = 'rgwRoleName'
+ RGW_ROLE_PATH = 'rgwRolePath'
+ FILE = 'file'
+
+
+class FormField(NamedTuple):
+ """
+ The key of a FormField is then used to send the data related to that key into the
+ POST and PUT endpoints. It is imperative for the developer to map keys of fields and containers
+ to the input of the POST and PUT endpoints.
+ """
+ name: str
+ key: str
+ field_type: Any = str
+ default_value: Optional[Any] = None
+ optional: bool = False
+ readonly: bool = False
+ help: str = ''
+ validators: List[Validator] = []
+
+ def get_type(self):
+ _type = ''
+ if self.field_type == str:
+ _type = 'string'
+ elif self.field_type == int:
+ _type = 'int'
+ elif self.field_type == bool:
+ _type = 'boolean'
+ elif self.field_type == 'textarea':
+ _type = 'textarea'
+ elif self.field_type == "file":
+ _type = 'file'
+ else:
+ raise NotImplementedError(f'Unimplemented type {self.field_type}')
+ return _type
+
+
+class Container:
+ def __init__(self, name: str, key: str, fields: List[Union[FormField, "Container"]],
+ optional: bool = False, readonly: bool = False, min_items=1):
+ self.name = name
+ self.key = key
+ self.fields = fields
+ self.optional = optional
+ self.readonly = readonly
+ self.min_items = min_items
+
+ def layout_type(self):
+ raise NotImplementedError
+
+ def _property_type(self):
+ raise NotImplementedError
+
+ def to_dict(self, key=''):
+ # intialize the schema of this container
+ ui_schemas = []
+ control_schema = {
+ 'type': self._property_type(),
+ 'title': self.name
+ }
+ items = None # layout items alias as it depends on the type of container
+ properties = None # control schema properties alias
+ required = None
+ if self._property_type() == 'array':
+ control_schema['required'] = []
+ control_schema['minItems'] = self.min_items
+ control_schema['items'] = {
+ 'type': 'object',
+ 'properties': {},
+ 'required': []
+ }
+ properties = control_schema['items']['properties']
+ required = control_schema['required']
+ control_schema['items']['required'] = required
+
+ ui_schemas.append({
+ 'key': key,
+ 'templateOptions': {
+ 'objectTemplateOptions': {
+ 'layoutType': self.layout_type()
+ }
+ },
+ 'items': []
+ })
+ items = ui_schemas[-1]['items']
+ else:
+ control_schema['properties'] = {}
+ control_schema['required'] = []
+ required = control_schema['required']
+ properties = control_schema['properties']
+ ui_schemas.append({
+ 'templateOptions': {
+ 'layoutType': self.layout_type()
+ },
+ 'key': key,
+ 'items': []
+ })
+ if key:
+ items = ui_schemas[-1]['items']
+ else:
+ items = ui_schemas
+
+ assert items is not None
+ assert properties is not None
+ assert required is not None
+
+ # include fields in this container's schema
+ for field in self.fields:
+ field_ui_schema: Dict[str, Any] = {}
+ properties[field.key] = {}
+ field_key = field.key
+ if key:
+ if self._property_type() == 'array':
+ field_key = key + '[].' + field.key
+ else:
+ field_key = key + '.' + field.key
+
+ if isinstance(field, FormField):
+ _type = field.get_type()
+ properties[field.key]['type'] = _type
+ properties[field.key]['title'] = field.name
+ field_ui_schema['key'] = field_key
+ field_ui_schema['readonly'] = field.readonly
+ field_ui_schema['help'] = f'{field.help}'
+ field_ui_schema['validators'] = [i.value for i in field.validators]
+ items.append(field_ui_schema)
+ elif isinstance(field, Container):
+ container_schema = field.to_dict(key+'.'+field.key if key else field.key)
+ properties[field.key] = container_schema['control_schema']
+ ui_schemas.extend(container_schema['ui_schema'])
+ if not field.optional:
+ required.append(field.key)
+ return {
+ 'ui_schema': ui_schemas,
+ 'control_schema': control_schema,
+ }
+
+
+class VerticalContainer(Container):
+ def layout_type(self):
+ return 'column'
+
+ def _property_type(self):
+ return 'object'
+
+
+class HorizontalContainer(Container):
+ def layout_type(self):
+ return 'row'
+
+ def _property_type(self):
+ return 'object'
+
+
+class ArrayVerticalContainer(Container):
+ def layout_type(self):
+ return 'column'
+
+ def _property_type(self):
+ return 'array'
+
+
+class ArrayHorizontalContainer(Container):
+ def layout_type(self):
+ return 'row'
+
+ def _property_type(self):
+ return 'array'
+
+
+class FormTaskInfo:
+ def __init__(self, message: str, metadata_fields: List[str]) -> None:
+ self.message = message
+ self.metadata_fields = metadata_fields
+
+ def to_dict(self):
+ return {'message': self.message, 'metadataFields': self.metadata_fields}
+
+
+class Form:
+ def __init__(self, path, root_container, method_type='',
+ task_info: FormTaskInfo = FormTaskInfo("Unknown task", []),
+ model_callback=None):
+ self.path = path
+ self.root_container: Container = root_container
+ self.method_type = method_type
+ self.task_info = task_info
+ self.model_callback = model_callback
+
+ def to_dict(self):
+ res = self.root_container.to_dict()
+ res['method_type'] = self.method_type
+ res['task_info'] = self.task_info.to_dict()
+ res['path'] = self.path
+ res['ask'] = self.path
+ return res
+
+
+class CRUDMeta(SerializableClass):
+ def __init__(self):
+ self.table = TableComponent()
+ self.permissions = []
+ self.actions = []
+ self.forms = []
+ self.columnKey = ''
+ self.detail_columns = []
+
+
+class CRUDCollectionMethod(NamedTuple):
+ func: Callable[..., Iterable[Any]]
+ doc: EndpointDoc
+
+
+class CRUDResourceMethod(NamedTuple):
+ func: Callable[..., Any]
+ doc: EndpointDoc
+
+
+# pylint: disable=R0902
+class CRUDEndpoint:
+ # for testing purposes
+ CRUDClass: Optional[RESTController] = None
+ CRUDClassMetadata: Optional[RESTController] = None
+
+ def __init__(self, router: APIRouter, doc: APIDoc,
+ set_column: Optional[Dict[str, Dict[str, str]]] = None,
+ actions: Optional[List[TableAction]] = None,
+ permissions: Optional[List[str]] = None, forms: Optional[List[Form]] = None,
+ column_key: Optional[str] = None,
+ meta: CRUDMeta = CRUDMeta(), get_all: Optional[CRUDCollectionMethod] = None,
+ create: Optional[CRUDCollectionMethod] = None,
+ delete: Optional[CRUDCollectionMethod] = None,
+ selection_type: SelectionType = SelectionType.SINGLE,
+ extra_endpoints: Optional[List[Tuple[str, CRUDCollectionMethod]]] = None,
+ edit: Optional[CRUDCollectionMethod] = None,
+ detail_columns: Optional[List[str]] = None):
+ self.router = router
+ self.doc = doc
+ self.set_column = set_column
+ self.actions = actions if actions is not None else []
+ self.forms = forms if forms is not None else []
+ self.meta = meta
+ self.get_all = get_all
+ self.create = create
+ self.delete = delete
+ self.edit = edit
+ self.permissions = permissions if permissions is not None else []
+ self.column_key = column_key if column_key is not None else ''
+ self.detail_columns = detail_columns if detail_columns is not None else []
+ self.extra_endpoints = extra_endpoints if extra_endpoints is not None else []
+ self.selection_type = selection_type
+
+ def __call__(self, cls: Any):
+ self.create_crud_class(cls)
+
+ self.meta.table.columns.extend(TableColumn(prop=field) for field in cls._fields)
+ self.create_meta_class(cls)
+ return cls
+
+ def create_crud_class(self, cls):
+ outer_self: CRUDEndpoint = self
+
+ funcs = {}
+ if self.get_all:
+ @self.get_all.doc
+ @wraps(self.get_all.func)
+ def _list(self, *args, **kwargs):
+ items = []
+ for item in outer_self.get_all.func(self, *args, **kwargs): # type: ignore
+ items.append(serialize(cls(**item)))
+ return items
+ funcs['list'] = _list
+
+ if self.create:
+ @self.create.doc
+ @wraps(self.create.func)
+ def _create(self, *args, **kwargs):
+ return outer_self.create.func(self, *args, **kwargs) # type: ignore
+ funcs['create'] = _create
+
+ if self.delete:
+ @self.delete.doc
+ @wraps(self.delete.func)
+ def delete(self, *args, **kwargs):
+ return outer_self.delete.func(self, *args, **kwargs) # type: ignore
+ funcs['delete'] = delete
+
+ if self.edit:
+ @self.edit.doc
+ @wraps(self.edit.func)
+ def singleton_set(self, *args, **kwargs):
+ return outer_self.edit.func(self, *args, **kwargs) # type: ignore
+ funcs['singleton_set'] = singleton_set
+
+ for extra_endpoint in self.extra_endpoints:
+ funcs[extra_endpoint[0]] = extra_endpoint[1].doc(extra_endpoint[1].func)
+
+ class_name = self.router.path.replace('/', '')
+ crud_class = type(f'{class_name}_CRUDClass',
+ (RESTController,),
+ {
+ **funcs,
+ 'outer_self': self,
+ })
+ self.router(self.doc(crud_class))
+ cls.CRUDClass = crud_class
+
+ def create_meta_class(self, cls):
+ def _list(self, model_key: str = ''):
+ self.update_columns()
+ self.generate_actions()
+ self.generate_forms(model_key)
+ self.set_permissions()
+ self.set_column_key()
+ self.get_detail_columns()
+ selection_type = self.__class__.outer_self.selection_type
+ self.__class__.outer_self.meta.table.set_selection_type(selection_type)
+ return serialize(self.__class__.outer_self.meta)
+
+ def get_detail_columns(self):
+ columns = self.__class__.outer_self.detail_columns
+ self.__class__.outer_self.meta.detail_columns = columns
+
+ def update_columns(self):
+ if self.__class__.outer_self.set_column:
+ for i, column in enumerate(self.__class__.outer_self.meta.table.columns):
+ if column.prop in dict(self.__class__.outer_self.set_column):
+ prop = self.__class__.outer_self.set_column[column.prop]
+ new_template = ""
+ if "cellTemplate" in prop:
+ new_template = prop["cellTemplate"]
+ hidden = prop['isHidden'] if 'isHidden' in prop else False
+ flex_grow = prop['flexGrow'] if 'flexGrow' in prop else column.flexGrow
+ new_column = TableColumn(column.prop,
+ new_template,
+ hidden,
+ column.filterable,
+ flex_grow)
+ self.__class__.outer_self.meta.table.columns[i] = new_column
+
+ def generate_actions(self):
+ self.__class__.outer_self.meta.actions.clear()
+
+ for action in self.__class__.outer_self.actions:
+ self.__class__.outer_self.meta.actions.append(action._asdict())
+
+ def generate_forms(self, model_key):
+ self.__class__.outer_self.meta.forms.clear()
+
+ for form in self.__class__.outer_self.forms:
+ form_as_dict = form.to_dict()
+ model = {}
+ if form.model_callback and model_key:
+ model = form.model_callback(model_key)
+ form_as_dict['model'] = model
+ self.__class__.outer_self.meta.forms.append(form_as_dict)
+
+ def set_permissions(self):
+ self.__class__.outer_self.meta.permissions.clear()
+
+ if self.__class__.outer_self.permissions:
+ self.outer_self.meta.permissions.extend(self.__class__.outer_self.permissions)
+
+ def set_column_key(self):
+ if self.__class__.outer_self.column_key:
+ self.outer_self.meta.columnKey = self.__class__.outer_self.column_key
+
+ class_name = self.router.path.replace('/', '')
+ meta_class = type(f'{class_name}_CRUDClassMetadata',
+ (RESTController,),
+ {
+ 'list': _list,
+ 'update_columns': update_columns,
+ 'generate_actions': generate_actions,
+ 'generate_forms': generate_forms,
+ 'set_permissions': set_permissions,
+ 'set_column_key': set_column_key,
+ 'get_detail_columns': get_detail_columns,
+ 'outer_self': self,
+ })
+ UIRouter(self.router.path, self.router.security_scope)(meta_class)
+ cls.CRUDClassMetadata = meta_class
diff --git a/src/pybind/mgr/dashboard/controllers/_docs.py b/src/pybind/mgr/dashboard/controllers/_docs.py
new file mode 100644
index 000000000..5bd7a5a7a
--- /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:
+ <name>: (<type>, <description>, [optional], [default value])
+ <name>: (<[type]>, <description>, [optional], [default value])
+ <name>: (<[nested parameters]>, <description>, [optional], [default value])
+ <name>: (<{nested parameters}>, <description>, [optional], [default value])"""
+ % (name, EndpointDoc.__name__))
+ return splitted
+
+ def _split_list(self, data: Union[List[Any], Tuple[Any, ...]], nested: bool) -> List[Any]:
+ splitted = [] # type: List[Any]
+ for item in data:
+ splitted.extend(self._split_parameters(item, nested))
+ return splitted
+
+ # nested = True means parameters are inside a dict or array
+ def _split_parameters(self, data: Optional[Union[DICT_TYPE, List[Any], Tuple[Any, ...]]],
+ nested: bool = False) -> List[Any]:
+ param_list = [] # type: List[Any]
+ if isinstance(data, dict):
+ param_list.extend(self._split_dict(data, nested))
+ elif isinstance(data, (list, tuple)):
+ param_list.extend(self._split_list(data, True))
+ return param_list
+
+ def __call__(self, func: Any) -> Any:
+ func.doc_info = {
+ 'summary': self.description,
+ 'tag': self.group,
+ 'parameters': self._split_parameters(self.parameters),
+ 'response': self.resp
+ }
+ return func
+
+
+class APIDoc(object):
+ def __init__(self, description="", group=""):
+ self.tag = group
+ self.tag_descr = description
+
+ def __call__(self, cls):
+ cls.doc_info = {
+ 'tag': self.tag,
+ 'tag_descr': self.tag_descr
+ }
+ return cls
diff --git a/src/pybind/mgr/dashboard/controllers/_endpoint.py b/src/pybind/mgr/dashboard/controllers/_endpoint.py
new file mode 100644
index 000000000..fccab89c3
--- /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 000000000..5ec49ee97
--- /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/_paginate.py b/src/pybind/mgr/dashboard/controllers/_paginate.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/controllers/_paginate.py
diff --git a/src/pybind/mgr/dashboard/controllers/_permissions.py b/src/pybind/mgr/dashboard/controllers/_permissions.py
new file mode 100644
index 000000000..eb190c9a9
--- /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 000000000..0224c366f
--- /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__"):
+ assert func
+ 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 name, 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 name in cls._method_mapping:
+ cls._update_endpoint_params_method_map(
+ func, res_id_params, endpoint_params, name=name)
+
+ 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, name=None):
+ meth = cls._method_mapping[func.__name__ if not name else 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 000000000..ad67532e3
--- /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 000000000..f03a1ff67
--- /dev/null
+++ b/src/pybind/mgr/dashboard/controllers/_task.py
@@ -0,0 +1,84 @@
+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 _get_metadata(self, arg_map):
+ 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
+ return metadata
+
+ def __call__(self, func):
+ @wraps(func)
+ def wrapper(*args, **kwargs):
+ arg_map = self._gen_arg_map(func, args, kwargs)
+ metadata = self._get_metadata(arg_map)
+
+ 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 000000000..7454afaeb
--- /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/_version.py b/src/pybind/mgr/dashboard/controllers/_version.py
new file mode 100644
index 000000000..3e7331c88
--- /dev/null
+++ b/src/pybind/mgr/dashboard/controllers/_version.py
@@ -0,0 +1,75 @@
+import re
+from typing import NamedTuple
+
+
+class APIVersion(NamedTuple):
+ """
+ >>> APIVersion(1,0)
+ APIVersion(major=1, minor=0)
+
+ >>> APIVersion._make([1,0])
+ APIVersion(major=1, minor=0)
+
+ >>> f'{APIVersion(1, 0)!r}'
+ 'APIVersion(major=1, minor=0)'
+ """
+ major: int
+ minor: int
+
+ DEFAULT = ... # type: ignore
+ EXPERIMENTAL = ... # type: ignore
+ NONE = ... # type: ignore
+
+ __MIME_TYPE_REGEX = re.compile( # type: ignore
+ r'^application/vnd\.ceph\.api\.v(\d+\.\d+)\+json$')
+
+ @classmethod
+ def from_string(cls, version_string: str) -> 'APIVersion':
+ """
+ >>> APIVersion.from_string("1.0")
+ APIVersion(major=1, minor=0)
+ """
+ return cls._make(int(s) for s in version_string.split('.'))
+
+ @classmethod
+ def from_mime_type(cls, mime_type: str) -> 'APIVersion':
+ """
+ >>> APIVersion.from_mime_type('application/vnd.ceph.api.v1.0+json')
+ APIVersion(major=1, minor=0)
+
+ """
+ return cls.from_string(cls.__MIME_TYPE_REGEX.match(mime_type).group(1))
+
+ def __str__(self):
+ """
+ >>> f'{APIVersion(1, 0)}'
+ '1.0'
+ """
+ return f'{self.major}.{self.minor}'
+
+ def to_mime_type(self, subtype='json'):
+ """
+ >>> APIVersion(1, 0).to_mime_type(subtype='xml')
+ 'application/vnd.ceph.api.v1.0+xml'
+ """
+ return f'application/vnd.ceph.api.v{self!s}+{subtype}'
+
+ def supports(self, client_version: "APIVersion") -> bool:
+ """
+ >>> APIVersion(1, 1).supports(APIVersion(1, 0))
+ True
+
+ >>> APIVersion(1, 0).supports(APIVersion(1, 1))
+ False
+
+ >>> APIVersion(2, 0).supports(APIVersion(1, 1))
+ False
+ """
+ return (self.major == client_version.major
+ and client_version.minor <= self.minor)
+
+
+# Sentinel Values
+APIVersion.DEFAULT = APIVersion(1, 0) # type: ignore
+APIVersion.EXPERIMENTAL = APIVersion(0, 1) # type: ignore
+APIVersion.NONE = APIVersion(0, 0) # type: ignore
diff --git a/src/pybind/mgr/dashboard/controllers/auth.py b/src/pybind/mgr/dashboard/controllers/auth.py
new file mode 100644
index 000000000..196f027b2
--- /dev/null
+++ b/src/pybind/mgr/dashboard/controllers/auth.py
@@ -0,0 +1,122 @@
+# -*- coding: utf-8 -*-
+
+import http.cookies
+import logging
+import sys
+
+from .. import mgr
+from ..exceptions import InvalidCredentialsError, UserDoesNotExist
+from ..services.auth import AuthManager, JwtManager
+from ..services.cluster import ClusterModel
+from ..settings import Settings
+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
+if sys.version_info < (3, 8):
+ http.cookies.Morsel._reserved["samesite"] = "SameSite" # type: ignore # pylint: disable=W0212
+
+logger = logging.getLogger('controllers.auth')
+
+AUTH_CHECK_SCHEMA = {
+ "username": (str, "Username"),
+ "permissions": ({
+ "cephfs": ([str], "")
+ }, "List of permissions acquired"),
+ "sso": (bool, "Uses single sign on?"),
+ "pwdUpdateRequired": (bool, "Is password update required?")
+}
+
+
+@APIRouter('/auth', secure=False)
+@APIDoc("Initiate a session with Ceph", "Auth")
+class Auth(RESTController, ControllerAuthMixin):
+ """
+ Provide authenticates and returns JWT token.
+ """
+
+ def create(self, username, password):
+ user_data = AuthManager.authenticate(username, password)
+ user_perms, pwd_expiration_date, pwd_update_required = None, None, None
+ max_attempt = Settings.ACCOUNT_LOCKOUT_ATTEMPTS
+ if max_attempt == 0 or mgr.ACCESS_CTRL_DB.get_attempt(username) < max_attempt:
+ if user_data:
+ user_perms = user_data.get('permissions')
+ pwd_expiration_date = user_data.get('pwdExpirationDate', None)
+ pwd_update_required = user_data.get('pwdUpdateRequired', False)
+
+ if user_perms is not None:
+ url_prefix = 'https' if mgr.get_localized_module_option('ssl') else 'http'
+
+ logger.info('Login successful: %s', username)
+ mgr.ACCESS_CTRL_DB.reset_attempt(username)
+ mgr.ACCESS_CTRL_DB.save()
+ token = JwtManager.gen_token(username)
+
+ # For backward-compatibility: PyJWT versions < 2.0.0 return bytes.
+ token = token.decode('utf-8') if isinstance(token, bytes) else token
+
+ self._set_token_cookie(url_prefix, token)
+ return {
+ 'token': token,
+ 'username': username,
+ 'permissions': user_perms,
+ 'pwdExpirationDate': pwd_expiration_date,
+ 'sso': mgr.SSO_DB.protocol == 'saml2',
+ 'pwdUpdateRequired': pwd_update_required
+ }
+ mgr.ACCESS_CTRL_DB.increment_attempt(username)
+ mgr.ACCESS_CTRL_DB.save()
+ else:
+ try:
+ user = mgr.ACCESS_CTRL_DB.get_user(username)
+ user.enabled = False
+ mgr.ACCESS_CTRL_DB.save()
+ logging.warning('Maximum number of unsuccessful log-in attempts '
+ '(%d) reached for '
+ 'username "%s" so the account was blocked. '
+ 'An administrator will need to re-enable the account',
+ max_attempt, username)
+ raise InvalidCredentialsError
+ except UserDoesNotExist:
+ raise InvalidCredentialsError
+ logger.info('Login failed: %s', username)
+ raise InvalidCredentialsError
+
+ @RESTController.Collection('POST')
+ @allow_empty_body
+ def logout(self):
+ logger.debug('Logout successful')
+ token = JwtManager.get_token_from_header()
+ JwtManager.blocklist_token(token)
+ self._delete_token_cookie(token)
+ redirect_url = '#/login'
+ if mgr.SSO_DB.protocol == 'saml2':
+ redirect_url = 'auth/saml2/slo'
+ return {
+ 'redirect_url': redirect_url
+ }
+
+ def _get_login_url(self):
+ if mgr.SSO_DB.protocol == 'saml2':
+ return 'auth/saml2/login'
+ return '#/login'
+
+ @RESTController.Collection('POST', query_params=['token'])
+ @EndpointDoc("Check token Authentication",
+ parameters={'token': (str, 'Authentication Token')},
+ responses={201: AUTH_CHECK_SCHEMA})
+ def check(self, token):
+ if token:
+ user = JwtManager.get_user(token)
+ if user:
+ return {
+ 'username': user.username,
+ 'permissions': user.permissions_dict(),
+ 'sso': mgr.SSO_DB.protocol == 'saml2',
+ 'pwdUpdateRequired': user.pwd_update_required
+ }
+ return {
+ 'login_url': self._get_login_url(),
+ 'cluster_status': ClusterModel.from_db().dict()['status']
+ }
diff --git a/src/pybind/mgr/dashboard/controllers/ceph_users.py b/src/pybind/mgr/dashboard/controllers/ceph_users.py
new file mode 100644
index 000000000..e1bdc1570
--- /dev/null
+++ b/src/pybind/mgr/dashboard/controllers/ceph_users.py
@@ -0,0 +1,216 @@
+import logging
+from errno import EINVAL
+from typing import List, NamedTuple, Optional
+
+from ..exceptions import DashboardException
+from ..security import Scope
+from ..services.ceph_service import CephService, SendCommandError
+from . import APIDoc, APIRouter, CRUDCollectionMethod, CRUDEndpoint, \
+ EndpointDoc, RESTController, SecretStr
+from ._crud import ArrayHorizontalContainer, CRUDMeta, Form, FormField, \
+ FormTaskInfo, Icon, MethodType, SelectionType, TableAction, Validator, \
+ VerticalContainer
+
+logger = logging.getLogger("controllers.ceph_users")
+
+
+class CephUserCaps(NamedTuple):
+ mon: str
+ osd: str
+ mgr: str
+ mds: str
+
+
+class Cap(NamedTuple):
+ entity: str
+ cap: str
+
+
+class CephUserEndpoints:
+ @staticmethod
+ def _run_auth_command(command: str, *args, **kwargs):
+ try:
+ return CephService.send_command('mon', command, *args, **kwargs)
+ except SendCommandError as ex:
+ msg = f'{ex} in command {ex.prefix}'
+ if ex.errno == -EINVAL:
+ raise DashboardException(msg, code=400)
+ raise DashboardException(msg, code=500)
+
+ @staticmethod
+ def user_list(_):
+ """
+ Get list of ceph users and its respective data
+ """
+ return CephUserEndpoints._run_auth_command('auth ls')["auth_dump"]
+
+ @staticmethod
+ def user_create(_, user_entity: str = '', capabilities: Optional[List[Cap]] = None,
+ import_data: str = ''):
+ """
+ Add a ceph user with its defined capabilities.
+ :param user_entity: Entity to change
+ :param capabilities: List of capabilities to add to user_entity
+ """
+ # Caps are represented as a vector in mon auth add commands.
+ # Look at AuthMonitor.cc::valid_caps for reference.
+ if import_data:
+ logger.debug("Sending import command 'auth import' \n%s", import_data)
+ CephUserEndpoints._run_auth_command('auth import', inbuf=import_data)
+ return "Successfully imported user"
+
+ assert user_entity
+ caps = []
+ for cap in capabilities:
+ caps.append(cap['entity'])
+ caps.append(cap['cap'])
+
+ logger.debug("Sending command 'auth add' of entity '%s' with caps '%s'",
+ user_entity, str(caps))
+ CephUserEndpoints._run_auth_command('auth add', entity=user_entity, caps=caps)
+
+ return f"Successfully created user '{user_entity}'"
+
+ @staticmethod
+ def user_delete(_, user_entity: str):
+ """
+ Delete a ceph user and it's defined capabilities.
+ :param user_entity: Entity to delete
+ """
+ logger.debug("Sending command 'auth del' of entity '%s'", user_entity)
+ CephUserEndpoints._run_auth_command('auth del', entity=user_entity)
+ return f"Successfully deleted user '{user_entity}'"
+
+ @staticmethod
+ def export(_, entities: List[str]):
+ export_string = ""
+ for entity in entities:
+ out = CephUserEndpoints._run_auth_command('auth export', entity=entity, to_json=False)
+ export_string += f'{out}\n'
+ return export_string
+
+ @staticmethod
+ def user_edit(_, user_entity: str = '', capabilities: List[Cap] = None):
+ """
+ Change the ceph user capabilities.
+ Setting new capabilities will overwrite current ones.
+ :param user_entity: Entity to change
+ :param capabilities: List of updated capabilities to user_entity
+ """
+ caps = []
+ for cap in capabilities:
+ caps.append(cap['entity'])
+ caps.append(cap['cap'])
+
+ logger.debug("Sending command 'auth caps' of entity '%s' with caps '%s'",
+ user_entity, str(caps))
+ CephUserEndpoints._run_auth_command('auth caps', entity=user_entity, caps=caps)
+ return f"Successfully edited user '{user_entity}'"
+
+ @staticmethod
+ def model(user_entity: str):
+ user_data = CephUserEndpoints._run_auth_command('auth get', entity=user_entity)[0]
+ model = {'user_entity': '', 'capabilities': []}
+ model['user_entity'] = user_data['entity']
+ for entity, cap in user_data['caps'].items():
+ model['capabilities'].append({'entity': entity, 'cap': cap})
+ return model
+
+
+cap_container = ArrayHorizontalContainer('Capabilities', 'capabilities', fields=[
+ FormField('Entity', 'entity',
+ field_type=str),
+ FormField('Entity Capabilities',
+ 'cap', field_type=str)
+], min_items=1)
+create_container = VerticalContainer('Create User', 'create_user', fields=[
+ FormField('User entity', 'user_entity',
+ field_type=str),
+ cap_container,
+])
+
+edit_container = VerticalContainer('Edit User', 'edit_user', fields=[
+ FormField('User entity', 'user_entity',
+ field_type=str, readonly=True),
+ cap_container,
+])
+
+create_form = Form(path='/cluster/user/create',
+ root_container=create_container,
+ method_type=MethodType.POST.value,
+ task_info=FormTaskInfo("Ceph user '{user_entity}' successfully",
+ ['user_entity']))
+
+# pylint: disable=C0301
+import_user_help = (
+ 'The imported file should be a keyring file and it must follow the schema described <a ' # noqa: E501
+ 'href="https://docs.ceph.com/en/latest/rados/operations/user-management/#authorization-capabilities"' # noqa: E501
+ 'target="_blank">here.</a>'
+)
+import_container = VerticalContainer('Import User', 'import_user', fields=[
+ FormField('User file import', 'import_data',
+ field_type="file", validators=[Validator.FILE],
+ help=import_user_help),
+])
+
+import_user_form = Form(path='/cluster/user/import',
+ root_container=import_container,
+ task_info=FormTaskInfo("successfully", []),
+ method_type=MethodType.POST.value)
+
+edit_form = Form(path='/cluster/user/edit',
+ root_container=edit_container,
+ method_type=MethodType.PUT.value,
+ task_info=FormTaskInfo("Ceph user '{user_entity}' successfully",
+ ['user_entity']),
+ model_callback=CephUserEndpoints.model)
+
+
+@CRUDEndpoint(
+ router=APIRouter('/cluster/user', Scope.CONFIG_OPT),
+ doc=APIDoc("Get Ceph Users", "Cluster"),
+ set_column={"caps": {"cellTemplate": "badgeDict"}},
+ actions=[
+ TableAction(name='Create', permission='create', icon=Icon.ADD.value,
+ routerLink='/cluster/user/create'),
+ TableAction(name='Edit', permission='update', icon=Icon.EDIT.value,
+ click='edit'),
+ TableAction(name='Delete', permission='delete', icon=Icon.DESTROY.value,
+ click='delete', disable=True),
+ TableAction(name='Import', permission='create', icon=Icon.IMPORT.value,
+ routerLink='/cluster/user/import'),
+ TableAction(name='Export', permission='read', icon=Icon.EXPORT.value,
+ click='authExport', disable=True)
+ ],
+ permissions=[Scope.CONFIG_OPT],
+ forms=[create_form, edit_form, import_user_form],
+ column_key='entity',
+ get_all=CRUDCollectionMethod(
+ func=CephUserEndpoints.user_list,
+ doc=EndpointDoc("Get Ceph Users")
+ ),
+ create=CRUDCollectionMethod(
+ func=CephUserEndpoints.user_create,
+ doc=EndpointDoc("Create Ceph User")
+ ),
+ edit=CRUDCollectionMethod(
+ func=CephUserEndpoints.user_edit,
+ doc=EndpointDoc("Edit Ceph User")
+ ),
+ delete=CRUDCollectionMethod(
+ func=CephUserEndpoints.user_delete,
+ doc=EndpointDoc("Delete Ceph User")
+ ),
+ extra_endpoints=[
+ ('export', CRUDCollectionMethod(
+ func=RESTController.Collection('POST', 'export')(CephUserEndpoints.export),
+ doc=EndpointDoc("Export Ceph Users")
+ ))
+ ],
+ selection_type=SelectionType.MULTI,
+ meta=CRUDMeta()
+)
+class CephUser(NamedTuple):
+ entity: str
+ caps: List[CephUserCaps]
+ key: SecretStr
diff --git a/src/pybind/mgr/dashboard/controllers/cephfs.py b/src/pybind/mgr/dashboard/controllers/cephfs.py
new file mode 100644
index 000000000..09b2bebfc
--- /dev/null
+++ b/src/pybind/mgr/dashboard/controllers/cephfs.py
@@ -0,0 +1,765 @@
+# -*- coding: utf-8 -*-
+import json
+import logging
+import os
+from collections import defaultdict
+from typing import Any, Dict
+
+import cephfs
+import cherrypy
+
+from .. import mgr
+from ..exceptions import DashboardException
+from ..security import Scope
+from ..services.ceph_service import CephService
+from ..services.cephfs import CephFS as CephFS_
+from ..services.exception import handle_cephfs_error
+from ..tools import ViewCache, str_to_bool
+from . import APIDoc, APIRouter, DeletePermission, Endpoint, EndpointDoc, \
+ RESTController, UIRouter, UpdatePermission, allow_empty_body
+
+GET_QUOTAS_SCHEMA = {
+ 'max_bytes': (int, ''),
+ 'max_files': (int, '')
+}
+
+logger = logging.getLogger("controllers.rgw")
+
+
+# pylint: disable=R0904
+@APIRouter('/cephfs', Scope.CEPHFS)
+@APIDoc("Cephfs Management API", "Cephfs")
+class CephFS(RESTController):
+ def __init__(self): # pragma: no cover
+ super().__init__()
+
+ # Stateful instances of CephFSClients, hold cached results. Key to
+ # dict is FSCID
+ self.cephfs_clients = {}
+
+ def list(self):
+ fsmap = mgr.get("fs_map")
+ return fsmap['filesystems']
+
+ def create(self, name: str, service_spec: Dict[str, Any]):
+ service_spec_str = '1 '
+ if 'labels' in service_spec['placement']:
+ for label in service_spec['placement']['labels']:
+ service_spec_str += f'label:{label},'
+ service_spec_str = service_spec_str[:-1]
+ if 'hosts' in service_spec['placement']:
+ for host in service_spec['placement']['hosts']:
+ service_spec_str += f'{host},'
+ service_spec_str = service_spec_str[:-1]
+
+ error_code, _, err = mgr.remote('volumes', '_cmd_fs_volume_create', None,
+ {'name': name, 'placement': service_spec_str})
+ if error_code != 0:
+ raise RuntimeError(
+ f'Error creating volume {name} with placement {str(service_spec)}: {err}')
+ return f'Volume {name} created successfully'
+
+ @EndpointDoc("Remove CephFS Volume",
+ parameters={
+ 'name': (str, 'File System Name'),
+ })
+ @allow_empty_body
+ @Endpoint('DELETE')
+ @DeletePermission
+ def remove(self, name):
+ error_code, _, err = mgr.remote('volumes', '_cmd_fs_volume_rm', None,
+ {'vol_name': name,
+ 'yes-i-really-mean-it': "--yes-i-really-mean-it"})
+ if error_code != 0:
+ raise DashboardException(
+ msg=f'Error deleting volume {name}: {err}',
+ component='cephfs')
+ return f'Volume {name} removed successfully'
+
+ @EndpointDoc("Rename CephFS Volume",
+ parameters={
+ 'name': (str, 'Existing FS Name'),
+ 'new_name': (str, 'New FS Name'),
+ })
+ @allow_empty_body
+ @UpdatePermission
+ @Endpoint('PUT')
+ def rename(self, name: str, new_name: str):
+ error_code, _, err = mgr.remote('volumes', '_cmd_fs_volume_rename', None,
+ {'vol_name': name, 'new_vol_name': new_name,
+ 'yes_i_really_mean_it': True})
+ if error_code != 0:
+ raise DashboardException(
+ msg=f'Error renaming volume {name} to {new_name}: {err}',
+ component='cephfs')
+ return f'Volume {name} renamed successfully to {new_name}'
+
+ def get(self, fs_id):
+ fs_id = self.fs_id_to_int(fs_id)
+ return self.fs_status(fs_id)
+
+ @RESTController.Resource('GET')
+ def clients(self, fs_id):
+ fs_id = self.fs_id_to_int(fs_id)
+
+ return self._clients(fs_id)
+
+ @RESTController.Resource('DELETE', path='/client/{client_id}')
+ def evict(self, fs_id, client_id):
+ fs_id = self.fs_id_to_int(fs_id)
+ client_id = self.client_id_to_int(client_id)
+
+ return self._evict(fs_id, client_id)
+
+ @RESTController.Resource('GET')
+ def mds_counters(self, fs_id, counters=None):
+ fs_id = self.fs_id_to_int(fs_id)
+ return self._mds_counters(fs_id, counters)
+
+ def _mds_counters(self, fs_id, counters=None):
+ """
+ Result format: map of daemon name to map of counter to list of datapoints
+ rtype: dict[str, dict[str, list]]
+ """
+
+ if counters is None:
+ # Opinionated list of interesting performance counters for the GUI
+ counters = [
+ "mds_server.handle_client_request",
+ "mds_log.ev",
+ "mds_cache.num_strays",
+ "mds.exported",
+ "mds.exported_inodes",
+ "mds.imported",
+ "mds.imported_inodes",
+ "mds.inodes",
+ "mds.caps",
+ "mds.subtrees",
+ "mds_mem.ino"
+ ]
+
+ result: dict = {}
+ mds_names = self._get_mds_names(fs_id)
+
+ for mds_name in mds_names:
+ result[mds_name] = {}
+ for counter in counters:
+ data = mgr.get_counter("mds", mds_name, counter)
+ if data is not None:
+ result[mds_name][counter] = data[counter]
+ else:
+ result[mds_name][counter] = []
+
+ return dict(result)
+
+ @staticmethod
+ def fs_id_to_int(fs_id):
+ try:
+ return int(fs_id)
+ except ValueError:
+ raise DashboardException(code='invalid_cephfs_id',
+ msg="Invalid cephfs ID {}".format(fs_id),
+ component='cephfs')
+
+ @staticmethod
+ def client_id_to_int(client_id):
+ try:
+ return int(client_id)
+ except ValueError:
+ raise DashboardException(code='invalid_cephfs_client_id',
+ msg="Invalid cephfs client ID {}".format(client_id),
+ component='cephfs')
+
+ def _get_mds_names(self, filesystem_id=None):
+ names = []
+
+ fsmap = mgr.get("fs_map")
+ for fs in fsmap['filesystems']:
+ if filesystem_id is not None and fs['id'] != filesystem_id:
+ continue
+ names.extend([info['name']
+ for _, info in fs['mdsmap']['info'].items()])
+
+ if filesystem_id is None:
+ names.extend(info['name'] for info in fsmap['standbys'])
+
+ return names
+
+ def _append_mds_metadata(self, mds_versions, metadata_key):
+ metadata = mgr.get_metadata('mds', metadata_key)
+ if metadata is None:
+ return
+ mds_versions[metadata.get('ceph_version', 'unknown')].append(metadata_key)
+
+ def _find_standby_replays(self, mdsmap_info, rank_table):
+ # pylint: disable=unused-variable
+ for gid_str, daemon_info in mdsmap_info.items():
+ if daemon_info['state'] != "up:standby-replay":
+ continue
+
+ inos = mgr.get_latest("mds", daemon_info['name'], "mds_mem.ino")
+ dns = mgr.get_latest("mds", daemon_info['name'], "mds_mem.dn")
+ dirs = mgr.get_latest("mds", daemon_info['name'], "mds_mem.dir")
+ caps = mgr.get_latest("mds", daemon_info['name'], "mds_mem.cap")
+
+ activity = CephService.get_rate(
+ "mds", daemon_info['name'], "mds_log.replay")
+
+ rank_table.append(
+ {
+ "rank": "{0}-s".format(daemon_info['rank']),
+ "state": "standby-replay",
+ "mds": daemon_info['name'],
+ "activity": activity,
+ "dns": dns,
+ "inos": inos,
+ "dirs": dirs,
+ "caps": caps
+ }
+ )
+
+ def get_standby_table(self, standbys, mds_versions):
+ standby_table = []
+ for standby in standbys:
+ self._append_mds_metadata(mds_versions, standby['name'])
+ standby_table.append({
+ 'name': standby['name']
+ })
+ return standby_table
+
+ # pylint: disable=too-many-statements,too-many-branches
+ def fs_status(self, fs_id):
+ mds_versions: dict = defaultdict(list)
+
+ fsmap = mgr.get("fs_map")
+ filesystem = None
+ for fs in fsmap['filesystems']:
+ if fs['id'] == fs_id:
+ filesystem = fs
+ break
+
+ if filesystem is None:
+ raise cherrypy.HTTPError(404,
+ "CephFS id {0} not found".format(fs_id))
+
+ rank_table = []
+
+ mdsmap = filesystem['mdsmap']
+
+ client_count = 0
+
+ for rank in mdsmap["in"]:
+ up = "mds_{0}".format(rank) in mdsmap["up"]
+ if up:
+ gid = mdsmap['up']["mds_{0}".format(rank)]
+ info = mdsmap['info']['gid_{0}'.format(gid)]
+ dns = mgr.get_latest("mds", info['name'], "mds_mem.dn")
+ inos = mgr.get_latest("mds", info['name'], "mds_mem.ino")
+ dirs = mgr.get_latest("mds", info['name'], "mds_mem.dir")
+ caps = mgr.get_latest("mds", info['name'], "mds_mem.cap")
+
+ # In case rank 0 was down, look at another rank's
+ # sessionmap to get an indication of clients.
+ if rank == 0 or client_count == 0:
+ client_count = mgr.get_latest("mds", info['name'],
+ "mds_sessions.session_count")
+
+ laggy = "laggy_since" in info
+
+ state = info['state'].split(":")[1]
+ if laggy:
+ state += "(laggy)"
+
+ # Populate based on context of state, e.g. client
+ # ops for an active daemon, replay progress, reconnect
+ # progress
+ if state == "active":
+ activity = CephService.get_rate("mds",
+ info['name'],
+ "mds_server.handle_client_request")
+ else:
+ activity = 0.0 # pragma: no cover
+
+ self._append_mds_metadata(mds_versions, info['name'])
+ rank_table.append(
+ {
+ "rank": rank,
+ "state": state,
+ "mds": info['name'],
+ "activity": activity,
+ "dns": dns,
+ "inos": inos,
+ "dirs": dirs,
+ "caps": caps
+ }
+ )
+
+ else:
+ rank_table.append(
+ {
+ "rank": rank,
+ "state": "failed",
+ "mds": "",
+ "activity": 0.0,
+ "dns": 0,
+ "inos": 0,
+ "dirs": 0,
+ "caps": 0
+ }
+ )
+
+ self._find_standby_replays(mdsmap['info'], rank_table)
+
+ df = mgr.get("df")
+ pool_stats = {p['id']: p['stats'] for p in df['pools']}
+ osdmap = mgr.get("osd_map")
+ pools = {p['pool']: p for p in osdmap['pools']}
+ metadata_pool_id = mdsmap['metadata_pool']
+ data_pool_ids = mdsmap['data_pools']
+
+ pools_table = []
+ for pool_id in [metadata_pool_id] + data_pool_ids:
+ pool_type = "metadata" if pool_id == metadata_pool_id else "data"
+ stats = pool_stats[pool_id]
+ pools_table.append({
+ "pool": pools[pool_id]['pool_name'],
+ "type": pool_type,
+ "used": stats['stored'],
+ "avail": stats['max_avail']
+ })
+
+ standby_table = self.get_standby_table(fsmap['standbys'], mds_versions)
+
+ return {
+ "cephfs": {
+ "id": fs_id,
+ "name": mdsmap['fs_name'],
+ "client_count": client_count,
+ "ranks": rank_table,
+ "pools": pools_table
+ },
+ "standbys": standby_table,
+ "versions": mds_versions
+ }
+
+ def _clients(self, fs_id):
+ cephfs_clients = self.cephfs_clients.get(fs_id, None)
+ if cephfs_clients is None:
+ cephfs_clients = CephFSClients(mgr, fs_id)
+ self.cephfs_clients[fs_id] = cephfs_clients
+
+ try:
+ status, clients = cephfs_clients.get()
+ except AttributeError:
+ raise cherrypy.HTTPError(404,
+ "No cephfs with id {0}".format(fs_id))
+
+ if clients is None:
+ raise cherrypy.HTTPError(404,
+ "No cephfs with id {0}".format(fs_id))
+
+ # Decorate the metadata with some fields that will be
+ # indepdendent of whether it's a kernel or userspace
+ # client, so that the javascript doesn't have to grok that.
+ for client in clients:
+ if "ceph_version" in client['client_metadata']: # pragma: no cover - no complexity
+ client['type'] = "userspace"
+ client['version'] = client['client_metadata']['ceph_version']
+ client['hostname'] = client['client_metadata']['hostname']
+ client['root'] = client['client_metadata']['root']
+ elif "kernel_version" in client['client_metadata']: # pragma: no cover - no complexity
+ client['type'] = "kernel"
+ client['version'] = client['client_metadata']['kernel_version']
+ client['hostname'] = client['client_metadata']['hostname']
+ client['root'] = client['client_metadata']['root']
+ else: # pragma: no cover - no complexity there
+ client['type'] = "unknown"
+ client['version'] = ""
+ client['hostname'] = ""
+
+ return {
+ 'status': status,
+ 'data': clients
+ }
+
+ def _evict(self, fs_id, client_id):
+ clients = self._clients(fs_id)
+ if not [c for c in clients['data'] if c['id'] == client_id]:
+ raise cherrypy.HTTPError(404,
+ "Client {0} does not exist in cephfs {1}".format(client_id,
+ fs_id))
+ filters = [f'id={client_id}']
+ CephService.send_command('mds', 'client evict',
+ srv_spec='{0}:0'.format(fs_id), filters=filters)
+
+ @staticmethod
+ def _cephfs_instance(fs_id):
+ """
+ :param fs_id: The filesystem identifier.
+ :type fs_id: int | str
+ :return: A instance of the CephFS class.
+ """
+ fs_name = CephFS_.fs_name_from_id(fs_id)
+ if fs_name is None:
+ raise cherrypy.HTTPError(404, "CephFS id {} not found".format(fs_id))
+ return CephFS_(fs_name)
+
+ @RESTController.Resource('GET')
+ def get_root_directory(self, fs_id):
+ """
+ The root directory that can't be fetched using ls_dir (api).
+ :param fs_id: The filesystem identifier.
+ :return: The root directory
+ :rtype: dict
+ """
+ try:
+ return self._get_root_directory(self._cephfs_instance(fs_id))
+ except (cephfs.PermissionError, cephfs.ObjectNotFound): # pragma: no cover
+ return None
+
+ def _get_root_directory(self, cfs):
+ """
+ The root directory that can't be fetched using ls_dir (api).
+ It's used in ls_dir (ui-api) and in get_root_directory (api).
+ :param cfs: CephFS service instance
+ :type cfs: CephFS
+ :return: The root directory
+ :rtype: dict
+ """
+ return cfs.get_directory(os.sep.encode())
+
+ @handle_cephfs_error()
+ @RESTController.Resource('GET')
+ def ls_dir(self, fs_id, path=None, depth=1):
+ """
+ List directories of specified path.
+ :param fs_id: The filesystem identifier.
+ :param path: The path where to start listing the directory content.
+ Defaults to '/' if not set.
+ :type path: str | bytes
+ :param depth: The number of steps to go down the directory tree.
+ :type depth: int | str
+ :return: The names of the directories below the specified path.
+ :rtype: list
+ """
+ path = self._set_ls_dir_path(path)
+ try:
+ cfs = self._cephfs_instance(fs_id)
+ paths = cfs.ls_dir(path, depth)
+ except (cephfs.PermissionError, cephfs.ObjectNotFound): # pragma: no cover
+ paths = []
+ return paths
+
+ def _set_ls_dir_path(self, path):
+ """
+ Transforms input path parameter of ls_dir methods (api and ui-api).
+ :param path: The path where to start listing the directory content.
+ Defaults to '/' if not set.
+ :type path: str | bytes
+ :return: Normalized path or root path
+ :return: str
+ """
+ if path is None:
+ path = os.sep
+ else:
+ path = os.path.normpath(path)
+ return path
+
+ @RESTController.Resource('POST', path='/tree')
+ @allow_empty_body
+ def mk_tree(self, fs_id, path):
+ """
+ Create a directory.
+ :param fs_id: The filesystem identifier.
+ :param path: The path of the directory.
+ """
+ cfs = self._cephfs_instance(fs_id)
+ cfs.mk_dirs(path)
+
+ @RESTController.Resource('DELETE', path='/tree')
+ def rm_tree(self, fs_id, path):
+ """
+ Remove a directory.
+ :param fs_id: The filesystem identifier.
+ :param path: The path of the directory.
+ """
+ cfs = self._cephfs_instance(fs_id)
+ cfs.rm_dir(path)
+
+ @RESTController.Resource('PUT', path='/quota')
+ @allow_empty_body
+ def quota(self, fs_id, path, max_bytes=None, max_files=None):
+ """
+ Set the quotas of the specified path.
+ :param fs_id: The filesystem identifier.
+ :param path: The path of the directory/file.
+ :param max_bytes: The byte limit.
+ :param max_files: The file limit.
+ """
+ cfs = self._cephfs_instance(fs_id)
+ return cfs.set_quotas(path, max_bytes, max_files)
+
+ @RESTController.Resource('GET', path='/quota')
+ @EndpointDoc("Get Cephfs Quotas of the specified path",
+ parameters={
+ 'fs_id': (str, 'File System Identifier'),
+ 'path': (str, 'File System Path'),
+ },
+ responses={200: GET_QUOTAS_SCHEMA})
+ def get_quota(self, fs_id, path):
+ """
+ Get the quotas of the specified path.
+ :param fs_id: The filesystem identifier.
+ :param path: The path of the directory/file.
+ :return: Returns a dictionary containing 'max_bytes'
+ and 'max_files'.
+ :rtype: dict
+ """
+ cfs = self._cephfs_instance(fs_id)
+ return cfs.get_quotas(path)
+
+ @RESTController.Resource('POST', path='/snapshot')
+ @allow_empty_body
+ def snapshot(self, fs_id, path, name=None):
+ """
+ Create a snapshot.
+ :param fs_id: The filesystem identifier.
+ :param path: The path of the directory.
+ :param name: The name of the snapshot. If not specified, a name using the
+ current time in RFC3339 UTC format will be generated.
+ :return: The name of the snapshot.
+ :rtype: str
+ """
+ cfs = self._cephfs_instance(fs_id)
+ list_snaps = cfs.ls_snapshots(path)
+ for snap in list_snaps:
+ if name == snap['name']:
+ raise DashboardException(code='Snapshot name already in use',
+ msg='Snapshot name {} is already in use.'
+ 'Please use another name'.format(name),
+ component='cephfs')
+
+ return cfs.mk_snapshot(path, name)
+
+ @RESTController.Resource('DELETE', path='/snapshot')
+ def rm_snapshot(self, fs_id, path, name):
+ """
+ Remove a snapshot.
+ :param fs_id: The filesystem identifier.
+ :param path: The path of the directory.
+ :param name: The name of the snapshot.
+ """
+ cfs = self._cephfs_instance(fs_id)
+ cfs.rm_snapshot(path, name)
+
+
+class CephFSClients(object):
+ def __init__(self, module_inst, fscid):
+ self._module = module_inst
+ self.fscid = fscid
+
+ @ViewCache()
+ def get(self):
+ return CephService.send_command('mds', 'session ls', srv_spec='{0}:0'.format(self.fscid))
+
+
+@UIRouter('/cephfs', Scope.CEPHFS)
+@APIDoc("Dashboard UI helper function; not part of the public API", "CephFSUi")
+class CephFsUi(CephFS):
+ RESOURCE_ID = 'fs_id'
+
+ @RESTController.Resource('GET')
+ def tabs(self, fs_id):
+ data = {}
+ fs_id = self.fs_id_to_int(fs_id)
+
+ # Needed for detail tab
+ fs_status = self.fs_status(fs_id)
+ for pool in fs_status['cephfs']['pools']:
+ pool['size'] = pool['used'] + pool['avail']
+ data['pools'] = fs_status['cephfs']['pools']
+ data['ranks'] = fs_status['cephfs']['ranks']
+ data['name'] = fs_status['cephfs']['name']
+ data['standbys'] = ', '.join([x['name'] for x in fs_status['standbys']])
+ counters = self._mds_counters(fs_id)
+ for k, v in counters.items():
+ v['name'] = k
+ data['mds_counters'] = counters
+
+ # Needed for client tab
+ data['clients'] = self._clients(fs_id)
+
+ return data
+
+ @handle_cephfs_error()
+ @RESTController.Resource('GET')
+ def ls_dir(self, fs_id, path=None, depth=1):
+ """
+ The difference to the API version is that the root directory will be send when listing
+ the root directory.
+ To only do one request this endpoint was created.
+ :param fs_id: The filesystem identifier.
+ :type fs_id: int | str
+ :param path: The path where to start listing the directory content.
+ Defaults to '/' if not set.
+ :type path: str | bytes
+ :param depth: The number of steps to go down the directory tree.
+ :type depth: int | str
+ :return: The names of the directories below the specified path.
+ :rtype: list
+ """
+ path = self._set_ls_dir_path(path)
+ try:
+ cfs = self._cephfs_instance(fs_id)
+ paths = cfs.ls_dir(path, depth)
+ if path == os.sep:
+ paths = [self._get_root_directory(cfs)] + paths
+ except (cephfs.PermissionError, cephfs.ObjectNotFound): # pragma: no cover
+ paths = []
+ return paths
+
+
+@APIRouter('/cephfs/subvolume', Scope.CEPHFS)
+@APIDoc('CephFS Subvolume Management API', 'CephFSSubvolume')
+class CephFSSubvolume(RESTController):
+
+ def get(self, vol_name: str, group_name: str = ""):
+ params = {'vol_name': vol_name}
+ if group_name:
+ params['group_name'] = group_name
+ error_code, out, err = mgr.remote(
+ 'volumes', '_cmd_fs_subvolume_ls', None, params)
+ if error_code != 0:
+ raise DashboardException(
+ f'Failed to list subvolumes for volume {vol_name}: {err}'
+ )
+ subvolumes = json.loads(out)
+ for subvolume in subvolumes:
+ params['sub_name'] = subvolume['name']
+ error_code, out, err = mgr.remote('volumes', '_cmd_fs_subvolume_info', None,
+ params)
+ if error_code != 0:
+ raise DashboardException(
+ f'Failed to get info for subvolume {subvolume["name"]}: {err}'
+ )
+ subvolume['info'] = json.loads(out)
+ return subvolumes
+
+ @RESTController.Resource('GET')
+ def info(self, vol_name: str, subvol_name: str, group_name: str = ""):
+ params = {'vol_name': vol_name, 'sub_name': subvol_name}
+ if group_name:
+ params['group_name'] = group_name
+ error_code, out, err = mgr.remote('volumes', '_cmd_fs_subvolume_info', None,
+ params)
+ if error_code != 0:
+ raise DashboardException(
+ f'Failed to get info for subvolume {subvol_name}: {err}'
+ )
+ return json.loads(out)
+
+ def create(self, vol_name: str, subvol_name: str, **kwargs):
+ error_code, _, err = mgr.remote('volumes', '_cmd_fs_subvolume_create', None, {
+ 'vol_name': vol_name, 'sub_name': subvol_name, **kwargs})
+ if error_code != 0:
+ raise DashboardException(
+ f'Failed to create subvolume {subvol_name}: {err}'
+ )
+
+ return f'Subvolume {subvol_name} created successfully'
+
+ def set(self, vol_name: str, subvol_name: str, size: str, group_name: str = ""):
+ params = {'vol_name': vol_name, 'sub_name': subvol_name}
+ if size:
+ params['new_size'] = size
+ if group_name:
+ params['group_name'] = group_name
+ error_code, _, err = mgr.remote('volumes', '_cmd_fs_subvolume_resize', None,
+ params)
+ if error_code != 0:
+ raise DashboardException(
+ f'Failed to update subvolume {subvol_name}: {err}'
+ )
+
+ return f'Subvolume {subvol_name} updated successfully'
+
+ def delete(self, vol_name: str, subvol_name: str, group_name: str = "",
+ retain_snapshots: bool = False):
+ params = {'vol_name': vol_name, 'sub_name': subvol_name}
+ if group_name:
+ params['group_name'] = group_name
+ retain_snapshots = str_to_bool(retain_snapshots)
+ if retain_snapshots:
+ params['retain_snapshots'] = 'True'
+ error_code, _, err = mgr.remote(
+ 'volumes', '_cmd_fs_subvolume_rm', None, params)
+ if error_code != 0:
+ raise DashboardException(
+ msg=f'Failed to remove subvolume {subvol_name}: {err}',
+ component='cephfs')
+ return f'Subvolume {subvol_name} removed successfully'
+
+
+@APIRouter('/cephfs/subvolume/group', Scope.CEPHFS)
+@APIDoc("Cephfs Subvolume Group Management API", "CephfsSubvolumeGroup")
+class CephFSSubvolumeGroups(RESTController):
+
+ def get(self, vol_name):
+ if not vol_name:
+ raise DashboardException(
+ f'Error listing subvolume groups for {vol_name}')
+ error_code, out, err = mgr.remote('volumes', '_cmd_fs_subvolumegroup_ls',
+ None, {'vol_name': vol_name})
+ if error_code != 0:
+ raise DashboardException(
+ f'Error listing subvolume groups for {vol_name}')
+ subvolume_groups = json.loads(out)
+ for group in subvolume_groups:
+ error_code, out, err = mgr.remote('volumes', '_cmd_fs_subvolumegroup_info',
+ None, {'vol_name': vol_name,
+ 'group_name': group['name']})
+ if error_code != 0:
+ raise DashboardException(
+ f'Failed to get info for subvolume group {group["name"]}: {err}'
+ )
+ group['info'] = json.loads(out)
+ return subvolume_groups
+
+ @RESTController.Resource('GET')
+ def info(self, vol_name: str, group_name: str):
+ error_code, out, err = mgr.remote('volumes', '_cmd_fs_subvolumegroup_info', None, {
+ 'vol_name': vol_name, 'group_name': group_name})
+ if error_code != 0:
+ raise DashboardException(
+ f'Failed to get info for subvolume group {group_name}: {err}'
+ )
+ return json.loads(out)
+
+ def create(self, vol_name: str, group_name: str, **kwargs):
+ error_code, _, err = mgr.remote('volumes', '_cmd_fs_subvolumegroup_create', None, {
+ 'vol_name': vol_name, 'group_name': group_name, **kwargs})
+ if error_code != 0:
+ raise DashboardException(
+ f'Failed to create subvolume group {group_name}: {err}'
+ )
+
+ def set(self, vol_name: str, group_name: str, size: str):
+ if not size:
+ return f'Failed to update subvolume group {group_name}, size was not provided'
+ error_code, _, err = mgr.remote('volumes', '_cmd_fs_subvolumegroup_resize', None, {
+ 'vol_name': vol_name, 'group_name': group_name, 'new_size': size})
+ if error_code != 0:
+ raise DashboardException(
+ f'Failed to update subvolume group {group_name}: {err}'
+ )
+ return f'Subvolume group {group_name} updated successfully'
+
+ def delete(self, vol_name: str, group_name: str):
+ error_code, _, err = mgr.remote(
+ 'volumes', '_cmd_fs_subvolumegroup_rm', None, {
+ 'vol_name': vol_name, 'group_name': group_name})
+ if error_code != 0:
+ raise DashboardException(
+ f'Failed to delete subvolume group {group_name}: {err}'
+ )
+ return f'Subvolume group {group_name} removed successfully'
diff --git a/src/pybind/mgr/dashboard/controllers/cluster.py b/src/pybind/mgr/dashboard/controllers/cluster.py
new file mode 100644
index 000000000..5091457ec
--- /dev/null
+++ b/src/pybind/mgr/dashboard/controllers/cluster.py
@@ -0,0 +1,101 @@
+# -*- coding: utf-8 -*-
+
+from typing import Dict, List, Optional
+
+from ..security import Scope
+from ..services.cluster import ClusterModel
+from ..services.exception import handle_orchestrator_error
+from ..services.orchestrator import OrchClient, OrchFeature
+from ..tools import str_to_bool
+from . import APIDoc, APIRouter, CreatePermission, Endpoint, EndpointDoc, \
+ ReadPermission, RESTController, UpdatePermission, allow_empty_body
+from ._version import APIVersion
+from .orchestrator import raise_if_no_orchestrator
+
+
+@APIRouter('/cluster', Scope.CONFIG_OPT)
+@APIDoc("Get Cluster Details", "Cluster")
+class Cluster(RESTController):
+ @RESTController.MethodMap(version=APIVersion.EXPERIMENTAL)
+ @EndpointDoc("Get the cluster status")
+ def list(self):
+ return ClusterModel.from_db().dict()
+
+ @RESTController.MethodMap(version=APIVersion.EXPERIMENTAL)
+ @EndpointDoc("Update the cluster status",
+ parameters={'status': (str, 'Cluster Status')})
+ def singleton_set(self, status: str):
+ ClusterModel(status).to_db() # -*- coding: utf-8 -*-
+
+
+@APIRouter('/cluster/upgrade', Scope.CONFIG_OPT)
+@APIDoc("Upgrade Management API", "Upgrade")
+class ClusterUpgrade(RESTController):
+ @RESTController.MethodMap()
+ @raise_if_no_orchestrator([OrchFeature.UPGRADE_LIST])
+ @handle_orchestrator_error('upgrade')
+ @EndpointDoc("Get the available versions to upgrade",
+ parameters={
+ 'image': (str, 'Ceph Image'),
+ 'tags': (bool, 'Show all image tags'),
+ 'show_all_versions': (bool, 'Show all available versions')
+ })
+ @ReadPermission
+ def list(self, tags: bool = False, image: Optional[str] = None,
+ show_all_versions: Optional[bool] = False) -> Dict:
+ orch = OrchClient.instance()
+ available_upgrades = orch.upgrades.list(image, str_to_bool(tags),
+ str_to_bool(show_all_versions))
+ return available_upgrades
+
+ @Endpoint()
+ @raise_if_no_orchestrator([OrchFeature.UPGRADE_STATUS])
+ @handle_orchestrator_error('upgrade')
+ @EndpointDoc("Get the cluster upgrade status")
+ @ReadPermission
+ def status(self) -> Dict:
+ orch = OrchClient.instance()
+ status = orch.upgrades.status().to_json()
+ return status
+
+ @Endpoint('POST')
+ @raise_if_no_orchestrator([OrchFeature.UPGRADE_START])
+ @handle_orchestrator_error('upgrade')
+ @EndpointDoc("Start the cluster upgrade")
+ @CreatePermission
+ def start(self, image: Optional[str] = None, version: Optional[str] = None,
+ daemon_types: Optional[List[str]] = None, host_placement: Optional[str] = None,
+ services: Optional[List[str]] = None, limit: Optional[int] = None) -> str:
+ orch = OrchClient.instance()
+ start = orch.upgrades.start(image, version, daemon_types, host_placement, services, limit)
+ return start
+
+ @Endpoint('PUT')
+ @raise_if_no_orchestrator([OrchFeature.UPGRADE_PAUSE])
+ @handle_orchestrator_error('upgrade')
+ @EndpointDoc("Pause the cluster upgrade")
+ @UpdatePermission
+ @allow_empty_body
+ def pause(self) -> str:
+ orch = OrchClient.instance()
+ return orch.upgrades.pause()
+
+ @Endpoint('PUT')
+ @raise_if_no_orchestrator([OrchFeature.UPGRADE_RESUME])
+ @handle_orchestrator_error('upgrade')
+ @EndpointDoc("Resume the cluster upgrade")
+ @UpdatePermission
+ @allow_empty_body
+ def resume(self) -> str:
+ orch = OrchClient.instance()
+ return orch.upgrades.resume()
+
+ @Endpoint('PUT')
+ @raise_if_no_orchestrator([OrchFeature.UPGRADE_STOP])
+ @handle_orchestrator_error('upgrade')
+ @EndpointDoc("Stop the cluster upgrade")
+ @UpdatePermission
+ @allow_empty_body
+ def stop(self) -> str:
+ orch = OrchClient.instance()
+ return orch.upgrades.stop()
diff --git a/src/pybind/mgr/dashboard/controllers/cluster_configuration.py b/src/pybind/mgr/dashboard/controllers/cluster_configuration.py
new file mode 100644
index 000000000..da5be2cc8
--- /dev/null
+++ b/src/pybind/mgr/dashboard/controllers/cluster_configuration.py
@@ -0,0 +1,132 @@
+# -*- coding: utf-8 -*-
+
+import cherrypy
+
+from .. import mgr
+from ..exceptions import DashboardException
+from ..security import Scope
+from ..services.ceph_service import CephService
+from . import APIDoc, APIRouter, EndpointDoc, RESTController
+
+FILTER_SCHEMA = [{
+ "name": (str, 'Name of the config option'),
+ "type": (str, 'Config option type'),
+ "level": (str, 'Config option level'),
+ "desc": (str, 'Description of the configuration'),
+ "long_desc": (str, 'Elaborated description'),
+ "default": (str, 'Default value for the config option'),
+ "daemon_default": (str, 'Daemon specific default value'),
+ "tags": ([str], 'Tags associated with the cluster'),
+ "services": ([str], 'Services associated with the config option'),
+ "see_also": ([str], 'Related config options'),
+ "enum_values": ([str], 'List of enums allowed'),
+ "min": (str, 'Minimum value'),
+ "max": (str, 'Maximum value'),
+ "can_update_at_runtime": (bool, 'Check if can update at runtime'),
+ "flags": ([str], 'List of flags associated')
+}]
+
+
+@APIRouter('/cluster_conf', Scope.CONFIG_OPT)
+@APIDoc("Manage Cluster Configurations", "ClusterConfiguration")
+class ClusterConfiguration(RESTController):
+
+ def _append_config_option_values(self, options):
+ """
+ Appends values from the config database (if available) to the given options
+ :param options: list of config options
+ :return: list of config options extended by their current values
+ """
+ config_dump = CephService.send_command('mon', 'config dump')
+ mgr_config = mgr.get('config')
+ config_dump.append({'name': 'fsid', 'section': 'mgr', 'value': mgr_config['fsid']})
+
+ for config_dump_entry in config_dump:
+ for i, elem in enumerate(options):
+ if config_dump_entry['name'] == elem['name']:
+ if 'value' not in elem:
+ options[i]['value'] = []
+ options[i]['source'] = 'mon'
+
+ options[i]['value'].append({'section': config_dump_entry['section'],
+ 'value': config_dump_entry['value']})
+ return options
+
+ def list(self):
+ options = mgr.get('config_options')['options']
+ return self._append_config_option_values(options)
+
+ def get(self, name):
+ return self._get_config_option(name)
+
+ @RESTController.Collection('GET', query_params=['name'])
+ @EndpointDoc("Get Cluster Configuration by name",
+ parameters={
+ 'names': (str, 'Config option names'),
+ },
+ responses={200: FILTER_SCHEMA})
+ def filter(self, names=None):
+ config_options = []
+
+ if names:
+ for name in names.split(','):
+ try:
+ config_options.append(self._get_config_option(name))
+ except cherrypy.HTTPError:
+ pass
+
+ if not config_options:
+ raise cherrypy.HTTPError(404, 'Config options `{}` not found'.format(names))
+
+ return config_options
+
+ def create(self, name, value):
+ # Check if config option is updateable at runtime
+ self._updateable_at_runtime([name])
+
+ # Update config option
+ avail_sections = ['global', 'mon', 'mgr', 'osd', 'mds', 'client']
+
+ for section in avail_sections:
+ for entry in value:
+ if entry['value'] is None:
+ break
+
+ if entry['section'] == section:
+ CephService.send_command('mon', 'config set', who=section, name=name,
+ value=str(entry['value']))
+ break
+ else:
+ CephService.send_command('mon', 'config rm', who=section, name=name)
+
+ def delete(self, name, section):
+ return CephService.send_command('mon', 'config rm', who=section, name=name)
+
+ def bulk_set(self, options):
+ self._updateable_at_runtime(options.keys())
+
+ for name, value in options.items():
+ CephService.send_command('mon', 'config set', who=value['section'],
+ name=name, value=str(value['value']))
+
+ def _get_config_option(self, name):
+ for option in mgr.get('config_options')['options']:
+ if option['name'] == name:
+ return self._append_config_option_values([option])[0]
+
+ raise cherrypy.HTTPError(404)
+
+ def _updateable_at_runtime(self, config_option_names):
+ not_updateable = []
+
+ for name in config_option_names:
+ config_option = self._get_config_option(name)
+ if not config_option['can_update_at_runtime']:
+ not_updateable.append(name)
+
+ if not_updateable:
+ raise DashboardException(
+ msg='Config option {} is/are not updatable at runtime'.format(
+ ', '.join(not_updateable)),
+ code='config_option_not_updatable_at_runtime',
+ component='cluster_configuration')
diff --git a/src/pybind/mgr/dashboard/controllers/crush_rule.py b/src/pybind/mgr/dashboard/controllers/crush_rule.py
new file mode 100644
index 000000000..250f657b2
--- /dev/null
+++ b/src/pybind/mgr/dashboard/controllers/crush_rule.py
@@ -0,0 +1,68 @@
+
+# -*- 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 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')
+}
+
+
+@APIRouter('/crush_rule', Scope.POOL)
+@APIDoc("Crush Rule Management API", "CrushRule")
+class CrushRule(RESTController):
+ @EndpointDoc("List Crush Rule Configuration",
+ responses={200: LIST_SCHEMA})
+ @RESTController.MethodMap(version=APIVersion(2, 0))
+ def list(self):
+ return mgr.get('osd_map_crush')['rules']
+
+ @RESTController.MethodMap(version=APIVersion(2, 0))
+ def get(self, name):
+ rules = mgr.get('osd_map_crush')['rules']
+ for r in rules:
+ if r['rule_name'] == name:
+ return r
+ raise NotFound('No such crush rule')
+
+ def create(self, name, root, failure_domain, device_class=None):
+ rule = {
+ 'name': name,
+ 'root': root,
+ 'type': failure_domain,
+ 'class': device_class
+ }
+ CephService.send_command('mon', 'osd crush rule create-replicated', **rule)
+
+ def delete(self, name):
+ CephService.send_command('mon', 'osd crush rule rm', name=name)
+
+
+@UIRouter('/crush_rule', Scope.POOL)
+@APIDoc("Dashboard UI helper function; not part of the public API", "CrushRuleUi")
+class CrushRuleUi(CrushRule):
+ @Endpoint()
+ @ReadPermission
+ def info(self):
+ '''Used for crush rule creation modal'''
+ osd_map = mgr.get_osdmap()
+ crush = osd_map.get_crush()
+ crush.dump()
+ return {
+ 'names': [r['rule_name'] for r in mgr.get('osd_map_crush')['rules']],
+ 'nodes': mgr.get('osd_map_tree')['nodes'],
+ 'roots': crush.find_roots()
+ }
diff --git a/src/pybind/mgr/dashboard/controllers/daemon.py b/src/pybind/mgr/dashboard/controllers/daemon.py
new file mode 100644
index 000000000..d5c288131
--- /dev/null
+++ b/src/pybind/mgr/dashboard/controllers/daemon.py
@@ -0,0 +1,49 @@
+# -*- coding: utf-8 -*-
+
+from typing import List, Optional
+
+from ..exceptions import DashboardException
+from ..security import Scope
+from ..services.exception import handle_orchestrator_error
+from ..services.orchestrator import OrchClient, OrchFeature
+from . import APIDoc, APIRouter, RESTController
+from ._version import APIVersion
+from .orchestrator import raise_if_no_orchestrator
+
+
+@APIRouter('/daemon', Scope.HOSTS)
+@APIDoc("Perform actions on daemons", "Daemon")
+class Daemon(RESTController):
+ @raise_if_no_orchestrator([OrchFeature.DAEMON_ACTION])
+ @handle_orchestrator_error('daemon')
+ @RESTController.MethodMap(version=APIVersion.EXPERIMENTAL)
+ def set(self, daemon_name: str, action: str = '',
+ container_image: Optional[str] = None):
+
+ if action not in ['start', 'stop', 'restart', 'redeploy']:
+ raise DashboardException(
+ code='invalid_daemon_action',
+ msg=f'Daemon action "{action}" is either not valid or not supported.')
+ # non 'None' container_images change need a redeploy
+ if container_image == '' and action != 'redeploy':
+ container_image = None
+
+ orch = OrchClient.instance()
+ res = orch.daemons.action(action=action, daemon_name=daemon_name, image=container_image)
+ return res
+
+ @raise_if_no_orchestrator([OrchFeature.DAEMON_LIST])
+ @handle_orchestrator_error('daemon')
+ @RESTController.MethodMap(version=APIVersion.DEFAULT)
+ def list(self, daemon_types: Optional[List[str]] = None):
+ """List all daemons in the cluster. Also filter by the daemon types specified
+
+ :param daemon_types: List of daemon types to filter by.
+ :return: Returns list of daemons.
+ :rtype: list
+ """
+ orch = OrchClient.instance()
+ daemons = [d.to_dict() for d in orch.services.list_daemons()]
+ if daemon_types:
+ daemons = [d for d in daemons if d['daemon_type'] in daemon_types]
+ return daemons
diff --git a/src/pybind/mgr/dashboard/controllers/docs.py b/src/pybind/mgr/dashboard/controllers/docs.py
new file mode 100644
index 000000000..2ade4ef9b
--- /dev/null
+++ b/src/pybind/mgr/dashboard/controllers/docs.py
@@ -0,0 +1,435 @@
+# -*- coding: utf-8 -*-
+import logging
+from typing import Any, Dict, List, Optional, Union
+
+import cherrypy
+
+from .. import mgr
+from ..api.doc import Schema, SchemaInput, SchemaType
+from . import ENDPOINT_MAP, BaseController, Endpoint, Router
+from ._version import APIVersion
+
+NO_DESCRIPTION_AVAILABLE = "*No description available*"
+
+logger = logging.getLogger('controllers.docs')
+
+
+@Router('/docs', secure=False)
+class Docs(BaseController):
+
+ @classmethod
+ def _gen_tags(cls, all_endpoints):
+ """ Generates a list of all tags and corresponding descriptions. """
+ # Scenarios to consider:
+ # * Intentionally make up a new tag name at controller => New tag name displayed.
+ # * Misspell or make up a new tag name at endpoint => Neither tag or endpoint displayed.
+ # * Misspell tag name at controller (when referring to another controller) =>
+ # Tag displayed but no endpoints assigned
+ # * Description for a tag added at multiple locations => Only one description displayed.
+ list_of_ctrl = set()
+ for endpoints in ENDPOINT_MAP.values():
+ for endpoint in endpoints:
+ if endpoint.is_api or all_endpoints:
+ list_of_ctrl.add(endpoint.ctrl)
+
+ tag_map: Dict[str, str] = {}
+ for ctrl in sorted(list_of_ctrl, key=lambda ctrl: ctrl.__name__):
+ tag_name = ctrl.__name__
+ tag_descr = ""
+ if hasattr(ctrl, 'doc_info'):
+ if ctrl.doc_info['tag']:
+ tag_name = ctrl.doc_info['tag']
+ tag_descr = ctrl.doc_info['tag_descr']
+ if tag_name not in tag_map or not tag_map[tag_name]:
+ tag_map[tag_name] = tag_descr
+
+ tags = [{'name': k, 'description': v if v else NO_DESCRIPTION_AVAILABLE}
+ for k, v in tag_map.items()]
+ tags.sort(key=lambda e: e['name'])
+ return tags
+
+ @classmethod
+ def _get_tag(cls, endpoint):
+ """ Returns the name of a tag to assign to a path. """
+ ctrl = endpoint.ctrl
+ func = endpoint.func
+ tag = ctrl.__name__
+ if hasattr(func, 'doc_info') and func.doc_info['tag']:
+ tag = func.doc_info['tag']
+ elif hasattr(ctrl, 'doc_info') and ctrl.doc_info['tag']:
+ tag = ctrl.doc_info['tag']
+ return tag
+
+ @classmethod
+ def _gen_type(cls, param):
+ # pylint: disable=too-many-return-statements
+ """
+ Generates the type of parameter based on its name and default value,
+ using very simple heuristics.
+ Used if type is not explicitly defined.
+ """
+ param_name = param['name']
+ def_value = param['default'] if 'default' in param else None
+ if param_name.startswith("is_"):
+ return str(SchemaType.BOOLEAN)
+ if "size" in param_name:
+ return str(SchemaType.INTEGER)
+ if "count" in param_name:
+ return str(SchemaType.INTEGER)
+ if "num" in param_name:
+ return str(SchemaType.INTEGER)
+ if isinstance(def_value, bool):
+ return str(SchemaType.BOOLEAN)
+ if isinstance(def_value, int):
+ return str(SchemaType.INTEGER)
+ return str(SchemaType.STRING)
+
+ @classmethod
+ # isinstance doesn't work: input is always <type 'type'>.
+ def _type_to_str(cls, type_as_type):
+ """ Used if type is explicitly defined. """
+ if type_as_type is str:
+ type_as_str = str(SchemaType.STRING)
+ elif type_as_type is int:
+ type_as_str = str(SchemaType.INTEGER)
+ elif type_as_type is bool:
+ type_as_str = str(SchemaType.BOOLEAN)
+ elif type_as_type is list or type_as_type is tuple:
+ type_as_str = str(SchemaType.ARRAY)
+ elif type_as_type is float:
+ type_as_str = str(SchemaType.NUMBER)
+ else:
+ type_as_str = str(SchemaType.OBJECT)
+ return type_as_str
+
+ @classmethod
+ def _add_param_info(cls, parameters, p_info):
+ # Cases to consider:
+ # * Parameter name (if not nested) misspelt in decorator => parameter not displayed
+ # * Sometimes a parameter is used for several endpoints (e.g. fs_id in CephFS).
+ # Currently, there is no possibility of reuse. Should there be?
+ # But what if there are two parameters with same name but different functionality?
+ """
+ Adds explicitly described information for parameters of an endpoint.
+
+ There are two cases:
+ * Either the parameter in p_info corresponds to an endpoint parameter. Implicit information
+ has higher priority, so only information that doesn't already exist is added.
+ * Or the parameter in p_info describes a nested parameter inside an endpoint parameter.
+ In that case there is no implicit information at all so all explicitly described info needs
+ to be added.
+ """
+ for p in p_info:
+ if not p['nested']:
+ for parameter in parameters:
+ if p['name'] == parameter['name']:
+ parameter['type'] = p['type']
+ parameter['description'] = p['description']
+ if 'nested_params' in p:
+ parameter['nested_params'] = cls._add_param_info([], p['nested_params'])
+ else:
+ nested_p = {
+ 'name': p['name'],
+ 'type': p['type'],
+ 'description': p['description'],
+ 'required': p['required'],
+ }
+ if 'default' in p:
+ nested_p['default'] = p['default']
+ if 'nested_params' in p:
+ nested_p['nested_params'] = cls._add_param_info([], p['nested_params'])
+ parameters.append(nested_p)
+
+ return parameters
+
+ @classmethod
+ def _gen_schema_for_content(cls, params: List[Any]) -> Dict[str, Any]:
+ """
+ Generates information to the content-object in OpenAPI Spec.
+ Used to for request body and responses.
+ """
+ required_params = []
+ properties = {}
+ schema_type = SchemaType.OBJECT
+ if isinstance(params, SchemaInput):
+ schema_type = params.type
+ params = params.params
+
+ for param in params:
+ if param['required']:
+ required_params.append(param['name'])
+
+ props = {}
+ if 'type' in param:
+ props['type'] = cls._type_to_str(param['type'])
+ if 'nested_params' in param:
+ if props['type'] == str(SchemaType.ARRAY): # dict in array
+ props['items'] = cls._gen_schema_for_content(param['nested_params'])
+ else: # dict in dict
+ props = cls._gen_schema_for_content(param['nested_params'])
+ elif props['type'] == str(SchemaType.OBJECT): # e.g. [int]
+ props['type'] = str(SchemaType.ARRAY)
+ props['items'] = {'type': cls._type_to_str(param['type'][0])}
+ else:
+ props['type'] = cls._gen_type(param)
+ if 'description' in param:
+ props['description'] = param['description']
+ if 'default' in param:
+ props['default'] = param['default']
+ properties[param['name']] = props
+
+ schema = Schema(schema_type=schema_type, properties=properties,
+ required=required_params)
+
+ return schema.as_dict()
+
+ @classmethod
+ def _gen_responses(cls, method, resp_object=None,
+ version: Optional[APIVersion] = None):
+ resp: Dict[str, Dict[str, Union[str, Any]]] = {
+ '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."
+ }
+ }
+
+ if not version:
+ version = APIVersion.DEFAULT
+
+ if method.lower() == 'get':
+ resp['200'] = {'description': "OK",
+ 'content': {version.to_mime_type():
+ {'type': 'object'}}}
+ if method.lower() == 'post':
+ resp['201'] = {'description': "Resource created.",
+ 'content': {version.to_mime_type():
+ {'type': 'object'}}}
+ if method.lower() == 'put':
+ resp['200'] = {'description': "Resource updated.",
+ 'content': {version.to_mime_type():
+ {'type': 'object'}}}
+ if method.lower() == 'delete':
+ resp['204'] = {'description': "Resource deleted.",
+ 'content': {version.to_mime_type():
+ {'type': 'object'}}}
+ if method.lower() in ['post', 'put', 'delete']:
+ resp['202'] = {'description': "Operation is still executing."
+ " Please check the task queue.",
+ 'content': {version.to_mime_type():
+ {'type': 'object'}}}
+
+ if resp_object:
+ for status_code, response_body in resp_object.items():
+ if status_code in resp:
+ resp[status_code].update(
+ {'content':
+ {version.to_mime_type():
+ {'schema': cls._gen_schema_for_content(response_body)}
+ }})
+
+ return resp
+
+ @classmethod
+ def _gen_params(cls, params, location):
+ parameters = []
+ for param in params:
+ if 'type' in param:
+ _type = cls._type_to_str(param['type'])
+ else:
+ _type = cls._gen_type(param)
+ res = {
+ 'name': param['name'],
+ 'in': location,
+ 'schema': {
+ 'type': _type
+ },
+ }
+ if param.get('description'):
+ res['description'] = param['description']
+ if param['required']:
+ res['required'] = True
+ elif param['default'] is None:
+ res['allowEmptyValue'] = True
+ else:
+ res['default'] = param['default']
+ parameters.append(res)
+
+ return parameters
+
+ @staticmethod
+ def _process_func_attr(func):
+ summary = ''
+ version = None
+ response = {}
+ p_info = []
+
+ if hasattr(func, '__method_map_method__'):
+ version = func.__method_map_method__['version']
+ elif hasattr(func, '__resource_method__'):
+ version = func.__resource_method__['version']
+ elif hasattr(func, '__collection_method__'):
+ version = func.__collection_method__['version']
+
+ if hasattr(func, 'doc_info'):
+ if func.doc_info['summary']:
+ summary = func.doc_info['summary']
+ response = func.doc_info['response']
+ p_info = func.doc_info['parameters']
+
+ return summary, version, response, p_info
+
+ @classmethod
+ def _get_params(cls, endpoint, para_info):
+ params = []
+
+ def extend_params(endpoint_params, param_name):
+ if endpoint_params:
+ params.extend(
+ cls._gen_params(
+ cls._add_param_info(endpoint_params, para_info), param_name))
+
+ extend_params(endpoint.path_params, 'path')
+ extend_params(endpoint.query_params, 'query')
+ return params
+
+ @classmethod
+ def set_request_body_param(cls, endpoint_param, method, methods, p_info):
+ if endpoint_param:
+ params_info = cls._add_param_info(endpoint_param, p_info)
+ methods[method.lower()]['requestBody'] = {
+ 'content': {
+ 'application/json': {
+ 'schema': cls._gen_schema_for_content(params_info)}}}
+
+ @classmethod
+ def gen_paths(cls, all_endpoints):
+ # pylint: disable=R0912
+ method_order = ['get', 'post', 'put', 'delete']
+ paths = {}
+ for path, endpoints in sorted(list(ENDPOINT_MAP.items()),
+ key=lambda p: p[0]):
+ methods = {}
+ skip = False
+
+ endpoint_list = sorted(endpoints, key=lambda e:
+ method_order.index(e.method.lower()))
+ for endpoint in endpoint_list:
+ if not endpoint.is_api and not all_endpoints:
+ skip = True
+ break
+
+ method = endpoint.method
+ func = endpoint.func
+
+ summary, version, resp, p_info = cls._process_func_attr(func)
+ params = cls._get_params(endpoint, p_info)
+
+ methods[method.lower()] = {
+ 'tags': [cls._get_tag(endpoint)],
+ 'description': func.__doc__,
+ 'parameters': params,
+ 'responses': cls._gen_responses(method, resp, version)
+ }
+ if summary:
+ methods[method.lower()]['summary'] = summary
+
+ if method.lower() in ['post', 'put']:
+ cls.set_request_body_param(endpoint.body_params, method, methods, p_info)
+ cls.set_request_body_param(endpoint.query_params, method, methods, p_info)
+
+ if endpoint.is_secure:
+ methods[method.lower()]['security'] = [{'jwt': []}]
+
+ if not skip:
+ paths[path] = methods
+
+ return paths
+
+ @classmethod
+ def _gen_spec(cls, all_endpoints=False, base_url="", offline=False):
+ if all_endpoints:
+ base_url = ""
+
+ host = cherrypy.request.base.split('://', 1)[1] if not offline else 'example.com'
+ logger.debug("Host: %s", host)
+
+ paths = cls.gen_paths(all_endpoints)
+
+ if not base_url:
+ base_url = "/"
+
+ scheme = 'https' if offline or mgr.get_localized_module_option('ssl') else 'http'
+
+ spec = {
+ 'openapi': "3.0.0",
+ 'info': {
+ 'description': "This is the official Ceph REST API",
+ 'version': "v1",
+ 'title': "Ceph REST API"
+ },
+ 'host': host,
+ 'basePath': base_url,
+ 'servers': [{'url': "{}{}".format(
+ cherrypy.request.base if not offline else '',
+ base_url)}],
+ 'tags': cls._gen_tags(all_endpoints),
+ 'schemes': [scheme],
+ 'paths': paths,
+ 'components': {
+ 'securitySchemes': {
+ 'jwt': {
+ 'type': 'http',
+ 'scheme': 'bearer',
+ 'bearerFormat': 'JWT'
+ }
+ }
+ }
+ }
+
+ return spec
+
+ @Endpoint(path="openapi.json", version=None)
+ def open_api_json(self):
+ return self._gen_spec(False, "/")
+
+ @Endpoint(path="api-all.json", version=None)
+ def api_all_json(self):
+ return self._gen_spec(True, "/")
+
+
+if __name__ == "__main__":
+ import sys
+
+ import yaml
+
+ def fix_null_descr(obj):
+ """
+ A hot fix for errors caused by null description values when generating
+ static documentation: better fix would be default values in source
+ to be 'None' strings: however, decorator changes didn't resolve
+ """
+ return {k: fix_null_descr(v) for k, v in obj.items() if v is not None} \
+ if isinstance(obj, dict) else obj
+
+ Router.generate_routes("/api")
+ try:
+ with open(sys.argv[1], 'w') as f:
+ # pylint: disable=protected-access
+ yaml.dump(
+ fix_null_descr(Docs._gen_spec(all_endpoints=False, base_url="/", offline=True)),
+ f)
+ except IndexError:
+ sys.exit("Output file name missing; correct syntax is: `cmd <file.yml>`")
+ except IsADirectoryError:
+ sys.exit("Specified output is a directory; correct syntax is: `cmd <file.yml>`")
diff --git a/src/pybind/mgr/dashboard/controllers/erasure_code_profile.py b/src/pybind/mgr/dashboard/controllers/erasure_code_profile.py
new file mode 100644
index 000000000..d0966025a
--- /dev/null
+++ b/src/pybind/mgr/dashboard/controllers/erasure_code_profile.py
@@ -0,0 +1,65 @@
+# -*- coding: utf-8 -*-
+
+from cherrypy import NotFound
+
+from .. import mgr
+from ..security import Scope
+from ..services.ceph_service import CephService
+from . import APIDoc, APIRouter, Endpoint, EndpointDoc, ReadPermission, RESTController, UIRouter
+
+LIST_CODE__SCHEMA = {
+ "crush-failure-domain": (str, ''),
+ "k": (int, 'Number of data chunks'),
+ "m": (int, 'Number of coding chunks'),
+ "plugin": (str, 'Plugin Info'),
+ "technique": (str, ''),
+ "name": (str, 'Name of the profile')
+}
+
+
+@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
+ ECP plugin.
+ """
+ @EndpointDoc("List Erasure Code Profile Information",
+ responses={'200': [LIST_CODE__SCHEMA]})
+ def list(self):
+ return CephService.get_erasure_code_profiles()
+
+ def get(self, name):
+ profiles = CephService.get_erasure_code_profiles()
+ for p in profiles:
+ if p['name'] == name:
+ return p
+ raise NotFound('No such erasure code profile')
+
+ def create(self, name, **kwargs):
+ profile = ['{}={}'.format(key, value) for key, value in kwargs.items()]
+ CephService.send_command('mon', 'osd erasure-code-profile set', name=name,
+ profile=profile)
+
+ def delete(self, name):
+ CephService.send_command('mon', 'osd erasure-code-profile rm', name=name)
+
+
+@UIRouter('/erasure_code_profile', Scope.POOL)
+@APIDoc("Dashboard UI helper function; not part of the public API", "ErasureCodeProfileUi")
+class ErasureCodeProfileUi(ErasureCodeProfile):
+ @Endpoint()
+ @ReadPermission
+ def info(self):
+ """
+ Used for profile creation and editing
+ """
+ config = mgr.get('config')
+ return {
+ # Because 'shec' and 'clay' are experimental they're not included
+ 'plugins': config['osd_erasure_code_plugins'].split() + ['shec', 'clay'],
+ 'directory': config['erasure_code_dir'],
+ 'nodes': mgr.get('osd_map_tree')['nodes'],
+ 'names': [name for name, _ in
+ mgr.get('osd_map').get('erasure_code_profiles', {}).items()]
+ }
diff --git a/src/pybind/mgr/dashboard/controllers/feedback.py b/src/pybind/mgr/dashboard/controllers/feedback.py
new file mode 100644
index 000000000..c75ffa94a
--- /dev/null
+++ b/src/pybind/mgr/dashboard/controllers/feedback.py
@@ -0,0 +1,120 @@
+# # -*- coding: utf-8 -*-
+
+from .. import mgr
+from ..exceptions import DashboardException
+from ..security import Scope
+from . import APIDoc, APIRouter, BaseController, Endpoint, ReadPermission, RESTController, UIRouter
+from ._version import APIVersion
+
+
+@APIRouter('/feedback', Scope.CONFIG_OPT)
+@APIDoc("Feedback API", "Report")
+class FeedbackController(RESTController):
+
+ @RESTController.MethodMap(version=APIVersion.EXPERIMENTAL)
+ def list(self):
+ """
+ List all issues details.
+ """
+ try:
+ response = mgr.remote('feedback', 'get_issues')
+ except RuntimeError as error:
+ raise DashboardException(msg=f'Error in fetching issue list: {str(error)}',
+ http_status_code=error.status_code,
+ component='feedback')
+ return response
+
+ @RESTController.MethodMap(version=APIVersion.EXPERIMENTAL)
+ def create(self, project, tracker, subject, description, api_key=None):
+ """
+ Create an issue.
+ :param project: The affected ceph component.
+ :param tracker: The tracker type.
+ :param subject: The title of the issue.
+ :param description: The description of the issue.
+ :param api_key: Ceph tracker api key.
+ """
+ try:
+ response = mgr.remote('feedback', 'validate_and_create_issue',
+ project, tracker, subject, description, api_key)
+ except RuntimeError as error:
+ if "Invalid issue tracker API key" in str(error):
+ raise DashboardException(msg='Error in creating tracker issue: Invalid API key',
+ component='feedback')
+ if "KeyError" in str(error):
+ raise DashboardException(msg=f'Error in creating tracker issue: {error}',
+ component='feedback')
+ raise DashboardException(msg=f'{error}',
+ http_status_code=500,
+ component='feedback')
+
+ return response
+
+
+@APIRouter('/feedback/api_key', Scope.CONFIG_OPT)
+@APIDoc(group="Report")
+class FeedbackApiController(RESTController):
+
+ @RESTController.MethodMap(version=APIVersion.EXPERIMENTAL)
+ def list(self):
+ """
+ Returns Ceph tracker API key.
+ """
+ try:
+ api_key = mgr.remote('feedback', 'get_api_key')
+ except ImportError:
+ raise DashboardException(msg='Feedback module not found.',
+ http_status_code=404,
+ component='feedback')
+ except RuntimeError as error:
+ raise DashboardException(msg=f'{error}',
+ http_status_code=500,
+ component='feedback')
+ if api_key is None:
+ raise DashboardException(msg='Issue tracker API key is not set',
+ component='feedback')
+ return api_key
+
+ @RESTController.MethodMap(version=APIVersion.EXPERIMENTAL)
+ def create(self, api_key):
+ """
+ Sets Ceph tracker API key.
+ :param api_key: The Ceph tracker API key.
+ """
+ try:
+ response = mgr.remote('feedback', 'set_api_key', api_key)
+ except RuntimeError as error:
+ raise DashboardException(msg=f'{error}',
+ component='feedback')
+ return response
+
+ @RESTController.MethodMap(version=APIVersion.EXPERIMENTAL)
+ def bulk_delete(self):
+ """
+ Deletes Ceph tracker API key.
+ """
+ try:
+ response = mgr.remote('feedback', 'delete_api_key')
+ except RuntimeError as error:
+ raise DashboardException(msg=f'{error}',
+ http_status_code=500,
+ component='feedback')
+ return response
+
+
+@UIRouter('/feedback/api_key', Scope.CONFIG_OPT)
+class FeedbackUiController(BaseController):
+ @Endpoint()
+ @ReadPermission
+ def exist(self):
+ """
+ Checks if Ceph tracker API key is stored.
+ """
+ try:
+ response = mgr.remote('feedback', 'is_api_key_set')
+ except RuntimeError:
+ raise DashboardException(msg='Feedback module is not enabled',
+ http_status_code=404,
+ component='feedback')
+
+ return response
diff --git a/src/pybind/mgr/dashboard/controllers/frontend_logging.py b/src/pybind/mgr/dashboard/controllers/frontend_logging.py
new file mode 100644
index 000000000..df9ca19cc
--- /dev/null
+++ b/src/pybind/mgr/dashboard/controllers/frontend_logging.py
@@ -0,0 +1,13 @@
+import logging
+
+from . import BaseController, Endpoint, UIRouter
+
+logger = logging.getLogger('frontend.error')
+
+
+@UIRouter('/logging', secure=False)
+class FrontendLogging(BaseController):
+
+ @Endpoint('POST', path='js-error')
+ def jsError(self, url, message, stack=None): # noqa: N802
+ logger.error('(%s): %s\n %s\n', url, message, stack)
diff --git a/src/pybind/mgr/dashboard/controllers/grafana.py b/src/pybind/mgr/dashboard/controllers/grafana.py
new file mode 100644
index 000000000..79a680671
--- /dev/null
+++ b/src/pybind/mgr/dashboard/controllers/grafana.py
@@ -0,0 +1,49 @@
+# -*- coding: utf-8 -*-
+from .. import mgr
+from ..grafana import GrafanaRestClient, push_local_dashboards
+from ..security import Scope
+from ..services.exception import handle_error
+from ..settings import Settings
+from . import APIDoc, APIRouter, BaseController, Endpoint, EndpointDoc, \
+ ReadPermission, UpdatePermission
+
+URL_SCHEMA = {
+ "instance": (str, "grafana instance")
+}
+
+
+@APIRouter('/grafana', Scope.GRAFANA)
+@APIDoc("Grafana Management API", "Grafana")
+class Grafana(BaseController):
+ @Endpoint()
+ @ReadPermission
+ @EndpointDoc("List Grafana URL Instance", responses={200: URL_SCHEMA})
+ def url(self):
+ grafana_url = mgr.get_module_option('GRAFANA_API_URL')
+ grafana_frontend_url = mgr.get_module_option('GRAFANA_FRONTEND_API_URL')
+ if grafana_frontend_url != '' and grafana_url == '':
+ url = ''
+ else:
+ url = (mgr.get_module_option('GRAFANA_FRONTEND_API_URL')
+ or mgr.get_module_option('GRAFANA_API_URL')).rstrip('/')
+ response = {'instance': url}
+ return response
+
+ @Endpoint()
+ @ReadPermission
+ @handle_error('grafana')
+ def validation(self, params):
+ grafana = GrafanaRestClient()
+ method = 'GET'
+ url = str(Settings.GRAFANA_API_URL).rstrip('/') + \
+ '/api/dashboards/uid/' + params
+ response = grafana.url_validation(method, url)
+ return response
+
+ @Endpoint(method='POST')
+ @UpdatePermission
+ @handle_error('grafana', 500)
+ def dashboards(self):
+ response = dict()
+ response['success'] = push_local_dashboards()
+ return response
diff --git a/src/pybind/mgr/dashboard/controllers/health.py b/src/pybind/mgr/dashboard/controllers/health.py
new file mode 100644
index 000000000..633d37a32
--- /dev/null
+++ b/src/pybind/mgr/dashboard/controllers/health.py
@@ -0,0 +1,302 @@
+# -*- coding: utf-8 -*-
+
+import json
+
+from .. import mgr
+from ..rest_client import RequestException
+from ..security import Permission, Scope
+from ..services.ceph_service import CephService
+from ..services.cluster import ClusterModel
+from ..services.iscsi_cli import IscsiGatewaysConfig
+from ..services.iscsi_client import IscsiClient
+from ..tools import partial_dict
+from . import APIDoc, APIRouter, BaseController, Endpoint, EndpointDoc
+from .host import get_hosts
+
+HEALTH_MINIMAL_SCHEMA = ({
+ 'client_perf': ({
+ 'read_bytes_sec': (int, ''),
+ 'read_op_per_sec': (int, ''),
+ 'recovering_bytes_per_sec': (int, ''),
+ 'write_bytes_sec': (int, ''),
+ 'write_op_per_sec': (int, ''),
+ }, ''),
+ 'df': ({
+ 'stats': ({
+ 'total_avail_bytes': (int, ''),
+ 'total_bytes': (int, ''),
+ 'total_used_raw_bytes': (int, ''),
+ }, '')
+ }, ''),
+ 'fs_map': ({
+ 'filesystems': ([{
+ 'mdsmap': ({
+ 'session_autoclose': (int, ''),
+ 'balancer': (str, ''),
+ 'up': (str, ''),
+ 'last_failure_osd_epoch': (int, ''),
+ 'in': ([int], ''),
+ 'last_failure': (int, ''),
+ 'max_file_size': (int, ''),
+ 'explicitly_allowed_features': (int, ''),
+ 'damaged': ([int], ''),
+ 'tableserver': (int, ''),
+ 'failed': ([int], ''),
+ 'metadata_pool': (int, ''),
+ 'epoch': (int, ''),
+ 'stopped': ([int], ''),
+ 'max_mds': (int, ''),
+ 'compat': ({
+ 'compat': (str, ''),
+ 'ro_compat': (str, ''),
+ 'incompat': (str, ''),
+ }, ''),
+ 'required_client_features': (str, ''),
+ 'data_pools': ([int], ''),
+ 'info': (str, ''),
+ 'fs_name': (str, ''),
+ 'created': (str, ''),
+ 'standby_count_wanted': (int, ''),
+ 'enabled': (bool, ''),
+ 'modified': (str, ''),
+ 'session_timeout': (int, ''),
+ 'flags': (int, ''),
+ 'ever_allowed_features': (int, ''),
+ 'root': (int, ''),
+ }, ''),
+ 'standbys': (str, ''),
+ }], ''),
+ }, ''),
+ 'health': ({
+ 'checks': (str, ''),
+ 'mutes': (str, ''),
+ 'status': (str, ''),
+ }, ''),
+ 'hosts': (int, ''),
+ 'iscsi_daemons': ({
+ 'up': (int, ''),
+ 'down': (int, '')
+ }, ''),
+ 'mgr_map': ({
+ 'active_name': (str, ''),
+ 'standbys': (str, '')
+ }, ''),
+ 'mon_status': ({
+ 'monmap': ({
+ 'mons': (str, ''),
+ }, ''),
+ 'quorum': ([int], '')
+ }, ''),
+ 'osd_map': ({
+ 'osds': ([{
+ 'in': (int, ''),
+ 'up': (int, ''),
+ }], '')
+ }, ''),
+ 'pg_info': ({
+ 'object_stats': ({
+ 'num_objects': (int, ''),
+ 'num_object_copies': (int, ''),
+ 'num_objects_degraded': (int, ''),
+ 'num_objects_misplaced': (int, ''),
+ 'num_objects_unfound': (int, ''),
+ }, ''),
+ 'pgs_per_osd': (int, ''),
+ 'statuses': (str, '')
+ }, ''),
+ 'pools': (str, ''),
+ 'rgw': (int, ''),
+ 'scrub_status': (str, '')
+})
+
+
+class HealthData(object):
+ """
+ A class to be used in combination with BaseController to allow either
+ "full" or "minimal" sets of health data to be collected.
+
+ To function properly, it needs BaseCollector._has_permissions to be passed
+ in as ``auth_callback``.
+ """
+
+ def __init__(self, auth_callback, minimal=True):
+ self._has_permissions = auth_callback
+ self._minimal = minimal
+
+ def all_health(self):
+ result = {
+ "health": self.basic_health(),
+ }
+
+ if self._has_permissions(Permission.READ, Scope.MONITOR):
+ result['mon_status'] = self.mon_status()
+
+ if self._has_permissions(Permission.READ, Scope.CEPHFS):
+ result['fs_map'] = self.fs_map()
+
+ if self._has_permissions(Permission.READ, Scope.OSD):
+ result['osd_map'] = self.osd_map()
+ result['scrub_status'] = self.scrub_status()
+ result['pg_info'] = self.pg_info()
+
+ if self._has_permissions(Permission.READ, Scope.MANAGER):
+ result['mgr_map'] = self.mgr_map()
+
+ if self._has_permissions(Permission.READ, Scope.POOL):
+ result['pools'] = self.pools()
+ result['df'] = self.df()
+ result['client_perf'] = self.client_perf()
+
+ if self._has_permissions(Permission.READ, Scope.HOSTS):
+ result['hosts'] = self.host_count()
+
+ if self._has_permissions(Permission.READ, Scope.RGW):
+ result['rgw'] = self.rgw_count()
+
+ if self._has_permissions(Permission.READ, Scope.ISCSI):
+ result['iscsi_daemons'] = self.iscsi_daemons()
+
+ return result
+
+ def basic_health(self):
+ health_data = mgr.get("health")
+ health = json.loads(health_data['json'])
+
+ # Transform the `checks` dict into a list for the convenience
+ # of rendering from javascript.
+ checks = []
+ for k, v in health['checks'].items():
+ v['type'] = k
+ checks.append(v)
+
+ checks = sorted(checks, key=lambda c: c['severity'])
+ health['checks'] = checks
+ return health
+
+ def client_perf(self):
+ result = CephService.get_client_perf()
+ if self._minimal:
+ result = partial_dict(
+ result,
+ ['read_bytes_sec', 'read_op_per_sec',
+ 'recovering_bytes_per_sec', 'write_bytes_sec',
+ 'write_op_per_sec']
+ )
+ return result
+
+ def df(self):
+ df = mgr.get('df')
+
+ del df['stats_by_class']
+
+ if self._minimal:
+ df = dict(stats=partial_dict(
+ df['stats'],
+ ['total_avail_bytes', 'total_bytes',
+ 'total_used_raw_bytes']
+ ))
+ return df
+
+ def fs_map(self):
+ fs_map = mgr.get('fs_map')
+ if self._minimal:
+ fs_map = partial_dict(fs_map, ['filesystems', 'standbys'])
+ fs_map['filesystems'] = [partial_dict(item, ['mdsmap']) for
+ item in fs_map['filesystems']]
+ for fs in fs_map['filesystems']:
+ mdsmap_info = fs['mdsmap']['info']
+ min_mdsmap_info = dict()
+ for k, v in mdsmap_info.items():
+ min_mdsmap_info[k] = partial_dict(v, ['state'])
+ return fs_map
+
+ def host_count(self):
+ return len(get_hosts())
+
+ def iscsi_daemons(self):
+ up_counter = 0
+ down_counter = 0
+ for gateway_name in IscsiGatewaysConfig.get_gateways_config()['gateways']:
+ try:
+ IscsiClient.instance(gateway_name=gateway_name).ping()
+ up_counter += 1
+ except RequestException:
+ down_counter += 1
+ return {'up': up_counter, 'down': down_counter}
+
+ def mgr_map(self):
+ mgr_map = mgr.get('mgr_map')
+ if self._minimal:
+ mgr_map = partial_dict(mgr_map, ['active_name', 'standbys'])
+ return mgr_map
+
+ def mon_status(self):
+ mon_status = json.loads(mgr.get('mon_status')['json'])
+ if self._minimal:
+ mon_status = partial_dict(mon_status, ['monmap', 'quorum'])
+ mon_status['monmap'] = partial_dict(
+ mon_status['monmap'], ['mons']
+ )
+ mon_status['monmap']['mons'] = [{}] * \
+ len(mon_status['monmap']['mons'])
+ return mon_status
+
+ def osd_map(self):
+ osd_map = mgr.get('osd_map')
+ assert osd_map is not None
+ # Not needed, skip the effort of transmitting this to UI
+ del osd_map['pg_temp']
+ if self._minimal:
+ osd_map = partial_dict(osd_map, ['osds'])
+ osd_map['osds'] = [
+ partial_dict(item, ['in', 'up', 'state'])
+ for item in osd_map['osds']
+ ]
+ else:
+ osd_map['tree'] = mgr.get('osd_map_tree')
+ osd_map['crush'] = mgr.get('osd_map_crush')
+ osd_map['crush_map_text'] = mgr.get('osd_map_crush_map_text')
+ osd_map['osd_metadata'] = mgr.get('osd_metadata')
+ return osd_map
+
+ def pg_info(self):
+ return CephService.get_pg_info()
+
+ def pools(self):
+ pools = CephService.get_pool_list_with_stats()
+ if self._minimal:
+ pools = [{}] * len(pools)
+ return pools
+
+ def rgw_count(self):
+ return len(CephService.get_service_list('rgw'))
+
+ def scrub_status(self):
+ return CephService.get_scrub_status()
+
+
+@APIRouter('/health')
+@APIDoc("Display Detailed Cluster health Status", "Health")
+class Health(BaseController):
+ def __init__(self):
+ super().__init__()
+ self.health_full = HealthData(self._has_permissions, minimal=False)
+ self.health_minimal = HealthData(self._has_permissions, minimal=True)
+
+ @Endpoint()
+ def full(self):
+ return self.health_full.all_health()
+
+ @Endpoint()
+ @EndpointDoc("Get Cluster's minimal health report",
+ responses={200: HEALTH_MINIMAL_SCHEMA})
+ def minimal(self):
+ return self.health_minimal.all_health()
+
+ @Endpoint()
+ def get_cluster_capacity(self):
+ return ClusterModel.get_capacity()
+
+ @Endpoint()
+ def get_cluster_fsid(self):
+ return mgr.get('config')['fsid']
diff --git a/src/pybind/mgr/dashboard/controllers/home.py b/src/pybind/mgr/dashboard/controllers/home.py
new file mode 100644
index 000000000..f911cf388
--- /dev/null
+++ b/src/pybind/mgr/dashboard/controllers/home.py
@@ -0,0 +1,148 @@
+# -*- coding: utf-8 -*-
+
+import json
+import logging
+import os
+import re
+
+try:
+ from functools import lru_cache
+except ImportError:
+ from ..plugins.lru_cache import lru_cache
+
+import cherrypy
+from cherrypy.lib.static import serve_file
+
+from .. import mgr
+from . import BaseController, Endpoint, Proxy, Router, UIRouter
+
+logger = logging.getLogger("controllers.home")
+
+
+class LanguageMixin(object):
+ def __init__(self):
+ try:
+ self.LANGUAGES = {
+ f
+ for f in os.listdir(mgr.get_frontend_path())
+ if os.path.isdir(os.path.join(mgr.get_frontend_path(), f))
+ }
+ except FileNotFoundError:
+ logger.exception("Build directory missing")
+ self.LANGUAGES = {}
+
+ self.LANGUAGES_PATH_MAP = {
+ f.lower(): {
+ 'lang': f,
+ 'path': os.path.join(mgr.get_frontend_path(), f)
+ }
+ for f in self.LANGUAGES
+ }
+ # pre-populating with the primary language subtag.
+ for lang in list(self.LANGUAGES_PATH_MAP.keys()):
+ if '-' in lang:
+ self.LANGUAGES_PATH_MAP[lang.split('-')[0]] = {
+ 'lang': self.LANGUAGES_PATH_MAP[lang]['lang'],
+ 'path': self.LANGUAGES_PATH_MAP[lang]['path']
+ }
+ with open(os.path.normpath("{}/../package.json".format(mgr.get_frontend_path())),
+ "r") as f:
+ config = json.load(f)
+ self.DEFAULT_LANGUAGE = config['config']['locale']
+ self.DEFAULT_LANGUAGE_PATH = os.path.join(mgr.get_frontend_path(),
+ self.DEFAULT_LANGUAGE)
+ super().__init__()
+
+
+@Router("/", secure=False)
+class HomeController(BaseController, LanguageMixin):
+ LANG_TAG_SEQ_RE = re.compile(r'\s*([^,]+)\s*,?\s*')
+ LANG_TAG_RE = re.compile(
+ r'^(?P<locale>[a-zA-Z]{1,8}(-[a-zA-Z0-9]{1,8})*|\*)(;q=(?P<weight>[01]\.\d{0,3}))?$')
+ MAX_ACCEPTED_LANGS = 10
+
+ @lru_cache()
+ def _parse_accept_language(self, accept_lang_header):
+ result = []
+ for i, m in enumerate(self.LANG_TAG_SEQ_RE.finditer(accept_lang_header)):
+ if i >= self.MAX_ACCEPTED_LANGS:
+ logger.debug("reached max accepted languages, skipping remaining")
+ break
+
+ tag_match = self.LANG_TAG_RE.match(m.group(1))
+ if tag_match is None:
+ raise cherrypy.HTTPError(400, "Malformed 'Accept-Language' header")
+ locale = tag_match.group('locale').lower()
+ weight = tag_match.group('weight')
+ if weight:
+ try:
+ ratio = float(weight)
+ except ValueError:
+ raise cherrypy.HTTPError(400, "Malformed 'Accept-Language' header")
+ else:
+ ratio = 1.0
+ result.append((locale, ratio))
+
+ result.sort(key=lambda l: l[0])
+ result.sort(key=lambda l: l[1], reverse=True)
+ logger.debug("language preference: %s", result)
+ return [r[0] for r in result]
+
+ def _language_dir(self, langs):
+ for lang in langs:
+ if lang in self.LANGUAGES_PATH_MAP:
+ logger.debug("found directory for language '%s'", lang)
+ cherrypy.response.headers[
+ 'Content-Language'] = self.LANGUAGES_PATH_MAP[lang]['lang']
+ return self.LANGUAGES_PATH_MAP[lang]['path']
+
+ logger.debug("Languages '%s' not available, falling back to %s",
+ langs, self.DEFAULT_LANGUAGE)
+ cherrypy.response.headers['Content-Language'] = self.DEFAULT_LANGUAGE
+ return self.DEFAULT_LANGUAGE_PATH
+
+ @Proxy()
+ def __call__(self, path, **params):
+ if not path:
+ path = "index.html"
+
+ if 'cd-lang' in cherrypy.request.cookie:
+ langs = [cherrypy.request.cookie['cd-lang'].value.lower()]
+ logger.debug("frontend language from cookie: %s", langs)
+ else:
+ if 'Accept-Language' in cherrypy.request.headers:
+ accept_lang_header = cherrypy.request.headers['Accept-Language']
+ langs = self._parse_accept_language(accept_lang_header)
+ else:
+ langs = [self.DEFAULT_LANGUAGE.lower()]
+ logger.debug("frontend language from headers: %s", langs)
+
+ base_dir = self._language_dir(langs)
+ full_path = os.path.join(base_dir, path)
+
+ # Block uplevel attacks
+ if not os.path.normpath(full_path).startswith(os.path.normpath(base_dir)):
+ raise cherrypy.HTTPError(403) # Forbidden
+
+ logger.debug("serving static content: %s", full_path)
+ if 'Vary' in cherrypy.response.headers:
+ cherrypy.response.headers['Vary'] = "{}, Accept-Language"
+ else:
+ cherrypy.response.headers['Vary'] = "Accept-Language"
+
+ cherrypy.response.headers['Cache-control'] = "no-cache"
+ return serve_file(full_path)
+
+
+@UIRouter("/langs", secure=False)
+class LangsController(BaseController, LanguageMixin):
+ @Endpoint('GET')
+ def __call__(self):
+ return list(self.LANGUAGES)
+
+
+@UIRouter("/login", secure=False)
+class LoginController(BaseController):
+ @Endpoint('GET', 'custom_banner')
+ def __call__(self):
+ return mgr.get_store('custom_login_banner')
diff --git a/src/pybind/mgr/dashboard/controllers/host.py b/src/pybind/mgr/dashboard/controllers/host.py
new file mode 100644
index 000000000..812b9c035
--- /dev/null
+++ b/src/pybind/mgr/dashboard/controllers/host.py
@@ -0,0 +1,514 @@
+# -*- coding: utf-8 -*-
+
+import os
+import time
+from collections import Counter
+from typing import Dict, List, Optional
+
+import cherrypy
+from mgr_util import merge_dicts
+
+from .. import mgr
+from ..exceptions import DashboardException
+from ..plugins.ttl_cache import ttl_cache, ttl_cache_invalidator
+from ..security import Scope
+from ..services._paginate import ListPaginator
+from ..services.ceph_service import CephService
+from ..services.exception import handle_orchestrator_error
+from ..services.orchestrator import OrchClient, OrchFeature
+from ..tools import TaskManager, merge_list_of_dicts_by_key, str_to_bool
+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 = {
+ "hostname": (str, "Hostname"),
+ "services": ([{
+ "type": (str, "type of service"),
+ "id": (str, "Service Id"),
+ }], "Services related to the host"),
+ "service_instances": ([{
+ "type": (str, "type of service"),
+ "count": (int, "Number of instances of the service"),
+ }], "Service instances related to the host"),
+ "ceph_version": (str, "Ceph version"),
+ "addr": (str, "Host address"),
+ "labels": ([str], "Labels related to the host"),
+ "service_type": (str, ""),
+ "sources": ({
+ "ceph": (bool, ""),
+ "orchestrator": (bool, "")
+ }, "Host Sources"),
+ "status": (str, "")
+}
+
+INVENTORY_SCHEMA = {
+ "name": (str, "Hostname"),
+ "addr": (str, "Host address"),
+ "devices": ([{
+ "rejected_reasons": ([str], ""),
+ "available": (bool, "If the device can be provisioned to an OSD"),
+ "path": (str, "Device path"),
+ "sys_api": ({
+ "removable": (str, ""),
+ "ro": (str, ""),
+ "vendor": (str, ""),
+ "model": (str, ""),
+ "rev": (str, ""),
+ "sas_address": (str, ""),
+ "sas_device_handle": (str, ""),
+ "support_discard": (str, ""),
+ "rotational": (str, ""),
+ "nr_requests": (str, ""),
+ "scheduler_mode": (str, ""),
+ "partitions": ({
+ "partition_name": ({
+ "start": (str, ""),
+ "sectors": (str, ""),
+ "sectorsize": (int, ""),
+ "size": (int, ""),
+ "human_readable_size": (str, ""),
+ "holders": ([str], "")
+ }, "")
+ }, ""),
+ "sectors": (int, ""),
+ "sectorsize": (str, ""),
+ "size": (int, ""),
+ "human_readable_size": (str, ""),
+ "path": (str, ""),
+ "locked": (int, "")
+ }, ""),
+ "lvs": ([{
+ "name": (str, ""),
+ "osd_id": (str, ""),
+ "cluster_name": (str, ""),
+ "type": (str, ""),
+ "osd_fsid": (str, ""),
+ "cluster_fsid": (str, ""),
+ "osdspec_affinity": (str, ""),
+ "block_uuid": (str, ""),
+ }], ""),
+ "human_readable_type": (str, "Device type. ssd or hdd"),
+ "device_id": (str, "Device's udev ID"),
+ "lsm_data": ({
+ "serialNum": (str, ""),
+ "transport": (str, ""),
+ "mediaType": (str, ""),
+ "rpm": (str, ""),
+ "linkSpeed": (str, ""),
+ "health": (str, ""),
+ "ledSupport": ({
+ "IDENTsupport": (str, ""),
+ "IDENTstatus": (str, ""),
+ "FAILsupport": (str, ""),
+ "FAILstatus": (str, ""),
+ }, ""),
+ "errors": ([str], "")
+ }, ""),
+ "osd_ids": ([int], "Device OSD IDs")
+ }], "Host devices"),
+ "labels": ([str], "Host labels")
+}
+
+
+def host_task(name, metadata, wait_for=10.0):
+ return Task("host/{}".format(name), metadata, wait_for)
+
+
+def populate_service_instances(hostname, services):
+ orch = OrchClient.instance()
+ if orch.available():
+ services = (daemon['daemon_type']
+ for daemon in (d.to_dict()
+ for d in orch.services.list_daemons(hostname=hostname)))
+ else:
+ services = (daemon['type'] for daemon in services)
+ return [{'type': k, 'count': v} for k, v in Counter(services).items()]
+
+
+@ttl_cache(60, label='get_hosts')
+def get_hosts(sources=None):
+ """
+ Get hosts from various sources.
+ """
+ from_ceph = True
+ from_orchestrator = True
+ if sources:
+ _sources = sources.split(',')
+ from_ceph = 'ceph' in _sources
+ from_orchestrator = 'orchestrator' in _sources
+
+ if from_orchestrator:
+ orch = OrchClient.instance()
+ if orch.available():
+ hosts = [
+ merge_dicts(
+ {
+ 'ceph_version': '',
+ 'services': [],
+ 'sources': {
+ 'ceph': False,
+ 'orchestrator': True
+ }
+ }, host.to_json()) for host in orch.hosts.list()
+ ]
+ return hosts
+
+ ceph_hosts = []
+ if from_ceph:
+ ceph_hosts = [
+ merge_dicts(
+ server, {
+ 'addr': '',
+ 'labels': [],
+ 'sources': {
+ 'ceph': True,
+ 'orchestrator': False
+ },
+ 'status': ''
+ }) for server in mgr.list_servers()
+ ]
+ return ceph_hosts
+
+
+def get_host(hostname: str) -> Dict:
+ """
+ Get a specific host from Ceph or Orchestrator (if available).
+ :param hostname: The name of the host to fetch.
+ :raises: cherrypy.HTTPError: If host not found.
+ """
+ for host in get_hosts():
+ if host['hostname'] == hostname:
+ return host
+ raise cherrypy.HTTPError(404)
+
+
+def get_device_osd_map():
+ """Get mappings from inventory devices to OSD IDs.
+
+ :return: Returns a dictionary containing mappings. Note one device might
+ shared between multiple OSDs.
+ e.g. {
+ 'node1': {
+ 'nvme0n1': [0, 1],
+ 'vdc': [0],
+ 'vdb': [1]
+ },
+ 'node2': {
+ 'vdc': [2]
+ }
+ }
+ :rtype: dict
+ """
+ result: dict = {}
+ for osd_id, osd_metadata in mgr.get('osd_metadata').items():
+ hostname = osd_metadata.get('hostname')
+ devices = osd_metadata.get('devices')
+ if not hostname or not devices:
+ continue
+ if hostname not in result:
+ result[hostname] = {}
+ # for OSD contains multiple devices, devices is in `sda,sdb`
+ for device in devices.split(','):
+ if device not in result[hostname]:
+ result[hostname][device] = [int(osd_id)]
+ else:
+ result[hostname][device].append(int(osd_id))
+ return result
+
+
+def get_inventories(hosts: Optional[List[str]] = None,
+ refresh: Optional[bool] = None) -> List[dict]:
+ """Get inventories from the Orchestrator and link devices with OSD IDs.
+
+ :param hosts: Hostnames to query.
+ :param refresh: Ask the Orchestrator to refresh the inventories. Note the this is an
+ asynchronous operation, the updated version of inventories need to
+ be re-qeuried later.
+ :return: Returns list of inventory.
+ :rtype: list
+ """
+ do_refresh = False
+ if refresh is not None:
+ do_refresh = str_to_bool(refresh)
+ orch = OrchClient.instance()
+ inventory_hosts = [host.to_json()
+ for host in orch.inventory.list(hosts=hosts, refresh=do_refresh)]
+ device_osd_map = get_device_osd_map()
+ for inventory_host in inventory_hosts:
+ host_osds = device_osd_map.get(inventory_host['name'])
+ for device in inventory_host['devices']:
+ if host_osds: # pragma: no cover
+ dev_name = os.path.basename(device['path'])
+ device['osd_ids'] = sorted(host_osds.get(dev_name, []))
+ else:
+ device['osd_ids'] = []
+ return inventory_hosts
+
+
+@allow_empty_body
+def add_host(hostname: str, addr: Optional[str] = None,
+ labels: Optional[List[str]] = None,
+ status: Optional[str] = None):
+ orch_client = OrchClient.instance()
+ host = Host()
+ host.check_orchestrator_host_op(orch_client, hostname)
+ orch_client.hosts.add(hostname, addr, labels)
+ if status == 'maintenance':
+ orch_client.hosts.enter_maintenance(hostname)
+
+
+@APIRouter('/host', Scope.HOSTS)
+@APIDoc("Get Host Details", "Host")
+class Host(RESTController):
+ @EndpointDoc("List Host Specifications",
+ parameters={
+ 'sources': (str, 'Host Sources'),
+ 'facts': (bool, 'Host Facts')
+ },
+ responses={200: LIST_HOST_SCHEMA})
+ @RESTController.MethodMap(version=APIVersion(1, 3))
+ def list(self, sources=None, facts=False, offset: int = 0,
+ limit: int = 5, search: str = '', sort: str = ''):
+ hosts = get_hosts(sources)
+ params = ['hostname']
+ paginator = ListPaginator(int(offset), int(limit), sort, search, hosts,
+ searchable_params=params, sortable_params=params,
+ default_sort='+hostname')
+ # pylint: disable=unnecessary-comprehension
+ hosts = [host for host in paginator.list()]
+ orch = OrchClient.instance()
+ cherrypy.response.headers['X-Total-Count'] = paginator.get_count()
+ for host in hosts:
+ if 'services' not in host:
+ host['services'] = []
+ host['service_instances'] = populate_service_instances(
+ host['hostname'], host['services'])
+ if str_to_bool(facts):
+ if orch.available():
+ if not orch.get_missing_features(['get_facts']):
+ hosts_facts = []
+ for host in hosts:
+ facts = orch.hosts.get_facts(host['hostname'])[0]
+ hosts_facts.append(facts)
+ return merge_list_of_dicts_by_key(hosts, hosts_facts, 'hostname')
+
+ raise DashboardException(
+ code='invalid_orchestrator_backend', # pragma: no cover
+ msg="Please enable the cephadm orchestrator backend "
+ "(try `ceph orch set backend cephadm`)",
+ component='orchestrator',
+ http_status_code=400)
+
+ raise DashboardException(code='orchestrator_status_unavailable', # pragma: no cover
+ msg="Please configure and enable the orchestrator if you "
+ "really want to gather facts from hosts",
+ component='orchestrator',
+ http_status_code=400)
+ return hosts
+
+ @raise_if_no_orchestrator([OrchFeature.HOST_LIST, OrchFeature.HOST_ADD])
+ @handle_orchestrator_error('host')
+ @host_task('add', {'hostname': '{hostname}'})
+ @EndpointDoc('',
+ parameters={
+ 'hostname': (str, 'Hostname'),
+ 'addr': (str, 'Network Address'),
+ 'labels': ([str], 'Host Labels'),
+ 'status': (str, 'Host Status')
+ },
+ responses={200: None, 204: None})
+ @RESTController.MethodMap(version=APIVersion.EXPERIMENTAL)
+ def create(self, hostname: str,
+ addr: Optional[str] = None,
+ labels: Optional[List[str]] = None,
+ status: Optional[str] = None): # pragma: no cover - requires realtime env
+ add_host(hostname, addr, labels, status)
+
+ @raise_if_no_orchestrator([OrchFeature.HOST_LIST, OrchFeature.HOST_REMOVE])
+ @handle_orchestrator_error('host')
+ @host_task('remove', {'hostname': '{hostname}'})
+ @allow_empty_body
+ def delete(self, hostname): # pragma: no cover - requires realtime env
+ orch_client = OrchClient.instance()
+ self.check_orchestrator_host_op(orch_client, hostname, False)
+ orch_client.hosts.remove(hostname)
+
+ def check_orchestrator_host_op(self, orch_client, hostname, add=True): # pragma:no cover
+ """Check if we can adding or removing a host with orchestrator
+
+ :param orch_client: Orchestrator client
+ :param add: True for adding host operation, False for removing host
+ :raise DashboardException
+ """
+ host = orch_client.hosts.get(hostname)
+ if add and host:
+ raise DashboardException(
+ code='orchestrator_add_existed_host',
+ msg='{} is already in orchestrator'.format(hostname),
+ component='orchestrator')
+ if not add and not host:
+ raise DashboardException(
+ code='orchestrator_remove_nonexistent_host',
+ msg='Remove a non-existent host {} from orchestrator'.format(hostname),
+ component='orchestrator')
+
+ @RESTController.Resource('GET')
+ def devices(self, hostname):
+ # (str) -> List
+ return CephService.get_devices_by_host(hostname)
+
+ @RESTController.Resource('GET')
+ def smart(self, hostname):
+ # type: (str) -> dict
+ return CephService.get_smart_data_by_host(hostname)
+
+ @RESTController.Resource('GET')
+ @raise_if_no_orchestrator([OrchFeature.DEVICE_LIST])
+ @handle_orchestrator_error('host')
+ @EndpointDoc('Get inventory of a host',
+ parameters={
+ 'hostname': (str, 'Hostname'),
+ 'refresh': (str, 'Trigger asynchronous refresh'),
+ },
+ responses={200: INVENTORY_SCHEMA})
+ def inventory(self, hostname, refresh=None):
+ inventory = get_inventories([hostname], refresh)
+ if inventory:
+ return inventory[0]
+ return {}
+
+ @RESTController.Resource('POST')
+ @UpdatePermission
+ @raise_if_no_orchestrator([OrchFeature.DEVICE_BLINK_LIGHT])
+ @handle_orchestrator_error('host')
+ @host_task('identify_device', ['{hostname}', '{device}'], wait_for=2.0)
+ def identify_device(self, hostname, device, duration):
+ # type: (str, str, int) -> None
+ """
+ Identify a device by switching on the device light for N seconds.
+ :param hostname: The hostname of the device to process.
+ :param device: The device identifier to process, e.g. ``/dev/dm-0`` or
+ ``ABC1234DEF567-1R1234_ABC8DE0Q``.
+ :param duration: The duration in seconds how long the LED should flash.
+ """
+ orch = OrchClient.instance()
+ TaskManager.current_task().set_progress(0)
+ orch.blink_device_light(hostname, device, 'ident', True)
+ for i in range(int(duration)):
+ percentage = int(round(i / float(duration) * 100))
+ TaskManager.current_task().set_progress(percentage)
+ time.sleep(1)
+ orch.blink_device_light(hostname, device, 'ident', False)
+ TaskManager.current_task().set_progress(100)
+
+ @RESTController.Resource('GET')
+ @raise_if_no_orchestrator([OrchFeature.DAEMON_LIST])
+ def daemons(self, hostname: str) -> List[dict]:
+ orch = OrchClient.instance()
+ daemons = orch.services.list_daemons(hostname=hostname)
+ return [d.to_dict() for d in daemons]
+
+ @handle_orchestrator_error('host')
+ @RESTController.MethodMap(version=APIVersion(1, 2))
+ def get(self, hostname: str) -> Dict:
+ """
+ Get the specified host.
+ :raises: cherrypy.HTTPError: If host not found.
+ """
+ host = get_host(hostname)
+ host['service_instances'] = populate_service_instances(
+ host['hostname'], host['services'])
+ return host
+
+ @ttl_cache_invalidator('get_hosts')
+ @raise_if_no_orchestrator([OrchFeature.HOST_LABEL_ADD,
+ OrchFeature.HOST_LABEL_REMOVE,
+ OrchFeature.HOST_MAINTENANCE_ENTER,
+ OrchFeature.HOST_MAINTENANCE_EXIT,
+ OrchFeature.HOST_DRAIN])
+ @handle_orchestrator_error('host')
+ @EndpointDoc('',
+ parameters={
+ 'hostname': (str, 'Hostname'),
+ 'update_labels': (bool, 'Update Labels'),
+ 'labels': ([str], 'Host Labels'),
+ 'maintenance': (bool, 'Enter/Exit Maintenance'),
+ 'force': (bool, 'Force Enter Maintenance'),
+ 'drain': (bool, 'Drain Host')
+ },
+ responses={200: None, 204: None})
+ @RESTController.MethodMap(version=APIVersion.EXPERIMENTAL)
+ def set(self, hostname: str, update_labels: bool = False,
+ labels: List[str] = None, maintenance: bool = False,
+ force: bool = False, drain: bool = False):
+ """
+ Update the specified host.
+ Note, this is only supported when Ceph Orchestrator is enabled.
+ :param hostname: The name of the host to be processed.
+ :param update_labels: To update the labels.
+ :param labels: List of labels.
+ :param maintenance: Enter/Exit maintenance mode.
+ :param force: Force enter maintenance mode.
+ :param drain: Drain host
+ """
+ orch = OrchClient.instance()
+ host = get_host(hostname)
+
+ if maintenance:
+ status = host['status']
+ if status != 'maintenance':
+ orch.hosts.enter_maintenance(hostname, force)
+
+ if status == 'maintenance':
+ orch.hosts.exit_maintenance(hostname)
+
+ if drain:
+ orch.hosts.drain(hostname)
+
+ if update_labels:
+ # only allow List[str] type for labels
+ if not isinstance(labels, list):
+ raise DashboardException(
+ msg='Expected list of labels. Please check API documentation.',
+ http_status_code=400,
+ component='orchestrator')
+ current_labels = set(host['labels'])
+ # Remove labels.
+ remove_labels = list(current_labels.difference(set(labels)))
+ for label in remove_labels:
+ orch.hosts.remove_label(hostname, label)
+ # Add labels.
+ add_labels = list(set(labels).difference(current_labels))
+ for label in add_labels:
+ orch.hosts.add_label(hostname, label)
+
+
+@UIRouter('/host', Scope.HOSTS)
+class HostUi(BaseController):
+ @Endpoint('GET')
+ @ReadPermission
+ @handle_orchestrator_error('host')
+ def labels(self) -> List[str]:
+ """
+ Get all host labels.
+ Note, host labels are only supported when Ceph Orchestrator is enabled.
+ If Ceph Orchestrator is not enabled, an empty list is returned.
+ :return: A list of all host labels.
+ """
+ labels = []
+ orch = OrchClient.instance()
+ if orch.available():
+ for host in orch.hosts.list():
+ labels.extend(host.labels)
+ labels.sort()
+ return list(set(labels)) # Filter duplicate labels.
+
+ @Endpoint('GET')
+ @ReadPermission
+ @raise_if_no_orchestrator([OrchFeature.DEVICE_LIST])
+ @handle_orchestrator_error('host')
+ def inventory(self, refresh=None):
+ return get_inventories(None, refresh)
diff --git a/src/pybind/mgr/dashboard/controllers/iscsi.py b/src/pybind/mgr/dashboard/controllers/iscsi.py
new file mode 100644
index 000000000..4754c1fab
--- /dev/null
+++ b/src/pybind/mgr/dashboard/controllers/iscsi.py
@@ -0,0 +1,1140 @@
+# -*- coding: utf-8 -*-
+# pylint: disable=C0302
+# pylint: disable=too-many-branches
+# pylint: disable=too-many-lines
+
+import json
+import re
+from copy import deepcopy
+from typing import Any, Dict, List, no_type_check
+
+import cherrypy
+import rados
+import rbd
+
+from .. import mgr
+from ..exceptions import DashboardException
+from ..rest_client import RequestException
+from ..security import Scope
+from ..services.exception import handle_request_error
+from ..services.iscsi_cli import IscsiGatewaysConfig
+from ..services.iscsi_client import IscsiClient
+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 APIDoc, APIRouter, BaseController, Endpoint, EndpointDoc, \
+ ReadPermission, RESTController, Task, UIRouter, UpdatePermission
+
+ISCSI_SCHEMA = {
+ 'user': (str, 'username'),
+ 'password': (str, 'password'),
+ 'mutual_user': (str, ''),
+ 'mutual_password': (str, '')
+}
+
+
+@UIRouter('/iscsi', Scope.ISCSI)
+class IscsiUi(BaseController):
+
+ REQUIRED_CEPH_ISCSI_CONFIG_MIN_VERSION = 10
+ REQUIRED_CEPH_ISCSI_CONFIG_MAX_VERSION = 11
+
+ @Endpoint()
+ @ReadPermission
+ @no_type_check
+ def status(self):
+ status = {'available': False}
+ try:
+ gateway = get_available_gateway()
+ except DashboardException as e:
+ status['message'] = str(e)
+ return status
+ try:
+ config = IscsiClient.instance(gateway_name=gateway).get_config()
+ if config['version'] < IscsiUi.REQUIRED_CEPH_ISCSI_CONFIG_MIN_VERSION or \
+ config['version'] > IscsiUi.REQUIRED_CEPH_ISCSI_CONFIG_MAX_VERSION:
+ status['message'] = 'Unsupported `ceph-iscsi` config version. ' \
+ 'Expected >= {} and <= {} but found' \
+ ' {}.'.format(IscsiUi.REQUIRED_CEPH_ISCSI_CONFIG_MIN_VERSION,
+ IscsiUi.REQUIRED_CEPH_ISCSI_CONFIG_MAX_VERSION,
+ config['version'])
+ return status
+ status['available'] = True
+ except RequestException as e:
+ if e.content:
+ try:
+ content = json.loads(e.content)
+ content_message = content.get('message')
+ except ValueError:
+ content_message = e.content
+ if content_message:
+ status['message'] = content_message
+
+ return status
+
+ @Endpoint()
+ @ReadPermission
+ def version(self):
+ gateway = get_available_gateway()
+ config = IscsiClient.instance(gateway_name=gateway).get_config()
+ return {
+ 'ceph_iscsi_config_version': config['version']
+ }
+
+ @Endpoint()
+ @ReadPermission
+ def settings(self):
+ gateway = get_available_gateway()
+ settings = IscsiClient.instance(gateway_name=gateway).get_settings()
+ if 'target_controls_limits' in settings:
+ target_default_controls = settings['target_default_controls']
+ for ctrl_k, ctrl_v in target_default_controls.items():
+ limits = settings['target_controls_limits'].get(ctrl_k, {})
+ if 'type' not in limits:
+ # default
+ limits['type'] = 'int'
+ # backward compatibility
+ if target_default_controls[ctrl_k] in ['Yes', 'No']:
+ limits['type'] = 'bool'
+ target_default_controls[ctrl_k] = str_to_bool(ctrl_v)
+ settings['target_controls_limits'][ctrl_k] = limits
+ if 'disk_controls_limits' in settings:
+ for backstore, disk_controls_limits in settings['disk_controls_limits'].items():
+ disk_default_controls = settings['disk_default_controls'][backstore]
+ for ctrl_k, ctrl_v in disk_default_controls.items():
+ limits = disk_controls_limits.get(ctrl_k, {})
+ if 'type' not in limits:
+ # default
+ limits['type'] = 'int'
+ settings['disk_controls_limits'][backstore][ctrl_k] = limits
+ return settings
+
+ @Endpoint()
+ @ReadPermission
+ def portals(self):
+ portals = []
+ gateways_config = IscsiGatewaysConfig.get_gateways_config()
+ for name in gateways_config['gateways']:
+ try:
+ ip_addresses = IscsiClient.instance(gateway_name=name).get_ip_addresses()
+ portals.append({'name': name, 'ip_addresses': ip_addresses['data']})
+ except RequestException:
+ pass
+ return sorted(portals, key=lambda p: '{}.{}'.format(p['name'], p['ip_addresses']))
+
+ @Endpoint()
+ @ReadPermission
+ def overview(self):
+ gateways_names = IscsiGatewaysConfig.get_gateways_config()['gateways'].keys()
+ config = None
+ for gateway_name in gateways_names:
+ try:
+ config = IscsiClient.instance(gateway_name=gateway_name).get_config()
+ break
+ except RequestException:
+ pass
+
+ result_gateways = self._get_gateways_info(gateways_names, config)
+ result_images = self._get_images_info(config)
+
+ return {
+ 'gateways': sorted(result_gateways, key=lambda g: g['name']),
+ 'images': sorted(result_images, key=lambda i: '{}/{}'.format(i['pool'], i['image']))
+ }
+
+ def _get_images_info(self, config):
+ # Images info
+ result_images = []
+ if config:
+ tcmu_info = TcmuService.get_iscsi_info()
+ for _, disk_config in config['disks'].items():
+ image = {
+ 'pool': disk_config['pool'],
+ 'image': disk_config['image'],
+ 'backstore': disk_config['backstore'],
+ 'optimized_since': None,
+ 'stats': None,
+ 'stats_history': None
+ }
+ tcmu_image_info = TcmuService.get_image_info(image['pool'],
+ image['image'],
+ tcmu_info)
+ if tcmu_image_info:
+ if 'optimized_since' in tcmu_image_info:
+ image['optimized_since'] = tcmu_image_info['optimized_since']
+ if 'stats' in tcmu_image_info:
+ image['stats'] = tcmu_image_info['stats']
+ if 'stats_history' in tcmu_image_info:
+ image['stats_history'] = tcmu_image_info['stats_history']
+ result_images.append(image)
+ return result_images
+
+ def _get_gateways_info(self, gateways_names, config):
+ result_gateways = []
+ # Gateways info
+ for gateway_name in gateways_names:
+ gateway = {
+ 'name': gateway_name,
+ 'state': '',
+ 'num_targets': 'n/a',
+ 'num_sessions': 'n/a'
+ }
+ try:
+ IscsiClient.instance(gateway_name=gateway_name).ping()
+ gateway['state'] = 'up'
+ if config:
+ gateway['num_sessions'] = 0
+ if gateway_name in config['gateways']:
+ gatewayinfo = IscsiClient.instance(
+ gateway_name=gateway_name).get_gatewayinfo()
+ gateway['num_sessions'] = gatewayinfo['num_sessions']
+ except RequestException:
+ gateway['state'] = 'down'
+ if config:
+ gateway['num_targets'] = len([target for _, target in config['targets'].items()
+ if gateway_name in target['portals']])
+ result_gateways.append(gateway)
+ return result_gateways
+
+
+@APIRouter('/iscsi', Scope.ISCSI)
+@APIDoc("Iscsi Management API", "Iscsi")
+class Iscsi(BaseController):
+ @Endpoint('GET', 'discoveryauth')
+ @ReadPermission
+ @EndpointDoc("Get Iscsi discoveryauth Details",
+ responses={'200': [ISCSI_SCHEMA]})
+ def get_discoveryauth(self):
+ gateway = get_available_gateway()
+ return self._get_discoveryauth(gateway)
+
+ @Endpoint('PUT', 'discoveryauth',
+ query_params=['user', 'password', 'mutual_user', 'mutual_password'])
+ @UpdatePermission
+ @EndpointDoc("Set Iscsi discoveryauth",
+ parameters={
+ 'user': (str, 'Username'),
+ 'password': (str, 'Password'),
+ 'mutual_user': (str, 'Mutual UserName'),
+ 'mutual_password': (str, 'Mutual Password'),
+ })
+ def set_discoveryauth(self, user, password, mutual_user, mutual_password):
+ validate_auth({
+ 'user': user,
+ 'password': password,
+ 'mutual_user': mutual_user,
+ 'mutual_password': mutual_password
+ })
+
+ gateway = get_available_gateway()
+ config = IscsiClient.instance(gateway_name=gateway).get_config()
+ gateway_names = list(config['gateways'].keys())
+ validate_rest_api(gateway_names)
+ IscsiClient.instance(gateway_name=gateway).update_discoveryauth(user,
+ password,
+ mutual_user,
+ mutual_password)
+ return self._get_discoveryauth(gateway)
+
+ def _get_discoveryauth(self, gateway):
+ config = IscsiClient.instance(gateway_name=gateway).get_config()
+ user = config['discovery_auth']['username']
+ password = config['discovery_auth']['password']
+ mutual_user = config['discovery_auth']['mutual_username']
+ mutual_password = config['discovery_auth']['mutual_password']
+ return {
+ 'user': user,
+ 'password': password,
+ 'mutual_user': mutual_user,
+ 'mutual_password': mutual_password
+ }
+
+
+def iscsi_target_task(name, metadata, wait_for=2.0):
+ return Task("iscsi/target/{}".format(name), metadata, wait_for)
+
+
+@APIRouter('/iscsi/target', Scope.ISCSI)
+@APIDoc("Get Iscsi Target Details", "IscsiTarget")
+class IscsiTarget(RESTController):
+
+ def list(self):
+ gateway = get_available_gateway()
+ config = IscsiClient.instance(gateway_name=gateway).get_config()
+ targets = []
+ for target_iqn in config['targets'].keys():
+ target = IscsiTarget._config_to_target(target_iqn, config)
+ IscsiTarget._set_info(target)
+ targets.append(target)
+ return targets
+
+ def get(self, target_iqn):
+ gateway = get_available_gateway()
+ config = IscsiClient.instance(gateway_name=gateway).get_config()
+ if target_iqn not in config['targets']:
+ raise cherrypy.HTTPError(404)
+ target = IscsiTarget._config_to_target(target_iqn, config)
+ IscsiTarget._set_info(target)
+ return target
+
+ @iscsi_target_task('delete', {'target_iqn': '{target_iqn}'})
+ def delete(self, target_iqn):
+ gateway = get_available_gateway()
+ config = IscsiClient.instance(gateway_name=gateway).get_config()
+ if target_iqn not in config['targets']:
+ raise DashboardException(msg='Target does not exist',
+ code='target_does_not_exist',
+ component='iscsi')
+ portal_names = list(config['targets'][target_iqn]['portals'].keys())
+ validate_rest_api(portal_names)
+ if portal_names:
+ portal_name = portal_names[0]
+ target_info = IscsiClient.instance(gateway_name=portal_name).get_targetinfo(target_iqn)
+ if target_info['num_sessions'] > 0:
+ raise DashboardException(msg='Target has active sessions',
+ code='target_has_active_sessions',
+ component='iscsi')
+ IscsiTarget._delete(target_iqn, config, 0, 100)
+
+ @iscsi_target_task('create', {'target_iqn': '{target_iqn}'})
+ def create(self, target_iqn=None, target_controls=None, acl_enabled=None,
+ auth=None, portals=None, disks=None, clients=None, groups=None):
+ target_controls = target_controls or {}
+ portals = portals or []
+ disks = disks or []
+ clients = clients or []
+ groups = groups or []
+
+ validate_auth(auth)
+ for client in clients:
+ validate_auth(client['auth'])
+
+ gateway = get_available_gateway()
+ config = IscsiClient.instance(gateway_name=gateway).get_config()
+ if target_iqn in config['targets']:
+ raise DashboardException(msg='Target already exists',
+ code='target_already_exists',
+ component='iscsi')
+ settings = IscsiClient.instance(gateway_name=gateway).get_settings()
+ IscsiTarget._validate(target_iqn, target_controls, portals, disks, groups, settings)
+
+ IscsiTarget._create(target_iqn, target_controls, acl_enabled, auth, portals, disks,
+ clients, groups, 0, 100, config, settings)
+
+ @iscsi_target_task('edit', {'target_iqn': '{target_iqn}'})
+ def set(self, target_iqn, new_target_iqn=None, target_controls=None, acl_enabled=None,
+ auth=None, portals=None, disks=None, clients=None, groups=None):
+ target_controls = target_controls or {}
+ portals = IscsiTarget._sorted_portals(portals)
+ disks = IscsiTarget._sorted_disks(disks)
+ clients = IscsiTarget._sorted_clients(clients)
+ groups = IscsiTarget._sorted_groups(groups)
+
+ validate_auth(auth)
+ for client in clients:
+ validate_auth(client['auth'])
+
+ gateway = get_available_gateway()
+ config = IscsiClient.instance(gateway_name=gateway).get_config()
+ if target_iqn not in config['targets']:
+ raise DashboardException(msg='Target does not exist',
+ code='target_does_not_exist',
+ component='iscsi')
+ if target_iqn != new_target_iqn and new_target_iqn in config['targets']:
+ raise DashboardException(msg='Target IQN already in use',
+ code='target_iqn_already_in_use',
+ component='iscsi')
+
+ settings = IscsiClient.instance(gateway_name=gateway).get_settings()
+ new_portal_names = {p['host'] for p in portals}
+ old_portal_names = set(config['targets'][target_iqn]['portals'].keys())
+ deleted_portal_names = list(old_portal_names - new_portal_names)
+ validate_rest_api(deleted_portal_names)
+ IscsiTarget._validate(new_target_iqn, target_controls, portals, disks, groups, settings)
+ IscsiTarget._validate_delete(gateway, target_iqn, config, new_target_iqn, target_controls,
+ disks, clients, groups)
+ config = IscsiTarget._delete(target_iqn, config, 0, 50, new_target_iqn, target_controls,
+ portals, disks, clients, groups)
+ IscsiTarget._create(new_target_iqn, target_controls, acl_enabled, auth, portals, disks,
+ clients, groups, 50, 100, config, settings)
+
+ @staticmethod
+ def _delete(target_iqn, config, task_progress_begin, task_progress_end, new_target_iqn=None,
+ new_target_controls=None, new_portals=None, new_disks=None, new_clients=None,
+ new_groups=None):
+ new_target_controls = new_target_controls or {}
+ new_portals = new_portals or []
+ new_disks = new_disks or []
+ new_clients = new_clients or []
+ new_groups = new_groups or []
+
+ TaskManager.current_task().set_progress(task_progress_begin)
+ target_config = config['targets'][target_iqn]
+ if not target_config['portals'].keys():
+ raise DashboardException(msg="Cannot delete a target that doesn't contain any portal",
+ code='cannot_delete_target_without_portals',
+ component='iscsi')
+ target = IscsiTarget._config_to_target(target_iqn, config)
+ n_groups = len(target_config['groups'])
+ n_clients = len(target_config['clients'])
+ n_target_disks = len(target_config['disks'])
+ task_progress_steps = n_groups + n_clients + n_target_disks
+ task_progress_inc = 0
+ if task_progress_steps != 0:
+ task_progress_inc = int((task_progress_end - task_progress_begin) / task_progress_steps)
+
+ gateway_name = list(target_config['portals'].keys())[0]
+ IscsiTarget._delete_groups(target_config, target, new_target_iqn,
+ new_target_controls, new_groups, gateway_name,
+ target_iqn, task_progress_inc)
+ deleted_clients, deleted_client_luns = IscsiTarget._delete_clients(
+ target_config, target, new_target_iqn, new_target_controls, new_clients,
+ gateway_name, target_iqn, new_groups, task_progress_inc)
+ IscsiTarget._delete_disks(target_config, target, new_target_iqn, new_target_controls,
+ new_disks, deleted_clients, new_groups, deleted_client_luns,
+ gateway_name, target_iqn, task_progress_inc)
+ IscsiTarget._delete_gateways(target, new_portals, gateway_name, target_iqn)
+ if IscsiTarget._target_deletion_required(target, new_target_iqn, new_target_controls):
+ IscsiClient.instance(gateway_name=gateway_name).delete_target(target_iqn)
+ TaskManager.current_task().set_progress(task_progress_end)
+ return IscsiClient.instance(gateway_name=gateway_name).get_config()
+
+ @staticmethod
+ def _delete_gateways(target, new_portals, gateway_name, target_iqn):
+ old_portals_by_host = IscsiTarget._get_portals_by_host(target['portals'])
+ new_portals_by_host = IscsiTarget._get_portals_by_host(new_portals)
+ for old_portal_host, old_portal_ip_list in old_portals_by_host.items():
+ if IscsiTarget._target_portal_deletion_required(old_portal_host,
+ old_portal_ip_list,
+ new_portals_by_host):
+ IscsiClient.instance(gateway_name=gateway_name).delete_gateway(target_iqn,
+ old_portal_host)
+
+ @staticmethod
+ def _delete_disks(target_config, target, new_target_iqn, new_target_controls,
+ new_disks, deleted_clients, new_groups, deleted_client_luns,
+ gateway_name, target_iqn, task_progress_inc):
+ for image_id in target_config['disks']:
+ if IscsiTarget._target_lun_deletion_required(target, new_target_iqn,
+ new_target_controls, new_disks, image_id):
+ all_clients = target_config['clients'].keys()
+ not_deleted_clients = [c for c in all_clients if c not in deleted_clients
+ and not IscsiTarget._client_in_group(target['groups'], c)
+ and not IscsiTarget._client_in_group(new_groups, c)]
+ for client_iqn in not_deleted_clients:
+ client_image_ids = target_config['clients'][client_iqn]['luns'].keys()
+ for client_image_id in client_image_ids:
+ if image_id == client_image_id and \
+ (client_iqn, client_image_id) not in deleted_client_luns:
+ IscsiClient.instance(gateway_name=gateway_name).delete_client_lun(
+ target_iqn, client_iqn, client_image_id)
+ IscsiClient.instance(gateway_name=gateway_name).delete_target_lun(target_iqn,
+ image_id)
+ pool, image = image_id.split('/', 1)
+ IscsiClient.instance(gateway_name=gateway_name).delete_disk(pool, image)
+ TaskManager.current_task().inc_progress(task_progress_inc)
+
+ @staticmethod
+ def _delete_clients(target_config, target, new_target_iqn, new_target_controls,
+ new_clients, gateway_name, target_iqn, new_groups, task_progress_inc):
+ deleted_clients = []
+ deleted_client_luns = []
+ for client_iqn, client_config in target_config['clients'].items():
+ if IscsiTarget._client_deletion_required(target, new_target_iqn, new_target_controls,
+ new_clients, client_iqn):
+ deleted_clients.append(client_iqn)
+ IscsiClient.instance(gateway_name=gateway_name).delete_client(target_iqn,
+ client_iqn)
+ else:
+ for image_id in list(client_config.get('luns', {}).keys()):
+ if IscsiTarget._client_lun_deletion_required(target, client_iqn, image_id,
+ new_clients, new_groups):
+ deleted_client_luns.append((client_iqn, image_id))
+ IscsiClient.instance(gateway_name=gateway_name).delete_client_lun(
+ target_iqn, client_iqn, image_id)
+ TaskManager.current_task().inc_progress(task_progress_inc)
+ return deleted_clients, deleted_client_luns
+
+ @staticmethod
+ def _delete_groups(target_config, target, new_target_iqn, new_target_controls,
+ new_groups, gateway_name, target_iqn, task_progress_inc):
+ for group_id in list(target_config['groups'].keys()):
+ if IscsiTarget._group_deletion_required(target, new_target_iqn, new_target_controls,
+ new_groups, group_id):
+ IscsiClient.instance(gateway_name=gateway_name).delete_group(target_iqn,
+ group_id)
+ else:
+ group = IscsiTarget._get_group(new_groups, group_id)
+
+ old_group_disks = set(target_config['groups'][group_id]['disks'].keys())
+ new_group_disks = {'{}/{}'.format(x['pool'], x['image']) for x in group['disks']}
+ local_deleted_disks = list(old_group_disks - new_group_disks)
+
+ old_group_members = set(target_config['groups'][group_id]['members'])
+ new_group_members = set(group['members'])
+ local_deleted_members = list(old_group_members - new_group_members)
+
+ if local_deleted_disks or local_deleted_members:
+ IscsiClient.instance(gateway_name=gateway_name).update_group(
+ target_iqn, group_id, local_deleted_members, local_deleted_disks)
+ TaskManager.current_task().inc_progress(task_progress_inc)
+
+ @staticmethod
+ def _get_group(groups, group_id):
+ for group in groups:
+ if group['group_id'] == group_id:
+ return group
+ return None
+
+ @staticmethod
+ def _group_deletion_required(target, new_target_iqn, new_target_controls,
+ new_groups, group_id):
+ if IscsiTarget._target_deletion_required(target, new_target_iqn, new_target_controls):
+ return True
+ new_group = IscsiTarget._get_group(new_groups, group_id)
+ if not new_group:
+ return True
+ return False
+
+ @staticmethod
+ def _get_client(clients, client_iqn):
+ for client in clients:
+ if client['client_iqn'] == client_iqn:
+ return client
+ return None
+
+ @staticmethod
+ def _client_deletion_required(target, new_target_iqn, new_target_controls,
+ new_clients, client_iqn):
+ if IscsiTarget._target_deletion_required(target, new_target_iqn, new_target_controls):
+ return True
+ new_client = IscsiTarget._get_client(new_clients, client_iqn)
+ if not new_client:
+ return True
+ return False
+
+ @staticmethod
+ def _client_in_group(groups, client_iqn):
+ for group in groups:
+ if client_iqn in group['members']:
+ return True
+ return False
+
+ @staticmethod
+ def _client_lun_deletion_required(target, client_iqn, image_id, new_clients, new_groups):
+ new_client = IscsiTarget._get_client(new_clients, client_iqn)
+ if not new_client:
+ return True
+
+ # Disks inherited from groups must be considered
+ was_in_group = IscsiTarget._client_in_group(target['groups'], client_iqn)
+ is_in_group = IscsiTarget._client_in_group(new_groups, client_iqn)
+
+ if not was_in_group and is_in_group:
+ return True
+
+ if is_in_group:
+ return False
+
+ new_lun = IscsiTarget._get_disk(new_client.get('luns', []), image_id)
+ if not new_lun:
+ return True
+
+ old_client = IscsiTarget._get_client(target['clients'], client_iqn)
+ if not old_client:
+ return False
+
+ old_lun = IscsiTarget._get_disk(old_client.get('luns', []), image_id)
+ return new_lun != old_lun
+
+ @staticmethod
+ def _get_disk(disks, image_id):
+ for disk in disks:
+ if '{}/{}'.format(disk['pool'], disk['image']) == image_id:
+ return disk
+ return None
+
+ @staticmethod
+ def _target_lun_deletion_required(target, new_target_iqn, new_target_controls,
+ new_disks, image_id):
+ if IscsiTarget._target_deletion_required(target, new_target_iqn, new_target_controls):
+ return True
+ new_disk = IscsiTarget._get_disk(new_disks, image_id)
+ if not new_disk:
+ return True
+ old_disk = IscsiTarget._get_disk(target['disks'], image_id)
+ new_disk_without_controls = deepcopy(new_disk)
+ new_disk_without_controls.pop('controls')
+ old_disk_without_controls = deepcopy(old_disk)
+ old_disk_without_controls.pop('controls')
+ if new_disk_without_controls != old_disk_without_controls:
+ return True
+ return False
+
+ @staticmethod
+ def _target_portal_deletion_required(old_portal_host, old_portal_ip_list, new_portals_by_host):
+ if old_portal_host not in new_portals_by_host:
+ return True
+ if sorted(old_portal_ip_list) != sorted(new_portals_by_host[old_portal_host]):
+ return True
+ return False
+
+ @staticmethod
+ def _target_deletion_required(target, new_target_iqn, new_target_controls):
+ gateway = get_available_gateway()
+ settings = IscsiClient.instance(gateway_name=gateway).get_settings()
+
+ if target['target_iqn'] != new_target_iqn:
+ return True
+ if settings['api_version'] < 2 and target['target_controls'] != new_target_controls:
+ return True
+ return False
+
+ @staticmethod
+ def _validate(target_iqn, target_controls, portals, disks, groups, settings):
+ if not target_iqn:
+ raise DashboardException(msg='Target IQN is required',
+ code='target_iqn_required',
+ component='iscsi')
+
+ minimum_gateways = max(1, settings['config']['minimum_gateways'])
+ portals_by_host = IscsiTarget._get_portals_by_host(portals)
+ if len(portals_by_host.keys()) < minimum_gateways:
+ if minimum_gateways == 1:
+ msg = 'At least one portal is required'
+ else:
+ msg = 'At least {} portals are required'.format(minimum_gateways)
+ raise DashboardException(msg=msg,
+ code='portals_required',
+ component='iscsi')
+
+ # 'target_controls_limits' was introduced in ceph-iscsi > 3.2
+ # When using an older `ceph-iscsi` version these validations will
+ # NOT be executed beforehand
+ IscsiTarget._validate_target_controls_limits(settings, target_controls)
+ portal_names = [p['host'] for p in portals]
+ validate_rest_api(portal_names)
+ IscsiTarget._validate_disks(disks, settings)
+ IscsiTarget._validate_initiators(groups)
+
+ @staticmethod
+ def _validate_initiators(groups):
+ initiators = [] # type: List[Any]
+ for group in groups:
+ initiators = initiators + group['members']
+ if len(initiators) != len(set(initiators)):
+ raise DashboardException(msg='Each initiator can only be part of 1 group at a time',
+ code='initiator_in_multiple_groups',
+ component='iscsi')
+
+ @staticmethod
+ def _validate_disks(disks, settings):
+ for disk in disks:
+ pool = disk['pool']
+ image = disk['image']
+ backstore = disk['backstore']
+ required_rbd_features = settings['required_rbd_features'][backstore]
+ unsupported_rbd_features = settings['unsupported_rbd_features'][backstore]
+ IscsiTarget._validate_image(pool, image, backstore, required_rbd_features,
+ unsupported_rbd_features)
+ IscsiTarget._validate_disk_controls_limits(settings, disk, backstore)
+
+ @staticmethod
+ def _validate_disk_controls_limits(settings, disk, backstore):
+ # 'disk_controls_limits' was introduced in ceph-iscsi > 3.2
+ # When using an older `ceph-iscsi` version these validations will
+ # NOT be executed beforehand
+ if 'disk_controls_limits' in settings:
+ for disk_control_name, disk_control_value in disk['controls'].items():
+ limits = settings['disk_controls_limits'][backstore].get(disk_control_name)
+ if limits is not None:
+ min_value = limits.get('min')
+ if min_value is not None and disk_control_value < min_value:
+ raise DashboardException(msg='Disk control {} must be >= '
+ '{}'.format(disk_control_name, min_value),
+ code='disk_control_invalid_min',
+ component='iscsi')
+ max_value = limits.get('max')
+ if max_value is not None and disk_control_value > max_value:
+ raise DashboardException(msg='Disk control {} must be <= '
+ '{}'.format(disk_control_name, max_value),
+ code='disk_control_invalid_max',
+ component='iscsi')
+
+ @staticmethod
+ def _validate_target_controls_limits(settings, target_controls):
+ if 'target_controls_limits' in settings:
+ for target_control_name, target_control_value in target_controls.items():
+ limits = settings['target_controls_limits'].get(target_control_name)
+ if limits is not None:
+ min_value = limits.get('min')
+ if min_value is not None and target_control_value < min_value:
+ raise DashboardException(msg='Target control {} must be >= '
+ '{}'.format(target_control_name, min_value),
+ code='target_control_invalid_min',
+ component='iscsi')
+ max_value = limits.get('max')
+ if max_value is not None and target_control_value > max_value:
+ raise DashboardException(msg='Target control {} must be <= '
+ '{}'.format(target_control_name, max_value),
+ code='target_control_invalid_max',
+ component='iscsi')
+
+ @staticmethod
+ def _validate_image(pool, image, backstore, required_rbd_features, unsupported_rbd_features):
+ try:
+ ioctx = mgr.rados.open_ioctx(pool)
+ try:
+ with rbd.Image(ioctx, image) as img:
+ if img.features() & required_rbd_features != required_rbd_features:
+ raise DashboardException(msg='Image {} cannot be exported using {} '
+ 'backstore because required features are '
+ 'missing (required features are '
+ '{})'.format(image,
+ backstore,
+ format_bitmask(
+ required_rbd_features)),
+ code='image_missing_required_features',
+ component='iscsi')
+ if img.features() & unsupported_rbd_features != 0:
+ raise DashboardException(msg='Image {} cannot be exported using {} '
+ 'backstore because it contains unsupported '
+ 'features ('
+ '{})'.format(image,
+ backstore,
+ format_bitmask(
+ unsupported_rbd_features)),
+ code='image_contains_unsupported_features',
+ component='iscsi')
+
+ except rbd.ImageNotFound:
+ raise DashboardException(msg='Image {} does not exist'.format(image),
+ code='image_does_not_exist',
+ component='iscsi')
+ except rados.ObjectNotFound:
+ raise DashboardException(msg='Pool {} does not exist'.format(pool),
+ code='pool_does_not_exist',
+ component='iscsi')
+
+ @staticmethod
+ def _validate_delete(gateway, target_iqn, config, new_target_iqn=None, new_target_controls=None,
+ new_disks=None, new_clients=None, new_groups=None):
+ new_target_controls = new_target_controls or {}
+ new_disks = new_disks or []
+ new_clients = new_clients or []
+ new_groups = new_groups or []
+
+ target_config = config['targets'][target_iqn]
+ target = IscsiTarget._config_to_target(target_iqn, config)
+ for client_iqn in list(target_config['clients'].keys()):
+ if IscsiTarget._client_deletion_required(target, new_target_iqn, new_target_controls,
+ new_clients, client_iqn):
+ client_info = IscsiClient.instance(gateway_name=gateway).get_clientinfo(target_iqn,
+ client_iqn)
+ if client_info.get('state', {}).get('LOGGED_IN', []):
+ raise DashboardException(msg="Client '{}' cannot be deleted until it's logged "
+ "out".format(client_iqn),
+ code='client_logged_in',
+ component='iscsi')
+
+ @staticmethod
+ def _update_targetauth(config, target_iqn, auth, gateway_name):
+ # Target level authentication was introduced in ceph-iscsi config v11
+ if config['version'] > 10:
+ user = auth['user']
+ password = auth['password']
+ mutual_user = auth['mutual_user']
+ mutual_password = auth['mutual_password']
+ IscsiClient.instance(gateway_name=gateway_name).update_targetauth(target_iqn,
+ user,
+ password,
+ mutual_user,
+ mutual_password)
+
+ @staticmethod
+ def _update_targetacl(target_config, target_iqn, acl_enabled, gateway_name):
+ if not target_config or target_config['acl_enabled'] != acl_enabled:
+ targetauth_action = ('enable_acl' if acl_enabled else 'disable_acl')
+ IscsiClient.instance(gateway_name=gateway_name).update_targetacl(target_iqn,
+ targetauth_action)
+
+ @staticmethod
+ def _is_auth_equal(auth_config, auth):
+ return auth['user'] == auth_config['username'] and \
+ auth['password'] == auth_config['password'] and \
+ auth['mutual_user'] == auth_config['mutual_username'] and \
+ auth['mutual_password'] == auth_config['mutual_password']
+
+ @staticmethod
+ @handle_request_error('iscsi')
+ def _create(target_iqn, target_controls, acl_enabled,
+ auth, portals, disks, clients, groups,
+ task_progress_begin, task_progress_end, config, settings):
+ target_config = config['targets'].get(target_iqn, None)
+ TaskManager.current_task().set_progress(task_progress_begin)
+ portals_by_host = IscsiTarget._get_portals_by_host(portals)
+ n_hosts = len(portals_by_host)
+ n_disks = len(disks)
+ n_clients = len(clients)
+ n_groups = len(groups)
+ task_progress_steps = n_hosts + n_disks + n_clients + n_groups
+ task_progress_inc = 0
+ if task_progress_steps != 0:
+ task_progress_inc = int((task_progress_end - task_progress_begin) / task_progress_steps)
+ gateway_name = portals[0]['host']
+ if not target_config:
+ IscsiClient.instance(gateway_name=gateway_name).create_target(target_iqn,
+ target_controls)
+ IscsiTarget._create_gateways(portals_by_host, target_config,
+ gateway_name, target_iqn, task_progress_inc)
+
+ update_acl = not target_config or \
+ acl_enabled != target_config['acl_enabled'] or \
+ not IscsiTarget._is_auth_equal(target_config['auth'], auth)
+ if update_acl:
+ IscsiTarget._update_acl(acl_enabled, config, target_iqn,
+ auth, gateway_name, target_config)
+
+ IscsiTarget._create_disks(disks, config, gateway_name, target_config,
+ target_iqn, settings, task_progress_inc)
+ IscsiTarget._create_clients(clients, target_config, gateway_name,
+ target_iqn, groups, task_progress_inc)
+ IscsiTarget._create_groups(groups, target_config, gateway_name,
+ target_iqn, task_progress_inc, target_controls,
+ task_progress_end)
+
+ @staticmethod
+ def _update_acl(acl_enabled, config, target_iqn, auth, gateway_name, target_config):
+ if acl_enabled:
+ IscsiTarget._update_targetauth(config, target_iqn, auth, gateway_name)
+ IscsiTarget._update_targetacl(target_config, target_iqn, acl_enabled,
+ gateway_name)
+ else:
+ IscsiTarget._update_targetacl(target_config, target_iqn, acl_enabled,
+ gateway_name)
+ IscsiTarget._update_targetauth(config, target_iqn, auth, gateway_name)
+
+ @staticmethod
+ def _create_gateways(portals_by_host, target_config, gateway_name, target_iqn,
+ task_progress_inc):
+ for host, ip_list in portals_by_host.items():
+ if not target_config or host not in target_config['portals']:
+ IscsiClient.instance(gateway_name=gateway_name).create_gateway(target_iqn,
+ host,
+ ip_list)
+ TaskManager.current_task().inc_progress(task_progress_inc)
+
+ @staticmethod
+ def _create_groups(groups, target_config, gateway_name, target_iqn, task_progress_inc,
+ target_controls, task_progress_end):
+ for group in groups:
+ group_id = group['group_id']
+ members = group['members']
+ image_ids = []
+ for disk in group['disks']:
+ image_ids.append('{}/{}'.format(disk['pool'], disk['image']))
+
+ if target_config and group_id in target_config['groups']:
+ old_members = target_config['groups'][group_id]['members']
+ old_disks = target_config['groups'][group_id]['disks'].keys()
+
+ if not target_config or group_id not in target_config['groups'] or \
+ list(set(group['members']) - set(old_members)) or \
+ list(set(image_ids) - set(old_disks)):
+ IscsiClient.instance(gateway_name=gateway_name).create_group(
+ target_iqn, group_id, members, image_ids)
+ TaskManager.current_task().inc_progress(task_progress_inc)
+ if target_controls:
+ if not target_config or target_controls != target_config['controls']:
+ IscsiClient.instance(gateway_name=gateway_name).reconfigure_target(
+ target_iqn, target_controls)
+ TaskManager.current_task().set_progress(task_progress_end)
+
+ @staticmethod
+ def _create_clients(clients, target_config, gateway_name, target_iqn, groups,
+ task_progress_inc):
+ for client in clients:
+ client_iqn = client['client_iqn']
+ if not target_config or client_iqn not in target_config['clients']:
+ IscsiClient.instance(gateway_name=gateway_name).create_client(target_iqn,
+ client_iqn)
+ if not target_config or client_iqn not in target_config['clients'] or \
+ not IscsiTarget._is_auth_equal(target_config['clients'][client_iqn]['auth'],
+ client['auth']):
+ user = client['auth']['user']
+ password = client['auth']['password']
+ m_user = client['auth']['mutual_user']
+ m_password = client['auth']['mutual_password']
+ IscsiClient.instance(gateway_name=gateway_name).create_client_auth(
+ target_iqn, client_iqn, user, password, m_user, m_password)
+ for lun in client['luns']:
+ pool = lun['pool']
+ image = lun['image']
+ image_id = '{}/{}'.format(pool, image)
+ # Disks inherited from groups must be considered
+ group_disks = []
+ for group in groups:
+ if client_iqn in group['members']:
+ group_disks = ['{}/{}'.format(x['pool'], x['image'])
+ for x in group['disks']]
+ if not target_config or client_iqn not in target_config['clients'] or \
+ (image_id not in target_config['clients'][client_iqn]['luns']
+ and image_id not in group_disks):
+ IscsiClient.instance(gateway_name=gateway_name).create_client_lun(
+ target_iqn, client_iqn, image_id)
+ TaskManager.current_task().inc_progress(task_progress_inc)
+
+ @staticmethod
+ def _create_disks(disks, config, gateway_name, target_config, target_iqn, settings,
+ task_progress_inc):
+ for disk in disks:
+ pool = disk['pool']
+ image = disk['image']
+ image_id = '{}/{}'.format(pool, image)
+ backstore = disk['backstore']
+ wwn = disk.get('wwn')
+ lun = disk.get('lun')
+ if image_id not in config['disks']:
+ IscsiClient.instance(gateway_name=gateway_name).create_disk(pool,
+ image,
+ backstore,
+ wwn)
+ if not target_config or image_id not in target_config['disks']:
+ IscsiClient.instance(gateway_name=gateway_name).create_target_lun(target_iqn,
+ image_id,
+ lun)
+
+ controls = disk['controls']
+ d_conf_controls = {}
+ if image_id in config['disks']:
+ d_conf_controls = config['disks'][image_id]['controls']
+ disk_default_controls = settings['disk_default_controls'][backstore]
+ for old_control in d_conf_controls.keys():
+ # If control was removed, restore the default value
+ if old_control not in controls:
+ controls[old_control] = disk_default_controls[old_control]
+
+ if (image_id not in config['disks'] or d_conf_controls != controls) and controls:
+ IscsiClient.instance(gateway_name=gateway_name).reconfigure_disk(pool,
+ image,
+ controls)
+ TaskManager.current_task().inc_progress(task_progress_inc)
+
+ @staticmethod
+ def _config_to_target(target_iqn, config):
+ target_config = config['targets'][target_iqn]
+ portals = []
+ for host, portal_config in target_config['portals'].items():
+ for portal_ip in portal_config['portal_ip_addresses']:
+ portal = {
+ 'host': host,
+ 'ip': portal_ip
+ }
+ portals.append(portal)
+ portals = IscsiTarget._sorted_portals(portals)
+ disks = []
+ for target_disk in target_config['disks']:
+ disk_config = config['disks'][target_disk]
+ disk = {
+ 'pool': disk_config['pool'],
+ 'image': disk_config['image'],
+ 'controls': disk_config['controls'],
+ 'backstore': disk_config['backstore'],
+ 'wwn': disk_config['wwn']
+ }
+ # lun_id was introduced in ceph-iscsi config v11
+ if config['version'] > 10:
+ disk['lun'] = target_config['disks'][target_disk]['lun_id']
+ disks.append(disk)
+ disks = IscsiTarget._sorted_disks(disks)
+ clients = []
+ for client_iqn, client_config in target_config['clients'].items():
+ luns = []
+ for client_lun in client_config['luns'].keys():
+ pool, image = client_lun.split('/', 1)
+ lun = {
+ 'pool': pool,
+ 'image': image
+ }
+ luns.append(lun)
+ user = client_config['auth']['username']
+ password = client_config['auth']['password']
+ mutual_user = client_config['auth']['mutual_username']
+ mutual_password = client_config['auth']['mutual_password']
+ client = {
+ 'client_iqn': client_iqn,
+ 'luns': luns,
+ 'auth': {
+ 'user': user,
+ 'password': password,
+ 'mutual_user': mutual_user,
+ 'mutual_password': mutual_password
+ }
+ }
+ clients.append(client)
+ clients = IscsiTarget._sorted_clients(clients)
+ groups = []
+ for group_id, group_config in target_config['groups'].items():
+ group_disks = []
+ for group_disk_key, _ in group_config['disks'].items():
+ pool, image = group_disk_key.split('/', 1)
+ group_disk = {
+ 'pool': pool,
+ 'image': image
+ }
+ group_disks.append(group_disk)
+ group = {
+ 'group_id': group_id,
+ 'disks': group_disks,
+ 'members': group_config['members'],
+ }
+ groups.append(group)
+ groups = IscsiTarget._sorted_groups(groups)
+ target_controls = target_config['controls']
+ acl_enabled = target_config['acl_enabled']
+ target = {
+ 'target_iqn': target_iqn,
+ 'portals': portals,
+ 'disks': disks,
+ 'clients': clients,
+ 'groups': groups,
+ 'target_controls': target_controls,
+ 'acl_enabled': acl_enabled
+ }
+ # Target level authentication was introduced in ceph-iscsi config v11
+ if config['version'] > 10:
+ target_user = target_config['auth']['username']
+ target_password = target_config['auth']['password']
+ target_mutual_user = target_config['auth']['mutual_username']
+ target_mutual_password = target_config['auth']['mutual_password']
+ target['auth'] = {
+ 'user': target_user,
+ 'password': target_password,
+ 'mutual_user': target_mutual_user,
+ 'mutual_password': target_mutual_password
+ }
+ return target
+
+ @staticmethod
+ def _is_executing(target_iqn):
+ executing_tasks, _ = TaskManager.list()
+ for t in executing_tasks:
+ if t.name.startswith('iscsi/target') and t.metadata.get('target_iqn') == target_iqn:
+ return True
+ return False
+
+ @staticmethod
+ def _set_info(target):
+ if not target['portals']:
+ return
+ target_iqn = target['target_iqn']
+ # During task execution, additional info is not available
+ if IscsiTarget._is_executing(target_iqn):
+ return
+ # If any portal is down, additional info is not available
+ for portal in target['portals']:
+ try:
+ IscsiClient.instance(gateway_name=portal['host']).ping()
+ except (IscsiGatewayDoesNotExist, RequestException):
+ return
+ gateway_name = target['portals'][0]['host']
+ try:
+ target_info = IscsiClient.instance(gateway_name=gateway_name).get_targetinfo(
+ target_iqn)
+ target['info'] = target_info
+ for client in target['clients']:
+ client_iqn = client['client_iqn']
+ client_info = IscsiClient.instance(gateway_name=gateway_name).get_clientinfo(
+ target_iqn, client_iqn)
+ client['info'] = client_info
+ except RequestException as e:
+ # Target/Client has been removed in the meanwhile (e.g. using gwcli)
+ if e.status_code != 404:
+ raise e
+
+ @staticmethod
+ def _sorted_portals(portals):
+ portals = portals or []
+ return sorted(portals, key=lambda p: '{}.{}'.format(p['host'], p['ip']))
+
+ @staticmethod
+ def _sorted_disks(disks):
+ disks = disks or []
+ return sorted(disks, key=lambda d: '{}.{}'.format(d['pool'], d['image']))
+
+ @staticmethod
+ def _sorted_clients(clients):
+ clients = clients or []
+ for client in clients:
+ client['luns'] = sorted(client['luns'],
+ key=lambda d: '{}.{}'.format(d['pool'], d['image']))
+ return sorted(clients, key=lambda c: c['client_iqn'])
+
+ @staticmethod
+ def _sorted_groups(groups):
+ groups = groups or []
+ for group in groups:
+ group['disks'] = sorted(group['disks'],
+ key=lambda d: '{}.{}'.format(d['pool'], d['image']))
+ group['members'] = sorted(group['members'])
+ return sorted(groups, key=lambda g: g['group_id'])
+
+ @staticmethod
+ def _get_portals_by_host(portals):
+ # type: (List[dict]) -> Dict[str, List[str]]
+ portals_by_host = {} # type: Dict[str, List[str]]
+ for portal in portals:
+ host = portal['host']
+ ip = portal['ip']
+ if host not in portals_by_host:
+ portals_by_host[host] = []
+ portals_by_host[host].append(ip)
+ return portals_by_host
+
+
+def get_available_gateway():
+ gateways = IscsiGatewaysConfig.get_gateways_config()['gateways']
+ if not gateways:
+ raise DashboardException(msg='There are no gateways defined',
+ code='no_gateways_defined',
+ component='iscsi')
+ for gateway in gateways:
+ try:
+ IscsiClient.instance(gateway_name=gateway).ping()
+ return gateway
+ except RequestException:
+ pass
+ raise DashboardException(msg='There are no gateways available',
+ code='no_gateways_available',
+ component='iscsi')
+
+
+def validate_rest_api(gateways):
+ for gateway in gateways:
+ try:
+ IscsiClient.instance(gateway_name=gateway).ping()
+ except RequestException:
+ raise DashboardException(msg='iSCSI REST Api not available for gateway '
+ '{}'.format(gateway),
+ code='ceph_iscsi_rest_api_not_available_for_gateway',
+ component='iscsi')
+
+
+def validate_auth(auth):
+ username_regex = re.compile(r'^[\w\.:@_-]{8,64}$')
+ password_regex = re.compile(r'^[\w@\-_\/]{12,16}$')
+ result = True
+
+ if auth['user'] or auth['password']:
+ result = bool(username_regex.match(auth['user'])) and \
+ bool(password_regex.match(auth['password']))
+
+ if auth['mutual_user'] or auth['mutual_password']:
+ result = result and bool(username_regex.match(auth['mutual_user'])) and \
+ bool(password_regex.match(auth['mutual_password'])) and auth['user']
+
+ if not result:
+ raise DashboardException(msg='Bad authentication',
+ code='target_bad_auth',
+ component='iscsi')
diff --git a/src/pybind/mgr/dashboard/controllers/logs.py b/src/pybind/mgr/dashboard/controllers/logs.py
new file mode 100644
index 000000000..133c33477
--- /dev/null
+++ b/src/pybind/mgr/dashboard/controllers/logs.py
@@ -0,0 +1,72 @@
+# -*- coding: utf-8 -*-
+
+import collections
+
+from ..security import Scope
+from ..services.ceph_service import CephService
+from ..tools import NotificationQueue
+from . import APIDoc, APIRouter, BaseController, Endpoint, EndpointDoc, ReadPermission
+
+LOG_BUFFER_SIZE = 30
+
+LOGS_SCHEMA = {
+ "clog": ([str], ""),
+ "audit_log": ([{
+ "name": (str, ""),
+ "rank": (str, ""),
+ "addrs": ({
+ "addrvec": ([{
+ "type": (str, ""),
+ "addr": (str, "IP Address"),
+ "nonce": (int, ""),
+ }], ""),
+ }, ""),
+ "stamp": (str, ""),
+ "seq": (int, ""),
+ "channel": (str, ""),
+ "priority": (str, ""),
+ "message": (str, ""),
+ }], "Audit log")
+}
+
+
+@APIRouter('/logs', Scope.LOG)
+@APIDoc("Logs Management API", "Logs")
+class Logs(BaseController):
+ def __init__(self):
+ super().__init__()
+ self._log_initialized = False
+ self.log_buffer = collections.deque(maxlen=LOG_BUFFER_SIZE)
+ self.audit_buffer = collections.deque(maxlen=LOG_BUFFER_SIZE)
+
+ def append_log(self, log_struct):
+ if log_struct['channel'] == 'audit':
+ self.audit_buffer.appendleft(log_struct)
+ else:
+ self.log_buffer.appendleft(log_struct)
+
+ def load_buffer(self, buf, channel_name):
+ lines = CephService.send_command(
+ 'mon', 'log last', channel=channel_name, num=LOG_BUFFER_SIZE, level='debug')
+ for line in lines:
+ buf.appendleft(line)
+
+ def initialize_buffers(self):
+ if not self._log_initialized:
+ self._log_initialized = True
+
+ self.load_buffer(self.log_buffer, 'cluster')
+ self.load_buffer(self.audit_buffer, 'audit')
+
+ NotificationQueue.register(self.append_log, 'clog')
+
+ @Endpoint()
+ @ReadPermission
+ @EndpointDoc("Display Logs Configuration",
+ responses={200: LOGS_SCHEMA})
+ def all(self):
+ self.initialize_buffers()
+ return dict(
+ clog=list(self.log_buffer),
+ audit_log=list(self.audit_buffer),
+ )
diff --git a/src/pybind/mgr/dashboard/controllers/mgr_modules.py b/src/pybind/mgr/dashboard/controllers/mgr_modules.py
new file mode 100644
index 000000000..57bb9b5ff
--- /dev/null
+++ b/src/pybind/mgr/dashboard/controllers/mgr_modules.py
@@ -0,0 +1,196 @@
+# -*- coding: utf-8 -*-
+
+from .. import mgr
+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 APIDoc, APIRouter, EndpointDoc, RESTController, allow_empty_body
+
+MGR_MODULE_SCHEMA = ([{
+ "name": (str, "Module Name"),
+ "enabled": (bool, "Is Module Enabled"),
+ "always_on": (bool, "Is it an always on module?"),
+ "options": ({
+ "Option_name": ({
+ "name": (str, "Name of the option"),
+ "type": (str, "Type of the option"),
+ "level": (str, "Option level"),
+ "flags": (int, "List of flags associated"),
+ "default_value": (int, "Default value for the option"),
+ "min": (str, "Minimum value"),
+ "max": (str, "Maximum value"),
+ "enum_allowed": ([str], ""),
+ "desc": (str, "Description of the option"),
+ "long_desc": (str, "Elaborated description"),
+ "tags": ([str], "Tags associated with the option"),
+ "see_also": ([str], "Related options")
+ }, "Options")
+ }, "Module Options")
+}])
+
+
+@APIRouter('/mgr/module', Scope.CONFIG_OPT)
+@APIDoc("Get details of MGR Module", "MgrModule")
+class MgrModules(RESTController):
+ ignore_modules = ['selftest']
+
+ @EndpointDoc("List Mgr modules",
+ responses={200: MGR_MODULE_SCHEMA})
+ def list(self):
+ """
+ Get the list of managed modules.
+ :return: A list of objects with the fields 'enabled', 'name' and 'options'.
+ :rtype: list
+ """
+ result = []
+ mgr_map = mgr.get('mgr_map')
+ always_on_modules = mgr_map['always_on_modules'].get(mgr.release_name, [])
+ for module_config in mgr_map['available_modules']:
+ module_name = module_config['name']
+ if module_name not in self.ignore_modules:
+ always_on = module_name in always_on_modules
+ enabled = module_name in mgr_map['modules'] or always_on
+ result.append({
+ 'name': module_name,
+ 'enabled': enabled,
+ 'always_on': always_on,
+ 'options': self._convert_module_options(
+ module_config['module_options'])
+ })
+ return result
+
+ def get(self, module_name):
+ """
+ Retrieve the values of the persistent configuration settings.
+ :param module_name: The name of the Ceph Mgr module.
+ :type module_name: str
+ :return: The values of the module options.
+ :rtype: dict
+ """
+ assert self._is_module_managed(module_name)
+ options = self._get_module_options(module_name)
+ result = {}
+ for name, option in options.items():
+ result[name] = mgr.get_module_option_ex(module_name, name,
+ option['default_value'])
+ return result
+
+ @RESTController.Resource('PUT')
+ def set(self, module_name, config):
+ """
+ Set the values of the persistent configuration settings.
+ :param module_name: The name of the Ceph Mgr module.
+ :type module_name: str
+ :param config: The values of the module options to be stored.
+ :type config: dict
+ """
+ assert self._is_module_managed(module_name)
+ options = self._get_module_options(module_name)
+ for name in options.keys():
+ if name in config:
+ mgr.set_module_option_ex(module_name, name, config[name])
+
+ @RESTController.Resource('POST')
+ @handle_send_command_error('mgr_modules')
+ @allow_empty_body
+ def enable(self, module_name):
+ """
+ Enable the specified Ceph Mgr module.
+ :param module_name: The name of the Ceph Mgr module.
+ :type module_name: str
+ """
+ assert self._is_module_managed(module_name)
+ CephService.send_command(
+ 'mon', 'mgr module enable', module=module_name)
+
+ @RESTController.Resource('POST')
+ @handle_send_command_error('mgr_modules')
+ @allow_empty_body
+ def disable(self, module_name):
+ """
+ Disable the specified Ceph Mgr module.
+ :param module_name: The name of the Ceph Mgr module.
+ :type module_name: str
+ """
+ assert self._is_module_managed(module_name)
+ CephService.send_command(
+ 'mon', 'mgr module disable', module=module_name)
+
+ @RESTController.Resource('GET')
+ def options(self, module_name):
+ """
+ Get the module options of the specified Ceph Mgr module.
+ :param module_name: The name of the Ceph Mgr module.
+ :type module_name: str
+ :return: The module options as list of dicts.
+ :rtype: list
+ """
+ assert self._is_module_managed(module_name)
+ return self._get_module_options(module_name)
+
+ def _is_module_managed(self, module_name):
+ """
+ Check if the specified Ceph Mgr module is managed by this service.
+ :param module_name: The name of the Ceph Mgr module.
+ :type module_name: str
+ :return: Returns ``true`` if the Ceph Mgr module is managed by
+ this service, otherwise ``false``.
+ :rtype: bool
+ """
+ if module_name in self.ignore_modules:
+ return False
+ mgr_map = mgr.get('mgr_map')
+ for module_config in mgr_map['available_modules']:
+ if module_name == module_config['name']:
+ return True
+ return False
+
+ def _get_module_config(self, module_name):
+ """
+ Helper function to get detailed module configuration.
+ :param module_name: The name of the Ceph Mgr module.
+ :type module_name: str
+ :return: The module information, e.g. module name, can run,
+ error string and available module options.
+ :rtype: dict or None
+ """
+ mgr_map = mgr.get('mgr_map')
+ return find_object_in_list('name', module_name,
+ mgr_map['available_modules'])
+
+ def _get_module_options(self, module_name):
+ """
+ Helper function to get the module options.
+ :param module_name: The name of the Ceph Mgr module.
+ :type module_name: str
+ :return: The module options.
+ :rtype: dict
+ """
+ options = self._get_module_config(module_name)['module_options']
+ return self._convert_module_options(options)
+
+ def _convert_module_options(self, options):
+ # Workaround a possible bug in the Ceph Mgr implementation.
+ # Various fields (e.g. default_value, min, max) are always
+ # returned as a string.
+ for option in options.values():
+ if option['type'] == 'str':
+ if option['default_value'] == 'None': # This is Python None
+ option['default_value'] = ''
+ elif option['type'] == 'bool':
+ if option['default_value'] == '':
+ option['default_value'] = False
+ else:
+ option['default_value'] = str_to_bool(
+ option['default_value'])
+ elif option['type'] in ['float', 'uint', 'int', 'size', 'secs']:
+ cls = {
+ 'float': float
+ }.get(option['type'], int)
+ for name in ['default_value', 'min', 'max']:
+ if option[name] == 'None': # This is Python None
+ option[name] = None
+ elif option[name]: # Skip empty entries
+ option[name] = cls(option[name])
+ return options
diff --git a/src/pybind/mgr/dashboard/controllers/monitor.py b/src/pybind/mgr/dashboard/controllers/monitor.py
new file mode 100644
index 000000000..288b6977a
--- /dev/null
+++ b/src/pybind/mgr/dashboard/controllers/monitor.py
@@ -0,0 +1,133 @@
+# -*- coding: utf-8 -*-
+
+import json
+
+from .. import mgr
+from ..security import Scope
+from . import APIDoc, APIRouter, BaseController, Endpoint, EndpointDoc, ReadPermission
+
+MONITOR_SCHEMA = {
+ "mon_status": ({
+ "name": (str, ""),
+ "rank": (int, ""),
+ "state": (str, ""),
+ "election_epoch": (int, ""),
+ "quorum": ([int], ""),
+ "quorum_age": (int, ""),
+ "features": ({
+ "required_con": (str, ""),
+ "required_mon": ([int], ""),
+ "quorum_con": (str, ""),
+ "quorum_mon": ([str], "")
+ }, ""),
+ "outside_quorum": ([str], ""),
+ "extra_probe_peers": ([str], ""),
+ "sync_provider": ([str], ""),
+ "monmap": ({
+ "epoch": (int, ""),
+ "fsid": (str, ""),
+ "modified": (str, ""),
+ "created": (str, ""),
+ "min_mon_release": (int, ""),
+ "min_mon_release_name": (str, ""),
+ "features": ({
+ "persistent": ([str], ""),
+ "optional": ([str], "")
+ }, ""),
+ "mons": ([{
+ "rank": (int, ""),
+ "name": (str, ""),
+ "public_addrs": ({
+ "addrvec": ([{
+ "type": (str, ""),
+ "addr": (str, ""),
+ "nonce": (int, "")
+ }], "")
+ }, ""),
+ "addr": (str, ""),
+ "public_addr": (str, ""),
+ "priority": (int, ""),
+ "weight": (int, ""),
+ "stats": ({
+ "num_sessions": ([int], ""),
+ }, "")
+ }], "")
+ }, ""),
+ "feature_map": ({
+ "mon": ([{
+ "features": (str, ""),
+ "release": (str, ""),
+ "num": (int, "")
+ }], ""),
+ "mds": ([{
+ "features": (str, ""),
+ "release": (str, ""),
+ "num": (int, "")
+ }], ""),
+ "client": ([{
+ "features": (str, ""),
+ "release": (str, ""),
+ "num": (int, "")
+ }], ""),
+ "mgr": ([{
+ "features": (str, ""),
+ "release": (str, ""),
+ "num": (int, "")
+ }], ""),
+ }, "")
+ }, ""),
+ "in_quorum": ([{
+ "rank": (int, ""),
+ "name": (str, ""),
+ "public_addrs": ({
+ "addrvec": ([{
+ "type": (str, ""),
+ "addr": (str, ""),
+ "nonce": (int, "")
+ }], "")
+ }, ""),
+ "addr": (str, ""),
+ "public_addr": (str, ""),
+ "priority": (int, ""),
+ "weight": (int, ""),
+ "stats": ({
+ "num_sessions": ([int], "")
+ }, "")
+ }], ""),
+ "out_quorum": ([int], "")
+}
+
+
+@APIRouter('/monitor', Scope.MONITOR)
+@APIDoc("Get Monitor Details", "Monitor")
+class Monitor(BaseController):
+ @Endpoint()
+ @ReadPermission
+ @EndpointDoc("Get Monitor Details",
+ responses={200: MONITOR_SCHEMA})
+ def __call__(self):
+ in_quorum, out_quorum = [], []
+
+ counters = ['mon.num_sessions']
+
+ mon_status_json = mgr.get("mon_status")
+ mon_status = json.loads(mon_status_json['json'])
+
+ for mon in mon_status["monmap"]["mons"]:
+ mon["stats"] = {}
+ for counter in counters:
+ data = mgr.get_counter("mon", mon["name"], counter)
+ if data is not None:
+ mon["stats"][counter.split(".")[1]] = data[counter]
+ else:
+ mon["stats"][counter.split(".")[1]] = []
+ if mon["rank"] in mon_status["quorum"]:
+ in_quorum.append(mon)
+ else:
+ out_quorum.append(mon)
+
+ return {
+ 'mon_status': mon_status,
+ 'in_quorum': in_quorum,
+ 'out_quorum': out_quorum
+ }
diff --git a/src/pybind/mgr/dashboard/controllers/nfs.py b/src/pybind/mgr/dashboard/controllers/nfs.py
new file mode 100644
index 000000000..36b88d76b
--- /dev/null
+++ b/src/pybind/mgr/dashboard/controllers/nfs.py
@@ -0,0 +1,279 @@
+# -*- coding: utf-8 -*-
+
+import json
+import logging
+import os
+from functools import partial
+from typing import Any, Dict, List, Optional
+
+import cephfs
+from mgr_module import NFS_GANESHA_SUPPORTED_FSALS
+
+from .. import mgr
+from ..security import Scope
+from ..services.cephfs import CephFS
+from ..services.exception import DashboardException, handle_cephfs_error, \
+ serialize_dashboard_exception
+from . import APIDoc, APIRouter, BaseController, Endpoint, EndpointDoc, \
+ ReadPermission, RESTController, Task, UIRouter
+from ._version import APIVersion
+
+logger = logging.getLogger('controllers.nfs')
+
+
+class NFSException(DashboardException):
+ def __init__(self, msg):
+ super(NFSException, self).__init__(component="nfs", msg=msg)
+
+
+# documentation helpers
+EXPORT_SCHEMA = {
+ 'export_id': (int, 'Export ID'),
+ 'path': (str, 'Export path'),
+ 'cluster_id': (str, 'Cluster identifier'),
+ 'pseudo': (str, 'Pseudo FS path'),
+ 'access_type': (str, 'Export access type'),
+ 'squash': (str, 'Export squash policy'),
+ 'security_label': (str, 'Security label'),
+ 'protocols': ([int], 'List of protocol types'),
+ 'transports': ([str], 'List of transport types'),
+ 'fsal': ({
+ 'name': (str, 'name of FSAL'),
+ 'fs_name': (str, 'CephFS filesystem name', True),
+ 'sec_label_xattr': (str, 'Name of xattr for security label', True),
+ 'user_id': (str, 'User id', True)
+ }, 'FSAL configuration'),
+ 'clients': ([{
+ 'addresses': ([str], 'list of IP addresses'),
+ 'access_type': (str, 'Client access type'),
+ 'squash': (str, 'Client squash policy')
+ }], 'List of client configurations'),
+}
+
+
+CREATE_EXPORT_SCHEMA = {
+ 'path': (str, 'Export path'),
+ 'cluster_id': (str, 'Cluster identifier'),
+ 'pseudo': (str, 'Pseudo FS path'),
+ 'access_type': (str, 'Export access type'),
+ 'squash': (str, 'Export squash policy'),
+ 'security_label': (str, 'Security label'),
+ 'protocols': ([int], 'List of protocol types'),
+ 'transports': ([str], 'List of transport types'),
+ 'fsal': ({
+ 'name': (str, 'name of FSAL'),
+ 'fs_name': (str, 'CephFS filesystem name', True),
+ 'sec_label_xattr': (str, 'Name of xattr for security label', True)
+ }, 'FSAL configuration'),
+ 'clients': ([{
+ 'addresses': ([str], 'list of IP addresses'),
+ 'access_type': (str, 'Client access type'),
+ 'squash': (str, 'Client squash policy')
+ }], 'List of client configurations')
+}
+
+
+# pylint: disable=not-callable
+def NfsTask(name, metadata, wait_for): # noqa: N802
+ def composed_decorator(func):
+ return Task("nfs/{}".format(name), metadata, wait_for,
+ partial(serialize_dashboard_exception,
+ include_http_status=True))(func)
+ return composed_decorator
+
+
+@APIRouter('/nfs-ganesha/cluster', Scope.NFS_GANESHA)
+@APIDoc("NFS-Ganesha Cluster Management API", "NFS-Ganesha")
+class NFSGaneshaCluster(RESTController):
+ @ReadPermission
+ @RESTController.MethodMap(version=APIVersion.EXPERIMENTAL)
+ def list(self):
+ return mgr.remote('nfs', 'cluster_ls')
+
+
+@APIRouter('/nfs-ganesha/export', Scope.NFS_GANESHA)
+@APIDoc(group="NFS-Ganesha")
+class NFSGaneshaExports(RESTController):
+ RESOURCE_ID = "cluster_id/export_id"
+
+ @staticmethod
+ def _get_schema_export(export: Dict[str, Any]) -> Dict[str, Any]:
+ """
+ Method that avoids returning export info not exposed in the export schema
+ e.g., rgw user access/secret keys.
+ """
+ schema_fsal_info = {}
+ for key in export['fsal'].keys():
+ if key in EXPORT_SCHEMA['fsal'][0].keys(): # type: ignore
+ schema_fsal_info[key] = export['fsal'][key]
+ export['fsal'] = schema_fsal_info
+ return export
+
+ @EndpointDoc("List all NFS-Ganesha exports",
+ responses={200: [EXPORT_SCHEMA]})
+ def list(self) -> List[Dict[str, Any]]:
+ exports = []
+ for export in mgr.remote('nfs', 'export_ls'):
+ exports.append(self._get_schema_export(export))
+
+ return exports
+
+ @handle_cephfs_error()
+ @NfsTask('create', {'path': '{path}', 'fsal': '{fsal.name}',
+ 'cluster_id': '{cluster_id}'}, 2.0)
+ @EndpointDoc("Creates a new NFS-Ganesha export",
+ parameters=CREATE_EXPORT_SCHEMA,
+ responses={201: EXPORT_SCHEMA})
+ @RESTController.MethodMap(version=APIVersion(2, 0)) # type: ignore
+ def create(self, path, cluster_id, pseudo, access_type,
+ squash, security_label, protocols, transports, fsal, clients) -> Dict[str, Any]:
+ export_mgr = mgr.remote('nfs', 'fetch_nfs_export_obj')
+ if export_mgr.get_export_by_pseudo(cluster_id, pseudo):
+ raise DashboardException(msg=f'Pseudo {pseudo} is already in use.',
+ component='nfs')
+ if hasattr(fsal, 'user_id'):
+ fsal.pop('user_id') # mgr/nfs does not let you customize user_id
+ raw_ex = {
+ 'path': path,
+ 'pseudo': pseudo,
+ 'cluster_id': cluster_id,
+ 'access_type': access_type,
+ 'squash': squash,
+ 'security_label': security_label,
+ 'protocols': protocols,
+ 'transports': transports,
+ 'fsal': fsal,
+ 'clients': clients
+ }
+ applied_exports = export_mgr.apply_export(cluster_id, json.dumps(raw_ex))
+ if not applied_exports.has_error:
+ return self._get_schema_export(
+ export_mgr.get_export_by_pseudo(cluster_id, pseudo))
+ raise NFSException(f"Export creation failed {applied_exports.changes[0].msg}")
+
+ @EndpointDoc("Get an NFS-Ganesha export",
+ parameters={
+ 'cluster_id': (str, 'Cluster identifier'),
+ 'export_id': (str, "Export ID")
+ },
+ responses={200: EXPORT_SCHEMA})
+ def get(self, cluster_id, export_id) -> Optional[Dict[str, Any]]:
+ export_id = int(export_id)
+ export = mgr.remote('nfs', 'export_get', cluster_id, export_id)
+ if export:
+ export = self._get_schema_export(export)
+
+ return export
+
+ @NfsTask('edit', {'cluster_id': '{cluster_id}', 'export_id': '{export_id}'},
+ 2.0)
+ @EndpointDoc("Updates an NFS-Ganesha export",
+ parameters=dict(export_id=(int, "Export ID"),
+ **CREATE_EXPORT_SCHEMA),
+ responses={200: EXPORT_SCHEMA})
+ @RESTController.MethodMap(version=APIVersion(2, 0)) # type: ignore
+ def set(self, cluster_id, export_id, path, pseudo, access_type,
+ squash, security_label, protocols, transports, fsal, clients) -> Dict[str, Any]:
+
+ if hasattr(fsal, 'user_id'):
+ fsal.pop('user_id') # mgr/nfs does not let you customize user_id
+ raw_ex = {
+ 'path': path,
+ 'pseudo': pseudo,
+ 'cluster_id': cluster_id,
+ 'export_id': export_id,
+ 'access_type': access_type,
+ 'squash': squash,
+ 'security_label': security_label,
+ 'protocols': protocols,
+ 'transports': transports,
+ 'fsal': fsal,
+ 'clients': clients
+ }
+
+ export_mgr = mgr.remote('nfs', 'fetch_nfs_export_obj')
+ applied_exports = export_mgr.apply_export(cluster_id, json.dumps(raw_ex))
+ if not applied_exports.has_error:
+ return self._get_schema_export(
+ export_mgr.get_export_by_pseudo(cluster_id, pseudo))
+ raise NFSException(f"Export creation failed {applied_exports.changes[0].msg}")
+
+ @NfsTask('delete', {'cluster_id': '{cluster_id}',
+ 'export_id': '{export_id}'}, 2.0)
+ @EndpointDoc("Deletes an NFS-Ganesha export",
+ parameters={
+ 'cluster_id': (str, 'Cluster identifier'),
+ 'export_id': (int, "Export ID")
+ })
+ @RESTController.MethodMap(version=APIVersion(2, 0)) # type: ignore
+ def delete(self, cluster_id, export_id):
+ export_id = int(export_id)
+
+ export = mgr.remote('nfs', 'export_get', cluster_id, export_id)
+ if not export:
+ raise DashboardException(
+ http_status_code=404,
+ msg=f'Export with id {export_id} not found.',
+ component='nfs')
+ mgr.remote('nfs', 'export_rm', cluster_id, export['pseudo'])
+
+
+@UIRouter('/nfs-ganesha', Scope.NFS_GANESHA)
+class NFSGaneshaUi(BaseController):
+ @Endpoint('GET', '/fsals')
+ @ReadPermission
+ def fsals(self):
+ return NFS_GANESHA_SUPPORTED_FSALS
+
+ @Endpoint('GET', '/lsdir')
+ @ReadPermission
+ def lsdir(self, fs_name, root_dir=None, depth=1): # pragma: no cover
+ if root_dir is None:
+ root_dir = "/"
+ if not root_dir.startswith('/'):
+ root_dir = '/{}'.format(root_dir)
+ root_dir = os.path.normpath(root_dir)
+
+ try:
+ depth = int(depth)
+ error_msg = ''
+ if depth < 0:
+ error_msg = '`depth` must be greater or equal to 0.'
+ if depth > 5:
+ logger.warning("Limiting depth to maximum value of 5: "
+ "input depth=%s", depth)
+ depth = 5
+ except ValueError:
+ error_msg = '`depth` must be an integer.'
+ finally:
+ if error_msg:
+ raise DashboardException(code=400,
+ component='nfs',
+ msg=error_msg)
+
+ try:
+ cfs = CephFS(fs_name)
+ paths = [root_dir]
+ paths.extend([p['path'].rstrip('/')
+ for p in cfs.ls_dir(root_dir, depth)])
+ except (cephfs.ObjectNotFound, cephfs.PermissionError):
+ paths = []
+ return {'paths': paths}
+
+ @Endpoint('GET', '/cephfs/filesystems')
+ @ReadPermission
+ def filesystems(self):
+ return CephFS.list_filesystems()
+
+ @Endpoint()
+ @ReadPermission
+ def status(self):
+ status = {'available': True, 'message': None}
+ try:
+ mgr.remote('nfs', 'cluster_ls')
+ except (ImportError, RuntimeError) as error:
+ logger.exception(error)
+ status['available'] = False
+ status['message'] = str(error) # type: ignore
+
+ return status
diff --git a/src/pybind/mgr/dashboard/controllers/orchestrator.py b/src/pybind/mgr/dashboard/controllers/orchestrator.py
new file mode 100644
index 000000000..3864820ea
--- /dev/null
+++ b/src/pybind/mgr/dashboard/controllers/orchestrator.py
@@ -0,0 +1,52 @@
+# -*- coding: utf-8 -*-
+
+from functools import wraps
+
+from .. import mgr
+from ..exceptions import DashboardException
+from ..services.orchestrator import OrchClient
+from . import APIDoc, Endpoint, EndpointDoc, ReadPermission, RESTController, UIRouter
+
+STATUS_SCHEMA = {
+ "available": (bool, "Orchestrator status"),
+ "message": (str, "Error message")
+}
+
+
+def raise_if_no_orchestrator(features=None):
+ def inner(method):
+ @wraps(method)
+ def _inner(self, *args, **kwargs):
+ orch = OrchClient.instance()
+ if not orch.available():
+ raise DashboardException(code='orchestrator_status_unavailable', # pragma: no cover
+ msg='Orchestrator is unavailable',
+ component='orchestrator',
+ http_status_code=503)
+ if features is not None:
+ missing = orch.get_missing_features(features)
+ if missing:
+ msg = 'Orchestrator feature(s) are unavailable: {}'.format(', '.join(missing))
+ raise DashboardException(code='orchestrator_features_unavailable',
+ msg=msg,
+ component='orchestrator',
+ http_status_code=503)
+ return method(self, *args, **kwargs)
+ return _inner
+ return inner
+
+
+@UIRouter('/orchestrator')
+@APIDoc("Orchestrator Management API", "Orchestrator")
+class Orchestrator(RESTController):
+
+ @Endpoint()
+ @ReadPermission
+ @EndpointDoc("Display Orchestrator Status",
+ responses={200: STATUS_SCHEMA})
+ def status(self):
+ return OrchClient.instance().status()
+
+ @Endpoint()
+ def get_name(self):
+ return mgr.get_module_option_ex('orchestrator', 'orchestrator')
diff --git a/src/pybind/mgr/dashboard/controllers/osd.py b/src/pybind/mgr/dashboard/controllers/osd.py
new file mode 100644
index 000000000..f6f8ce1f5
--- /dev/null
+++ b/src/pybind/mgr/dashboard/controllers/osd.py
@@ -0,0 +1,658 @@
+# -*- coding: utf-8 -*-
+
+import json
+import logging
+import time
+from typing import Any, Dict, List, Optional, Union
+
+from ceph.deployment.drive_group import DriveGroupSpec, DriveGroupValidationError # type: ignore
+from mgr_util import get_most_recent_rate
+
+from .. import mgr
+from ..exceptions import DashboardException
+from ..security import Scope
+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 ..services.osd import HostStorageSummary, OsdDeploymentOptions
+from ..tools import str_to_bool
+from . import APIDoc, APIRouter, CreatePermission, DeletePermission, Endpoint, \
+ EndpointDoc, ReadPermission, RESTController, Task, UIRouter, \
+ UpdatePermission, allow_empty_body
+from ._version import APIVersion
+from .orchestrator import raise_if_no_orchestrator
+
+logger = logging.getLogger('controllers.osd')
+
+SAFE_TO_DESTROY_SCHEMA = {
+ "safe_to_destroy": ([str], "Is OSD safe to destroy?"),
+ "active": ([int], ""),
+ "missing_stats": ([str], ""),
+ "stored_pgs": ([str], "Stored Pool groups in Osd"),
+ "is_safe_to_destroy": (bool, "Is OSD safe to destroy?")
+}
+
+EXPORT_FLAGS_SCHEMA = {
+ "list_of_flags": ([str], "")
+}
+
+EXPORT_INDIV_FLAGS_SCHEMA = {
+ "added": ([str], "List of added flags"),
+ "removed": ([str], "List of removed flags"),
+ "ids": ([int], "List of updated OSDs")
+}
+
+EXPORT_INDIV_FLAGS_GET_SCHEMA = {
+ "osd": (int, "OSD ID"),
+ "flags": ([str], "List of active flags")
+}
+
+
+class DeploymentOptions:
+ def __init__(self):
+ self.options = {
+ OsdDeploymentOptions.COST_CAPACITY:
+ HostStorageSummary(OsdDeploymentOptions.COST_CAPACITY,
+ title='Cost/Capacity-optimized',
+ desc='All the available HDDs are selected'),
+ OsdDeploymentOptions.THROUGHPUT:
+ HostStorageSummary(OsdDeploymentOptions.THROUGHPUT,
+ title='Throughput-optimized',
+ desc="HDDs/SSDs are selected for data"
+ "devices and SSDs/NVMes for DB/WAL devices"),
+ OsdDeploymentOptions.IOPS:
+ HostStorageSummary(OsdDeploymentOptions.IOPS,
+ title='IOPS-optimized',
+ desc='All the available NVMes are selected'),
+ }
+ self.recommended_option = None
+
+ def as_dict(self):
+ return {
+ 'options': {k: v.as_dict() for k, v in self.options.items()},
+ 'recommended_option': self.recommended_option
+ }
+
+
+predefined_drive_groups = {
+ OsdDeploymentOptions.COST_CAPACITY: {
+ 'service_type': 'osd',
+ 'service_id': 'cost_capacity',
+ 'placement': {
+ 'host_pattern': '*'
+ },
+ 'data_devices': {
+ 'rotational': 1
+ },
+ 'encrypted': False
+ },
+ OsdDeploymentOptions.THROUGHPUT: {
+ 'service_type': 'osd',
+ 'service_id': 'throughput_optimized',
+ 'placement': {
+ 'host_pattern': '*'
+ },
+ 'data_devices': {
+ 'rotational': 1
+ },
+ 'db_devices': {
+ 'rotational': 0
+ },
+ 'encrypted': False
+ },
+ OsdDeploymentOptions.IOPS: {
+ 'service_type': 'osd',
+ 'service_id': 'iops_optimized',
+ 'placement': {
+ 'host_pattern': '*'
+ },
+ 'data_devices': {
+ 'rotational': 0
+ },
+ 'encrypted': False
+ },
+}
+
+
+def osd_task(name, metadata, wait_for=2.0):
+ return Task("osd/{}".format(name), metadata, wait_for)
+
+
+@APIRouter('/osd', Scope.OSD)
+@APIDoc('OSD management API', 'OSD')
+class Osd(RESTController):
+ def list(self):
+ osds = self.get_osd_map()
+
+ # Extending by osd stats information
+ for stat in mgr.get('osd_stats')['osd_stats']:
+ if stat['osd'] in osds:
+ osds[stat['osd']]['osd_stats'] = stat
+
+ # Extending by osd node information
+ nodes = mgr.get('osd_map_tree')['nodes']
+ for node in nodes:
+ if node['type'] == 'osd' and node['id'] in osds:
+ osds[node['id']]['tree'] = node
+
+ # Extending by osd parent node information
+ for host in [n for n in nodes if n['type'] == 'host']:
+ for osd_id in host['children']:
+ if osd_id >= 0 and osd_id in osds:
+ osds[osd_id]['host'] = host
+
+ removing_osd_ids = self.get_removing_osds()
+
+ # Extending by osd histogram and orchestrator data
+ for osd_id, osd in osds.items():
+ osd['stats'] = {}
+ osd['stats_history'] = {}
+ osd_spec = str(osd_id)
+ if 'osd' not in osd:
+ continue # pragma: no cover - simple early continue
+ self.gauge_stats(osd, osd_spec)
+ osd['operational_status'] = self._get_operational_status(osd_id, removing_osd_ids)
+ return list(osds.values())
+
+ @staticmethod
+ def gauge_stats(osd, osd_spec):
+ for stat in ['osd.op_w', 'osd.op_in_bytes', 'osd.op_r', 'osd.op_out_bytes']:
+ prop = stat.split('.')[1]
+ rates = CephService.get_rates('osd', osd_spec, stat)
+ osd['stats'][prop] = get_most_recent_rate(rates)
+ osd['stats_history'][prop] = rates
+ # Gauge stats
+ for stat in ['osd.numpg', 'osd.stat_bytes', 'osd.stat_bytes_used']:
+ osd['stats'][stat.split('.')[1]] = mgr.get_latest('osd', osd_spec, stat)
+
+ @RESTController.Collection('GET', version=APIVersion.EXPERIMENTAL)
+ @ReadPermission
+ def settings(self):
+ result = CephService.send_command('mon', 'osd dump')
+ return {
+ 'nearfull_ratio': result['nearfull_ratio'],
+ 'full_ratio': result['full_ratio']
+ }
+
+ def _get_operational_status(self, osd_id: int, removing_osd_ids: Optional[List[int]]):
+ if removing_osd_ids is None:
+ return 'unmanaged'
+ if osd_id in removing_osd_ids:
+ return 'deleting'
+ return 'working'
+
+ @staticmethod
+ def get_removing_osds() -> Optional[List[int]]:
+ orch = OrchClient.instance()
+ if orch.available(features=[OrchFeature.OSD_GET_REMOVE_STATUS]):
+ return [osd.osd_id for osd in orch.osds.removing_status()]
+ return None
+
+ @staticmethod
+ def get_osd_map(svc_id=None):
+ # type: (Union[int, None]) -> Dict[int, Union[dict, Any]]
+ def add_id(osd):
+ osd['id'] = osd['osd']
+ return osd
+
+ resp = {
+ osd['osd']: add_id(osd)
+ for osd in mgr.get('osd_map')['osds'] if svc_id is None or osd['osd'] == int(svc_id)
+ }
+ return resp if svc_id is None else resp[int(svc_id)]
+
+ @staticmethod
+ def _get_smart_data(osd_id):
+ # type: (str) -> dict
+ """Returns S.M.A.R.T data for the given OSD ID."""
+ logger.debug('[SMART] retrieving data from OSD with ID %s', osd_id)
+ return CephService.get_smart_data_by_daemon('osd', osd_id)
+
+ @RESTController.Resource('GET')
+ def smart(self, svc_id):
+ # type: (str) -> dict
+ return self._get_smart_data(svc_id)
+
+ @handle_send_command_error('osd')
+ def get(self, svc_id):
+ """
+ Returns collected data about an OSD.
+
+ :return: Returns the requested data.
+ """
+ return {
+ 'osd_map': self.get_osd_map(svc_id),
+ 'osd_metadata': mgr.get_metadata('osd', svc_id),
+ 'operational_status': self._get_operational_status(int(svc_id),
+ self.get_removing_osds())
+ }
+
+ @RESTController.Resource('GET')
+ @handle_send_command_error('osd')
+ def histogram(self, svc_id):
+ # type: (int) -> Dict[str, Any]
+ """
+ :return: Returns the histogram data.
+ """
+ try:
+ histogram = CephService.send_command(
+ 'osd', srv_spec=svc_id, prefix='perf histogram dump')
+ except SendCommandError as e: # pragma: no cover - the handling is too obvious
+ raise DashboardException(
+ component='osd', http_status_code=400, msg=str(e))
+
+ return histogram
+
+ def set(self, svc_id, device_class): # pragma: no cover
+ old_device_class = CephService.send_command('mon', 'osd crush get-device-class',
+ ids=[svc_id])
+ old_device_class = old_device_class[0]['device_class']
+ if old_device_class != device_class:
+ CephService.send_command('mon', 'osd crush rm-device-class',
+ ids=[svc_id])
+ if device_class:
+ CephService.send_command('mon', 'osd crush set-device-class', **{
+ 'class': device_class,
+ 'ids': [svc_id]
+ })
+
+ def _check_delete(self, osd_ids):
+ # type: (List[str]) -> Dict[str, Any]
+ """
+ Check if it's safe to remove OSD(s).
+
+ :param osd_ids: list of OSD IDs
+ :return: a dictionary contains the following attributes:
+ `safe`: bool, indicate if it's safe to remove OSDs.
+ `message`: str, help message if it's not safe to remove OSDs.
+ """
+ _ = osd_ids
+ health_data = mgr.get('health') # type: ignore
+ health = json.loads(health_data['json'])
+ checks = health['checks'].keys()
+ unsafe_checks = set(['OSD_FULL', 'OSD_BACKFILLFULL', 'OSD_NEARFULL'])
+ failed_checks = checks & unsafe_checks
+ msg = 'Removing OSD(s) is not recommended because of these failed health check(s): {}.'.\
+ format(', '.join(failed_checks)) if failed_checks else ''
+ return {
+ 'safe': not bool(failed_checks),
+ 'message': msg
+ }
+
+ @DeletePermission
+ @raise_if_no_orchestrator([OrchFeature.OSD_DELETE, OrchFeature.OSD_GET_REMOVE_STATUS])
+ @handle_orchestrator_error('osd')
+ @osd_task('delete', {'svc_id': '{svc_id}'})
+ def delete(self, svc_id, preserve_id=None, force=None): # pragma: no cover
+ replace = False
+ check: Union[Dict[str, Any], bool] = False
+ try:
+ if preserve_id is not None:
+ replace = str_to_bool(preserve_id)
+ if force is not None:
+ check = not str_to_bool(force)
+ except ValueError:
+ raise DashboardException(
+ component='osd', http_status_code=400, msg='Invalid parameter(s)')
+ orch = OrchClient.instance()
+ if check:
+ logger.info('Check for removing osd.%s...', svc_id)
+ check = self._check_delete([svc_id])
+ if not check['safe']:
+ logger.error('Unable to remove osd.%s: %s', svc_id, check['message'])
+ raise DashboardException(component='osd', msg=check['message'])
+
+ logger.info('Start removing osd.%s (replace: %s)...', svc_id, replace)
+ orch.osds.remove([svc_id], replace)
+ while True:
+ removal_osds = orch.osds.removing_status()
+ logger.info('Current removing OSDs %s', removal_osds)
+ pending = [osd for osd in removal_osds if osd.osd_id == int(svc_id)]
+ if not pending:
+ break
+ logger.info('Wait until osd.%s is removed...', svc_id)
+ time.sleep(60)
+
+ @RESTController.Resource('POST', query_params=['deep'])
+ @UpdatePermission
+ @allow_empty_body
+ def scrub(self, svc_id, deep=False):
+ api_scrub = "osd deep-scrub" if str_to_bool(deep) else "osd scrub"
+ CephService.send_command("mon", api_scrub, who=svc_id)
+
+ @RESTController.Resource('PUT')
+ @EndpointDoc("Mark OSD flags (out, in, down, lost, ...)",
+ parameters={'svc_id': (str, 'SVC ID')})
+ def mark(self, svc_id, action):
+ """
+ Note: osd must be marked `down` before marking lost.
+ """
+ valid_actions = ['out', 'in', 'down', 'lost']
+ args = {'srv_type': 'mon', 'prefix': 'osd ' + action}
+ if action.lower() in valid_actions:
+ if action == 'lost':
+ args['id'] = int(svc_id)
+ args['yes_i_really_mean_it'] = True
+ else:
+ args['ids'] = [svc_id]
+
+ CephService.send_command(**args)
+ else:
+ logger.error("Invalid OSD mark action: %s attempted on SVC_ID: %s", action, svc_id)
+
+ @RESTController.Resource('POST')
+ @allow_empty_body
+ def reweight(self, svc_id, weight):
+ """
+ Reweights the OSD temporarily.
+
+ Note that ‘ceph osd reweight’ is not a persistent setting. When an OSD
+ gets marked out, the osd weight will be set to 0. When it gets marked
+ in again, the weight will be changed to 1.
+
+ Because of this ‘ceph osd reweight’ is a temporary solution. You should
+ only use it to keep your cluster running while you’re ordering more
+ hardware.
+
+ - Craig Lewis (http://lists.ceph.com/pipermail/ceph-users-ceph.com/2014-June/040967.html)
+ """
+ CephService.send_command(
+ 'mon',
+ 'osd reweight',
+ id=int(svc_id),
+ weight=float(weight))
+
+ def _create_predefined_drive_group(self, data):
+ orch = OrchClient.instance()
+ option = OsdDeploymentOptions(data[0]['option'])
+ if option in list(OsdDeploymentOptions):
+ try:
+ predefined_drive_groups[
+ option]['encrypted'] = data[0]['encrypted']
+ orch.osds.create([DriveGroupSpec.from_json(
+ predefined_drive_groups[option])])
+ except (ValueError, TypeError, KeyError, DriveGroupValidationError) as e:
+ raise DashboardException(e, component='osd')
+
+ def _create_bare(self, data):
+ """Create a OSD container that has no associated device.
+
+ :param data: contain attributes to create a bare OSD.
+ : `uuid`: will be set automatically if the OSD starts up
+ : `svc_id`: the ID is only used if a valid uuid is given.
+ """
+ try:
+ uuid = data['uuid']
+ svc_id = int(data['svc_id'])
+ except (KeyError, ValueError) as e:
+ raise DashboardException(e, component='osd', http_status_code=400)
+
+ result = CephService.send_command(
+ 'mon', 'osd create', id=svc_id, uuid=uuid)
+ return {
+ 'result': result,
+ 'svc_id': svc_id,
+ 'uuid': uuid,
+ }
+
+ @raise_if_no_orchestrator([OrchFeature.OSD_CREATE])
+ @handle_orchestrator_error('osd')
+ def _create_with_drive_groups(self, drive_groups):
+ """Create OSDs with DriveGroups."""
+ orch = OrchClient.instance()
+ try:
+ dg_specs = [DriveGroupSpec.from_json(dg) for dg in drive_groups]
+ orch.osds.create(dg_specs)
+ except (ValueError, TypeError, DriveGroupValidationError) as e:
+ raise DashboardException(e, component='osd')
+
+ @CreatePermission
+ @osd_task('create', {'tracking_id': '{tracking_id}'})
+ def create(self, method, data, tracking_id): # pylint: disable=unused-argument
+ if method == 'bare':
+ return self._create_bare(data)
+ if method == 'drive_groups':
+ return self._create_with_drive_groups(data)
+ if method == 'predefined':
+ return self._create_predefined_drive_group(data)
+ raise DashboardException(
+ component='osd', http_status_code=400, msg='Unknown method: {}'.format(method))
+
+ @RESTController.Resource('POST')
+ @allow_empty_body
+ def purge(self, svc_id):
+ """
+ Note: osd must be marked `down` before removal.
+ """
+ CephService.send_command('mon', 'osd purge-actual', id=int(svc_id),
+ yes_i_really_mean_it=True)
+
+ @RESTController.Resource('POST')
+ @allow_empty_body
+ def destroy(self, svc_id):
+ """
+ Mark osd as being destroyed. Keeps the ID intact (allowing reuse), but
+ removes cephx keys, config-key data and lockbox keys, rendering data
+ permanently unreadable.
+
+ The osd must be marked down before being destroyed.
+ """
+ CephService.send_command(
+ 'mon', 'osd destroy-actual', id=int(svc_id), yes_i_really_mean_it=True)
+
+ @Endpoint('GET', query_params=['ids'])
+ @ReadPermission
+ @EndpointDoc("Check If OSD is Safe to Destroy",
+ parameters={
+ 'ids': (str, 'OSD Service Identifier'),
+ },
+ responses={200: SAFE_TO_DESTROY_SCHEMA})
+ def safe_to_destroy(self, ids):
+ """
+ :type ids: int|[int]
+ """
+
+ ids = json.loads(ids)
+ if isinstance(ids, list):
+ ids = list(map(str, ids))
+ else:
+ ids = [str(ids)]
+
+ try:
+ result = CephService.send_command(
+ 'mon', 'osd safe-to-destroy', ids=ids, target=('mgr', ''))
+ result['is_safe_to_destroy'] = set(result['safe_to_destroy']) == set(map(int, ids))
+ return result
+
+ except SendCommandError as e:
+ return {
+ 'message': str(e),
+ 'is_safe_to_destroy': False,
+ }
+
+ @Endpoint('GET', query_params=['svc_ids'])
+ @ReadPermission
+ @raise_if_no_orchestrator()
+ @handle_orchestrator_error('osd')
+ def safe_to_delete(self, svc_ids):
+ """
+ :type ids: int|[int]
+ """
+ check = self._check_delete(svc_ids)
+ return {
+ 'is_safe_to_delete': check.get('safe', False),
+ 'message': check.get('message', '')
+ }
+
+ @RESTController.Resource('GET')
+ def devices(self, svc_id):
+ # type: (str) -> Union[list, str]
+ devices: Union[list, str] = CephService.send_command(
+ 'mon', 'device ls-by-daemon', who='osd.{}'.format(svc_id))
+ mgr_map = mgr.get('mgr_map')
+ available_modules = [m['name'] for m in mgr_map['available_modules']]
+
+ life_expectancy_enabled = any(
+ item.startswith('diskprediction_') for item in available_modules)
+ for device in devices:
+ device['life_expectancy_enabled'] = life_expectancy_enabled
+
+ return devices
+
+
+@UIRouter('/osd', Scope.OSD)
+@APIDoc("Dashboard UI helper function; not part of the public API", "OsdUI")
+class OsdUi(Osd):
+ @Endpoint('GET')
+ @ReadPermission
+ @raise_if_no_orchestrator([OrchFeature.DAEMON_LIST])
+ @handle_orchestrator_error('host')
+ def deployment_options(self):
+ orch = OrchClient.instance()
+ hdds = 0
+ ssds = 0
+ nvmes = 0
+ res = DeploymentOptions()
+
+ for inventory_host in orch.inventory.list(hosts=None, refresh=True):
+ for device in inventory_host.devices.devices:
+ if device.available:
+ if device.human_readable_type == 'hdd':
+ hdds += 1
+ # SSDs and NVMe are both counted as 'ssd'
+ # so differentiating nvme using its path
+ elif '/dev/nvme' in device.path:
+ nvmes += 1
+ else:
+ ssds += 1
+
+ if hdds:
+ res.options[OsdDeploymentOptions.COST_CAPACITY].available = True
+ res.recommended_option = OsdDeploymentOptions.COST_CAPACITY
+ if hdds and ssds:
+ res.options[OsdDeploymentOptions.THROUGHPUT].available = True
+ res.recommended_option = OsdDeploymentOptions.THROUGHPUT
+ if nvmes:
+ res.options[OsdDeploymentOptions.IOPS].available = True
+
+ return res.as_dict()
+
+
+@APIRouter('/osd/flags', Scope.OSD)
+@APIDoc(group='OSD')
+class OsdFlagsController(RESTController):
+ @staticmethod
+ def _osd_flags():
+ enabled_flags = mgr.get('osd_map')['flags_set']
+ if 'pauserd' in enabled_flags and 'pausewr' in enabled_flags:
+ # 'pause' is set by calling `ceph osd set pause` and unset by
+ # calling `set osd unset pause`, but `ceph osd dump | jq '.flags'`
+ # will contain 'pauserd,pausewr' if pause is set.
+ # Let's pretend to the API that 'pause' is in fact a proper flag.
+ enabled_flags = list(
+ set(enabled_flags) - {'pauserd', 'pausewr'} | {'pause'})
+ return sorted(enabled_flags)
+
+ @staticmethod
+ def _update_flags(action, flags, ids=None):
+ if ids:
+ if flags:
+ ids = list(map(str, ids))
+ CephService.send_command('mon', 'osd ' + action, who=ids,
+ flags=','.join(flags))
+ else:
+ for flag in flags:
+ CephService.send_command('mon', 'osd ' + action, '', key=flag)
+
+ @EndpointDoc("Display OSD Flags",
+ responses={200: EXPORT_FLAGS_SCHEMA})
+ def list(self):
+ return self._osd_flags()
+
+ @EndpointDoc('Sets OSD flags for the entire cluster.',
+ parameters={
+ 'flags': ([str], 'List of flags to set. The flags `recovery_deletes`, '
+ '`sortbitwise` and `pglog_hardlimit` cannot be unset. '
+ 'Additionally `purged_snapshots` cannot even be set.')
+ },
+ responses={200: EXPORT_FLAGS_SCHEMA})
+ def bulk_set(self, flags):
+ """
+ The `recovery_deletes`, `sortbitwise` and `pglog_hardlimit` flags cannot be unset.
+ `purged_snapshots` cannot even be set. It is therefore required to at
+ least include those four flags for a successful operation.
+ """
+ assert isinstance(flags, list)
+
+ enabled_flags = set(self._osd_flags())
+ data = set(flags)
+ added = data - enabled_flags
+ removed = enabled_flags - data
+
+ self._update_flags('set', added)
+ self._update_flags('unset', removed)
+
+ logger.info('Changed OSD flags: added=%s removed=%s', added, removed)
+
+ return sorted(enabled_flags - removed | added)
+
+ @Endpoint('PUT', 'individual')
+ @UpdatePermission
+ @EndpointDoc('Sets OSD flags for a subset of individual OSDs.',
+ parameters={
+ 'flags': ({'noout': (bool, 'Sets/unsets `noout`', True, None),
+ 'noin': (bool, 'Sets/unsets `noin`', True, None),
+ 'noup': (bool, 'Sets/unsets `noup`', True, None),
+ 'nodown': (bool, 'Sets/unsets `nodown`', True, None)},
+ 'Directory of flags to set or unset. The flags '
+ '`noin`, `noout`, `noup` and `nodown` are going to '
+ 'be considered only.'),
+ 'ids': ([int], 'List of OSD ids the flags should be applied '
+ 'to.')
+ },
+ responses={200: EXPORT_INDIV_FLAGS_SCHEMA})
+ def set_individual(self, flags, ids):
+ """
+ Updates flags (`noout`, `noin`, `nodown`, `noup`) for an individual
+ subset of OSDs.
+ """
+ assert isinstance(flags, dict)
+ assert isinstance(ids, list)
+ assert all(isinstance(id, int) for id in ids)
+
+ # These are to only flags that can be applied to an OSD individually.
+ all_flags = {'noin', 'noout', 'nodown', 'noup'}
+ added = set()
+ removed = set()
+ for flag, activated in flags.items():
+ if flag in all_flags:
+ if activated is not None:
+ if activated:
+ added.add(flag)
+ else:
+ removed.add(flag)
+
+ self._update_flags('set-group', added, ids)
+ self._update_flags('unset-group', removed, ids)
+
+ logger.error('Changed individual OSD flags: added=%s removed=%s for ids=%s',
+ added, removed, ids)
+
+ return {'added': sorted(added),
+ 'removed': sorted(removed),
+ 'ids': ids}
+
+ @Endpoint('GET', 'individual')
+ @ReadPermission
+ @EndpointDoc('Displays individual OSD flags',
+ responses={200: EXPORT_INDIV_FLAGS_GET_SCHEMA})
+ def get_individual(self):
+ osd_map = mgr.get('osd_map')['osds']
+ resp = []
+
+ for osd in osd_map:
+ resp.append({
+ 'osd': osd['osd'],
+ 'flags': osd['state']
+ })
+ return resp
diff --git a/src/pybind/mgr/dashboard/controllers/perf_counters.py b/src/pybind/mgr/dashboard/controllers/perf_counters.py
new file mode 100644
index 000000000..ab0bdcb0b
--- /dev/null
+++ b/src/pybind/mgr/dashboard/controllers/perf_counters.py
@@ -0,0 +1,82 @@
+# -*- coding: utf-8 -*-
+
+import cherrypy
+
+from .. import mgr
+from ..security import Scope
+from ..services.ceph_service import CephService
+from . import APIDoc, APIRouter, EndpointDoc, RESTController
+
+PERF_SCHEMA = {
+ "mon.a": ({
+ ".cache_bytes": ({
+ "description": (str, ""),
+ "nick": (str, ""),
+ "type": (int, ""),
+ "priority": (int, ""),
+ "units": (int, ""),
+ "value": (int, "")
+ }, ""),
+ }, "Service ID"),
+}
+
+
+class PerfCounter(RESTController):
+ service_type = None # type: str
+
+ def get(self, service_id):
+ try:
+ return CephService.get_service_perf_counters(self.service_type, str(service_id))
+ except KeyError as error:
+ raise cherrypy.HTTPError(404, "{0} not found".format(error))
+
+
+@APIRouter('perf_counters/mds', Scope.CEPHFS)
+@APIDoc("Mds Perf Counters Management API", "MdsPerfCounter")
+class MdsPerfCounter(PerfCounter):
+ service_type = 'mds'
+
+
+@APIRouter('perf_counters/mon', Scope.MONITOR)
+@APIDoc("Mon Perf Counters Management API", "MonPerfCounter")
+class MonPerfCounter(PerfCounter):
+ service_type = 'mon'
+
+
+@APIRouter('perf_counters/osd', Scope.OSD)
+@APIDoc("OSD Perf Counters Management API", "OsdPerfCounter")
+class OsdPerfCounter(PerfCounter):
+ service_type = 'osd'
+
+
+@APIRouter('perf_counters/rgw', Scope.RGW)
+@APIDoc("Rgw Perf Counters Management API", "RgwPerfCounter")
+class RgwPerfCounter(PerfCounter):
+ service_type = 'rgw'
+
+
+@APIRouter('perf_counters/rbd-mirror', Scope.RBD_MIRRORING)
+@APIDoc("Rgw Mirroring Perf Counters Management API", "RgwMirrorPerfCounter")
+class RbdMirrorPerfCounter(PerfCounter):
+ service_type = 'rbd-mirror'
+
+
+@APIRouter('perf_counters/mgr', Scope.MANAGER)
+@APIDoc("Mgr Perf Counters Management API", "MgrPerfCounter")
+class MgrPerfCounter(PerfCounter):
+ service_type = 'mgr'
+
+
+@APIRouter('perf_counters/tcmu-runner', Scope.ISCSI)
+@APIDoc("Tcmu Runner Perf Counters Management API", "TcmuRunnerPerfCounter")
+class TcmuRunnerPerfCounter(PerfCounter):
+ service_type = 'tcmu-runner'
+
+
+@APIRouter('perf_counters')
+@APIDoc("Perf Counters Management API", "PerfCounters")
+class PerfCounters(RESTController):
+ @EndpointDoc("Display Perf Counters",
+ responses={200: PERF_SCHEMA})
+ def list(self):
+ return mgr.get_unlabeled_perf_counters()
diff --git a/src/pybind/mgr/dashboard/controllers/pool.py b/src/pybind/mgr/dashboard/controllers/pool.py
new file mode 100644
index 000000000..1e2e04e1b
--- /dev/null
+++ b/src/pybind/mgr/dashboard/controllers/pool.py
@@ -0,0 +1,353 @@
+# -*- coding: utf-8 -*-
+
+import time
+from typing import Any, Dict, Iterable, List, Optional, Union, cast
+
+import cherrypy
+
+from .. import mgr
+from ..security import Scope
+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 APIDoc, APIRouter, Endpoint, EndpointDoc, ReadPermission, \
+ RESTController, Task, UIRouter
+
+POOL_SCHEMA = ([{
+ "pool": (int, "pool id"),
+ "pool_name": (str, "pool name"),
+ "flags": (int, ""),
+ "flags_names": (str, "flags name"),
+ "type": (str, "type of pool"),
+ "size": (int, "pool size"),
+ "min_size": (int, ""),
+ "crush_rule": (str, ""),
+ "object_hash": (int, ""),
+ "pg_autoscale_mode": (str, ""),
+ "pg_num": (int, ""),
+ "pg_placement_num": (int, ""),
+ "pg_placement_num_target": (int, ""),
+ "pg_num_target": (int, ""),
+ "pg_num_pending": (int, ""),
+ "last_pg_merge_meta": ({
+ "ready_epoch": (int, ""),
+ "last_epoch_started": (int, ""),
+ "last_epoch_clean": (int, ""),
+ "source_pgid": (str, ""),
+ "source_version": (str, ""),
+ "target_version": (str, ""),
+ }, ""),
+ "auid": (int, ""),
+ "snap_mode": (str, ""),
+ "snap_seq": (int, ""),
+ "snap_epoch": (int, ""),
+ "pool_snaps": ([str], ""),
+ "quota_max_bytes": (int, ""),
+ "quota_max_objects": (int, ""),
+ "tiers": ([str], ""),
+ "tier_of": (int, ""),
+ "read_tier": (int, ""),
+ "write_tier": (int, ""),
+ "cache_mode": (str, ""),
+ "target_max_bytes": (int, ""),
+ "target_max_objects": (int, ""),
+ "cache_target_dirty_ratio_micro": (int, ""),
+ "cache_target_dirty_high_ratio_micro": (int, ""),
+ "cache_target_full_ratio_micro": (int, ""),
+ "cache_min_flush_age": (int, ""),
+ "cache_min_evict_age": (int, ""),
+ "erasure_code_profile": (str, ""),
+ "hit_set_params": ({
+ "type": (str, "")
+ }, ""),
+ "hit_set_period": (int, ""),
+ "hit_set_count": (int, ""),
+ "use_gmt_hitset": (bool, ""),
+ "min_read_recency_for_promote": (int, ""),
+ "min_write_recency_for_promote": (int, ""),
+ "hit_set_grade_decay_rate": (int, ""),
+ "hit_set_search_last_n": (int, ""),
+ "grade_table": ([str], ""),
+ "stripe_width": (int, ""),
+ "expected_num_objects": (int, ""),
+ "fast_read": (bool, ""),
+ "options": ({
+ "pg_num_min": (int, ""),
+ "pg_num_max": (int, "")
+ }, ""),
+ "application_metadata": ([str], ""),
+ "create_time": (str, ""),
+ "last_change": (str, ""),
+ "last_force_op_resend": (str, ""),
+ "last_force_op_resend_prenautilus": (str, ""),
+ "last_force_op_resend_preluminous": (str, ""),
+ "removed_snaps": ([str], "")
+}])
+
+
+def pool_task(name, metadata, wait_for=2.0):
+ return Task("pool/{}".format(name), metadata, wait_for)
+
+
+@APIRouter('/pool', Scope.POOL)
+@APIDoc("Get pool details by pool name", "Pool")
+class Pool(RESTController):
+
+ @staticmethod
+ def _serialize_pool(pool, attrs):
+ if not attrs or not isinstance(attrs, list):
+ attrs = pool.keys()
+
+ crush_rules = {r['rule_id']: r["rule_name"] for r in mgr.get('osd_map_crush')['rules']}
+
+ res: Dict[Union[int, str], Union[str, List[Any]]] = {}
+ for attr in attrs:
+ if attr not in pool:
+ continue
+ if attr == 'type':
+ res[attr] = {1: 'replicated', 3: 'erasure'}[pool[attr]]
+ elif attr == 'crush_rule':
+ res[attr] = crush_rules[pool[attr]]
+ elif attr == 'application_metadata':
+ res[attr] = list(pool[attr].keys())
+ else:
+ res[attr] = pool[attr]
+
+ # pool_name is mandatory
+ res['pool_name'] = pool['pool_name']
+ return res
+
+ @classmethod
+ def _pool_list(cls, attrs=None, stats=False):
+ if attrs:
+ attrs = attrs.split(',')
+
+ if str_to_bool(stats):
+ pools = CephService.get_pool_list_with_stats()
+ else:
+ pools = CephService.get_pool_list()
+
+ return [cls._serialize_pool(pool, attrs) for pool in pools]
+
+ @EndpointDoc("Display Pool List",
+ parameters={
+ 'attrs': (str, 'Pool Attributes'),
+ 'stats': (bool, 'Pool Stats')
+ },
+ responses={200: POOL_SCHEMA})
+ def list(self, attrs=None, stats=False):
+ return self._pool_list(attrs, stats)
+
+ @classmethod
+ def _get(cls, pool_name: str, attrs: Optional[str] = None, stats: bool = False) -> dict:
+ pools = cls._pool_list(attrs, stats)
+ pool = [p for p in pools if p['pool_name'] == pool_name]
+ if not pool:
+ raise cherrypy.NotFound('No such pool')
+ return pool[0]
+
+ def get(self, pool_name: str, attrs: Optional[str] = None, stats: bool = False) -> dict:
+ pool = self._get(pool_name, attrs, stats)
+ pool['configuration'] = RbdConfiguration(pool_name).list()
+ return pool
+
+ @pool_task('delete', ['{pool_name}'])
+ @handle_send_command_error('pool')
+ def delete(self, pool_name):
+ return CephService.send_command('mon', 'osd pool delete', pool=pool_name, pool2=pool_name,
+ yes_i_really_really_mean_it=True)
+
+ @pool_task('edit', ['{pool_name}'])
+ def set(self, pool_name, flags=None, application_metadata=None, configuration=None, **kwargs):
+ self._set_pool_values(pool_name, application_metadata, flags, True, kwargs)
+ if kwargs.get('pool'):
+ pool_name = kwargs['pool']
+ RbdConfiguration(pool_name).set_configuration(configuration)
+ self._wait_for_pgs(pool_name)
+
+ @pool_task('create', {'pool_name': '{pool}'})
+ @handle_send_command_error('pool')
+ def create(self, pool, pg_num, pool_type, erasure_code_profile=None, flags=None,
+ application_metadata=None, rule_name=None, configuration=None, **kwargs):
+ ecp = erasure_code_profile if erasure_code_profile else None
+ CephService.send_command('mon', 'osd pool create', pool=pool, pg_num=int(pg_num),
+ pgp_num=int(pg_num), pool_type=pool_type, erasure_code_profile=ecp,
+ rule=rule_name)
+ self._set_pool_values(pool, application_metadata, flags, False, kwargs)
+ RbdConfiguration(pool).set_configuration(configuration)
+ self._wait_for_pgs(pool)
+
+ def _set_pool_values(self, pool, application_metadata, flags, update_existing, kwargs):
+ current_pool = self._get(pool)
+ if update_existing and kwargs.get('compression_mode') == 'unset':
+ self._prepare_compression_removal(current_pool.get('options'), kwargs)
+ if flags and 'ec_overwrites' in flags:
+ CephService.send_command('mon', 'osd pool set', pool=pool, var='allow_ec_overwrites',
+ val='true')
+ if application_metadata is not None:
+ def set_app(app_metadata, set_app_what):
+ for app in app_metadata:
+ CephService.send_command('mon', 'osd pool application ' + set_app_what,
+ pool=pool, app=app, yes_i_really_mean_it=True)
+
+ if update_existing:
+ original_app_metadata = set(
+ cast(Iterable[Any], current_pool.get('application_metadata')))
+ else:
+ original_app_metadata = set()
+
+ set_app(original_app_metadata - set(application_metadata), 'disable')
+ set_app(set(application_metadata) - original_app_metadata, 'enable')
+
+ quotas = {}
+ quotas['max_objects'] = kwargs.pop('quota_max_objects', None)
+ quotas['max_bytes'] = kwargs.pop('quota_max_bytes', None)
+ self._set_quotas(pool, quotas)
+ self._set_pool_keys(pool, kwargs)
+
+ def _set_pool_keys(self, pool, pool_items):
+ def set_key(key, value):
+ CephService.send_command('mon', 'osd pool set', pool=pool, var=key, val=str(value))
+
+ update_name = False
+ for key, value in pool_items.items():
+ if key == 'pool':
+ update_name = True
+ destpool = value
+ else:
+ set_key(key, value)
+ if key == 'pg_num':
+ set_key('pgp_num', value)
+ if update_name:
+ CephService.send_command('mon', 'osd pool rename', srcpool=pool, destpool=destpool)
+
+ def _set_quotas(self, pool, quotas):
+ for field, value in quotas.items():
+ if value is not None:
+ CephService.send_command('mon', 'osd pool set-quota',
+ pool=pool, field=field, val=str(value))
+
+ def _prepare_compression_removal(self, options, kwargs):
+ """
+ Presets payload with values to remove compression attributes in case they are not
+ needed anymore.
+
+ In case compression is not needed the dashboard will send 'compression_mode' with the
+ value 'unset'.
+
+ :param options: All set options for the current pool.
+ :param kwargs: Payload of the PUT / POST call
+ """
+ if options is not None:
+ def reset_arg(arg, value):
+ if options.get(arg):
+ kwargs[arg] = value
+ for arg in ['compression_min_blob_size', 'compression_max_blob_size',
+ 'compression_required_ratio']:
+ reset_arg(arg, '0')
+ reset_arg('compression_algorithm', 'unset')
+
+ @classmethod
+ def _wait_for_pgs(cls, pool_name):
+ """
+ Keep the task waiting for until all pg changes are complete
+ :param pool_name: The name of the pool.
+ :type pool_name: string
+ """
+ current_pool = cls._get(pool_name)
+ initial_pgs = int(current_pool['pg_placement_num']) + int(current_pool['pg_num'])
+ cls._pg_wait_loop(current_pool, initial_pgs)
+
+ @classmethod
+ def _pg_wait_loop(cls, pool, initial_pgs):
+ """
+ Compares if all pg changes are completed, if not it will call itself
+ until all changes are completed.
+ :param pool: The dict that represents a pool.
+ :type pool: dict
+ :param initial_pgs: The pg and pg_num count before any change happened.
+ :type initial_pgs: int
+ """
+ if 'pg_num_target' in pool:
+ target = int(pool['pg_num_target']) + int(pool['pg_placement_num_target'])
+ current = int(pool['pg_placement_num']) + int(pool['pg_num'])
+ if current != target:
+ max_diff = abs(target - initial_pgs)
+ diff = max_diff - abs(target - current)
+ percentage = int(round(diff / float(max_diff) * 100))
+ TaskManager.current_task().set_progress(percentage)
+ time.sleep(4)
+ cls._pg_wait_loop(cls._get(pool['pool_name']), initial_pgs)
+
+ @RESTController.Resource()
+ @ReadPermission
+ def configuration(self, pool_name):
+ return RbdConfiguration(pool_name).list()
+
+
+@UIRouter('/pool', Scope.POOL)
+@APIDoc("Dashboard UI helper function; not part of the public API", "PoolUi")
+class PoolUi(Pool):
+ @Endpoint()
+ @ReadPermission
+ def info(self):
+ """Used by the create-pool dialog"""
+ osd_map_crush = mgr.get('osd_map_crush')
+ options = mgr.get('config_options')['options']
+
+ def rules(pool_type):
+ return [r
+ for r in osd_map_crush['rules']
+ if r['type'] == pool_type]
+
+ def all_bluestore():
+ return all(o['osd_objectstore'] == 'bluestore'
+ for o in mgr.get('osd_metadata').values())
+
+ def get_config_option_enum(conf_name):
+ return [[v for v in o['enum_values'] if len(v) > 0]
+ for o in options
+ if o['name'] == conf_name][0]
+
+ profiles = CephService.get_erasure_code_profiles()
+ used_rules: Dict[str, List[str]] = {}
+ used_profiles: Dict[str, List[str]] = {}
+ pool_names = []
+ for p in self._pool_list():
+ name = p['pool_name']
+ pool_names.append(name)
+ rule = p['crush_rule']
+ if rule in used_rules:
+ used_rules[rule].append(name)
+ else:
+ used_rules[rule] = [name]
+ profile = p['erasure_code_profile']
+ if profile in used_profiles:
+ used_profiles[profile].append(name)
+ else:
+ used_profiles[profile] = [name]
+
+ mgr_config = mgr.get('config')
+ return {
+ "pool_names": pool_names,
+ "crush_rules_replicated": rules(1),
+ "crush_rules_erasure": rules(3),
+ "is_all_bluestore": all_bluestore(),
+ "osd_count": len(mgr.get('osd_map')['osds']),
+ "bluestore_compression_algorithm": mgr_config['bluestore_compression_algorithm'],
+ "compression_algorithms": get_config_option_enum('bluestore_compression_algorithm'),
+ "compression_modes": get_config_option_enum('bluestore_compression_mode'),
+ "pg_autoscale_default_mode": mgr_config['osd_pool_default_pg_autoscale_mode'],
+ "pg_autoscale_modes": get_config_option_enum('osd_pool_default_pg_autoscale_mode'),
+ "erasure_code_profiles": profiles,
+ "used_rules": used_rules,
+ "used_profiles": used_profiles,
+ 'nodes': mgr.get('osd_map_tree')['nodes']
+ }
+
+
+class RBDPool(Pool):
+ def create(self, pool='rbd-mirror'): # pylint: disable=arguments-differ
+ super().create(pool, pg_num=1, pool_type='replicated',
+ rule_name='replicated_rule', application_metadata=['rbd'])
diff --git a/src/pybind/mgr/dashboard/controllers/prometheus.py b/src/pybind/mgr/dashboard/controllers/prometheus.py
new file mode 100644
index 000000000..b639d8826
--- /dev/null
+++ b/src/pybind/mgr/dashboard/controllers/prometheus.py
@@ -0,0 +1,173 @@
+# -*- coding: utf-8 -*-
+import json
+import os
+import tempfile
+from datetime import datetime
+
+import requests
+
+from .. import mgr
+from ..exceptions import DashboardException
+from ..security import Scope
+from ..services import ceph_service
+from ..services.settings import SettingsService
+from ..settings import Options, Settings
+from . import APIDoc, APIRouter, BaseController, Endpoint, RESTController, Router, UIRouter
+
+
+@Router('/api/prometheus_receiver', secure=False)
+class PrometheusReceiver(BaseController):
+ """
+ The receiver is needed in order to receive alert notifications (reports)
+ """
+ notifications = []
+
+ @Endpoint('POST', path='/', version=None)
+ def fetch_alert(self, **notification):
+ notification['notified'] = datetime.now().isoformat()
+ notification['id'] = str(len(self.notifications))
+ self.notifications.append(notification)
+
+
+class PrometheusRESTController(RESTController):
+ def prometheus_proxy(self, method, path, params=None, payload=None):
+ # type (str, str, dict, dict)
+ user, password, cert_file = self.get_access_info('prometheus')
+ verify = cert_file.name if cert_file else Settings.PROMETHEUS_API_SSL_VERIFY
+ response = self._proxy(self._get_api_url(Settings.PROMETHEUS_API_HOST),
+ method, path, 'Prometheus', params, payload,
+ user=user, password=password, verify=verify)
+ if cert_file:
+ cert_file.close()
+ os.unlink(cert_file.name)
+ return response
+
+ def alert_proxy(self, method, path, params=None, payload=None):
+ # type (str, str, dict, dict)
+ user, password, cert_file = self.get_access_info('alertmanager')
+ verify = cert_file.name if cert_file else Settings.ALERTMANAGER_API_SSL_VERIFY
+ response = self._proxy(self._get_api_url(Settings.ALERTMANAGER_API_HOST),
+ method, path, 'Alertmanager', params, payload,
+ user=user, password=password, verify=verify)
+ if cert_file:
+ cert_file.close()
+ os.unlink(cert_file.name)
+ return response
+
+ def get_access_info(self, module_name):
+ # type (str, str, str)
+ if module_name not in ['prometheus', 'alertmanager']:
+ raise DashboardException(f'Invalid module name {module_name}', component='prometheus')
+ user = None
+ password = None
+ cert_file = None
+
+ orch_backend = mgr.get_module_option_ex('orchestrator', 'orchestrator')
+ if orch_backend == 'cephadm':
+ secure_monitoring_stack = mgr.get_module_option_ex('cephadm',
+ 'secure_monitoring_stack',
+ False)
+ if secure_monitoring_stack:
+ cmd = {'prefix': f'orch {module_name} get-credentials'}
+ ret, out, _ = mgr.mon_command(cmd)
+ if ret == 0 and out is not None:
+ access_info = json.loads(out)
+ user = access_info['user']
+ password = access_info['password']
+ certificate = access_info['certificate']
+ cert_file = tempfile.NamedTemporaryFile(delete=False)
+ cert_file.write(certificate.encode('utf-8'))
+ cert_file.flush()
+
+ return user, password, cert_file
+
+ def _get_api_url(self, host):
+ return host.rstrip('/') + '/api/v1'
+
+ def balancer_status(self):
+ return ceph_service.CephService.send_command('mon', 'balancer status')
+
+ def _proxy(self, base_url, method, path, api_name, params=None, payload=None, verify=True,
+ user=None, password=None):
+ # type (str, str, str, str, dict, dict, bool)
+ try:
+ from requests.auth import HTTPBasicAuth
+ auth = HTTPBasicAuth(user, password) if user and password else None
+ response = requests.request(method, base_url + path, params=params,
+ json=payload, verify=verify,
+ auth=auth)
+ except Exception:
+ raise DashboardException(
+ "Could not reach {}'s API on {}".format(api_name, base_url),
+ http_status_code=404,
+ component='prometheus')
+ try:
+ content = json.loads(response.content, strict=False)
+ except json.JSONDecodeError as e:
+ raise DashboardException(
+ "Error parsing Prometheus Alertmanager response: {}".format(e.msg),
+ component='prometheus')
+ balancer_status = self.balancer_status()
+ if content['status'] == 'success': # pylint: disable=R1702
+ alerts_info = []
+ if 'data' in content:
+ if balancer_status['active'] and balancer_status['no_optimization_needed'] and path == '/alerts': # noqa E501 #pylint: disable=line-too-long
+ alerts_info = [alert for alert in content['data'] if alert['labels']['alertname'] != 'CephPGImbalance'] # noqa E501 #pylint: disable=line-too-long
+ return alerts_info
+ return content['data']
+ return content
+ raise DashboardException(content, http_status_code=400, component='prometheus')
+
+
+@APIRouter('/prometheus', Scope.PROMETHEUS)
+@APIDoc("Prometheus Management API", "Prometheus")
+class Prometheus(PrometheusRESTController):
+ def list(self, **params):
+ return self.alert_proxy('GET', '/alerts', params)
+
+ @RESTController.Collection(method='GET')
+ def rules(self, **params):
+ return self.prometheus_proxy('GET', '/rules', params)
+
+ @RESTController.Collection(method='GET', path='/data')
+ def get_prometeus_data(self, **params):
+ params['query'] = params.pop('params')
+ return self.prometheus_proxy('GET', '/query_range', params)
+
+ @RESTController.Collection(method='GET', path='/silences')
+ def get_silences(self, **params):
+ return self.alert_proxy('GET', '/silences', params)
+
+ @RESTController.Collection(method='POST', path='/silence', status=201)
+ def create_silence(self, **params):
+ return self.alert_proxy('POST', '/silences', payload=params)
+
+ @RESTController.Collection(method='DELETE', path='/silence/{s_id}', status=204)
+ def delete_silence(self, s_id):
+ return self.alert_proxy('DELETE', '/silence/' + s_id) if s_id else None
+
+
+@APIRouter('/prometheus/notifications', Scope.PROMETHEUS)
+@APIDoc("Prometheus Notifications Management API", "PrometheusNotifications")
+class PrometheusNotifications(RESTController):
+
+ def list(self, **params):
+ if 'from' in params:
+ f = params['from']
+ if f == 'last':
+ return PrometheusReceiver.notifications[-1:]
+ return PrometheusReceiver.notifications[int(f) + 1:]
+ return PrometheusReceiver.notifications
+
+
+@UIRouter('/prometheus', Scope.PROMETHEUS)
+class PrometheusSettings(RESTController):
+ def get(self, name):
+ with SettingsService.attribute_handler(name) as settings_name:
+ setting = getattr(Options, settings_name)
+ return {
+ 'name': settings_name,
+ 'default': setting.default_value,
+ 'type': setting.types_as_str(),
+ 'value': getattr(Settings, settings_name)
+ }
diff --git a/src/pybind/mgr/dashboard/controllers/rbd.py b/src/pybind/mgr/dashboard/controllers/rbd.py
new file mode 100644
index 000000000..d0aef6f00
--- /dev/null
+++ b/src/pybind/mgr/dashboard/controllers/rbd.py
@@ -0,0 +1,435 @@
+# -*- coding: utf-8 -*-
+# pylint: disable=unused-argument
+# pylint: disable=too-many-statements,too-many-branches
+
+import logging
+import math
+from datetime import datetime
+from functools import partial
+
+import cherrypy
+import rbd
+
+from .. import mgr
+from ..exceptions import DashboardException
+from ..security import Scope
+from ..services.ceph_service import CephService
+from ..services.exception import handle_rados_error, handle_rbd_error, serialize_dashboard_exception
+from ..services.rbd import MIRROR_IMAGE_MODE, RbdConfiguration, \
+ RbdImageMetadataService, RbdMirroringService, RbdService, \
+ RbdSnapshotService, format_bitmask, format_features, get_image_spec, \
+ parse_image_spec, rbd_call, rbd_image_call
+from ..tools import ViewCache, str_to_bool
+from . import APIDoc, APIRouter, BaseController, CreatePermission, \
+ DeletePermission, Endpoint, EndpointDoc, ReadPermission, RESTController, \
+ Task, UIRouter, UpdatePermission, allow_empty_body
+from ._version import APIVersion
+
+logger = logging.getLogger(__name__)
+
+RBD_SCHEMA = ([{
+ "value": ([str], ''),
+ "pool_name": (str, 'pool name')
+}])
+
+RBD_TRASH_SCHEMA = [{
+ "status": (int, ''),
+ "value": ([str], ''),
+ "pool_name": (str, 'pool name')
+}]
+
+
+# pylint: disable=not-callable
+def RbdTask(name, metadata, wait_for): # noqa: N802
+ def composed_decorator(func):
+ func = handle_rados_error('pool')(func)
+ func = handle_rbd_error()(func)
+ return Task("rbd/{}".format(name), metadata, wait_for,
+ partial(serialize_dashboard_exception, include_http_status=True))(func)
+ return composed_decorator
+
+
+@APIRouter('/block/image', Scope.RBD_IMAGE)
+@APIDoc("RBD Management API", "Rbd")
+class Rbd(RESTController):
+
+ DEFAULT_LIMIT = 5
+
+ def _rbd_list(self, pool_name=None, offset=0, limit=DEFAULT_LIMIT, search='', sort=''):
+ if pool_name:
+ pools = [pool_name]
+ else:
+ pools = [p['pool_name'] for p in CephService.get_pool_list('rbd')]
+
+ images, num_total_images = RbdService.rbd_pool_list(
+ pools, offset=offset, limit=limit, search=search, sort=sort)
+ cherrypy.response.headers['X-Total-Count'] = num_total_images
+ pool_result = {}
+ for i, image in enumerate(images):
+ pool = image['pool_name']
+ if pool not in pool_result:
+ pool_result[pool] = {'value': [], 'pool_name': image['pool_name']}
+ pool_result[pool]['value'].append(image)
+
+ images[i]['configuration'] = RbdConfiguration(
+ pool, image['namespace'], image['name']).list()
+ images[i]['metadata'] = rbd_image_call(
+ pool, image['namespace'], image['name'],
+ lambda ioctx, image: RbdImageMetadataService(image).list())
+
+ return list(pool_result.values())
+
+ @handle_rbd_error()
+ @handle_rados_error('pool')
+ @EndpointDoc("Display Rbd Images",
+ parameters={
+ 'pool_name': (str, 'Pool Name'),
+ 'limit': (int, 'limit'),
+ 'offset': (int, 'offset'),
+ },
+ responses={200: RBD_SCHEMA})
+ @RESTController.MethodMap(version=APIVersion(2, 0)) # type: ignore
+ def list(self, pool_name=None, offset: int = 0, limit: int = DEFAULT_LIMIT,
+ search: str = '', sort: str = ''):
+ return self._rbd_list(pool_name, offset=int(offset), limit=int(limit),
+ search=search, sort=sort)
+
+ @handle_rbd_error()
+ @handle_rados_error('pool')
+ def get(self, image_spec):
+ return RbdService.get_image(image_spec)
+
+ @RbdTask('create',
+ {'pool_name': '{pool_name}', 'namespace': '{namespace}', 'image_name': '{name}'}, 2.0)
+ def create(self, name, pool_name, size, namespace=None, schedule_interval='',
+ obj_size=None, features=None, stripe_unit=None, stripe_count=None,
+ data_pool=None, configuration=None, metadata=None,
+ mirror_mode=None):
+
+ RbdService.create(name, pool_name, size, namespace,
+ obj_size, features, stripe_unit, stripe_count,
+ data_pool, configuration, metadata)
+
+ if mirror_mode:
+ RbdMirroringService.enable_image(name, pool_name, namespace,
+ MIRROR_IMAGE_MODE[mirror_mode])
+
+ if schedule_interval:
+ image_spec = get_image_spec(pool_name, namespace, name)
+ RbdMirroringService.snapshot_schedule_add(image_spec, schedule_interval)
+
+ @RbdTask('delete', ['{image_spec}'], 2.0)
+ def delete(self, image_spec):
+ return RbdService.delete(image_spec)
+
+ @RbdTask('edit', ['{image_spec}', '{name}'], 4.0)
+ def set(self, image_spec, name=None, size=None, features=None,
+ configuration=None, metadata=None, enable_mirror=None, primary=None,
+ force=False, resync=False, mirror_mode=None, schedule_interval='',
+ remove_scheduling=False):
+ return RbdService.set(image_spec, name, size, features,
+ configuration, metadata, enable_mirror, primary,
+ force, resync, mirror_mode, schedule_interval,
+ remove_scheduling)
+
+ @RbdTask('copy',
+ {'src_image_spec': '{image_spec}',
+ 'dest_pool_name': '{dest_pool_name}',
+ 'dest_namespace': '{dest_namespace}',
+ 'dest_image_name': '{dest_image_name}'}, 2.0)
+ @RESTController.Resource('POST')
+ @allow_empty_body
+ def copy(self, image_spec, dest_pool_name, dest_namespace, dest_image_name,
+ snapshot_name=None, obj_size=None, features=None,
+ stripe_unit=None, stripe_count=None, data_pool=None,
+ configuration=None, metadata=None):
+ return RbdService.copy(image_spec, dest_pool_name, dest_namespace, dest_image_name,
+ snapshot_name, obj_size, features,
+ stripe_unit, stripe_count, data_pool,
+ configuration, metadata)
+
+ @RbdTask('flatten', ['{image_spec}'], 2.0)
+ @RESTController.Resource('POST')
+ @UpdatePermission
+ @allow_empty_body
+ def flatten(self, image_spec):
+ return RbdService.flatten(image_spec)
+
+ @RESTController.Collection('GET')
+ def default_features(self):
+ rbd_default_features = mgr.get('config')['rbd_default_features']
+ return format_bitmask(int(rbd_default_features))
+
+ @RESTController.Collection('GET')
+ def clone_format_version(self):
+ """Return the RBD clone format version.
+ """
+ rbd_default_clone_format = mgr.get('config')['rbd_default_clone_format']
+ if rbd_default_clone_format != 'auto':
+ return int(rbd_default_clone_format)
+ osd_map = mgr.get_osdmap().dump()
+ min_compat_client = osd_map.get('min_compat_client', '')
+ require_min_compat_client = osd_map.get('require_min_compat_client', '')
+ if max(min_compat_client, require_min_compat_client) < 'mimic':
+ return 1
+
+ return 2
+
+ @RbdTask('trash/move', ['{image_spec}'], 2.0)
+ @RESTController.Resource('POST')
+ @allow_empty_body
+ def move_trash(self, image_spec, delay=0):
+ """Move an image to the trash.
+ Images, even ones actively in-use by clones,
+ can be moved to the trash and deleted at a later time.
+ """
+ return RbdService.move_image_to_trash(image_spec, delay)
+
+
+@UIRouter('/block/rbd')
+class RbdStatus(BaseController):
+ @EndpointDoc("Display RBD Image feature status")
+ @Endpoint()
+ @ReadPermission
+ def status(self):
+ status = {'available': True, 'message': None}
+ if not CephService.get_pool_list('rbd'):
+ status['available'] = False
+ status['message'] = 'No RBD pools in the cluster. Please create a pool '\
+ 'with the "rbd" application label.' # type: ignore
+ return status
+
+
+@APIRouter('/block/image/{image_spec}/snap', Scope.RBD_IMAGE)
+@APIDoc("RBD Snapshot Management API", "RbdSnapshot")
+class RbdSnapshot(RESTController):
+
+ RESOURCE_ID = "snapshot_name"
+
+ @RbdTask('snap/create',
+ ['{image_spec}', '{snapshot_name}', '{mirrorImageSnapshot}'], 2.0)
+ def create(self, image_spec, snapshot_name, mirrorImageSnapshot):
+ pool_name, namespace, image_name = parse_image_spec(image_spec)
+
+ def _create_snapshot(ioctx, img, snapshot_name):
+ mirror_info = img.mirror_image_get_info()
+ mirror_mode = img.mirror_image_get_mode()
+ if (mirror_info['state'] == rbd.RBD_MIRROR_IMAGE_ENABLED and mirror_mode == rbd.RBD_MIRROR_IMAGE_MODE_SNAPSHOT) and mirrorImageSnapshot: # noqa E501 #pylint: disable=line-too-long
+ img.mirror_image_create_snapshot()
+ else:
+ img.create_snap(snapshot_name)
+
+ return rbd_image_call(pool_name, namespace, image_name, _create_snapshot,
+ snapshot_name)
+
+ @RbdTask('snap/delete',
+ ['{image_spec}', '{snapshot_name}'], 2.0)
+ def delete(self, image_spec, snapshot_name):
+ return RbdSnapshotService.remove_snapshot(image_spec, snapshot_name)
+
+ @RbdTask('snap/edit',
+ ['{image_spec}', '{snapshot_name}'], 4.0)
+ def set(self, image_spec, snapshot_name, new_snap_name=None,
+ is_protected=None):
+ def _edit(ioctx, img, snapshot_name):
+ if new_snap_name and new_snap_name != snapshot_name:
+ img.rename_snap(snapshot_name, new_snap_name)
+ snapshot_name = new_snap_name
+ if is_protected is not None and \
+ is_protected != img.is_protected_snap(snapshot_name):
+ if is_protected:
+ img.protect_snap(snapshot_name)
+ else:
+ img.unprotect_snap(snapshot_name)
+
+ pool_name, namespace, image_name = parse_image_spec(image_spec)
+ return rbd_image_call(pool_name, namespace, image_name, _edit, snapshot_name)
+
+ @RbdTask('snap/rollback',
+ ['{image_spec}', '{snapshot_name}'], 5.0)
+ @RESTController.Resource('POST')
+ @UpdatePermission
+ @allow_empty_body
+ def rollback(self, image_spec, snapshot_name):
+ def _rollback(ioctx, img, snapshot_name):
+ img.rollback_to_snap(snapshot_name)
+
+ pool_name, namespace, image_name = parse_image_spec(image_spec)
+ return rbd_image_call(pool_name, namespace, image_name, _rollback, snapshot_name)
+
+ @RbdTask('clone',
+ {'parent_image_spec': '{image_spec}',
+ 'child_pool_name': '{child_pool_name}',
+ 'child_namespace': '{child_namespace}',
+ 'child_image_name': '{child_image_name}'}, 2.0)
+ @RESTController.Resource('POST')
+ @allow_empty_body
+ def clone(self, image_spec, snapshot_name, child_pool_name,
+ child_image_name, child_namespace=None, obj_size=None, features=None,
+ stripe_unit=None, stripe_count=None, data_pool=None,
+ configuration=None, metadata=None):
+ """
+ Clones a snapshot to an image
+ """
+
+ pool_name, namespace, image_name = parse_image_spec(image_spec)
+
+ def _parent_clone(p_ioctx):
+ def _clone(ioctx):
+ # Set order
+ l_order = None
+ if obj_size and obj_size > 0:
+ l_order = int(round(math.log(float(obj_size), 2)))
+
+ # Set features
+ feature_bitmask = format_features(features)
+
+ rbd_inst = rbd.RBD()
+ rbd_inst.clone(p_ioctx, image_name, snapshot_name, ioctx,
+ child_image_name, feature_bitmask, l_order,
+ stripe_unit, stripe_count, data_pool)
+
+ RbdConfiguration(pool_ioctx=ioctx, image_name=child_image_name).set_configuration(
+ configuration)
+ if metadata:
+ with rbd.Image(ioctx, child_image_name) as image:
+ RbdImageMetadataService(image).set_metadata(metadata)
+
+ return rbd_call(child_pool_name, child_namespace, _clone)
+
+ rbd_call(pool_name, namespace, _parent_clone)
+
+
+@APIRouter('/block/image/trash', Scope.RBD_IMAGE)
+@APIDoc("RBD Trash Management API", "RbdTrash")
+class RbdTrash(RESTController):
+ RESOURCE_ID = "image_id_spec"
+
+ def __init__(self):
+ super().__init__()
+ self.rbd_inst = rbd.RBD()
+
+ @ViewCache()
+ def _trash_pool_list(self, pool_name):
+ with mgr.rados.open_ioctx(pool_name) as ioctx:
+ result = []
+ namespaces = self.rbd_inst.namespace_list(ioctx)
+ # images without namespace
+ namespaces.append('')
+ for namespace in namespaces:
+ ioctx.set_namespace(namespace)
+ images = self.rbd_inst.trash_list(ioctx)
+ for trash in images:
+ trash['pool_name'] = pool_name
+ trash['namespace'] = namespace
+ trash['deletion_time'] = "{}Z".format(trash['deletion_time'].isoformat())
+ trash['deferment_end_time'] = "{}Z".format(
+ trash['deferment_end_time'].isoformat())
+ result.append(trash)
+ return result
+
+ def _trash_list(self, pool_name=None):
+ if pool_name:
+ pools = [pool_name]
+ else:
+ pools = [p['pool_name'] for p in CephService.get_pool_list('rbd')]
+
+ result = []
+ for pool in pools:
+ # pylint: disable=unbalanced-tuple-unpacking
+ status, value = self._trash_pool_list(pool)
+ result.append({'status': status, 'value': value, 'pool_name': pool})
+ return result
+
+ @handle_rbd_error()
+ @handle_rados_error('pool')
+ @EndpointDoc("Get RBD Trash Details by pool name",
+ parameters={
+ 'pool_name': (str, 'Name of the pool'),
+ },
+ responses={200: RBD_TRASH_SCHEMA})
+ def list(self, pool_name=None):
+ """List all entries from trash."""
+ return self._trash_list(pool_name)
+
+ @handle_rbd_error()
+ @handle_rados_error('pool')
+ @RbdTask('trash/purge', ['{pool_name}'], 2.0)
+ @RESTController.Collection('POST', query_params=['pool_name'])
+ @DeletePermission
+ @allow_empty_body
+ def purge(self, pool_name=None):
+ """Remove all expired images from trash."""
+ now = "{}Z".format(datetime.utcnow().isoformat())
+ pools = self._trash_list(pool_name)
+
+ for pool in pools:
+ for image in pool['value']:
+ if image['deferment_end_time'] < now:
+ logger.info('Removing trash image %s (pool=%s, namespace=%s, name=%s)',
+ image['id'], pool['pool_name'], image['namespace'], image['name'])
+ rbd_call(pool['pool_name'], image['namespace'],
+ self.rbd_inst.trash_remove, image['id'], 0)
+
+ @RbdTask('trash/restore', ['{image_id_spec}', '{new_image_name}'], 2.0)
+ @RESTController.Resource('POST')
+ @CreatePermission
+ @allow_empty_body
+ def restore(self, image_id_spec, new_image_name):
+ """Restore an image from trash."""
+ pool_name, namespace, image_id = parse_image_spec(image_id_spec)
+ return rbd_call(pool_name, namespace, self.rbd_inst.trash_restore, image_id,
+ new_image_name)
+
+ @RbdTask('trash/remove', ['{image_id_spec}'], 2.0)
+ def delete(self, image_id_spec, force=False):
+ """Delete an image from trash.
+ If image deferment time has not expired you can not removed it unless use force.
+ But an actively in-use by clones or has snapshots can not be removed.
+ """
+ pool_name, namespace, image_id = parse_image_spec(image_id_spec)
+ return rbd_call(pool_name, namespace, self.rbd_inst.trash_remove, image_id,
+ int(str_to_bool(force)))
+
+
+@APIRouter('/block/pool/{pool_name}/namespace', Scope.RBD_IMAGE)
+@APIDoc("RBD Namespace Management API", "RbdNamespace")
+class RbdNamespace(RESTController):
+
+ def __init__(self):
+ super().__init__()
+ self.rbd_inst = rbd.RBD()
+
+ def create(self, pool_name, namespace):
+ with mgr.rados.open_ioctx(pool_name) as ioctx:
+ namespaces = self.rbd_inst.namespace_list(ioctx)
+ if namespace in namespaces:
+ raise DashboardException(
+ msg='Namespace already exists',
+ code='namespace_already_exists',
+ component='rbd')
+ return self.rbd_inst.namespace_create(ioctx, namespace)
+
+ def delete(self, pool_name, namespace):
+ with mgr.rados.open_ioctx(pool_name) as ioctx:
+ # pylint: disable=unbalanced-tuple-unpacking
+ images, _ = RbdService.rbd_pool_list([pool_name], namespace=namespace)
+ if images:
+ raise DashboardException(
+ msg='Namespace contains images which must be deleted first',
+ code='namespace_contains_images',
+ component='rbd')
+ return self.rbd_inst.namespace_remove(ioctx, namespace)
+
+ def list(self, pool_name):
+ with mgr.rados.open_ioctx(pool_name) as ioctx:
+ result = []
+ namespaces = self.rbd_inst.namespace_list(ioctx)
+ for namespace in namespaces:
+ # pylint: disable=unbalanced-tuple-unpacking
+ images, _ = RbdService.rbd_pool_list([pool_name], namespace=namespace)
+ result.append({
+ 'namespace': namespace,
+ 'num_images': len(images) if images else 0
+ })
+ return result
diff --git a/src/pybind/mgr/dashboard/controllers/rbd_mirroring.py b/src/pybind/mgr/dashboard/controllers/rbd_mirroring.py
new file mode 100644
index 000000000..1e1053077
--- /dev/null
+++ b/src/pybind/mgr/dashboard/controllers/rbd_mirroring.py
@@ -0,0 +1,687 @@
+# -*- coding: utf-8 -*-
+
+import json
+import logging
+import re
+from enum import IntEnum
+from functools import partial
+from typing import NamedTuple, Optional, no_type_check
+
+import cherrypy
+import rbd
+
+from .. import mgr
+from ..controllers.pool import RBDPool
+from ..controllers.service import Service
+from ..security import Scope
+from ..services.ceph_service import CephService
+from ..services.exception import handle_rados_error, handle_rbd_error, serialize_dashboard_exception
+from ..services.orchestrator import OrchClient
+from ..services.rbd import rbd_call
+from ..tools import ViewCache
+from . import APIDoc, APIRouter, BaseController, CreatePermission, Endpoint, \
+ EndpointDoc, ReadPermission, RESTController, Task, UIRouter, \
+ UpdatePermission, allow_empty_body
+
+logger = logging.getLogger('controllers.rbd_mirror')
+
+
+class MirrorHealth(IntEnum):
+ # RBD defined mirroring health states in in src/tools/rbd/action/MirrorPool.cc where the order
+ # is relevant.
+ MIRROR_HEALTH_OK = 0
+ MIRROR_HEALTH_UNKNOWN = 1
+ MIRROR_HEALTH_WARNING = 2
+ MIRROR_HEALTH_ERROR = 3
+
+ # extra states for the dashboard
+ MIRROR_HEALTH_DISABLED = 4
+ MIRROR_HEALTH_INFO = 5
+
+# pylint: disable=not-callable
+
+
+def handle_rbd_mirror_error():
+ def composed_decorator(func):
+ func = handle_rados_error('rbd-mirroring')(func)
+ return handle_rbd_error()(func)
+ return composed_decorator
+
+
+# pylint: disable=not-callable
+def RbdMirroringTask(name, metadata, wait_for): # noqa: N802
+ def composed_decorator(func):
+ func = handle_rbd_mirror_error()(func)
+ return Task("rbd/mirroring/{}".format(name), metadata, wait_for,
+ partial(serialize_dashboard_exception, include_http_status=True))(func)
+ return composed_decorator
+
+
+def get_daemons():
+ daemons = []
+ for hostname, server in CephService.get_service_map('rbd-mirror').items():
+ for service in server['services']:
+ id = service['id'] # pylint: disable=W0622
+ metadata = service['metadata']
+ status = service['status'] or {}
+
+ try:
+ status = json.loads(status['json'])
+ except (ValueError, KeyError):
+ status = {}
+
+ instance_id = metadata['instance_id']
+ if id == instance_id:
+ # new version that supports per-cluster leader elections
+ id = metadata['id']
+
+ # extract per-daemon service data and health
+ daemon = {
+ 'id': id,
+ 'instance_id': instance_id,
+ 'version': metadata['ceph_version'],
+ 'server_hostname': hostname,
+ 'service': service,
+ 'server': server,
+ 'metadata': metadata,
+ 'status': status
+ }
+ daemon = dict(daemon, **get_daemon_health(daemon))
+ daemons.append(daemon)
+
+ return sorted(daemons, key=lambda k: k['instance_id'])
+
+
+def get_daemon_health(daemon):
+ health = {
+ 'health': MirrorHealth.MIRROR_HEALTH_DISABLED
+ }
+ for _, pool_data in daemon['status'].items():
+ if (health['health'] != MirrorHealth.MIRROR_HEALTH_ERROR
+ and [k for k, v in pool_data.get('callouts', {}).items()
+ if v['level'] == 'error']):
+ health = {
+ 'health': MirrorHealth.MIRROR_HEALTH_ERROR
+ }
+ elif (health['health'] != MirrorHealth.MIRROR_HEALTH_ERROR
+ and [k for k, v in pool_data.get('callouts', {}).items()
+ if v['level'] == 'warning']):
+ health = {
+ 'health': MirrorHealth.MIRROR_HEALTH_WARNING
+ }
+ elif health['health'] == MirrorHealth.MIRROR_HEALTH_DISABLED:
+ health = {
+ 'health': MirrorHealth.MIRROR_HEALTH_OK
+ }
+ return health
+
+
+def get_pools(daemons): # pylint: disable=R0912, R0915
+ pool_names = [pool['pool_name'] for pool in CephService.get_pool_list('rbd')
+ if pool.get('type', 1) == 1]
+ pool_stats = _get_pool_stats(pool_names)
+ _update_pool_stats(daemons, pool_stats)
+ return pool_stats
+
+
+def transform_mirror_health(stat):
+ health = 'OK'
+ health_color = 'success'
+ if stat['health'] == MirrorHealth.MIRROR_HEALTH_ERROR:
+ health = 'Error'
+ health_color = 'error'
+ elif stat['health'] == MirrorHealth.MIRROR_HEALTH_WARNING:
+ health = 'Warning'
+ health_color = 'warning'
+ elif stat['health'] == MirrorHealth.MIRROR_HEALTH_UNKNOWN:
+ health = 'Unknown'
+ health_color = 'warning'
+ elif stat['health'] == MirrorHealth.MIRROR_HEALTH_OK:
+ health = 'OK'
+ health_color = 'success'
+ elif stat['health'] == MirrorHealth.MIRROR_HEALTH_DISABLED:
+ health = 'Disabled'
+ health_color = 'info'
+ stat['health'] = health
+ stat['health_color'] = health_color
+
+
+def _update_pool_stats(daemons, pool_stats):
+ _update_pool_stats_with_daemons(daemons, pool_stats)
+ for pool_stat in pool_stats.values():
+ transform_mirror_health(pool_stat)
+
+
+def _update_pool_stats_with_daemons(daemons, pool_stats):
+ for daemon in daemons:
+ for _, pool_data in daemon['status'].items():
+ pool_stat = pool_stats.get(pool_data['name'], None) # type: ignore
+ if pool_stat is None:
+ continue
+
+ if pool_data.get('leader', False):
+ # leader instance stores image counts
+ pool_stat['leader_id'] = daemon['metadata']['instance_id']
+ pool_stat['image_local_count'] = pool_data.get('image_local_count', 0)
+ pool_stat['image_remote_count'] = pool_data.get('image_remote_count', 0)
+
+ pool_stat['health'] = max(pool_stat['health'], daemon['health'])
+
+
+def _get_pool_stats(pool_names):
+ pool_stats = {}
+ rbdctx = rbd.RBD()
+ for pool_name in pool_names:
+ logger.debug("Constructing IOCtx %s", pool_name)
+ try:
+ ioctx = mgr.rados.open_ioctx(pool_name)
+ except TypeError:
+ logger.exception("Failed to open pool %s", pool_name)
+ continue
+
+ try:
+ mirror_mode = rbdctx.mirror_mode_get(ioctx)
+ peer_uuids = [x['uuid'] for x in rbdctx.mirror_peer_list(ioctx)]
+ except: # noqa pylint: disable=W0702
+ logger.exception("Failed to query mirror settings %s", pool_name)
+ mirror_mode = None
+ peer_uuids = []
+
+ stats = {}
+ if mirror_mode == rbd.RBD_MIRROR_MODE_DISABLED:
+ mirror_mode = "disabled"
+ stats['health'] = MirrorHealth.MIRROR_HEALTH_DISABLED
+ elif mirror_mode == rbd.RBD_MIRROR_MODE_IMAGE:
+ mirror_mode = "image"
+ elif mirror_mode == rbd.RBD_MIRROR_MODE_POOL:
+ mirror_mode = "pool"
+ else:
+ mirror_mode = "unknown"
+
+ if mirror_mode != "disabled":
+ # In case of a pool being enabled we will infer the health like the RBD cli tool does
+ # in src/tools/rbd/action/MirrorPool.cc::execute_status
+ mirror_image_health: MirrorHealth = MirrorHealth.MIRROR_HEALTH_OK
+ for status, _ in rbdctx.mirror_image_status_summary(ioctx):
+ if (mirror_image_health < MirrorHealth.MIRROR_HEALTH_WARNING
+ and status != rbd.MIRROR_IMAGE_STATUS_STATE_REPLAYING
+ and status != rbd.MIRROR_IMAGE_STATUS_STATE_STOPPED):
+ mirror_image_health = MirrorHealth.MIRROR_HEALTH_WARNING
+ if (mirror_image_health < MirrorHealth.MIRROR_HEALTH_ERROR
+ and status == rbd.MIRROR_IMAGE_STATUS_STATE_ERROR):
+ mirror_image_health = MirrorHealth.MIRROR_HEALTH_ERROR
+ stats['health'] = mirror_image_health
+
+ pool_stats[pool_name] = dict(stats, **{
+ 'mirror_mode': mirror_mode,
+ 'peer_uuids': peer_uuids
+ })
+ return pool_stats
+
+
+@ViewCache()
+def get_daemons_and_pools(): # pylint: disable=R0915
+ daemons = get_daemons()
+ daemons_and_pools = {
+ 'daemons': daemons,
+ 'pools': get_pools(daemons)
+ }
+ for daemon in daemons:
+ transform_mirror_health(daemon)
+ return daemons_and_pools
+
+
+class ReplayingData(NamedTuple):
+ bytes_per_second: Optional[int] = None
+ seconds_until_synced: Optional[int] = None
+ syncing_percent: Optional[float] = None
+ entries_behind_primary: Optional[int] = None
+
+
+def _get_mirror_mode(ioctx, image_name):
+ with rbd.Image(ioctx, image_name) as img:
+ mirror_mode = img.mirror_image_get_mode()
+ mirror_mode_str = 'Disabled'
+ if mirror_mode == rbd.RBD_MIRROR_IMAGE_MODE_JOURNAL:
+ mirror_mode_str = 'journal'
+ elif mirror_mode == rbd.RBD_MIRROR_IMAGE_MODE_SNAPSHOT:
+ mirror_mode_str = 'snapshot'
+ return mirror_mode_str
+
+
+@ViewCache()
+@no_type_check
+def _get_pool_datum(pool_name):
+ data = {}
+ logger.debug("Constructing IOCtx %s", pool_name)
+ try:
+ ioctx = mgr.rados.open_ioctx(pool_name)
+ except TypeError:
+ logger.exception("Failed to open pool %s", pool_name)
+ return None
+
+ mirror_state = {
+ 'down': {
+ 'health': 'issue',
+ 'state_color': 'warning',
+ 'state': 'Unknown',
+ 'description': None
+ },
+ rbd.MIRROR_IMAGE_STATUS_STATE_UNKNOWN: {
+ 'health': 'issue',
+ 'state_color': 'warning',
+ 'state': 'Unknown'
+ },
+ rbd.MIRROR_IMAGE_STATUS_STATE_ERROR: {
+ 'health': 'issue',
+ 'state_color': 'error',
+ 'state': 'Error'
+ },
+ rbd.MIRROR_IMAGE_STATUS_STATE_SYNCING: {
+ 'health': 'syncing',
+ 'state_color': 'success',
+ 'state': 'Syncing'
+ },
+ rbd.MIRROR_IMAGE_STATUS_STATE_STARTING_REPLAY: {
+ 'health': 'syncing',
+ 'state_color': 'success',
+ 'state': 'Starting'
+ },
+ rbd.MIRROR_IMAGE_STATUS_STATE_REPLAYING: {
+ 'health': 'syncing',
+ 'state_color': 'success',
+ 'state': 'Replaying'
+ },
+ rbd.MIRROR_IMAGE_STATUS_STATE_STOPPING_REPLAY: {
+ 'health': 'ok',
+ 'state_color': 'success',
+ 'state': 'Stopping'
+ },
+ rbd.MIRROR_IMAGE_STATUS_STATE_STOPPED: {
+ 'health': 'ok',
+ 'state_color': 'info',
+ 'state': 'Stopped'
+ }
+
+ }
+
+ rbdctx = rbd.RBD()
+ try:
+ mirror_image_status = rbdctx.mirror_image_status_list(ioctx)
+ data['mirror_images'] = sorted([
+ dict({
+ 'name': image['name'],
+ 'description': image['description'],
+ 'mirror_mode': _get_mirror_mode(ioctx, image['name'])
+ }, **mirror_state['down' if not image['up'] else image['state']])
+ for image in mirror_image_status
+ ], key=lambda k: k['name'])
+ except rbd.ImageNotFound:
+ pass
+ except: # noqa pylint: disable=W0702
+ logger.exception("Failed to list mirror image status %s", pool_name)
+ raise
+
+ return data
+
+
+def _update_syncing_image_data(mirror_image, image):
+ if mirror_image['state'] == 'Replaying':
+ p = re.compile("replaying, ({.*})")
+ replaying_data = p.findall(mirror_image['description'])
+ assert len(replaying_data) == 1
+ replaying_data = json.loads(replaying_data[0])
+ if 'replay_state' in replaying_data and replaying_data['replay_state'] == 'idle':
+ image.update({
+ 'state_color': 'info',
+ 'state': 'Idle'
+ })
+ for field in ReplayingData._fields:
+ try:
+ image[field] = replaying_data[field]
+ except KeyError:
+ pass
+ else:
+ p = re.compile("bootstrapping, IMAGE_COPY/COPY_OBJECT (.*)%")
+ image.update({
+ 'progress': (p.findall(mirror_image['description']) or [0])[0]
+ })
+
+
+@ViewCache()
+def _get_content_data(): # pylint: disable=R0914
+ pool_names = [pool['pool_name'] for pool in CephService.get_pool_list('rbd')
+ if pool.get('type', 1) == 1]
+ _, data = get_daemons_and_pools()
+ daemons = data.get('daemons', [])
+ pool_stats = data.get('pools', {})
+
+ pools = []
+ image_error = []
+ image_syncing = []
+ image_ready = []
+ for pool_name in pool_names:
+ _, pool = _get_pool_datum(pool_name)
+ if not pool:
+ pool = {}
+
+ stats = pool_stats.get(pool_name, {})
+ if stats.get('mirror_mode', None) is None:
+ continue
+
+ mirror_images = pool.get('mirror_images', [])
+ for mirror_image in mirror_images:
+ image = {
+ 'pool_name': pool_name,
+ 'name': mirror_image['name'],
+ 'state_color': mirror_image['state_color'],
+ 'state': mirror_image['state'],
+ 'mirror_mode': mirror_image['mirror_mode']
+ }
+
+ if mirror_image['health'] == 'ok':
+ image.update({
+ 'description': mirror_image['description']
+ })
+ image_ready.append(image)
+ elif mirror_image['health'] == 'syncing':
+ _update_syncing_image_data(mirror_image, image)
+ image_syncing.append(image)
+ else:
+ image.update({
+ 'description': mirror_image['description']
+ })
+ image_error.append(image)
+
+ pools.append(dict({
+ 'name': pool_name
+ }, **stats))
+
+ return {
+ 'daemons': daemons,
+ 'pools': pools,
+ 'image_error': image_error,
+ 'image_syncing': image_syncing,
+ 'image_ready': image_ready
+ }
+
+
+def _reset_view_cache():
+ get_daemons_and_pools.reset()
+ _get_pool_datum.reset()
+ _get_content_data.reset()
+
+
+RBD_MIRROR_SCHEMA = {
+ "site_name": (str, "Site Name")
+}
+
+RBDM_POOL_SCHEMA = {
+ "mirror_mode": (str, "Mirror Mode")
+}
+
+RBDM_SUMMARY_SCHEMA = {
+ "site_name": (str, "site name"),
+ "status": (int, ""),
+ "content_data": ({
+ "daemons": ([str], ""),
+ "pools": ([{
+ "name": (str, "Pool name"),
+ "health_color": (str, ""),
+ "health": (str, "pool health"),
+ "mirror_mode": (str, "status"),
+ "peer_uuids": ([str], "")
+ }], "Pools"),
+ "image_error": ([str], ""),
+ "image_syncing": ([str], ""),
+ "image_ready": ([str], "")
+ }, "")
+}
+
+
+@APIRouter('/block/mirroring', Scope.RBD_MIRRORING)
+@APIDoc("RBD Mirroring Management API", "RbdMirroring")
+class RbdMirroring(BaseController):
+
+ @Endpoint(method='GET', path='site_name')
+ @handle_rbd_mirror_error()
+ @ReadPermission
+ @EndpointDoc("Display Rbd Mirroring sitename",
+ responses={200: RBD_MIRROR_SCHEMA})
+ def get(self):
+ return self._get_site_name()
+
+ @Endpoint(method='PUT', path='site_name')
+ @handle_rbd_mirror_error()
+ @UpdatePermission
+ def set(self, site_name):
+ rbd.RBD().mirror_site_name_set(mgr.rados, site_name)
+ return self._get_site_name()
+
+ def _get_site_name(self):
+ return {'site_name': rbd.RBD().mirror_site_name_get(mgr.rados)}
+
+
+@APIRouter('/block/mirroring/summary', Scope.RBD_MIRRORING)
+@APIDoc("RBD Mirroring Summary Management API", "RbdMirroringSummary")
+class RbdMirroringSummary(BaseController):
+
+ @Endpoint()
+ @handle_rbd_mirror_error()
+ @ReadPermission
+ @EndpointDoc("Display Rbd Mirroring Summary",
+ responses={200: RBDM_SUMMARY_SCHEMA})
+ def __call__(self):
+ site_name = rbd.RBD().mirror_site_name_get(mgr.rados)
+
+ status, content_data = _get_content_data()
+ return {'site_name': site_name,
+ 'status': status,
+ 'content_data': content_data}
+
+
+@APIRouter('/block/mirroring/pool', Scope.RBD_MIRRORING)
+@APIDoc("RBD Mirroring Pool Mode Management API", "RbdMirroringPoolMode")
+class RbdMirroringPoolMode(RESTController):
+
+ RESOURCE_ID = "pool_name"
+ MIRROR_MODES = {
+ rbd.RBD_MIRROR_MODE_DISABLED: 'disabled',
+ rbd.RBD_MIRROR_MODE_IMAGE: 'image',
+ rbd.RBD_MIRROR_MODE_POOL: 'pool'
+ }
+
+ @handle_rbd_mirror_error()
+ @EndpointDoc("Display Rbd Mirroring Summary",
+ parameters={
+ 'pool_name': (str, 'Pool Name'),
+ },
+ responses={200: RBDM_POOL_SCHEMA})
+ def get(self, pool_name):
+ ioctx = mgr.rados.open_ioctx(pool_name)
+ mode = rbd.RBD().mirror_mode_get(ioctx)
+ data = {
+ 'mirror_mode': self.MIRROR_MODES.get(mode, 'unknown')
+ }
+ return data
+
+ @RbdMirroringTask('pool/edit', {'pool_name': '{pool_name}'}, 5.0)
+ def set(self, pool_name, mirror_mode=None):
+ def _edit(ioctx, mirror_mode=None):
+ if mirror_mode:
+ mode_enum = {x[1]: x[0] for x in
+ self.MIRROR_MODES.items()}.get(mirror_mode, None)
+ if mode_enum is None:
+ raise rbd.Error('invalid mirror mode "{}"'.format(mirror_mode))
+
+ current_mode_enum = rbd.RBD().mirror_mode_get(ioctx)
+ if mode_enum != current_mode_enum:
+ rbd.RBD().mirror_mode_set(ioctx, mode_enum)
+ _reset_view_cache()
+
+ return rbd_call(pool_name, None, _edit, mirror_mode)
+
+
+@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')
+ @handle_rbd_mirror_error()
+ @UpdatePermission
+ @allow_empty_body
+ def create_token(self, pool_name):
+ ioctx = mgr.rados.open_ioctx(pool_name)
+ token = rbd.RBD().mirror_peer_bootstrap_create(ioctx)
+ return {'token': token}
+
+ @Endpoint(method='POST', path='peer')
+ @handle_rbd_mirror_error()
+ @UpdatePermission
+ @allow_empty_body
+ def import_token(self, pool_name, direction, token):
+ ioctx = mgr.rados.open_ioctx(pool_name)
+
+ directions = {
+ 'rx': rbd.RBD_MIRROR_PEER_DIRECTION_RX,
+ 'rx-tx': rbd.RBD_MIRROR_PEER_DIRECTION_RX_TX
+ }
+
+ direction_enum = directions.get(direction)
+ if direction_enum is None:
+ raise rbd.Error('invalid direction "{}"'.format(direction))
+
+ rbd.RBD().mirror_peer_bootstrap_import(ioctx, direction_enum, token)
+ return {}
+
+
+@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"
+
+ @handle_rbd_mirror_error()
+ def list(self, pool_name):
+ ioctx = mgr.rados.open_ioctx(pool_name)
+ peer_list = rbd.RBD().mirror_peer_list(ioctx)
+ return [x['uuid'] for x in peer_list]
+
+ @handle_rbd_mirror_error()
+ def create(self, pool_name, cluster_name, client_id, mon_host=None,
+ key=None):
+ ioctx = mgr.rados.open_ioctx(pool_name)
+ mode = rbd.RBD().mirror_mode_get(ioctx)
+ if mode == rbd.RBD_MIRROR_MODE_DISABLED:
+ raise rbd.Error('mirroring must be enabled')
+
+ uuid = rbd.RBD().mirror_peer_add(ioctx, cluster_name,
+ 'client.{}'.format(client_id))
+
+ attributes = {}
+ if mon_host is not None:
+ attributes[rbd.RBD_MIRROR_PEER_ATTRIBUTE_NAME_MON_HOST] = mon_host
+ if key is not None:
+ attributes[rbd.RBD_MIRROR_PEER_ATTRIBUTE_NAME_KEY] = key
+ if attributes:
+ rbd.RBD().mirror_peer_set_attributes(ioctx, uuid, attributes)
+
+ _reset_view_cache()
+ return {'uuid': uuid}
+
+ @handle_rbd_mirror_error()
+ def get(self, pool_name, peer_uuid):
+ ioctx = mgr.rados.open_ioctx(pool_name)
+ peer_list = rbd.RBD().mirror_peer_list(ioctx)
+ peer = next((x for x in peer_list if x['uuid'] == peer_uuid), None)
+ if not peer:
+ raise cherrypy.HTTPError(404)
+
+ # convert full client name to just the client id
+ peer['client_id'] = peer['client_name'].split('.', 1)[-1]
+ del peer['client_name']
+
+ # convert direction enum to string
+ directions = {
+ rbd.RBD_MIRROR_PEER_DIRECTION_RX: 'rx',
+ rbd.RBD_MIRROR_PEER_DIRECTION_TX: 'tx',
+ rbd.RBD_MIRROR_PEER_DIRECTION_RX_TX: 'rx-tx'
+ }
+ peer['direction'] = directions[peer.get('direction', rbd.RBD_MIRROR_PEER_DIRECTION_RX)]
+
+ try:
+ attributes = rbd.RBD().mirror_peer_get_attributes(ioctx, peer_uuid)
+ except rbd.ImageNotFound:
+ attributes = {}
+
+ peer['mon_host'] = attributes.get(rbd.RBD_MIRROR_PEER_ATTRIBUTE_NAME_MON_HOST, '')
+ peer['key'] = attributes.get(rbd.RBD_MIRROR_PEER_ATTRIBUTE_NAME_KEY, '')
+ return peer
+
+ @handle_rbd_mirror_error()
+ def delete(self, pool_name, peer_uuid):
+ ioctx = mgr.rados.open_ioctx(pool_name)
+ rbd.RBD().mirror_peer_remove(ioctx, peer_uuid)
+ _reset_view_cache()
+
+ @handle_rbd_mirror_error()
+ def set(self, pool_name, peer_uuid, cluster_name=None, client_id=None,
+ mon_host=None, key=None):
+ ioctx = mgr.rados.open_ioctx(pool_name)
+ if cluster_name:
+ rbd.RBD().mirror_peer_set_cluster(ioctx, peer_uuid, cluster_name)
+ if client_id:
+ rbd.RBD().mirror_peer_set_client(ioctx, peer_uuid,
+ 'client.{}'.format(client_id))
+
+ if mon_host is not None or key is not None:
+ try:
+ attributes = rbd.RBD().mirror_peer_get_attributes(ioctx, peer_uuid)
+ except rbd.ImageNotFound:
+ attributes = {}
+
+ if mon_host is not None:
+ attributes[rbd.RBD_MIRROR_PEER_ATTRIBUTE_NAME_MON_HOST] = mon_host
+ if key is not None:
+ attributes[rbd.RBD_MIRROR_PEER_ATTRIBUTE_NAME_KEY] = key
+ rbd.RBD().mirror_peer_set_attributes(ioctx, peer_uuid, attributes)
+
+ _reset_view_cache()
+
+
+@UIRouter('/block/mirroring', Scope.RBD_MIRRORING)
+class RbdMirroringStatus(BaseController):
+ @EndpointDoc('Display RBD Mirroring Status')
+ @Endpoint()
+ @ReadPermission
+ def status(self):
+ status = {'available': True, 'message': None}
+ orch_status = OrchClient.instance().status()
+
+ # if the orch is not available we can't create the service
+ # using dashboard.
+ if not orch_status['available']:
+ return status
+ if not CephService.get_service_list('rbd-mirror') and not CephService.get_pool_list('rbd'):
+ status['available'] = False
+ status['message'] = 'RBD mirroring is not configured' # type: ignore
+ return status
+
+ @Endpoint('POST')
+ @EndpointDoc('Configure RBD Mirroring')
+ @CreatePermission
+ def configure(self):
+ rbd_pool = RBDPool()
+ service = Service()
+
+ service_spec = {
+ 'service_type': 'rbd-mirror',
+ 'placement': {},
+ 'unmanaged': False
+ }
+
+ if not CephService.get_service_list('rbd-mirror'):
+ service.create(service_spec, 'rbd-mirror')
+
+ if not CephService.get_pool_list('rbd'):
+ rbd_pool.create()
diff --git a/src/pybind/mgr/dashboard/controllers/rgw.py b/src/pybind/mgr/dashboard/controllers/rgw.py
new file mode 100644
index 000000000..9ccf4b36b
--- /dev/null
+++ b/src/pybind/mgr/dashboard/controllers/rgw.py
@@ -0,0 +1,970 @@
+# -*- coding: utf-8 -*-
+
+import json
+import logging
+import re
+from typing import Any, Dict, List, NamedTuple, Optional, Union
+
+import cherrypy
+
+from .. import mgr
+from ..exceptions import DashboardException
+from ..rest_client import RequestException
+from ..security import Permission, Scope
+from ..services.auth import AuthManager, JwtManager
+from ..services.ceph_service import CephService
+from ..services.rgw_client import NoRgwDaemonsException, RgwClient, RgwMultisite
+from ..tools import json_str_to_object, str_to_bool
+from . import APIDoc, APIRouter, BaseController, CreatePermission, \
+ CRUDCollectionMethod, CRUDEndpoint, Endpoint, EndpointDoc, ReadPermission, \
+ RESTController, UIRouter, UpdatePermission, allow_empty_body
+from ._crud import CRUDMeta, Form, FormField, FormTaskInfo, Icon, MethodType, \
+ TableAction, Validator, VerticalContainer
+from ._version import APIVersion
+
+logger = logging.getLogger("controllers.rgw")
+
+RGW_SCHEMA = {
+ "available": (bool, "Is RGW available?"),
+ "message": (str, "Descriptions")
+}
+
+RGW_DAEMON_SCHEMA = {
+ "id": (str, "Daemon ID"),
+ "version": (str, "Ceph Version"),
+ "server_hostname": (str, ""),
+ "zonegroup_name": (str, "Zone Group"),
+ "zone_name": (str, "Zone"),
+ "port": (int, "Port"),
+}
+
+RGW_USER_SCHEMA = {
+ "list_of_users": ([str], "list of rgw users")
+}
+
+
+@UIRouter('/rgw', Scope.RGW)
+@APIDoc("RGW Management API", "Rgw")
+class Rgw(BaseController):
+ @Endpoint()
+ @ReadPermission
+ @EndpointDoc("Display RGW Status",
+ responses={200: RGW_SCHEMA})
+ def status(self) -> dict:
+ status = {'available': False, 'message': None}
+ try:
+ instance = RgwClient.admin_instance()
+ # Check if the service is online.
+ try:
+ is_online = instance.is_service_online()
+ except RequestException as e:
+ # Drop this instance because the RGW client seems not to
+ # exist anymore (maybe removed via orchestrator). Removing
+ # the instance from the cache will result in the correct
+ # error message next time when the backend tries to
+ # establish a new connection (-> 'No RGW found' instead
+ # of 'RGW REST API failed request ...').
+ # Note, this only applies to auto-detected RGW clients.
+ RgwClient.drop_instance(instance)
+ raise e
+ if not is_online:
+ msg = 'Failed to connect to the Object Gateway\'s Admin Ops API.'
+ raise RequestException(msg)
+ # Ensure the system flag is set for the API user ID.
+ if not instance.is_system_user(): # pragma: no cover - no complexity there
+ msg = 'The system flag is not set for user "{}".'.format(
+ instance.userid)
+ raise RequestException(msg)
+ status['available'] = True
+ except (DashboardException, RequestException, NoRgwDaemonsException) as ex:
+ status['message'] = str(ex) # type: ignore
+ return status
+
+
+@UIRouter('/rgw/multisite')
+class RgwMultisiteStatus(RESTController):
+ @Endpoint()
+ @ReadPermission
+ # pylint: disable=R0801
+ def status(self):
+ status = {'available': True, 'message': None}
+ multisite_instance = RgwMultisite()
+ is_multisite_configured = multisite_instance.get_multisite_status()
+ if not is_multisite_configured:
+ status['available'] = False
+ status['message'] = 'Multi-site provides disaster recovery and may also \
+ serve as a foundation for content delivery networks' # type: ignore
+ return status
+
+ @RESTController.Collection(method='PUT', path='/migrate')
+ @allow_empty_body
+ # pylint: disable=W0102,W0613
+ def migrate(self, daemon_name=None, realm_name=None, zonegroup_name=None, zone_name=None,
+ zonegroup_endpoints=None, zone_endpoints=None, access_key=None,
+ secret_key=None):
+ multisite_instance = RgwMultisite()
+ result = multisite_instance.migrate_to_multisite(realm_name, zonegroup_name,
+ zone_name, zonegroup_endpoints,
+ zone_endpoints, access_key,
+ secret_key)
+ return result
+
+ @RESTController.Collection(method='GET', path='/sync_status')
+ @allow_empty_body
+ # pylint: disable=W0102,W0613
+ def get_sync_status(self):
+ multisite_instance = RgwMultisite()
+ result = multisite_instance.get_multisite_sync_status()
+ return result
+
+
+@APIRouter('/rgw/daemon', Scope.RGW)
+@APIDoc("RGW Daemon Management API", "RgwDaemon")
+class RgwDaemon(RESTController):
+ @EndpointDoc("Display RGW Daemons",
+ responses={200: [RGW_DAEMON_SCHEMA]})
+ def list(self) -> List[dict]:
+ daemons: List[dict] = []
+ try:
+ instance = RgwClient.admin_instance()
+ except NoRgwDaemonsException:
+ return daemons
+
+ for hostname, server in CephService.get_service_map('rgw').items():
+ for service in server['services']:
+ metadata = service['metadata']
+
+ # extract per-daemon service data and health
+ daemon = {
+ 'id': metadata['id'],
+ 'service_map_id': service['id'],
+ 'version': metadata['ceph_version'],
+ 'server_hostname': hostname,
+ 'realm_name': metadata['realm_name'],
+ 'zonegroup_name': metadata['zonegroup_name'],
+ 'zone_name': metadata['zone_name'],
+ 'default': instance.daemon.name == metadata['id'],
+ 'port': int(re.findall(r'port=(\d+)', metadata['frontend_config#0'])[0])
+ }
+
+ daemons.append(daemon)
+
+ return sorted(daemons, key=lambda k: k['id'])
+
+ def get(self, svc_id):
+ # type: (str) -> dict
+ daemon = {
+ 'rgw_metadata': [],
+ 'rgw_id': svc_id,
+ 'rgw_status': []
+ }
+ service = CephService.get_service('rgw', svc_id)
+ if not service:
+ raise cherrypy.NotFound('Service rgw {} is not available'.format(svc_id))
+
+ metadata = service['metadata']
+ status = service['status']
+ if 'json' in status:
+ try:
+ status = json.loads(status['json'])
+ except ValueError:
+ logger.warning('%s had invalid status json', service['id'])
+ status = {}
+ else:
+ logger.warning('%s has no key "json" in status', service['id'])
+
+ daemon['rgw_metadata'] = metadata
+ daemon['rgw_status'] = status
+ return daemon
+
+ @RESTController.Collection(method='PUT', path='/set_multisite_config')
+ @allow_empty_body
+ def set_multisite_config(self, realm_name=None, zonegroup_name=None,
+ zone_name=None, daemon_name=None):
+ CephService.set_multisite_config(realm_name, zonegroup_name, zone_name, daemon_name)
+
+
+class RgwRESTController(RESTController):
+ def proxy(self, daemon_name, method, path, params=None, json_response=True):
+ try:
+ instance = RgwClient.admin_instance(daemon_name=daemon_name)
+ result = instance.proxy(method, path, params, None)
+ if json_response:
+ result = json_str_to_object(result)
+ return result
+ except (DashboardException, RequestException) as e:
+ http_status_code = e.status if isinstance(e, DashboardException) else 500
+ raise DashboardException(e, http_status_code=http_status_code, component='rgw')
+
+
+@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':
+ return RgwClient.admin_instance(daemon_name=daemon_name).get_placement_targets()
+ if query == 'realms':
+ return RgwClient.admin_instance(daemon_name=daemon_name).get_realms()
+ if query == 'default-realm':
+ return RgwClient.admin_instance(daemon_name=daemon_name).get_default_realm()
+
+ # @TODO: for multisite: by default, retrieve cluster topology/map.
+ raise DashboardException(http_status_code=501, component='rgw', msg='Not Implemented')
+
+
+@APIRouter('/rgw/bucket', Scope.RGW)
+@APIDoc("RGW Bucket Management API", "RgwBucket")
+class RgwBucket(RgwRESTController):
+ def _append_bid(self, bucket):
+ """
+ Append the bucket identifier that looks like [<tenant>/]<bucket>.
+ See http://docs.ceph.com/docs/nautilus/radosgw/multitenancy/ for
+ more information.
+ :param bucket: The bucket parameters.
+ :type bucket: dict
+ :return: The modified bucket parameters including the 'bid' parameter.
+ :rtype: dict
+ """
+ if isinstance(bucket, dict):
+ bucket['bid'] = '{}/{}'.format(bucket['tenant'], bucket['bucket']) \
+ if bucket['tenant'] else bucket['bucket']
+ return bucket
+
+ def _get_versioning(self, owner, daemon_name, bucket_name):
+ rgw_client = RgwClient.instance(owner, daemon_name)
+ return rgw_client.get_bucket_versioning(bucket_name)
+
+ def _set_versioning(self, owner, daemon_name, bucket_name, versioning_state, mfa_delete,
+ mfa_token_serial, mfa_token_pin):
+ bucket_versioning = self._get_versioning(owner, daemon_name, bucket_name)
+ if versioning_state != bucket_versioning['Status']\
+ or (mfa_delete and mfa_delete != bucket_versioning['MfaDelete']):
+ rgw_client = RgwClient.instance(owner, daemon_name)
+ rgw_client.set_bucket_versioning(bucket_name, versioning_state, mfa_delete,
+ mfa_token_serial, mfa_token_pin)
+
+ def _set_encryption(self, bid, encryption_type, key_id, daemon_name, owner):
+
+ rgw_client = RgwClient.instance(owner, daemon_name)
+ rgw_client.set_bucket_encryption(bid, key_id, encryption_type)
+
+ # pylint: disable=W0613
+ def _set_encryption_config(self, encryption_type, kms_provider, auth_method, secret_engine,
+ secret_path, namespace, address, token, daemon_name, owner,
+ ssl_cert, client_cert, client_key):
+
+ CephService.set_encryption_config(encryption_type, kms_provider, auth_method,
+ secret_engine, secret_path, namespace, address,
+ token, daemon_name, ssl_cert, client_cert, client_key)
+
+ def _get_encryption(self, bucket_name, daemon_name, owner):
+ rgw_client = RgwClient.instance(owner, daemon_name)
+ return rgw_client.get_bucket_encryption(bucket_name)
+
+ def _delete_encryption(self, bucket_name, daemon_name, owner):
+ rgw_client = RgwClient.instance(owner, daemon_name)
+ return rgw_client.delete_bucket_encryption(bucket_name)
+
+ def _get_locking(self, owner, daemon_name, bucket_name):
+ rgw_client = RgwClient.instance(owner, daemon_name)
+ return rgw_client.get_bucket_locking(bucket_name)
+
+ def _set_locking(self, owner, daemon_name, bucket_name, mode,
+ retention_period_days, retention_period_years):
+ rgw_client = RgwClient.instance(owner, daemon_name)
+ return rgw_client.set_bucket_locking(bucket_name, mode,
+ retention_period_days,
+ retention_period_years)
+
+ @staticmethod
+ def strip_tenant_from_bucket_name(bucket_name):
+ # type (str) -> str
+ """
+ >>> RgwBucket.strip_tenant_from_bucket_name('tenant/bucket-name')
+ 'bucket-name'
+ >>> RgwBucket.strip_tenant_from_bucket_name('bucket-name')
+ 'bucket-name'
+ """
+ return bucket_name[bucket_name.find('/') + 1:]
+
+ @staticmethod
+ def get_s3_bucket_name(bucket_name, tenant=None):
+ # type (str, str) -> str
+ """
+ >>> RgwBucket.get_s3_bucket_name('bucket-name', 'tenant')
+ 'tenant:bucket-name'
+ >>> RgwBucket.get_s3_bucket_name('tenant/bucket-name', 'tenant')
+ 'tenant:bucket-name'
+ >>> RgwBucket.get_s3_bucket_name('bucket-name')
+ 'bucket-name'
+ """
+ bucket_name = RgwBucket.strip_tenant_from_bucket_name(bucket_name)
+ if tenant:
+ bucket_name = '{}:{}'.format(tenant, bucket_name)
+ return bucket_name
+
+ @RESTController.MethodMap(version=APIVersion(1, 1)) # type: ignore
+ def list(self, stats: bool = False, daemon_name: Optional[str] = None,
+ uid: Optional[str] = None) -> List[Union[str, Dict[str, Any]]]:
+ query_params = f'?stats={str_to_bool(stats)}'
+ if uid and uid.strip():
+ query_params = f'{query_params}&uid={uid.strip()}'
+ result = self.proxy(daemon_name, 'GET', 'bucket{}'.format(query_params))
+
+ if stats:
+ result = [self._append_bid(bucket) for bucket in result]
+
+ return result
+
+ def get(self, bucket, daemon_name=None):
+ # type: (str, Optional[str]) -> dict
+ result = self.proxy(daemon_name, 'GET', 'bucket', {'bucket': bucket})
+ bucket_name = RgwBucket.get_s3_bucket_name(result['bucket'],
+ result['tenant'])
+
+ # Append the versioning configuration.
+ versioning = self._get_versioning(result['owner'], daemon_name, bucket_name)
+ encryption = self._get_encryption(bucket_name, daemon_name, result['owner'])
+ result['encryption'] = encryption['Status']
+ result['versioning'] = versioning['Status']
+ result['mfa_delete'] = versioning['MfaDelete']
+
+ # Append the locking configuration.
+ locking = self._get_locking(result['owner'], daemon_name, bucket_name)
+ result.update(locking)
+
+ return self._append_bid(result)
+
+ @allow_empty_body
+ def create(self, bucket, uid, zonegroup=None, placement_target=None,
+ lock_enabled='false', lock_mode=None,
+ lock_retention_period_days=None,
+ lock_retention_period_years=None, encryption_state='false',
+ encryption_type=None, key_id=None, daemon_name=None):
+ lock_enabled = str_to_bool(lock_enabled)
+ encryption_state = str_to_bool(encryption_state)
+ try:
+ rgw_client = RgwClient.instance(uid, daemon_name)
+ result = rgw_client.create_bucket(bucket, zonegroup,
+ placement_target,
+ lock_enabled)
+ if lock_enabled:
+ self._set_locking(uid, daemon_name, bucket, lock_mode,
+ lock_retention_period_days,
+ lock_retention_period_years)
+
+ if encryption_state:
+ self._set_encryption(bucket, encryption_type, key_id, daemon_name, uid)
+
+ return result
+ except RequestException as e: # pragma: no cover - handling is too obvious
+ raise DashboardException(e, http_status_code=500, component='rgw')
+
+ @allow_empty_body
+ def set(self, bucket, bucket_id, uid, versioning_state=None,
+ encryption_state='false', encryption_type=None, key_id=None,
+ mfa_delete=None, mfa_token_serial=None, mfa_token_pin=None,
+ lock_mode=None, lock_retention_period_days=None,
+ lock_retention_period_years=None, daemon_name=None):
+ encryption_state = str_to_bool(encryption_state)
+ # When linking a non-tenant-user owned bucket to a tenanted user, we
+ # need to prefix bucket name with '/'. e.g. photos -> /photos
+ if '$' in uid and '/' not in bucket:
+ bucket = '/{}'.format(bucket)
+
+ # Link bucket to new user:
+ result = self.proxy(daemon_name,
+ 'PUT',
+ 'bucket', {
+ 'bucket': bucket,
+ 'bucket-id': bucket_id,
+ 'uid': uid
+ },
+ json_response=False)
+
+ uid_tenant = uid[:uid.find('$')] if uid.find('$') >= 0 else None
+ bucket_name = RgwBucket.get_s3_bucket_name(bucket, uid_tenant)
+
+ locking = self._get_locking(uid, daemon_name, bucket_name)
+ if versioning_state:
+ if versioning_state == 'Suspended' and locking['lock_enabled']:
+ raise DashboardException(msg='Bucket versioning cannot be disabled/suspended '
+ 'on buckets with object lock enabled ',
+ http_status_code=409, component='rgw')
+ self._set_versioning(uid, daemon_name, bucket_name, versioning_state,
+ mfa_delete, mfa_token_serial, mfa_token_pin)
+
+ # Update locking if it is enabled.
+ if locking['lock_enabled']:
+ self._set_locking(uid, daemon_name, bucket_name, lock_mode,
+ lock_retention_period_days,
+ lock_retention_period_years)
+
+ encryption_status = self._get_encryption(bucket_name, daemon_name, uid)
+ if encryption_state and encryption_status['Status'] != 'Enabled':
+ self._set_encryption(bucket_name, encryption_type, key_id, daemon_name, uid)
+ if encryption_status['Status'] == 'Enabled' and (not encryption_state):
+ self._delete_encryption(bucket_name, daemon_name, uid)
+ return self._append_bid(result)
+
+ def delete(self, bucket, purge_objects='true', daemon_name=None):
+ return self.proxy(daemon_name, 'DELETE', 'bucket', {
+ 'bucket': bucket,
+ 'purge-objects': purge_objects
+ }, json_response=False)
+
+ @RESTController.Collection(method='PUT', path='/setEncryptionConfig')
+ @allow_empty_body
+ def set_encryption_config(self, encryption_type=None, kms_provider=None, auth_method=None,
+ secret_engine=None, secret_path='', namespace='', address=None,
+ token=None, daemon_name=None, owner=None, ssl_cert=None,
+ client_cert=None, client_key=None):
+ return self._set_encryption_config(encryption_type, kms_provider, auth_method,
+ secret_engine, secret_path, namespace,
+ address, token, daemon_name, owner, ssl_cert,
+ client_cert, client_key)
+
+ @RESTController.Collection(method='GET', path='/getEncryption')
+ @allow_empty_body
+ def get_encryption(self, bucket_name, daemon_name=None, owner=None):
+ return self._get_encryption(bucket_name, daemon_name, owner)
+
+ @RESTController.Collection(method='DELETE', path='/deleteEncryption')
+ @allow_empty_body
+ def delete_encryption(self, bucket_name, daemon_name=None, owner=None):
+ return self._delete_encryption(bucket_name, daemon_name, owner)
+
+ @RESTController.Collection(method='GET', path='/getEncryptionConfig')
+ @allow_empty_body
+ def get_encryption_config(self, daemon_name=None, owner=None):
+ return CephService.get_encryption_config(daemon_name)
+
+
+@UIRouter('/rgw/bucket', Scope.RGW)
+class RgwBucketUi(RgwBucket):
+ @Endpoint('GET')
+ @ReadPermission
+ # pylint: disable=W0613
+ def buckets_and_users_count(self, daemon_name=None):
+ buckets_count = 0
+ users_count = 0
+ daemon_object = RgwDaemon()
+ daemons = json.loads(daemon_object.list())
+ unique_realms = set()
+ for daemon in daemons:
+ realm_name = daemon.get('realm_name', None)
+ if realm_name:
+ if realm_name not in unique_realms:
+ unique_realms.add(realm_name)
+ buckets = json.loads(RgwBucket.list(self, daemon_name=daemon['id']))
+ users = json.loads(RgwUser.list(self, daemon_name=daemon['id']))
+ users_count += len(users)
+ buckets_count += len(buckets)
+ else:
+ buckets = json.loads(RgwBucket.list(self, daemon_name=daemon['id']))
+ users = json.loads(RgwUser.list(self, daemon_name=daemon['id']))
+ users_count = len(users)
+ buckets_count = len(buckets)
+
+ return {
+ 'buckets_count': buckets_count,
+ 'users_count': users_count
+ }
+
+
+@APIRouter('/rgw/user', Scope.RGW)
+@APIDoc("RGW User Management API", "RgwUser")
+class RgwUser(RgwRESTController):
+ def _append_uid(self, user):
+ """
+ Append the user identifier that looks like [<tenant>$]<user>.
+ See http://docs.ceph.com/docs/jewel/radosgw/multitenancy/ for
+ more information.
+ :param user: The user parameters.
+ :type user: dict
+ :return: The modified user parameters including the 'uid' parameter.
+ :rtype: dict
+ """
+ if isinstance(user, dict):
+ user['uid'] = '{}${}'.format(user['tenant'], user['user_id']) \
+ if user['tenant'] else user['user_id']
+ return user
+
+ @staticmethod
+ def _keys_allowed():
+ permissions = AuthManager.get_user(JwtManager.get_username()).permissions_dict()
+ edit_permissions = [Permission.CREATE, Permission.UPDATE, Permission.DELETE]
+ return Scope.RGW in permissions and Permission.READ in permissions[Scope.RGW] \
+ and len(set(edit_permissions).intersection(set(permissions[Scope.RGW]))) > 0
+
+ @EndpointDoc("Display RGW Users",
+ responses={200: RGW_USER_SCHEMA})
+ def list(self, daemon_name=None):
+ # type: (Optional[str]) -> List[str]
+ users = [] # type: List[str]
+ marker = None
+ while True:
+ params = {} # type: dict
+ if marker:
+ params['marker'] = marker
+ result = self.proxy(daemon_name, 'GET', 'user?list', params)
+ users.extend(result['keys'])
+ if not result['truncated']:
+ break
+ # Make sure there is a marker.
+ assert result['marker']
+ # Make sure the marker has changed.
+ assert marker != result['marker']
+ marker = result['marker']
+ return users
+
+ def get(self, uid, daemon_name=None, stats=True) -> dict:
+ query_params = '?stats' if stats else ''
+ result = self.proxy(daemon_name, 'GET', 'user{}'.format(query_params),
+ {'uid': uid, 'stats': stats})
+ if not self._keys_allowed():
+ del result['keys']
+ del result['swift_keys']
+ return self._append_uid(result)
+
+ @Endpoint()
+ @ReadPermission
+ def get_emails(self, daemon_name=None):
+ # type: (Optional[str]) -> List[str]
+ emails = []
+ for uid in json.loads(self.list(daemon_name)): # type: ignore
+ user = json.loads(self.get(uid, daemon_name)) # type: ignore
+ if user["email"]:
+ emails.append(user["email"])
+ return emails
+
+ @allow_empty_body
+ def create(self, uid, display_name, email=None, max_buckets=None,
+ suspended=None, generate_key=None, access_key=None,
+ secret_key=None, daemon_name=None):
+ params = {'uid': uid}
+ if display_name is not None:
+ params['display-name'] = display_name
+ if email is not None:
+ params['email'] = email
+ if max_buckets is not None:
+ params['max-buckets'] = max_buckets
+ if suspended is not None:
+ params['suspended'] = suspended
+ if generate_key is not None:
+ params['generate-key'] = generate_key
+ if access_key is not None:
+ params['access-key'] = access_key
+ if secret_key is not None:
+ params['secret-key'] = secret_key
+ result = self.proxy(daemon_name, 'PUT', 'user', params)
+ return self._append_uid(result)
+
+ @allow_empty_body
+ def set(self, uid, display_name=None, email=None, max_buckets=None,
+ suspended=None, daemon_name=None):
+ params = {'uid': uid}
+ if display_name is not None:
+ params['display-name'] = display_name
+ if email is not None:
+ params['email'] = email
+ if max_buckets is not None:
+ params['max-buckets'] = max_buckets
+ if suspended is not None:
+ params['suspended'] = suspended
+ result = self.proxy(daemon_name, 'POST', 'user', params)
+ return self._append_uid(result)
+
+ def delete(self, uid, daemon_name=None):
+ try:
+ instance = RgwClient.admin_instance(daemon_name=daemon_name)
+ # Ensure the user is not configured to access the RGW Object Gateway.
+ if instance.userid == uid:
+ raise DashboardException(msg='Unable to delete "{}" - this user '
+ 'account is required for managing the '
+ 'Object Gateway'.format(uid))
+ # Finally redirect request to the RGW proxy.
+ return self.proxy(daemon_name, 'DELETE', 'user', {'uid': uid}, json_response=False)
+ except (DashboardException, RequestException) as e: # pragma: no cover
+ raise DashboardException(e, component='rgw')
+
+ # pylint: disable=redefined-builtin
+ @RESTController.Resource(method='POST', path='/capability', status=201)
+ @allow_empty_body
+ def create_cap(self, uid, type, perm, daemon_name=None):
+ return self.proxy(daemon_name, 'PUT', 'user?caps', {
+ 'uid': uid,
+ 'user-caps': '{}={}'.format(type, perm)
+ })
+
+ # pylint: disable=redefined-builtin
+ @RESTController.Resource(method='DELETE', path='/capability', status=204)
+ def delete_cap(self, uid, type, perm, daemon_name=None):
+ return self.proxy(daemon_name, 'DELETE', 'user?caps', {
+ 'uid': uid,
+ 'user-caps': '{}={}'.format(type, perm)
+ })
+
+ @RESTController.Resource(method='POST', path='/key', status=201)
+ @allow_empty_body
+ def create_key(self, uid, key_type='s3', subuser=None, generate_key='true',
+ access_key=None, secret_key=None, daemon_name=None):
+ params = {'uid': uid, 'key-type': key_type, 'generate-key': generate_key}
+ if subuser is not None:
+ params['subuser'] = subuser
+ if access_key is not None:
+ params['access-key'] = access_key
+ if secret_key is not None:
+ params['secret-key'] = secret_key
+ return self.proxy(daemon_name, 'PUT', 'user?key', params)
+
+ @RESTController.Resource(method='DELETE', path='/key', status=204)
+ def delete_key(self, uid, key_type='s3', subuser=None, access_key=None, daemon_name=None):
+ params = {'uid': uid, 'key-type': key_type}
+ if subuser is not None:
+ params['subuser'] = subuser
+ if access_key is not None:
+ params['access-key'] = access_key
+ return self.proxy(daemon_name, 'DELETE', 'user?key', params, json_response=False)
+
+ @RESTController.Resource(method='GET', path='/quota')
+ def get_quota(self, uid, daemon_name=None):
+ return self.proxy(daemon_name, 'GET', 'user?quota', {'uid': uid})
+
+ @RESTController.Resource(method='PUT', path='/quota')
+ @allow_empty_body
+ def set_quota(self, uid, quota_type, enabled, max_size_kb, max_objects, daemon_name=None):
+ return self.proxy(daemon_name, 'PUT', 'user?quota', {
+ 'uid': uid,
+ 'quota-type': quota_type,
+ 'enabled': enabled,
+ 'max-size-kb': max_size_kb,
+ 'max-objects': max_objects
+ }, json_response=False)
+
+ @RESTController.Resource(method='POST', path='/subuser', status=201)
+ @allow_empty_body
+ def create_subuser(self, uid, subuser, access, key_type='s3',
+ generate_secret='true', access_key=None,
+ secret_key=None, daemon_name=None):
+ # pylint: disable=R1705
+ subusr_array = []
+ user = json.loads(self.get(uid, daemon_name)) # type: ignore
+ subusers = user["subusers"]
+ for sub_usr in subusers:
+ subusr_array.append(sub_usr["id"])
+ if subuser in subusr_array:
+ return self.proxy(daemon_name, 'POST', 'user', {
+ 'uid': uid,
+ 'subuser': subuser,
+ 'key-type': key_type,
+ 'access': access,
+ 'generate-secret': generate_secret,
+ 'access-key': access_key,
+ 'secret-key': secret_key
+ })
+ else:
+ return self.proxy(daemon_name, 'PUT', 'user', {
+ 'uid': uid,
+ 'subuser': subuser,
+ 'key-type': key_type,
+ 'access': access,
+ 'generate-secret': generate_secret,
+ 'access-key': access_key,
+ 'secret-key': secret_key
+ })
+
+ @RESTController.Resource(method='DELETE', path='/subuser/{subuser}', status=204)
+ def delete_subuser(self, uid, subuser, purge_keys='true', daemon_name=None):
+ """
+ :param purge_keys: Set to False to do not purge the keys.
+ Note, this only works for s3 subusers.
+ """
+ return self.proxy(daemon_name, 'DELETE', 'user', {
+ 'uid': uid,
+ 'subuser': subuser,
+ 'purge-keys': purge_keys
+ }, json_response=False)
+
+
+class RGWRoleEndpoints:
+ @staticmethod
+ def role_list(_):
+ rgw_client = RgwClient.admin_instance()
+ roles = rgw_client.list_roles()
+ return roles
+
+ @staticmethod
+ def role_create(_, role_name: str = '', role_path: str = '', role_assume_policy_doc: str = ''):
+ assert role_name
+ assert role_path
+ rgw_client = RgwClient.admin_instance()
+ rgw_client.create_role(role_name, role_path, role_assume_policy_doc)
+ return f'Role {role_name} created successfully'
+
+
+# pylint: disable=C0301
+assume_role_policy_help = (
+ 'Paste a json assume role policy document, to find more information on how to get this document, <a ' # noqa: E501
+ 'href="https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-iam-role.html#cfn-iam-role-assumerolepolicydocument"' # noqa: E501
+ 'target="_blank">click here.</a>'
+)
+
+create_container = VerticalContainer('Create Role', 'create_role', fields=[
+ FormField('Role name', 'role_name', validators=[Validator.RGW_ROLE_NAME]),
+ FormField('Path', 'role_path', validators=[Validator.RGW_ROLE_PATH]),
+ FormField('Assume Role Policy Document',
+ 'role_assume_policy_doc',
+ help=assume_role_policy_help,
+ field_type='textarea',
+ validators=[Validator.JSON]),
+])
+create_role_form = Form(path='/rgw/roles/create',
+ root_container=create_container,
+ task_info=FormTaskInfo("IAM RGW Role '{role_name}' created successfully",
+ ['role_name']),
+ method_type=MethodType.POST.value)
+
+
+@CRUDEndpoint(
+ router=APIRouter('/rgw/roles', Scope.RGW),
+ doc=APIDoc("List of RGW roles", "RGW"),
+ actions=[
+ TableAction(name='Create', permission='create', icon=Icon.ADD.value,
+ routerLink='/rgw/roles/create')
+ ],
+ forms=[create_role_form],
+ permissions=[Scope.CONFIG_OPT],
+ get_all=CRUDCollectionMethod(
+ func=RGWRoleEndpoints.role_list,
+ doc=EndpointDoc("List RGW roles")
+ ),
+ create=CRUDCollectionMethod(
+ func=RGWRoleEndpoints.role_create,
+ doc=EndpointDoc("Create Ceph User")
+ ),
+ set_column={
+ "CreateDate": {'cellTemplate': 'date'},
+ "MaxSessionDuration": {'cellTemplate': 'duration'},
+ "RoleId": {'isHidden': True},
+ "AssumeRolePolicyDocument": {'isHidden': True}
+ },
+ detail_columns=['RoleId', 'AssumeRolePolicyDocument'],
+ meta=CRUDMeta()
+)
+class RgwUserRole(NamedTuple):
+ RoleId: int
+ RoleName: str
+ Path: str
+ Arn: str
+ CreateDate: str
+ MaxSessionDuration: int
+ AssumeRolePolicyDocument: str
+
+
+@APIRouter('/rgw/realm', Scope.RGW)
+class RgwRealm(RESTController):
+ @allow_empty_body
+ # pylint: disable=W0613
+ def create(self, realm_name, default):
+ multisite_instance = RgwMultisite()
+ result = multisite_instance.create_realm(realm_name, default)
+ return result
+
+ @allow_empty_body
+ # pylint: disable=W0613
+ def list(self):
+ multisite_instance = RgwMultisite()
+ result = multisite_instance.list_realms()
+ return result
+
+ @allow_empty_body
+ # pylint: disable=W0613
+ def get(self, realm_name):
+ multisite_instance = RgwMultisite()
+ result = multisite_instance.get_realm(realm_name)
+ return result
+
+ @Endpoint()
+ @ReadPermission
+ def get_all_realms_info(self):
+ multisite_instance = RgwMultisite()
+ result = multisite_instance.get_all_realms_info()
+ return result
+
+ @allow_empty_body
+ # pylint: disable=W0613
+ def set(self, realm_name: str, new_realm_name: str, default: str = ''):
+ multisite_instance = RgwMultisite()
+ result = multisite_instance.edit_realm(realm_name, new_realm_name, default)
+ return result
+
+ @Endpoint()
+ @ReadPermission
+ def get_realm_tokens(self):
+ try:
+ result = CephService.get_realm_tokens()
+ return result
+ except NoRgwDaemonsException as e:
+ raise DashboardException(e, http_status_code=404, component='rgw')
+
+ @Endpoint(method='POST')
+ @UpdatePermission
+ @allow_empty_body
+ # pylint: disable=W0613
+ def import_realm_token(self, realm_token, zone_name, port, placement_spec):
+ try:
+ multisite_instance = RgwMultisite()
+ result = CephService.import_realm_token(realm_token, zone_name, port, placement_spec)
+ multisite_instance.update_period()
+ return result
+ except NoRgwDaemonsException as e:
+ raise DashboardException(e, http_status_code=404, component='rgw')
+
+ def delete(self, realm_name):
+ multisite_instance = RgwMultisite()
+ result = multisite_instance.delete_realm(realm_name)
+ return result
+
+
+@APIRouter('/rgw/zonegroup', Scope.RGW)
+class RgwZonegroup(RESTController):
+ @allow_empty_body
+ # pylint: disable=W0613
+ def create(self, realm_name, zonegroup_name, default=None, master=None,
+ zonegroup_endpoints=None):
+ multisite_instance = RgwMultisite()
+ result = multisite_instance.create_zonegroup(realm_name, zonegroup_name, default,
+ master, zonegroup_endpoints)
+ return result
+
+ @allow_empty_body
+ # pylint: disable=W0613
+ def list(self):
+ multisite_instance = RgwMultisite()
+ result = multisite_instance.list_zonegroups()
+ return result
+
+ @allow_empty_body
+ # pylint: disable=W0613
+ def get(self, zonegroup_name):
+ multisite_instance = RgwMultisite()
+ result = multisite_instance.get_zonegroup(zonegroup_name)
+ return result
+
+ @Endpoint()
+ @ReadPermission
+ def get_all_zonegroups_info(self):
+ multisite_instance = RgwMultisite()
+ result = multisite_instance.get_all_zonegroups_info()
+ return result
+
+ def delete(self, zonegroup_name, delete_pools, pools: Optional[List[str]] = None):
+ if pools is None:
+ pools = []
+ try:
+ multisite_instance = RgwMultisite()
+ result = multisite_instance.delete_zonegroup(zonegroup_name, delete_pools, pools)
+ return result
+ except NoRgwDaemonsException as e:
+ raise DashboardException(e, http_status_code=404, component='rgw')
+
+ @allow_empty_body
+ # pylint: disable=W0613,W0102
+ def set(self, zonegroup_name: str, realm_name: str, new_zonegroup_name: str,
+ default: str = '', master: str = '', zonegroup_endpoints: str = '',
+ add_zones: List[str] = [], remove_zones: List[str] = [],
+ placement_targets: List[Dict[str, str]] = []):
+ multisite_instance = RgwMultisite()
+ result = multisite_instance.edit_zonegroup(realm_name, zonegroup_name, new_zonegroup_name,
+ default, master, zonegroup_endpoints, add_zones,
+ remove_zones, placement_targets)
+ return result
+
+
+@APIRouter('/rgw/zone', Scope.RGW)
+class RgwZone(RESTController):
+ @allow_empty_body
+ # pylint: disable=W0613
+ def create(self, zone_name, zonegroup_name=None, default=False, master=False,
+ zone_endpoints=None, access_key=None, secret_key=None):
+ multisite_instance = RgwMultisite()
+ result = multisite_instance.create_zone(zone_name, zonegroup_name, default,
+ master, zone_endpoints, access_key,
+ secret_key)
+ return result
+
+ @allow_empty_body
+ # pylint: disable=W0613
+ def list(self):
+ multisite_instance = RgwMultisite()
+ result = multisite_instance.list_zones()
+ return result
+
+ @allow_empty_body
+ # pylint: disable=W0613
+ def get(self, zone_name):
+ multisite_instance = RgwMultisite()
+ result = multisite_instance.get_zone(zone_name)
+ return result
+
+ @Endpoint()
+ @ReadPermission
+ def get_all_zones_info(self):
+ multisite_instance = RgwMultisite()
+ result = multisite_instance.get_all_zones_info()
+ return result
+
+ def delete(self, zone_name, delete_pools, pools: Optional[List[str]] = None,
+ zonegroup_name=None):
+ if pools is None:
+ pools = []
+ if zonegroup_name is None:
+ zonegroup_name = ''
+ try:
+ multisite_instance = RgwMultisite()
+ result = multisite_instance.delete_zone(zone_name, delete_pools, pools, zonegroup_name)
+ return result
+ except NoRgwDaemonsException as e:
+ raise DashboardException(e, http_status_code=404, component='rgw')
+
+ @allow_empty_body
+ # pylint: disable=W0613,W0102
+ def set(self, zone_name: str, new_zone_name: str, zonegroup_name: str, default: str = '',
+ master: str = '', zone_endpoints: str = '', access_key: str = '', secret_key: str = '',
+ placement_target: str = '', data_pool: str = '', index_pool: str = '',
+ data_extra_pool: str = '', storage_class: str = '', data_pool_class: str = '',
+ compression: str = ''):
+ multisite_instance = RgwMultisite()
+ result = multisite_instance.edit_zone(zone_name, new_zone_name, zonegroup_name, default,
+ master, zone_endpoints, access_key, secret_key,
+ placement_target, data_pool, index_pool,
+ data_extra_pool, storage_class, data_pool_class,
+ compression)
+ return result
+
+ @Endpoint()
+ @ReadPermission
+ def get_pool_names(self):
+ pool_names = []
+ ret, out, _ = mgr.check_mon_command({
+ 'prefix': 'osd lspools',
+ 'format': 'json',
+ })
+ if ret == 0 and out is not None:
+ pool_names = json.loads(out)
+ return pool_names
+
+ @Endpoint('PUT')
+ @CreatePermission
+ def create_system_user(self, userName: str, zoneName: str):
+ multisite_instance = RgwMultisite()
+ result = multisite_instance.create_system_user(userName, zoneName)
+ return result
+
+ @Endpoint()
+ @ReadPermission
+ def get_user_list(self, zoneName=None):
+ multisite_instance = RgwMultisite()
+ result = multisite_instance.get_user_list(zoneName)
+ return result
diff --git a/src/pybind/mgr/dashboard/controllers/role.py b/src/pybind/mgr/dashboard/controllers/role.py
new file mode 100644
index 000000000..cdd73ddf1
--- /dev/null
+++ b/src/pybind/mgr/dashboard/controllers/role.py
@@ -0,0 +1,143 @@
+# -*- coding: utf-8 -*-
+
+import cherrypy
+
+from .. import mgr
+from ..exceptions import DashboardException, RoleAlreadyExists, \
+ RoleDoesNotExist, RoleIsAssociatedWithUser
+from ..security import Permission
+from ..security import Scope as SecurityScope
+from ..services.access_control import SYSTEM_ROLES
+from . import APIDoc, APIRouter, CreatePermission, EndpointDoc, RESTController, UIRouter
+
+ROLE_SCHEMA = [{
+ "name": (str, "Role Name"),
+ "description": (str, "Role Descriptions"),
+ "scopes_permissions": ({
+ "cephfs": ([str], "")
+ }, ""),
+ "system": (bool, "")
+}]
+
+
+@APIRouter('/role', SecurityScope.USER)
+@APIDoc("Role Management API", "Role")
+class Role(RESTController):
+ @staticmethod
+ def _role_to_dict(role):
+ role_dict = role.to_dict()
+ role_dict['system'] = role_dict['name'] in SYSTEM_ROLES
+ return role_dict
+
+ @staticmethod
+ def _validate_permissions(scopes_permissions):
+ if scopes_permissions:
+ for scope, permissions in scopes_permissions.items():
+ if scope not in SecurityScope.all_scopes():
+ raise DashboardException(msg='Invalid scope',
+ code='invalid_scope',
+ component='role')
+ if any(permission not in Permission.all_permissions()
+ for permission in permissions):
+ raise DashboardException(msg='Invalid permission',
+ code='invalid_permission',
+ component='role')
+
+ @staticmethod
+ def _set_permissions(role, scopes_permissions):
+ role.reset_scope_permissions()
+ if scopes_permissions:
+ for scope, permissions in scopes_permissions.items():
+ if permissions:
+ role.set_scope_permissions(scope, permissions)
+
+ @EndpointDoc("Display Role list",
+ responses={200: ROLE_SCHEMA})
+ def list(self):
+ # type: () -> list
+ roles = dict(mgr.ACCESS_CTRL_DB.roles)
+ roles.update(SYSTEM_ROLES)
+ roles = sorted(roles.values(), key=lambda role: role.name)
+ return [Role._role_to_dict(r) for r in roles]
+
+ @staticmethod
+ def _get(name):
+ role = SYSTEM_ROLES.get(name)
+ if not role:
+ try:
+ role = mgr.ACCESS_CTRL_DB.get_role(name)
+ except RoleDoesNotExist:
+ raise cherrypy.HTTPError(404)
+ return Role._role_to_dict(role)
+
+ def get(self, name):
+ # type: (str) -> dict
+ return Role._get(name)
+
+ @staticmethod
+ def _create(name=None, description=None, scopes_permissions=None):
+ if not name:
+ raise DashboardException(msg='Name is required',
+ code='name_required',
+ component='role')
+ Role._validate_permissions(scopes_permissions)
+ try:
+ role = mgr.ACCESS_CTRL_DB.create_role(name, description)
+ except RoleAlreadyExists:
+ raise DashboardException(msg='Role already exists',
+ code='role_already_exists',
+ component='role')
+ Role._set_permissions(role, scopes_permissions)
+ mgr.ACCESS_CTRL_DB.save()
+ return Role._role_to_dict(role)
+
+ def create(self, name=None, description=None, scopes_permissions=None):
+ # type: (str, str, dict) -> dict
+ return Role._create(name, description, scopes_permissions)
+
+ def set(self, name, description=None, scopes_permissions=None):
+ # type: (str, str, dict) -> dict
+ try:
+ role = mgr.ACCESS_CTRL_DB.get_role(name)
+ except RoleDoesNotExist:
+ if name in SYSTEM_ROLES:
+ raise DashboardException(msg='Cannot update system role',
+ code='cannot_update_system_role',
+ component='role')
+ raise cherrypy.HTTPError(404)
+ Role._validate_permissions(scopes_permissions)
+ Role._set_permissions(role, scopes_permissions)
+ role.description = description
+ mgr.ACCESS_CTRL_DB.update_users_with_roles(role)
+ mgr.ACCESS_CTRL_DB.save()
+ return Role._role_to_dict(role)
+
+ def delete(self, name):
+ # type: (str) -> None
+ try:
+ mgr.ACCESS_CTRL_DB.delete_role(name)
+ except RoleDoesNotExist:
+ if name in SYSTEM_ROLES:
+ raise DashboardException(msg='Cannot delete system role',
+ code='cannot_delete_system_role',
+ component='role')
+ raise cherrypy.HTTPError(404)
+ except RoleIsAssociatedWithUser:
+ raise DashboardException(msg='Role is associated with user',
+ code='role_is_associated_with_user',
+ component='role')
+ mgr.ACCESS_CTRL_DB.save()
+
+ @RESTController.Resource('POST', status=201)
+ @CreatePermission
+ def clone(self, name, new_name):
+ # type: (str, str) -> dict
+ role = Role._get(name)
+ return Role._create(new_name, role.get('description'),
+ role.get('scopes_permissions'))
+
+
+@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
new file mode 100644
index 000000000..c11b18a27
--- /dev/null
+++ b/src/pybind/mgr/dashboard/controllers/saml2.py
@@ -0,0 +1,113 @@
+# -*- coding: utf-8 -*-
+
+import cherrypy
+
+try:
+ from onelogin.saml2.auth import OneLogin_Saml2_Auth
+ from onelogin.saml2.errors import OneLogin_Saml2_Error
+ from onelogin.saml2.settings import OneLogin_Saml2_Settings
+
+ python_saml_imported = True
+except ImportError:
+ python_saml_imported = False
+
+from .. import mgr
+from ..exceptions import UserDoesNotExist
+from ..services.auth import JwtManager
+from ..tools import prepare_url_prefix
+from . import BaseController, ControllerAuthMixin, Endpoint, Router, allow_empty_body
+
+
+@Router('/auth/saml2', secure=False)
+class Saml2(BaseController, ControllerAuthMixin):
+
+ @staticmethod
+ def _build_req(request, post_data):
+ return {
+ 'https': 'on' if request.scheme == 'https' else 'off',
+ 'http_host': request.host,
+ 'script_name': request.path_info,
+ 'server_port': str(request.port),
+ 'get_data': {},
+ 'post_data': post_data
+ }
+
+ @staticmethod
+ def _check_python_saml():
+ if not python_saml_imported:
+ raise cherrypy.HTTPError(400, 'Required library not found: `python3-saml`')
+ try:
+ OneLogin_Saml2_Settings(mgr.SSO_DB.saml2.onelogin_settings)
+ except OneLogin_Saml2_Error:
+ raise cherrypy.HTTPError(400, 'Single Sign-On is not configured.')
+
+ @Endpoint('POST', path="", version=None)
+ @allow_empty_body
+ def auth_response(self, **kwargs):
+ Saml2._check_python_saml()
+ req = Saml2._build_req(self._request, kwargs)
+ auth = OneLogin_Saml2_Auth(req, mgr.SSO_DB.saml2.onelogin_settings)
+ auth.process_response()
+ errors = auth.get_errors()
+
+ if auth.is_authenticated():
+ JwtManager.reset_user()
+ username_attribute = auth.get_attribute(mgr.SSO_DB.saml2.get_username_attribute())
+ if username_attribute is None:
+ raise cherrypy.HTTPError(400,
+ 'SSO error - `{}` not found in auth attributes. '
+ 'Received attributes: {}'
+ .format(
+ mgr.SSO_DB.saml2.get_username_attribute(),
+ auth.get_attributes()))
+ username = username_attribute[0]
+ url_prefix = prepare_url_prefix(mgr.get_module_option('url_prefix', default=''))
+ try:
+ mgr.ACCESS_CTRL_DB.get_user(username)
+ except UserDoesNotExist:
+ raise cherrypy.HTTPRedirect("{}/#/sso/404".format(url_prefix))
+
+ token = JwtManager.gen_token(username)
+ JwtManager.set_user(JwtManager.decode_token(token))
+
+ # For backward-compatibility: PyJWT versions < 2.0.0 return bytes.
+ token = token.decode('utf-8') if isinstance(token, bytes) else token
+
+ self._set_token_cookie(url_prefix, token)
+ raise cherrypy.HTTPRedirect("{}/#/login?access_token={}".format(url_prefix, token))
+
+ return {
+ 'is_authenticated': auth.is_authenticated(),
+ 'errors': errors,
+ 'reason': auth.get_last_error_reason()
+ }
+
+ @Endpoint(xml=True, version=None)
+ def metadata(self):
+ Saml2._check_python_saml()
+ saml_settings = OneLogin_Saml2_Settings(mgr.SSO_DB.saml2.onelogin_settings)
+ return saml_settings.get_sp_metadata()
+
+ @Endpoint(json_response=False, version=None)
+ def login(self):
+ Saml2._check_python_saml()
+ req = Saml2._build_req(self._request, {})
+ auth = OneLogin_Saml2_Auth(req, mgr.SSO_DB.saml2.onelogin_settings)
+ raise cherrypy.HTTPRedirect(auth.login())
+
+ @Endpoint(json_response=False, version=None)
+ def slo(self):
+ Saml2._check_python_saml()
+ req = Saml2._build_req(self._request, {})
+ auth = OneLogin_Saml2_Auth(req, mgr.SSO_DB.saml2.onelogin_settings)
+ raise cherrypy.HTTPRedirect(auth.logout())
+
+ @Endpoint(json_response=False, version=None)
+ def logout(self, **kwargs):
+ # pylint: disable=unused-argument
+ Saml2._check_python_saml()
+ JwtManager.reset_user()
+ token = JwtManager.get_token_from_header()
+ self._delete_token_cookie(token)
+ url_prefix = prepare_url_prefix(mgr.get_module_option('url_prefix', default=''))
+ raise cherrypy.HTTPRedirect("{}/#/login".format(url_prefix))
diff --git a/src/pybind/mgr/dashboard/controllers/service.py b/src/pybind/mgr/dashboard/controllers/service.py
new file mode 100644
index 000000000..b75f41736
--- /dev/null
+++ b/src/pybind/mgr/dashboard/controllers/service.py
@@ -0,0 +1,95 @@
+from typing import Dict, List, Optional
+
+import cherrypy
+from ceph.deployment.service_spec import ServiceSpec
+
+from ..security import Scope
+from ..services.exception import handle_custom_error, handle_orchestrator_error
+from ..services.orchestrator import OrchClient, OrchFeature
+from . import APIDoc, APIRouter, CreatePermission, DeletePermission, Endpoint, \
+ ReadPermission, RESTController, Task, UpdatePermission
+from ._version import APIVersion
+from .orchestrator import raise_if_no_orchestrator
+
+
+def service_task(name, metadata, wait_for=2.0):
+ return Task("service/{}".format(name), metadata, wait_for)
+
+
+@APIRouter('/service', Scope.HOSTS)
+@APIDoc("Service Management API", "Service")
+class Service(RESTController):
+
+ @Endpoint()
+ @ReadPermission
+ def known_types(self) -> List[str]:
+ """
+ Get a list of known service types, e.g. 'alertmanager',
+ 'node-exporter', 'osd' or 'rgw'.
+ """
+ return ServiceSpec.KNOWN_SERVICE_TYPES
+
+ @raise_if_no_orchestrator([OrchFeature.SERVICE_LIST])
+ @RESTController.MethodMap(version=APIVersion(2, 0)) # type: ignore
+ def list(self, service_name: Optional[str] = None, offset: int = 0, limit: int = 5,
+ search: str = '', sort: str = '+service_name') -> List[dict]:
+ orch = OrchClient.instance()
+ services, count = orch.services.list(service_name=service_name, offset=int(offset),
+ limit=int(limit), search=search, sort=sort)
+ cherrypy.response.headers['X-Total-Count'] = count
+ return services
+
+ @raise_if_no_orchestrator([OrchFeature.SERVICE_LIST])
+ def get(self, service_name: str) -> List[dict]:
+ orch = OrchClient.instance()
+ services = orch.services.get(service_name)
+ if not services:
+ raise cherrypy.HTTPError(404, 'Service {} not found'.format(service_name))
+ return services[0].to_json()
+
+ @RESTController.Resource('GET')
+ @raise_if_no_orchestrator([OrchFeature.DAEMON_LIST])
+ def daemons(self, service_name: str) -> List[dict]:
+ orch = OrchClient.instance()
+ daemons = orch.services.list_daemons(service_name=service_name)
+ return [d.to_dict() for d in daemons]
+
+ @CreatePermission
+ @handle_custom_error('service', exceptions=(ValueError, TypeError))
+ @raise_if_no_orchestrator([OrchFeature.SERVICE_CREATE])
+ @handle_orchestrator_error('service')
+ @service_task('create', {'service_name': '{service_name}'})
+ def create(self, service_spec: Dict, service_name: str): # pylint: disable=W0613
+ """
+ :param service_spec: The service specification as JSON.
+ :param service_name: The service name, e.g. 'alertmanager'.
+ :return: None
+ """
+
+ OrchClient.instance().services.apply(service_spec, no_overwrite=True)
+
+ @UpdatePermission
+ @handle_custom_error('service', exceptions=(ValueError, TypeError))
+ @raise_if_no_orchestrator([OrchFeature.SERVICE_CREATE])
+ @handle_orchestrator_error('service')
+ @service_task('edit', {'service_name': '{service_name}'})
+ def set(self, service_spec: Dict, service_name: str): # pylint: disable=W0613
+ """
+ :param service_spec: The service specification as JSON.
+ :param service_name: The service name, e.g. 'alertmanager'.
+ :return: None
+ """
+
+ OrchClient.instance().services.apply(service_spec, no_overwrite=False)
+
+ @DeletePermission
+ @raise_if_no_orchestrator([OrchFeature.SERVICE_DELETE])
+ @handle_orchestrator_error('service')
+ @service_task('delete', {'service_name': '{service_name}'})
+ def delete(self, service_name: str):
+ """
+ :param service_name: The service name, e.g. 'mds' or 'crash.foo'.
+ :return: None
+ """
+ orch = OrchClient.instance()
+ orch.services.remove(service_name)
diff --git a/src/pybind/mgr/dashboard/controllers/settings.py b/src/pybind/mgr/dashboard/controllers/settings.py
new file mode 100644
index 000000000..3876ce2e5
--- /dev/null
+++ b/src/pybind/mgr/dashboard/controllers/settings.py
@@ -0,0 +1,113 @@
+# -*- coding: utf-8 -*-
+from ..security import Scope
+from ..services.settings import SettingsService, _to_native
+from ..settings import Options
+from ..settings import Settings as SettingsModule
+from . import APIDoc, APIRouter, EndpointDoc, RESTController, UIRouter
+
+SETTINGS_SCHEMA = [{
+ "name": (str, 'Settings Name'),
+ "default": (bool, 'Default Settings'),
+ "type": (str, 'Type of Settings'),
+ "value": (bool, 'Settings Value')
+}]
+
+
+@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).
+ """
+ @EndpointDoc("Display Settings Information",
+ parameters={
+ 'names': (str, 'Name of Settings'),
+ },
+ responses={200: SETTINGS_SCHEMA})
+ def list(self, names=None):
+ """
+ Get the list of available options.
+ :param names: A comma separated list of option names that should
+ be processed. Defaults to ``None``.
+ :type names: None|str
+ :return: A list of available options.
+ :rtype: list[dict]
+ """
+ option_names = [
+ name for name in Options.__dict__
+ if name.isupper() and not name.startswith('_')
+ ]
+ if names:
+ names = names.split(',')
+ option_names = list(set(option_names) & set(names))
+ return [self._get(name) for name in option_names]
+
+ def _get(self, name):
+ with SettingsService.attribute_handler(name) as sname:
+ setting = getattr(Options, sname)
+ return {
+ 'name': sname,
+ 'default': setting.default_value,
+ 'type': setting.types_as_str(),
+ 'value': getattr(SettingsModule, sname)
+ }
+
+ def get(self, name):
+ """
+ Get the given option.
+ :param name: The name of the option.
+ :return: Returns a dict containing the name, type,
+ default value and current value of the given option.
+ :rtype: dict
+ """
+ return self._get(name)
+
+ def set(self, name, value):
+ with SettingsService.attribute_handler(name) as sname:
+ setattr(SettingsModule, _to_native(sname), value)
+
+ def delete(self, name):
+ with SettingsService.attribute_handler(name) as sname:
+ delattr(SettingsModule, _to_native(sname))
+
+ def bulk_set(self, **kwargs):
+ with SettingsService.attribute_handler(kwargs) as data:
+ for name, value in data.items():
+ setattr(SettingsModule, _to_native(name), value)
+
+
+@UIRouter('/standard_settings')
+class StandardSettings(RESTController):
+ def list(self):
+ """
+ Get various Dashboard related settings.
+ :return: Returns a dictionary containing various Dashboard
+ settings.
+ :rtype: dict
+ """
+ return { # pragma: no cover - no complexity there
+ 'user_pwd_expiration_span':
+ SettingsModule.USER_PWD_EXPIRATION_SPAN,
+ 'user_pwd_expiration_warning_1':
+ SettingsModule.USER_PWD_EXPIRATION_WARNING_1,
+ 'user_pwd_expiration_warning_2':
+ SettingsModule.USER_PWD_EXPIRATION_WARNING_2,
+ 'pwd_policy_enabled':
+ SettingsModule.PWD_POLICY_ENABLED,
+ 'pwd_policy_min_length':
+ SettingsModule.PWD_POLICY_MIN_LENGTH,
+ 'pwd_policy_check_length_enabled':
+ SettingsModule.PWD_POLICY_CHECK_LENGTH_ENABLED,
+ 'pwd_policy_check_oldpwd_enabled':
+ SettingsModule.PWD_POLICY_CHECK_OLDPWD_ENABLED,
+ 'pwd_policy_check_username_enabled':
+ SettingsModule.PWD_POLICY_CHECK_USERNAME_ENABLED,
+ 'pwd_policy_check_exclusion_list_enabled':
+ SettingsModule.PWD_POLICY_CHECK_EXCLUSION_LIST_ENABLED,
+ 'pwd_policy_check_repetitive_chars_enabled':
+ SettingsModule.PWD_POLICY_CHECK_REPETITIVE_CHARS_ENABLED,
+ 'pwd_policy_check_sequential_chars_enabled':
+ SettingsModule.PWD_POLICY_CHECK_SEQUENTIAL_CHARS_ENABLED,
+ 'pwd_policy_check_complexity_enabled':
+ SettingsModule.PWD_POLICY_CHECK_COMPLEXITY_ENABLED
+ }
diff --git a/src/pybind/mgr/dashboard/controllers/summary.py b/src/pybind/mgr/dashboard/controllers/summary.py
new file mode 100644
index 000000000..9da482208
--- /dev/null
+++ b/src/pybind/mgr/dashboard/controllers/summary.py
@@ -0,0 +1,123 @@
+# -*- coding: utf-8 -*-
+
+import json
+
+from .. import mgr
+from ..controllers.rbd_mirroring import get_daemons_and_pools
+from ..exceptions import ViewCacheNoDataException
+from ..security import Permission, Scope
+from ..services import progress
+from ..tools import TaskManager
+from . import APIDoc, APIRouter, BaseController, Endpoint, EndpointDoc
+
+SUMMARY_SCHEMA = {
+ "health_status": (str, ""),
+ "mgr_id": (str, ""),
+ "mgr_host": (str, ""),
+ "have_mon_connection": (str, ""),
+ "executing_tasks": ([str], ""),
+ "finished_tasks": ([{
+ "name": (str, ""),
+ "metadata": ({
+ "pool": (int, ""),
+ }, ""),
+ "begin_time": (str, ""),
+ "end_time": (str, ""),
+ "duration": (int, ""),
+ "progress": (int, ""),
+ "success": (bool, ""),
+ "ret_value": (str, ""),
+ "exception": (str, ""),
+ }], ""),
+ "version": (str, ""),
+ "rbd_mirroring": ({
+ "warnings": (int, ""),
+ "errors": (int, "")
+ }, "")
+}
+
+
+@APIRouter('/summary')
+@APIDoc("Get Ceph Summary Details", "Summary")
+class Summary(BaseController):
+ def _health_status(self):
+ health_data = mgr.get("health")
+ return json.loads(health_data["json"])['status']
+
+ def _rbd_mirroring(self):
+ try:
+ _, data = get_daemons_and_pools()
+ except ViewCacheNoDataException: # pragma: no cover
+ return {} # pragma: no cover
+
+ daemons = data.get('daemons', [])
+ pools = data.get('pools', {})
+
+ warnings = 0
+ errors = 0
+ for daemon in daemons:
+ if daemon['health_color'] == 'error': # pragma: no cover
+ errors += 1
+ elif daemon['health_color'] == 'warning': # pragma: no cover
+ warnings += 1
+ for _, pool in pools.items():
+ if pool['health_color'] == 'error': # pragma: no cover
+ errors += 1
+ elif pool['health_color'] == 'warning': # pragma: no cover
+ warnings += 1
+ return {'warnings': warnings, 'errors': errors}
+
+ def _task_permissions(self, name): # pragma: no cover
+ result = True
+ if name == 'pool/create':
+ result = self._has_permissions(Permission.CREATE, Scope.POOL)
+ elif name == 'pool/edit':
+ result = self._has_permissions(Permission.UPDATE, Scope.POOL)
+ elif name == 'pool/delete':
+ result = self._has_permissions(Permission.DELETE, Scope.POOL)
+ elif name in [
+ 'rbd/create', 'rbd/copy', 'rbd/snap/create',
+ 'rbd/clone', 'rbd/trash/restore']:
+ result = self._has_permissions(Permission.CREATE, Scope.RBD_IMAGE)
+ elif name in [
+ 'rbd/edit', 'rbd/snap/edit', 'rbd/flatten',
+ 'rbd/snap/rollback']:
+ result = self._has_permissions(Permission.UPDATE, Scope.RBD_IMAGE)
+ elif name in [
+ 'rbd/delete', 'rbd/snap/delete', 'rbd/trash/move',
+ 'rbd/trash/remove', 'rbd/trash/purge']:
+ result = self._has_permissions(Permission.DELETE, Scope.RBD_IMAGE)
+ return result
+
+ def _get_host(self):
+ # type: () -> str
+ services = mgr.get('mgr_map')['services']
+ return services['dashboard'] if 'dashboard' in services else ''
+
+ @Endpoint()
+ @EndpointDoc("Display Summary",
+ responses={200: SUMMARY_SCHEMA})
+ def __call__(self):
+ exe_t, fin_t = TaskManager.list_serializable()
+ executing_tasks = [task for task in exe_t if self._task_permissions(task['name'])]
+ finished_tasks = [task for task in fin_t if self._task_permissions(task['name'])]
+
+ e, f = progress.get_progress_tasks()
+ executing_tasks.extend(e)
+ finished_tasks.extend(f)
+
+ executing_tasks.sort(key=lambda t: t['begin_time'], reverse=True)
+ finished_tasks.sort(key=lambda t: t['end_time'], reverse=True)
+
+ result = {
+ 'health_status': self._health_status(),
+ 'mgr_id': mgr.get_mgr_id(),
+ 'mgr_host': self._get_host(),
+ 'have_mon_connection': mgr.have_mon_connection(),
+ 'executing_tasks': executing_tasks,
+ 'finished_tasks': finished_tasks,
+ 'version': mgr.version
+ }
+ if self._has_permissions(Permission.READ, Scope.RBD_MIRRORING):
+ result['rbd_mirroring'] = self._rbd_mirroring()
+ return result
diff --git a/src/pybind/mgr/dashboard/controllers/task.py b/src/pybind/mgr/dashboard/controllers/task.py
new file mode 100644
index 000000000..d5fbc34a7
--- /dev/null
+++ b/src/pybind/mgr/dashboard/controllers/task.py
@@ -0,0 +1,46 @@
+# -*- coding: utf-8 -*-
+
+from ..services import progress
+from ..tools import TaskManager
+from . import APIDoc, APIRouter, EndpointDoc, RESTController
+
+TASK_SCHEMA = {
+ "executing_tasks": (str, "ongoing executing tasks"),
+ "finished_tasks": ([{
+ "name": (str, "finished tasks name"),
+ "metadata": ({
+ "pool": (int, "")
+ }, ""),
+ "begin_time": (str, "Task begin time"),
+ "end_time": (str, "Task end time"),
+ "duration": (int, ""),
+ "progress": (int, "Progress of tasks"),
+ "success": (bool, ""),
+ "ret_value": (bool, ""),
+ "exception": (bool, "")
+ }], "")
+}
+
+
+@APIRouter('/task')
+@APIDoc("Task Management API", "Task")
+class Task(RESTController):
+ @EndpointDoc("Display Tasks",
+ parameters={
+ 'name': (str, 'Task Name'),
+ },
+ responses={200: TASK_SCHEMA})
+ def list(self, name=None):
+ executing_t, finished_t = TaskManager.list_serializable(name)
+
+ e, f = progress.get_progress_tasks()
+ executing_t.extend(e)
+ finished_t.extend(f)
+
+ executing_t.sort(key=lambda t: t['begin_time'], reverse=True)
+ finished_t.sort(key=lambda t: t['end_time'], reverse=True)
+
+ return {
+ 'executing_tasks': executing_t,
+ 'finished_tasks': finished_t
+ }
diff --git a/src/pybind/mgr/dashboard/controllers/telemetry.py b/src/pybind/mgr/dashboard/controllers/telemetry.py
new file mode 100644
index 000000000..792f54711
--- /dev/null
+++ b/src/pybind/mgr/dashboard/controllers/telemetry.py
@@ -0,0 +1,239 @@
+# -*- coding: utf-8 -*-
+
+from .. import mgr
+from ..exceptions import DashboardException
+from ..security import Scope
+from . import APIDoc, APIRouter, EndpointDoc, RESTController
+
+REPORT_SCHEMA = {
+ "report": ({
+ "leaderboard": (bool, ""),
+ "report_version": (int, ""),
+ "report_timestamp": (str, ""),
+ "report_id": (str, ""),
+ "channels": ([str], ""),
+ "channels_available": ([str], ""),
+ "license": (str, ""),
+ "created": (str, ""),
+ "mon": ({
+ "count": (int, ""),
+ "features": ({
+ "persistent": ([str], ""),
+ "optional": ([int], "")
+ }, ""),
+ "min_mon_release": (int, ""),
+ "v1_addr_mons": (int, ""),
+ "v2_addr_mons": (int, ""),
+ "ipv4_addr_mons": (int, ""),
+ "ipv6_addr_mons": (int, ""),
+ }, ""),
+ "config": ({
+ "cluster_changed": ([str], ""),
+ "active_changed": ([str], "")
+ }, ""),
+ "rbd": ({
+ "num_pools": (int, ""),
+ "num_images_by_pool": ([int], ""),
+ "mirroring_by_pool": ([bool], ""),
+ }, ""),
+ "pools": ([{
+ "pool": (int, ""),
+ "type": (str, ""),
+ "pg_num": (int, ""),
+ "pgp_num": (int, ""),
+ "size": (int, ""),
+ "min_size": (int, ""),
+ "pg_autoscale_mode": (str, ""),
+ "target_max_bytes": (int, ""),
+ "target_max_objects": (int, ""),
+ "erasure_code_profile": (str, ""),
+ "cache_mode": (str, ""),
+ }], ""),
+ "osd": ({
+ "count": (int, ""),
+ "require_osd_release": (str, ""),
+ "require_min_compat_client": (str, ""),
+ "cluster_network": (bool, ""),
+ }, ""),
+ "crush": ({
+ "num_devices": (int, ""),
+ "num_types": (int, ""),
+ "num_buckets": (int, ""),
+ "num_rules": (int, ""),
+ "device_classes": ([int], ""),
+ "tunables": ({
+ "choose_local_tries": (int, ""),
+ "choose_local_fallback_tries": (int, ""),
+ "choose_total_tries": (int, ""),
+ "chooseleaf_descend_once": (int, ""),
+ "chooseleaf_vary_r": (int, ""),
+ "chooseleaf_stable": (int, ""),
+ "straw_calc_version": (int, ""),
+ "allowed_bucket_algs": (int, ""),
+ "profile": (str, ""),
+ "optimal_tunables": (int, ""),
+ "legacy_tunables": (int, ""),
+ "minimum_required_version": (str, ""),
+ "require_feature_tunables": (int, ""),
+ "require_feature_tunables2": (int, ""),
+ "has_v2_rules": (int, ""),
+ "require_feature_tunables3": (int, ""),
+ "has_v3_rules": (int, ""),
+ "has_v4_buckets": (int, ""),
+ "require_feature_tunables5": (int, ""),
+ "has_v5_rules": (int, ""),
+ }, ""),
+ "compat_weight_set": (bool, ""),
+ "num_weight_sets": (int, ""),
+ "bucket_algs": ({
+ "straw2": (int, ""),
+ }, ""),
+ "bucket_sizes": ({
+ "1": (int, ""),
+ "3": (int, ""),
+ }, ""),
+ "bucket_types": ({
+ "1": (int, ""),
+ "11": (int, ""),
+ }, ""),
+ }, ""),
+ "fs": ({
+ "count": (int, ""),
+ "feature_flags": ({
+ "enable_multiple": (bool, ""),
+ "ever_enabled_multiple": (bool, ""),
+ }, ""),
+ "num_standby_mds": (int, ""),
+ "filesystems": ([int], ""),
+ "total_num_mds": (int, ""),
+ }, ""),
+ "metadata": ({
+ "osd": ({
+ "osd_objectstore": ({
+ "bluestore": (int, ""),
+ }, ""),
+ "rotational": ({
+ "1": (int, ""),
+ }, ""),
+ "arch": ({
+ "x86_64": (int, ""),
+ }, ""),
+ "ceph_version": ({
+ "ceph version 16.0.0-3151-gf202994fcf": (int, ""),
+ }, ""),
+ "os": ({
+ "Linux": (int, ""),
+ }, ""),
+ "cpu": ({
+ "Intel(R) Core(TM) i7-8665U CPU @ 1.90GHz": (int, ""),
+ }, ""),
+ "kernel_description": ({
+ "#1 SMP Wed Jul 1 19:53:01 UTC 2020": (int, ""),
+ }, ""),
+ "kernel_version": ({
+ "5.7.7-200.fc32.x86_64": (int, ""),
+ }, ""),
+ "distro_description": ({
+ "CentOS Linux 8 (Core)": (int, ""),
+ }, ""),
+ "distro": ({
+ "centos": (int, ""),
+ }, ""),
+ }, ""),
+ "mon": ({
+ "arch": ({
+ "x86_64": (int, ""),
+ }, ""),
+ "ceph_version": ({
+ "ceph version 16.0.0-3151-gf202994fcf": (int, ""),
+ }, ""),
+ "os": ({
+ "Linux": (int, ""),
+ }, ""),
+ "cpu": ({
+ "Intel(R) Core(TM) i7-8665U CPU @ 1.90GHz": (int, ""),
+ }, ""),
+ "kernel_description": ({
+ "#1 SMP Wed Jul 1 19:53:01 UTC 2020": (int, ""),
+ }, ""),
+ "kernel_version": ({
+ "5.7.7-200.fc32.x86_64": (int, ""),
+ }, ""),
+ "distro_description": ({
+ "CentOS Linux 8 (Core)": (int, ""),
+ }, ""),
+ "distro": ({
+ "centos": (int, ""),
+ }, ""),
+ }, ""),
+ }, ""),
+ "hosts": ({
+ "num": (int, ""),
+ "num_with_mon": (int, ""),
+ "num_with_mds": (int, ""),
+ "num_with_osd": (int, ""),
+ "num_with_mgr": (int, ""),
+ }, ""),
+ "usage": ({
+ "pools": (int, ""),
+ "pg_num": (int, ""),
+ "total_used_bytes": (int, ""),
+ "total_bytes": (int, ""),
+ "total_avail_bytes": (int, ""),
+ }, ""),
+ "services": ({
+ "rgw": (int, ""),
+ }, ""),
+ "rgw": ({
+ "count": (int, ""),
+ "zones": (int, ""),
+ "zonegroups": (int, ""),
+ "frontends": ([str], "")
+ }, ""),
+ "balancer": ({
+ "active": (bool, ""),
+ "mode": (str, ""),
+ }, ""),
+ "crashes": ([int], "")
+ }, ""),
+ "device_report": (str, "")
+}
+
+
+@APIRouter('/telemetry', Scope.CONFIG_OPT)
+@APIDoc("Display Telemetry Report", "Telemetry")
+class Telemetry(RESTController):
+
+ @RESTController.Collection('GET')
+ @EndpointDoc("Get Detailed Telemetry report",
+ responses={200: REPORT_SCHEMA})
+ def report(self):
+ """
+ Get Ceph and device report data
+ :return: Ceph and device report data
+ :rtype: dict
+ """
+ return mgr.remote('telemetry', 'get_report_locked', 'all')
+
+ def singleton_set(self, enable=True, license_name=None):
+ """
+ Enables or disables sending data collected by the Telemetry
+ module.
+ :param enable: Enable or disable sending data
+ :type enable: bool
+ :param license_name: License string e.g. 'sharing-1-0' to
+ make sure the user is aware of and accepts the license
+ for sharing Telemetry data.
+ :type license_name: string
+ """
+ if enable:
+ if not license_name or (license_name != 'sharing-1-0'):
+ raise DashboardException(
+ code='telemetry_enable_license_missing',
+ msg='Telemetry data is licensed under the Community Data License Agreement - '
+ 'Sharing - Version 1.0 (https://cdla.io/sharing-1-0/). To enable, add '
+ '{"license": "sharing-1-0"} to the request payload.'
+ )
+ mgr.remote('telemetry', 'on', license_name)
+ else:
+ mgr.remote('telemetry', 'off')
diff --git a/src/pybind/mgr/dashboard/controllers/user.py b/src/pybind/mgr/dashboard/controllers/user.py
new file mode 100644
index 000000000..9141cfe68
--- /dev/null
+++ b/src/pybind/mgr/dashboard/controllers/user.py
@@ -0,0 +1,214 @@
+# -*- coding: utf-8 -*-
+
+import time
+from datetime import datetime
+
+import cherrypy
+from ceph_argparse import CephString
+
+from .. import mgr
+from ..exceptions import DashboardException, PasswordPolicyException, \
+ PwdExpirationDateNotValid, UserAlreadyExists, UserDoesNotExist
+from ..security import Scope
+from ..services.access_control import SYSTEM_ROLES, PasswordPolicy
+from ..services.auth import JwtManager
+from . import APIDoc, APIRouter, BaseController, Endpoint, EndpointDoc, \
+ RESTController, allow_empty_body, validate_ceph_type
+
+USER_SCHEMA = ([{
+ "username": (str, 'Username of the user'),
+ "roles": ([str], 'User Roles'),
+ "name": (str, 'User Name'),
+ "email": (str, 'User email address'),
+ "lastUpdate": (int, 'Details last updated'),
+ "enabled": (bool, 'Is the user enabled?'),
+ "pwdExpirationDate": (str, 'Password Expiration date'),
+ "pwdUpdateRequired": (bool, 'Is Password Update Required?')
+}], '')
+
+
+def validate_password_policy(password, username=None, old_password=None):
+ """
+ :param password: The password to validate.
+ :param username: The name of the user (optional).
+ :param old_password: The old password (optional).
+ :return: Returns the password complexity credits.
+ :rtype: int
+ :raises DashboardException: If a password policy fails.
+ """
+ pw_policy = PasswordPolicy(password, username, old_password)
+ try:
+ pw_policy.check_all()
+ return pw_policy.complexity_credits
+ except PasswordPolicyException as ex:
+ raise DashboardException(msg=str(ex),
+ code='password_policy_validation_failed',
+ component='user')
+
+
+@APIRouter('/user', Scope.USER)
+@APIDoc("Display User Details", "User")
+class User(RESTController):
+
+ @staticmethod
+ def _user_to_dict(user):
+ result = user.to_dict()
+ del result['password']
+ return result
+
+ @staticmethod
+ def _get_user_roles(roles):
+ all_roles = dict(mgr.ACCESS_CTRL_DB.roles)
+ all_roles.update(SYSTEM_ROLES)
+ try:
+ return [all_roles[rolename] for rolename in roles]
+ except KeyError:
+ raise DashboardException(msg='Role does not exist',
+ code='role_does_not_exist',
+ component='user')
+
+ @EndpointDoc("Get List Of Users",
+ responses={200: USER_SCHEMA})
+ def list(self):
+ users = mgr.ACCESS_CTRL_DB.users
+ result = [User._user_to_dict(u) for _, u in users.items()]
+ return result
+
+ def get(self, username):
+ try:
+ user = mgr.ACCESS_CTRL_DB.get_user(username)
+ except UserDoesNotExist:
+ raise cherrypy.HTTPError(404)
+ return User._user_to_dict(user)
+
+ @validate_ceph_type([('username', CephString())], 'user')
+ def create(self, username=None, password=None, name=None, email=None,
+ roles=None, enabled=True, pwdExpirationDate=None, pwdUpdateRequired=True):
+ if not username:
+ raise DashboardException(msg='Username is required',
+ code='username_required',
+ component='user')
+ user_roles = None
+ if roles:
+ user_roles = User._get_user_roles(roles)
+ if password:
+ validate_password_policy(password, username)
+ try:
+ user = mgr.ACCESS_CTRL_DB.create_user(username, password, name,
+ email, enabled, pwdExpirationDate,
+ pwdUpdateRequired)
+ except UserAlreadyExists:
+ raise DashboardException(msg='Username already exists',
+ code='username_already_exists',
+ component='user')
+ except PwdExpirationDateNotValid:
+ raise DashboardException(msg='Password expiration date must not be in '
+ 'the past',
+ code='pwd_past_expiration_date',
+ component='user')
+
+ if user_roles:
+ user.set_roles(user_roles)
+ mgr.ACCESS_CTRL_DB.save()
+ return User._user_to_dict(user)
+
+ def delete(self, username):
+ session_username = JwtManager.get_username()
+ if session_username == username:
+ raise DashboardException(msg='Cannot delete current user',
+ code='cannot_delete_current_user',
+ component='user')
+ try:
+ mgr.ACCESS_CTRL_DB.delete_user(username)
+ except UserDoesNotExist:
+ raise cherrypy.HTTPError(404)
+ mgr.ACCESS_CTRL_DB.save()
+
+ def set(self, username, password=None, name=None, email=None, roles=None,
+ enabled=None, pwdExpirationDate=None, pwdUpdateRequired=False):
+ if JwtManager.get_username() == username and enabled is False:
+ raise DashboardException(msg='You are not allowed to disable your user',
+ code='cannot_disable_current_user',
+ component='user')
+
+ try:
+ user = mgr.ACCESS_CTRL_DB.get_user(username)
+ except UserDoesNotExist:
+ raise cherrypy.HTTPError(404)
+ user_roles = []
+ if roles:
+ user_roles = User._get_user_roles(roles)
+ if password:
+ validate_password_policy(password, username)
+ user.set_password(password)
+ if pwdExpirationDate and \
+ (pwdExpirationDate < int(time.mktime(datetime.utcnow().timetuple()))):
+ raise DashboardException(
+ msg='Password expiration date must not be in the past',
+ code='pwd_past_expiration_date', component='user')
+ user.name = name
+ user.email = email
+ if enabled is not None:
+ user.enabled = enabled
+ user.pwd_expiration_date = pwdExpirationDate
+ user.set_roles(user_roles)
+ user.pwd_update_required = pwdUpdateRequired
+ mgr.ACCESS_CTRL_DB.save()
+ return User._user_to_dict(user)
+
+
+@APIRouter('/user')
+@APIDoc("Get User Password Policy Details", "UserPasswordPolicy")
+class UserPasswordPolicy(RESTController):
+
+ @Endpoint('POST')
+ @allow_empty_body
+ def validate_password(self, password, username=None, old_password=None):
+ """
+ Check if the password meets the password policy.
+ :param password: The password to validate.
+ :param username: The name of the user (optional).
+ :param old_password: The old password (optional).
+ :return: An object with properties valid, credits and valuation.
+ 'credits' contains the password complexity credits and
+ 'valuation' the textual summary of the validation.
+ """
+ result = {'valid': False, 'credits': 0, 'valuation': None}
+ try:
+ result['credits'] = validate_password_policy(password, username, old_password)
+ if result['credits'] < 15:
+ result['valuation'] = 'Weak'
+ elif result['credits'] < 20:
+ result['valuation'] = 'OK'
+ elif result['credits'] < 25:
+ result['valuation'] = 'Strong'
+ else:
+ result['valuation'] = 'Very strong'
+ result['valid'] = True
+ except DashboardException as ex:
+ result['valuation'] = str(ex)
+ return result
+
+
+@APIRouter('/user/{username}')
+@APIDoc("Change User Password", "UserChangePassword")
+class UserChangePassword(BaseController):
+
+ @Endpoint('POST')
+ def change_password(self, username, old_password, new_password):
+ session_username = JwtManager.get_username()
+ if username != session_username:
+ raise DashboardException(msg='Invalid user context',
+ code='invalid_user_context',
+ component='user')
+ try:
+ user = mgr.ACCESS_CTRL_DB.get_user(session_username)
+ except UserDoesNotExist:
+ raise cherrypy.HTTPError(404)
+ if not user.compare_password(old_password):
+ raise DashboardException(msg='Invalid old password',
+ code='invalid_old_password',
+ component='user')
+ validate_password_policy(new_password, username, old_password)
+ user.set_password(new_password)
+ mgr.ACCESS_CTRL_DB.save()