diff options
Diffstat (limited to '')
-rw-r--r-- | src/pybind/mgr/dashboard/controllers/rbd_mirroring.py | 687 |
1 files changed, 687 insertions, 0 deletions
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() |