]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: iSCSI management API 25638/head
authorRicardo Marques <rimarques@suse.com>
Wed, 24 Oct 2018 12:42:23 +0000 (13:42 +0100)
committerRicardo Marques <rimarques@suse.com>
Thu, 24 Jan 2019 09:24:19 +0000 (09:24 +0000)
Fixes: https://tracker.ceph.com/issues/35903
Signed-off-by: Ricardo Marques <rimarques@suse.com>
doc/mgr/dashboard.rst
src/pybind/mgr/dashboard/controllers/iscsi.py [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts
src/pybind/mgr/dashboard/frontend/src/locale/messages.xlf
src/pybind/mgr/dashboard/module.py
src/pybind/mgr/dashboard/services/iscsi_cli.py [new file with mode: 0644]
src/pybind/mgr/dashboard/services/iscsi_client.py [new file with mode: 0644]
src/pybind/mgr/dashboard/tests/test_iscsi.py [new file with mode: 0644]

index 2048be3bcc359983ad83df0ba994afea102abee6..e06f40e8ff5ac45c2f83489af2c9cae5f8560ba2 100644 (file)
@@ -249,6 +249,19 @@ into timeouts, then you can set the timeout value to your needs::
 
 The default value is 45 seconds.
 
+Enabling iSCSI Management
+^^^^^^^^^^^^^^^^^^^^^^^^^
+
+The Ceph Manager Dashboard can manage iSCSI targets using the REST API provided
+by the `rbd-target-api` service of the `ceph-iscsi <https://github.com/ceph/ceph-iscsi>`_
+project. Please make sure that it's installed and enabled on the iSCSI gateways.
+
+The available iSCSI gateways must be defined using the following commands::
+
+    $ ceph dashboard iscsi-gateway-list
+    $ ceph dashboard iscsi-gateway-add <gateway_name> <scheme>://<username>:<password>@<host>[:port]
+    $ ceph dashboard iscsi-gateway-rm <gateway_name>
+
 Enabling the Embedding of Grafana Dashboards
 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
 
diff --git a/src/pybind/mgr/dashboard/controllers/iscsi.py b/src/pybind/mgr/dashboard/controllers/iscsi.py
new file mode 100644 (file)
index 0000000..504aca8
--- /dev/null
@@ -0,0 +1,538 @@
+# -*- coding: utf-8 -*-
+# pylint: disable=too-many-branches
+
+from __future__ import absolute_import
+
+from copy import deepcopy
+import json
+import cherrypy
+
+import rados
+import rbd
+
+from . import ApiController, UiApiController, RESTController, BaseController, Endpoint,\
+    ReadPermission, Task
+from .. import mgr
+from ..rest_client import RequestException
+from ..security import Scope
+from ..services.iscsi_client import IscsiClient
+from ..services.iscsi_cli import IscsiGatewaysConfig
+from ..exceptions import DashboardException
+from ..tools import TaskManager
+
+
+@UiApiController('/iscsi', Scope.ISCSI)
+class Iscsi(BaseController):
+
+    @Endpoint()
+    @ReadPermission
+    def status(self):
+        status = {'available': False}
+        if not IscsiGatewaysConfig.get_gateways_config()['gateways']:
+            status['message'] = 'There are no gateways defined'
+            return status
+        try:
+            IscsiClient.instance().get_config()
+            status['available'] = True
+        except RequestException as e:
+            if e.content:
+                content = json.loads(e.content)
+                content_message = content.get('message')
+                if content_message:
+                    status['message'] = content_message
+        return status
+
+    @Endpoint()
+    @ReadPermission
+    def settings(self):
+        return IscsiClient.instance().get_settings()
+
+    @Endpoint()
+    @ReadPermission
+    def portals(self):
+        portals = []
+        gateways_config = IscsiGatewaysConfig.get_gateways_config()
+        for name in gateways_config['gateways'].keys():
+            ip_addresses = IscsiClient.instance(gateway_name=name).get_ip_addresses()
+            portals.append({'name': name, 'ip_addresses': ip_addresses['data']})
+        return sorted(portals, key=lambda p: '{}.{}'.format(p['name'], p['ip_addresses']))
+
+
+def iscsi_target_task(name, metadata, wait_for=2.0):
+    return Task("iscsi/target/{}".format(name), metadata, wait_for)
+
+
+@ApiController('/iscsi/target', Scope.ISCSI)
+class IscsiTarget(RESTController):
+
+    def list(self):
+        config = IscsiClient.instance().get_config()
+        targets = []
+        for target_iqn in config['targets'].keys():
+            target = IscsiTarget._config_to_target(target_iqn, config)
+            targets.append(target)
+        return targets
+
+    def get(self, target_iqn):
+        config = IscsiClient.instance().get_config()
+        if target_iqn not in config['targets']:
+            raise cherrypy.HTTPError(404)
+        return IscsiTarget._config_to_target(target_iqn, config)
+
+    @iscsi_target_task('delete', {'target_iqn': '{target_iqn}'})
+    def delete(self, target_iqn):
+        config = IscsiClient.instance().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 not in config['targets']:
+            raise DashboardException(msg='Target does not exist',
+                                     code='target_does_not_exist',
+                                     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,
+               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 []
+
+        config = IscsiClient.instance().get_config()
+        if target_iqn in config['targets']:
+            raise DashboardException(msg='Target already exists',
+                                     code='target_already_exists',
+                                     component='iscsi')
+        IscsiTarget._validate(target_iqn, portals, disks)
+        IscsiTarget._create(target_iqn, target_controls, portals, disks, clients, groups, 0, 100,
+                            config)
+
+    @iscsi_target_task('edit', {'target_iqn': '{target_iqn}'})
+    def set(self, target_iqn, new_target_iqn=None, target_controls=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)
+
+        config = IscsiClient.instance().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')
+        IscsiTarget._validate(new_target_iqn, portals, disks)
+        config = IscsiTarget._delete(target_iqn, config, 0, 50, new_target_iqn, target_controls,
+                                     portals, disks, clients, groups)
+        IscsiTarget._create(new_target_iqn, target_controls, portals, disks, clients, groups,
+                            50, 100, config)
+
+    @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]
+        deleted_groups = []
+        for group_id in list(target_config['groups'].keys()):
+            if IscsiTarget._group_deletion_required(target, new_target_iqn, new_target_controls,
+                                                    new_portals, new_groups, group_id, new_clients,
+                                                    new_disks):
+                deleted_groups.append(group_id)
+                IscsiClient.instance(gateway_name=gateway_name).delete_group(target_iqn,
+                                                                             group_id)
+            TaskManager.current_task().inc_progress(task_progress_inc)
+        for client_iqn in list(target_config['clients'].keys()):
+            if IscsiTarget._client_deletion_required(target, new_target_iqn, new_target_controls,
+                                                     new_portals, new_clients, client_iqn,
+                                                     new_groups, deleted_groups):
+                IscsiClient.instance(gateway_name=gateway_name).delete_client(target_iqn,
+                                                                              client_iqn)
+            TaskManager.current_task().inc_progress(task_progress_inc)
+        for image_id in target_config['disks']:
+            if IscsiTarget._target_lun_deletion_required(target, new_target_iqn,
+                                                         new_target_controls, new_portals,
+                                                         new_disks, image_id):
+                IscsiClient.instance(gateway_name=gateway_name).delete_target_lun(target_iqn,
+                                                                                  image_id)
+                IscsiClient.instance(gateway_name=gateway_name).delete_disk(image_id)
+            TaskManager.current_task().inc_progress(task_progress_inc)
+        if IscsiTarget._target_deletion_required(target, new_target_iqn, new_target_controls,
+                                                 new_portals):
+            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 _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_portals,
+                                 new_groups, group_id, new_clients, new_disks):
+        if IscsiTarget._target_deletion_required(target, new_target_iqn, new_target_controls,
+                                                 new_portals):
+            return True
+        new_group = IscsiTarget._get_group(new_groups, group_id)
+        if not new_group:
+            return True
+        old_group = IscsiTarget._get_group(target['groups'], group_id)
+        if new_group != old_group:
+            return True
+        # Check if any client inside this group has changed
+        for client_iqn in new_group['members']:
+            if IscsiTarget._client_deletion_required(target, new_target_iqn, new_target_controls,
+                                                     new_portals, new_clients, client_iqn,
+                                                     new_groups, []):
+                return True
+        # Check if any disk inside this group has changed
+        for disk in new_group['disks']:
+            image_id = '{}.{}'.format(disk['pool'], disk['image'])
+            if IscsiTarget._target_lun_deletion_required(target, new_target_iqn,
+                                                         new_target_controls, new_portals,
+                                                         new_disks, image_id):
+                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_portals,
+                                  new_clients, client_iqn, new_groups, deleted_groups):
+        if IscsiTarget._target_deletion_required(target, new_target_iqn, new_target_controls,
+                                                 new_portals):
+            return True
+        new_client = deepcopy(IscsiTarget._get_client(new_clients, client_iqn))
+        if not new_client:
+            return True
+        # Disks inherited from groups must be considered
+        for group in new_groups:
+            if client_iqn in group['members']:
+                new_client['luns'] += group['disks']
+        old_client = IscsiTarget._get_client(target['clients'], client_iqn)
+        if new_client != old_client:
+            return True
+        # Check if client belongs to a groups that has been deleted
+        for group in target['groups']:
+            if group['group_id'] in deleted_groups and client_iqn in group['members']:
+                return True
+        return False
+
+    @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_portals,
+                                      new_disks, image_id):
+        if IscsiTarget._target_deletion_required(target, new_target_iqn, new_target_controls,
+                                                 new_portals):
+            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)
+        if new_disk != old_disk:
+            return True
+        return False
+
+    @staticmethod
+    def _target_deletion_required(target, new_target_iqn, new_target_controls, new_portals):
+        if target['target_iqn'] != new_target_iqn:
+            return True
+        if target['target_controls'] != new_target_controls:
+            return True
+        if target['portals'] != new_portals:
+            return True
+        return False
+
+    @staticmethod
+    def _validate(target_iqn, portals, disks):
+        if not target_iqn:
+            raise DashboardException(msg='Target IQN is required',
+                                     code='target_iqn_required',
+                                     component='iscsi')
+
+        settings = IscsiClient.instance().get_settings()
+        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')
+
+        for portal in portals:
+            gateway_name = portal['host']
+            try:
+                IscsiClient.instance(gateway_name=gateway_name).ping()
+            except RequestException:
+                raise DashboardException(msg='iSCSI REST Api not available for gateway '
+                                             '{}'.format(gateway_name),
+                                         code='ceph_iscsi_rest_api_not_available_for_gateway',
+                                         component='iscsi')
+
+        for disk in disks:
+            pool = disk['pool']
+            image = disk['image']
+            IscsiTarget._validate_image_exists(pool, image)
+
+    @staticmethod
+    def _validate_image_exists(pool, image):
+        try:
+            ioctx = mgr.rados.open_ioctx(pool)
+            try:
+                rbd.Image(ioctx, image)
+            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 _create(target_iqn, target_controls,
+                portals, disks, clients, groups,
+                task_progress_begin, task_progress_end, config):
+        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)
+        try:
+            gateway_name = portals[0]['host']
+            if not target_config:
+                IscsiClient.instance(gateway_name=gateway_name).create_target(target_iqn,
+                                                                              target_controls)
+                for host, ip_list in portals_by_host.items():
+                    IscsiClient.instance(gateway_name=gateway_name).create_gateway(target_iqn,
+                                                                                   host,
+                                                                                   ip_list)
+                    TaskManager.current_task().inc_progress(task_progress_inc)
+            for disk in disks:
+                pool = disk['pool']
+                image = disk['image']
+                image_id = '{}.{}'.format(pool, image)
+                if image_id not in config['disks']:
+                    IscsiClient.instance(gateway_name=gateway_name).create_disk(image_id)
+                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)
+                    controls = disk['controls']
+                    if controls:
+                        IscsiClient.instance(gateway_name=gateway_name).reconfigure_disk(image_id,
+                                                                                         controls)
+                TaskManager.current_task().inc_progress(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)
+                    for lun in client['luns']:
+                        pool = lun['pool']
+                        image = lun['image']
+                        image_id = '{}.{}'.format(pool, image)
+                        IscsiClient.instance(gateway_name=gateway_name).create_client_lun(
+                            target_iqn, client_iqn, image_id)
+                    user = client['auth']['user']
+                    password = client['auth']['password']
+                    chap = '{}/{}'.format(user, password) if user and password else ''
+                    m_user = client['auth']['mutual_user']
+                    m_password = client['auth']['mutual_password']
+                    m_chap = '{}/{}'.format(m_user, m_password) if m_user and m_password else ''
+                    IscsiClient.instance(gateway_name=gateway_name).create_client_auth(
+                        target_iqn, client_iqn, chap, m_chap)
+                TaskManager.current_task().inc_progress(task_progress_inc)
+            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 not target_config or group_id not in target_config['groups']:
+                    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)
+        except RequestException as e:
+            if e.content:
+                content = json.loads(e.content)
+                content_message = content.get('message')
+                if content_message:
+                    raise DashboardException(msg=content_message, component='iscsi')
+            raise DashboardException(e=e, component='iscsi')
+
+    @staticmethod
+    def _config_to_target(target_iqn, config):
+        target_config = config['targets'][target_iqn]
+        portals = []
+        for host in target_config['portals'].keys():
+            ips = IscsiClient.instance(gateway_name=host).get_ip_addresses()['data']
+            portal_ips = [ip for ip in ips if ip in target_config['ip_list']]
+            for portal_ip in portal_ips:
+                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'],
+            }
+            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 = None
+            password = None
+            if '/' in client_config['auth']['chap']:
+                user, password = client_config['auth']['chap'].split('/', 1)
+            mutual_user = None
+            mutual_password = None
+            if '/' in client_config['auth']['chap_mutual']:
+                mutual_user, mutual_password = client_config['auth']['chap_mutual'].split('/', 1)
+            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']
+        for key, value in target_controls.items():
+            if isinstance(value, bool):
+                target_controls[key] = 'Yes' if value else 'No'
+        target = {
+            'target_iqn': target_iqn,
+            'portals': portals,
+            'disks': disks,
+            'clients': clients,
+            'groups': groups,
+            'target_controls': target_controls,
+        }
+        return target
+
+    @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):
+        portals_by_host = {}
+        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
index 209f5ca7b14edeba9148ddf711bacc681ac54e2a..265ccdeec5476f6875679704d8a69b58cdffdcec 100644 (file)
@@ -311,6 +311,16 @@ export class TaskMessageService {
       this.commonOperations.delete,
       this.rbd_mirroring.pool_peer,
       (metadata) => ({})
+    ),
+    // iSCSI target tasks
+    'iscsi/target/create': this.newTaskMessage(this.commonOperations.create, (metadata) =>
+      this.iscsiTarget(metadata)
+    ),
+    'iscsi/target/edit': this.newTaskMessage(this.commonOperations.update, (metadata) =>
+      this.iscsiTarget(metadata)
+    ),
+    'iscsi/target/delete': this.newTaskMessage(this.commonOperations.delete, (metadata) =>
+      this.iscsiTarget(metadata)
     )
   };
 
@@ -332,6 +342,10 @@ export class TaskMessageService {
     return this.i18n(`erasure code profile '{{name}}'`, { name: metadata.name });
   }
 
+  iscsiTarget(metadata) {
+    return this.i18n(`target '{{target_iqn}}'`, { target_iqn: metadata.target_iqn });
+  }
+
   _getTaskTitle(task: Task) {
     return this.messages[task.name] || this.defaultMessage;
   }
index d6c8808f42c9ee3352a2aae9cafda92bc11042bc..40e35a9bf4bb9175a0c4dc010b43def5e456a5e0 100644 (file)
           <context context-type="linenumber">1</context>
         </context-group>
       </trans-unit>
+      <trans-unit id="369462e5e018360e0600bb570866201ad5c3c8a8" datatype="html">
+        <source>target &apos;<x id="INTERPOLATION" equiv-text="{{target_iqn}}"/>&apos;</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/shared/services/task-message.service.ts</context>
+          <context context-type="linenumber">1</context>
+        </context-group>
+      </trans-unit>
     </body>
   </file>
 </xliff>
index ac45f23bdeeb8d6dec058d77993f78f63330c41e..f4671d381098b1bd2a00d4bf5ad8cc5e8fa4694f 100644 (file)
@@ -19,6 +19,10 @@ from OpenSSL import crypto
 
 from mgr_module import MgrModule, MgrStandbyModule
 
+# Imports required for CLI commands registration
+# pylint: disable=unused-import
+from .services import iscsi_cli
+
 try:
     import cherrypy
     from cherrypy._cptools import HandlerWrapperTool
diff --git a/src/pybind/mgr/dashboard/services/iscsi_cli.py b/src/pybind/mgr/dashboard/services/iscsi_cli.py
new file mode 100644 (file)
index 0000000..c03d69f
--- /dev/null
@@ -0,0 +1,131 @@
+# -*- coding: utf-8 -*-
+from __future__ import absolute_import
+
+import errno
+import json
+
+try:
+    from urlparse import urlparse
+except ImportError:
+    from urllib.parse import urlparse
+
+from mgr_module import CLIReadCommand, CLIWriteCommand
+
+from .orchestrator import OrchClient
+from .. import mgr
+
+
+class IscsiGatewayAlreadyExists(Exception):
+    def __init__(self, gateway_name):
+        super(IscsiGatewayAlreadyExists, self).__init__(
+            "iSCSI gateway '{}' already exists".format(gateway_name))
+
+
+class IscsiGatewayDoesNotExist(Exception):
+    def __init__(self, hostname):
+        super(IscsiGatewayDoesNotExist, self).__init__(
+            "iSCSI gateway '{}' does not exist".format(hostname))
+
+
+class InvalidServiceUrl(Exception):
+    def __init__(self, service_url):
+        super(InvalidServiceUrl, self).__init__(
+            "Invalid service URL '{}'. "
+            "Valid format: '<scheme>://<username>:<password>@<host>[:port]'.".format(service_url))
+
+
+class ManagedByOrchestratorException(Exception):
+    def __init__(self):
+        super(ManagedByOrchestratorException, self).__init__(
+            "iSCSI configuration is managed by the orchestrator")
+
+
+_ISCSI_STORE_KEY = "_iscsi_config"
+
+
+class IscsiGatewaysConfig(object):
+    @classmethod
+    def _load_config(cls):
+        if OrchClient.instance().available():
+            raise ManagedByOrchestratorException()
+        json_db = mgr.get_store(_ISCSI_STORE_KEY,
+                                '{"gateways": {}}')
+        return json.loads(json_db)
+
+    @classmethod
+    def _save_config(cls, config):
+        mgr.set_store(_ISCSI_STORE_KEY, json.dumps(config))
+
+    @classmethod
+    def add_gateway(cls, name, service_url):
+        config = cls._load_config()
+        if name in config:
+            raise IscsiGatewayAlreadyExists(name)
+        url = urlparse(service_url)
+        if not url.scheme or not url.hostname or not url.username or not url.password:
+            raise InvalidServiceUrl(service_url)
+        config['gateways'][name] = {'service_url': service_url}
+        cls._save_config(config)
+
+    @classmethod
+    def remove_gateway(cls, name):
+        config = cls._load_config()
+        if name not in config['gateways']:
+            raise IscsiGatewayDoesNotExist(name)
+
+        del config['gateways'][name]
+        cls._save_config(config)
+
+    @classmethod
+    def get_gateways_config(cls):
+        try:
+            config = cls._load_config()
+        except ManagedByOrchestratorException:
+            config = {'gateways': {}}
+            instances = OrchClient.instance().list_service_info("iscsi")
+            for instance in instances:
+                config['gateways'][instance.nodename] = {
+                    'service_url': instance.service_url
+                }
+        return config
+
+    @classmethod
+    def get_gateway_config(cls, name):
+        config = IscsiGatewaysConfig.get_gateways_config()
+        if name not in config['gateways']:
+            raise IscsiGatewayDoesNotExist(name)
+        return config['gateways'][name]
+
+
+@CLIReadCommand('dashboard iscsi-gateway-list', desc='List iSCSI gateways')
+def list_iscsi_gateways(_):
+    return 0, json.dumps(IscsiGatewaysConfig.get_gateways_config()), ''
+
+
+@CLIWriteCommand('dashboard iscsi-gateway-add',
+                 'name=name,type=CephString '
+                 'name=service_url,type=CephString',
+                 'Add iSCSI gateway configuration')
+def add_iscsi_gateway(_, name, service_url):
+    try:
+        IscsiGatewaysConfig.add_gateway(name, service_url)
+        return 0, 'Success', ''
+    except IscsiGatewayAlreadyExists as ex:
+        return -errno.EEXIST, '', str(ex)
+    except InvalidServiceUrl as ex:
+        return -errno.EINVAL, '', str(ex)
+    except ManagedByOrchestratorException as ex:
+        return -errno.EINVAL, '', str(ex)
+
+
+@CLIWriteCommand('dashboard iscsi-gateway-rm',
+                 'name=name,type=CephString',
+                 'Remove iSCSI gateway configuration')
+def remove_iscsi_gateway(_, name):
+    try:
+        IscsiGatewaysConfig.remove_gateway(name)
+        return 0, 'Success', ''
+    except IscsiGatewayDoesNotExist as ex:
+        return -errno.ENOENT, '', str(ex)
+    except ManagedByOrchestratorException as ex:
+        return -errno.EINVAL, '', str(ex)
diff --git a/src/pybind/mgr/dashboard/services/iscsi_client.py b/src/pybind/mgr/dashboard/services/iscsi_client.py
new file mode 100644 (file)
index 0000000..41f2b64
--- /dev/null
@@ -0,0 +1,165 @@
+# -*- coding: utf-8 -*-
+from __future__ import absolute_import
+
+import json
+
+from requests.auth import HTTPBasicAuth
+
+try:
+    from urlparse import urlparse
+except ImportError:
+    from urllib.parse import urlparse
+
+from .iscsi_cli import IscsiGatewaysConfig
+from .. import logger
+from ..rest_client import RestClient
+
+
+class IscsiClient(RestClient):
+    _CLIENT_NAME = 'iscsi'
+    _instances = {}
+
+    service_url = None
+
+    @classmethod
+    def instance(cls, gateway_name=None):
+        if not gateway_name:
+            gateway_name = list(IscsiGatewaysConfig.get_gateways_config()['gateways'].keys())[0]
+        gateways_config = IscsiGatewaysConfig.get_gateway_config(gateway_name)
+        service_url = gateways_config['service_url']
+
+        instance = cls._instances.get(gateway_name)
+        if not instance or service_url != instance.service_url:
+            url = urlparse(service_url)
+            ssl = url.scheme == 'https'
+            host = url.hostname
+            port = url.port
+            username = url.username
+            password = url.password
+            if not port:
+                port = 443 if ssl else 80
+            auth = HTTPBasicAuth(username, password)
+            instance = IscsiClient(host, port, IscsiClient._CLIENT_NAME, ssl, auth)
+            instance.service_url = service_url
+            cls._instances[gateway_name] = instance
+
+        return instance
+
+    @RestClient.api_get('/api/_ping')
+    def ping(self, request=None):
+        return request()
+
+    @RestClient.api_get('/api/settings')
+    def get_settings(self, request=None):
+        return request()
+
+    @RestClient.api_get('/api/sysinfo/ip_addresses')
+    def get_ip_addresses(self, request=None):
+        return request()
+
+    @RestClient.api_get('/api/config')
+    def get_config(self, request=None):
+        return request()
+
+    @RestClient.api_put('/api/target/{target_iqn}')
+    def create_target(self, target_iqn, target_controls, request=None):
+        logger.debug("iSCSI: Creating target: %s", target_iqn)
+        return request({
+            'controls': json.dumps(target_controls)
+        })
+
+    @RestClient.api_delete('/api/target/{target_iqn}')
+    def delete_target(self, target_iqn, request=None):
+        logger.debug("iSCSI: Deleting target: %s", target_iqn)
+        return request()
+
+    @RestClient.api_put('/api/target/{target_iqn}')
+    def reconfigure_target(self, target_iqn, target_controls, request=None):
+        logger.debug("iSCSI: Reconfiguring target: %s", target_iqn)
+        return request({
+            'mode': 'reconfigure',
+            'controls': json.dumps(target_controls)
+        })
+
+    @RestClient.api_put('/api/gateway/{target_iqn}/{gateway_name}')
+    def create_gateway(self, target_iqn, gateway_name, ip_address, request=None):
+        logger.debug("iSCSI: Creating gateway: %s/%s", target_iqn, gateway_name)
+        return request({
+            'ip_address': ','.join(ip_address),
+            'skipchecks': 'true'
+        })
+
+    @RestClient.api_put('/api/disk/{image_id}')
+    def create_disk(self, image_id, request=None):
+        logger.debug("iSCSI: Creating disk: %s", image_id)
+        return request({
+            'mode': 'create'
+        })
+
+    @RestClient.api_delete('/api/disk/{image_id}')
+    def delete_disk(self, image_id, request=None):
+        logger.debug("iSCSI: Deleting disk: %s", image_id)
+        return request({
+            'preserve_image': 'true'
+        })
+
+    @RestClient.api_put('/api/disk/{image_id}')
+    def reconfigure_disk(self, image_id, controls, request=None):
+        logger.debug("iSCSI: Reconfiguring disk: %s", image_id)
+        return request({
+            'controls': json.dumps(controls),
+            'mode': 'reconfigure'
+        })
+
+    @RestClient.api_put('/api/targetlun/{target_iqn}')
+    def create_target_lun(self, target_iqn, image_id, request=None):
+        logger.debug("iSCSI: Creating target lun: %s/%s", target_iqn, image_id)
+        return request({
+            'disk': image_id
+        })
+
+    @RestClient.api_delete('/api/targetlun/{target_iqn}')
+    def delete_target_lun(self, target_iqn, image_id, request=None):
+        logger.debug("iSCSI: Deleting target lun: %s/%s", target_iqn, image_id)
+        return request({
+            'disk': image_id
+        })
+
+    @RestClient.api_put('/api/client/{target_iqn}/{client_iqn}')
+    def create_client(self, target_iqn, client_iqn, request=None):
+        logger.debug("iSCSI: Creating client: %s/%s", target_iqn, client_iqn)
+        return request()
+
+    @RestClient.api_delete('/api/client/{target_iqn}/{client_iqn}')
+    def delete_client(self, target_iqn, client_iqn, request=None):
+        logger.debug("iSCSI: Deleting client: %s/%s", target_iqn, client_iqn)
+        return request()
+
+    @RestClient.api_put('/api/clientlun/{target_iqn}/{client_iqn}')
+    def create_client_lun(self, target_iqn, client_iqn, image_id, request=None):
+        logger.debug("iSCSI: Creating client lun: %s/%s", target_iqn, client_iqn)
+        return request({
+            'disk': image_id
+        })
+
+    @RestClient.api_put('/api/clientauth/{target_iqn}/{client_iqn}')
+    def create_client_auth(self, target_iqn, client_iqn, chap, chap_mutual, request=None):
+        logger.debug("iSCSI: Creating client auth: %s/%s/%s/%s",
+                     target_iqn, client_iqn, chap, chap_mutual)
+        return request({
+            'chap': chap,
+            'chap_mutual': chap_mutual
+        })
+
+    @RestClient.api_put('/api/hostgroup/{target_iqn}/{group_name}')
+    def create_group(self, target_iqn, group_name, members, image_ids, request=None):
+        logger.debug("iSCSI: Creating group: %s/%s", target_iqn, group_name)
+        return request({
+            'members': ','.join(members),
+            'disks': ','.join(image_ids)
+        })
+
+    @RestClient.api_delete('/api/hostgroup/{target_iqn}/{group_name}')
+    def delete_group(self, target_iqn, group_name, request=None):
+        logger.debug("iSCSI: Deleting group: %s/%s", target_iqn, group_name)
+        return request()
diff --git a/src/pybind/mgr/dashboard/tests/test_iscsi.py b/src/pybind/mgr/dashboard/tests/test_iscsi.py
new file mode 100644 (file)
index 0000000..9c59707
--- /dev/null
@@ -0,0 +1,508 @@
+import copy
+import mock
+
+from .helper import ControllerTestCase
+from .. import mgr
+from ..controllers.iscsi import IscsiTarget
+from ..services.iscsi_client import IscsiClient
+
+
+class IscsiTest(ControllerTestCase):
+
+    @classmethod
+    def setup_server(cls):
+        mgr.rados.side_effect = None
+        # pylint: disable=protected-access
+        IscsiTarget._cp_config['tools.authenticate.on'] = False
+        cls.setup_controllers([IscsiTarget])
+
+    def setUp(self):
+        # pylint: disable=protected-access
+        IscsiClientMock._instance = IscsiClientMock()
+        IscsiClient.instance = IscsiClientMock.instance
+
+    def test_list_empty(self):
+        self._get('/api/iscsi/target')
+        self.assertStatus(200)
+        self.assertJsonBody([])
+
+    @mock.patch('dashboard.controllers.iscsi.IscsiTarget._validate_image_exists')
+    def test_list(self, _validate_image_exists_mock):
+        target_iqn = "iqn.2003-01.com.redhat.iscsi-gw:iscsi-igw1"
+        request = copy.deepcopy(iscsi_target_request)
+        request['target_iqn'] = target_iqn
+        self._post('/api/iscsi/target', request)
+        self.assertStatus(201)
+        self._get('/api/iscsi/target')
+        self.assertStatus(200)
+        response = copy.deepcopy(iscsi_target_response)
+        response['target_iqn'] = target_iqn
+        self.assertJsonBody([response])
+
+    @mock.patch('dashboard.controllers.iscsi.IscsiTarget._validate_image_exists')
+    def test_create(self, _validate_image_exists_mock):
+        target_iqn = "iqn.2003-01.com.redhat.iscsi-gw:iscsi-igw2"
+        request = copy.deepcopy(iscsi_target_request)
+        request['target_iqn'] = target_iqn
+        self._post('/api/iscsi/target', request)
+        self.assertStatus(201)
+        self._get('/api/iscsi/target/{}'.format(request['target_iqn']))
+        self.assertStatus(200)
+        response = copy.deepcopy(iscsi_target_response)
+        response['target_iqn'] = target_iqn
+        self.assertJsonBody(response)
+
+    @mock.patch('dashboard.controllers.iscsi.IscsiTarget._validate_image_exists')
+    def test_delete(self, _validate_image_exists_mock):
+        target_iqn = "iqn.2003-01.com.redhat.iscsi-gw:iscsi-igw3"
+        request = copy.deepcopy(iscsi_target_request)
+        request['target_iqn'] = target_iqn
+        self._post('/api/iscsi/target', request)
+        self.assertStatus(201)
+        self._delete('/api/iscsi/target/{}'.format(request['target_iqn']))
+        self.assertStatus(204)
+        self._get('/api/iscsi/target')
+        self.assertStatus(200)
+        self.assertJsonBody([])
+
+    @mock.patch('dashboard.controllers.iscsi.IscsiTarget._validate_image_exists')
+    def test_add_client(self, _validate_image_exists_mock):
+        target_iqn = "iqn.2003-01.com.redhat.iscsi-gw:iscsi-igw4"
+        create_request = copy.deepcopy(iscsi_target_request)
+        create_request['target_iqn'] = target_iqn
+        update_request = copy.deepcopy(create_request)
+        update_request['new_target_iqn'] = target_iqn
+        update_request['clients'].append(
+            {
+                "luns": [{"image": "lun1", "pool": "rbd"}],
+                "client_iqn": "iqn.1994-05.com.redhat:rh7-client3",
+                "auth": {
+                    "password": "myiscsipassword5",
+                    "user": "myiscsiusername5",
+                    "mutual_password": "myiscsipassword6",
+                    "mutual_user": "myiscsiusername6"}
+            })
+        response = copy.deepcopy(iscsi_target_response)
+        response['target_iqn'] = target_iqn
+        response['clients'].append(
+            {
+                "luns": [{"image": "lun1", "pool": "rbd"}],
+                "client_iqn": "iqn.1994-05.com.redhat:rh7-client3",
+                "auth": {
+                    "password": "myiscsipassword5",
+                    "user": "myiscsiusername5",
+                    "mutual_password": "myiscsipassword6",
+                    "mutual_user": "myiscsiusername6"}
+            })
+        self._update_iscsi_target(create_request, update_request, response)
+
+    @mock.patch('dashboard.controllers.iscsi.IscsiTarget._validate_image_exists')
+    def test_change_client_password(self, _validate_image_exists_mock):
+        target_iqn = "iqn.2003-01.com.redhat.iscsi-gw:iscsi-igw5"
+        create_request = copy.deepcopy(iscsi_target_request)
+        create_request['target_iqn'] = target_iqn
+        update_request = copy.deepcopy(create_request)
+        update_request['new_target_iqn'] = target_iqn
+        update_request['clients'][0]['auth']['password'] = 'mynewiscsipassword'
+        response = copy.deepcopy(iscsi_target_response)
+        response['target_iqn'] = target_iqn
+        response['clients'][0]['auth']['password'] = 'mynewiscsipassword'
+        self._update_iscsi_target(create_request, update_request, response)
+
+    @mock.patch('dashboard.controllers.iscsi.IscsiTarget._validate_image_exists')
+    def test_rename_client(self, _validate_image_exists_mock):
+        target_iqn = "iqn.2003-01.com.redhat.iscsi-gw:iscsi-igw6"
+        create_request = copy.deepcopy(iscsi_target_request)
+        create_request['target_iqn'] = target_iqn
+        update_request = copy.deepcopy(create_request)
+        update_request['new_target_iqn'] = target_iqn
+        update_request['clients'][0]['client_iqn'] = 'iqn.1994-05.com.redhat:rh7-client0'
+        response = copy.deepcopy(iscsi_target_response)
+        response['target_iqn'] = target_iqn
+        response['clients'][0]['client_iqn'] = 'iqn.1994-05.com.redhat:rh7-client0'
+        self._update_iscsi_target(create_request, update_request, response)
+
+    @mock.patch('dashboard.controllers.iscsi.IscsiTarget._validate_image_exists')
+    def test_add_disk(self, _validate_image_exists_mock):
+        target_iqn = "iqn.2003-01.com.redhat.iscsi-gw:iscsi-igw7"
+        create_request = copy.deepcopy(iscsi_target_request)
+        create_request['target_iqn'] = target_iqn
+        update_request = copy.deepcopy(create_request)
+        update_request['new_target_iqn'] = target_iqn
+        update_request['disks'].append(
+            {
+                "image": "lun3",
+                "pool": "rbd",
+                "controls": {}
+            })
+        update_request['clients'][0]['luns'].append({"image": "lun3", "pool": "rbd"})
+        response = copy.deepcopy(iscsi_target_response)
+        response['target_iqn'] = target_iqn
+        response['disks'].append(
+            {
+                "image": "lun3",
+                "pool": "rbd",
+                "controls": {}
+            })
+        response['clients'][0]['luns'].append({"image": "lun3", "pool": "rbd"})
+        self._update_iscsi_target(create_request, update_request, response)
+
+    @mock.patch('dashboard.controllers.iscsi.IscsiTarget._validate_image_exists')
+    def test_change_disk_image(self, _validate_image_exists_mock):
+        target_iqn = "iqn.2003-01.com.redhat.iscsi-gw:iscsi-igw8"
+        create_request = copy.deepcopy(iscsi_target_request)
+        create_request['target_iqn'] = target_iqn
+        update_request = copy.deepcopy(create_request)
+        update_request['new_target_iqn'] = target_iqn
+        update_request['disks'][0]['image'] = 'lun0'
+        update_request['clients'][0]['luns'][0]['image'] = 'lun0'
+        response = copy.deepcopy(iscsi_target_response)
+        response['target_iqn'] = target_iqn
+        response['disks'][0]['image'] = 'lun0'
+        response['clients'][0]['luns'][0]['image'] = 'lun0'
+        self._update_iscsi_target(create_request, update_request, response)
+
+    @mock.patch('dashboard.controllers.iscsi.IscsiTarget._validate_image_exists')
+    def test_change_disk_controls(self, _validate_image_exists_mock):
+        target_iqn = "iqn.2003-01.com.redhat.iscsi-gw:iscsi-igw9"
+        create_request = copy.deepcopy(iscsi_target_request)
+        create_request['target_iqn'] = target_iqn
+        update_request = copy.deepcopy(create_request)
+        update_request['new_target_iqn'] = target_iqn
+        update_request['disks'][0]['controls'] = {"qfull_timeout": 15}
+        response = copy.deepcopy(iscsi_target_response)
+        response['target_iqn'] = target_iqn
+        response['disks'][0]['controls'] = {"qfull_timeout": 15}
+        self._update_iscsi_target(create_request, update_request, response)
+
+    @mock.patch('dashboard.controllers.iscsi.IscsiTarget._validate_image_exists')
+    def test_rename_target(self, _validate_image_exists_mock):
+        target_iqn = "iqn.2003-01.com.redhat.iscsi-gw:iscsi-igw10"
+        new_target_iqn = "iqn.2003-01.com.redhat.iscsi-gw:iscsi-igw11"
+        create_request = copy.deepcopy(iscsi_target_request)
+        create_request['target_iqn'] = target_iqn
+        update_request = copy.deepcopy(create_request)
+        update_request['new_target_iqn'] = new_target_iqn
+        response = copy.deepcopy(iscsi_target_response)
+        response['target_iqn'] = new_target_iqn
+        self._update_iscsi_target(create_request, update_request, response)
+
+    @mock.patch('dashboard.controllers.iscsi.IscsiTarget._validate_image_exists')
+    def test_rename_group(self, _validate_image_exists_mock):
+        target_iqn = "iqn.2003-01.com.redhat.iscsi-gw:iscsi-igw12"
+        create_request = copy.deepcopy(iscsi_target_request)
+        create_request['target_iqn'] = target_iqn
+        update_request = copy.deepcopy(create_request)
+        update_request['new_target_iqn'] = target_iqn
+        update_request['groups'][0]['group_id'] = 'mygroup0'
+        response = copy.deepcopy(iscsi_target_response)
+        response['target_iqn'] = target_iqn
+        response['groups'][0]['group_id'] = 'mygroup0'
+        self._update_iscsi_target(create_request, update_request, response)
+
+    @mock.patch('dashboard.controllers.iscsi.IscsiTarget._validate_image_exists')
+    def test_add_client_to_group(self, _validate_image_exists_mock):
+        target_iqn = "iqn.2003-01.com.redhat.iscsi-gw:iscsi-igw13"
+        create_request = copy.deepcopy(iscsi_target_request)
+        create_request['target_iqn'] = target_iqn
+        update_request = copy.deepcopy(create_request)
+        update_request['new_target_iqn'] = target_iqn
+        update_request['clients'].append(
+            {
+                "luns": [],
+                "client_iqn": "iqn.1994-05.com.redhat:rh7-client3",
+                "auth": {
+                    "password": None,
+                    "user": None,
+                    "mutual_password": None,
+                    "mutual_user": None}
+            })
+        update_request['groups'][0]['members'].append('iqn.1994-05.com.redhat:rh7-client3')
+        response = copy.deepcopy(iscsi_target_response)
+        response['target_iqn'] = target_iqn
+        response['clients'].append(
+            {
+                "luns": [],
+                "client_iqn": "iqn.1994-05.com.redhat:rh7-client3",
+                "auth": {
+                    "password": None,
+                    "user": None,
+                    "mutual_password": None,
+                    "mutual_user": None}
+            })
+        response['groups'][0]['members'].append('iqn.1994-05.com.redhat:rh7-client3')
+        self._update_iscsi_target(create_request, update_request, response)
+
+    @mock.patch('dashboard.controllers.iscsi.IscsiTarget._validate_image_exists')
+    def test_remove_client_from_group(self, _validate_image_exists_mock):
+        target_iqn = "iqn.2003-01.com.redhat.iscsi-gw:iscsi-igw14"
+        create_request = copy.deepcopy(iscsi_target_request)
+        create_request['target_iqn'] = target_iqn
+        update_request = copy.deepcopy(create_request)
+        update_request['new_target_iqn'] = target_iqn
+        update_request['groups'][0]['members'].remove('iqn.1994-05.com.redhat:rh7-client2')
+        response = copy.deepcopy(iscsi_target_response)
+        response['target_iqn'] = target_iqn
+        response['groups'][0]['members'].remove('iqn.1994-05.com.redhat:rh7-client2')
+        self._update_iscsi_target(create_request, update_request, response)
+
+    @mock.patch('dashboard.controllers.iscsi.IscsiTarget._validate_image_exists')
+    def test_remove_groups(self, _validate_image_exists_mock):
+        target_iqn = "iqn.2003-01.com.redhat.iscsi-gw:iscsi-igw15"
+        create_request = copy.deepcopy(iscsi_target_request)
+        create_request['target_iqn'] = target_iqn
+        update_request = copy.deepcopy(create_request)
+        update_request['new_target_iqn'] = target_iqn
+        update_request['groups'] = []
+        response = copy.deepcopy(iscsi_target_response)
+        response['target_iqn'] = target_iqn
+        response['groups'] = []
+        self._update_iscsi_target(create_request, update_request, response)
+
+    def _update_iscsi_target(self, create_request, update_request, response):
+        self._post('/api/iscsi/target', create_request)
+        self.assertStatus(201)
+        self._put('/api/iscsi/target/{}'.format(create_request['target_iqn']), update_request)
+        self.assertStatus(200)
+        self._get('/api/iscsi/target/{}'.format(update_request['new_target_iqn']))
+        self.assertStatus(200)
+        self.assertJsonBody(response)
+
+
+iscsi_target_request = {
+    "target_iqn": "iqn.2003-01.com.redhat.iscsi-gw:iscsi-igw",
+    "portals": [
+        {"ip": "192.168.100.202", "host": "node2"},
+        {"ip": "10.0.2.15", "host": "node2"},
+        {"ip": "192.168.100.203", "host": "node3"}
+    ],
+    "disks": [
+        {"image": "lun1", "pool": "rbd", "controls": {"max_data_area_mb": 128}},
+        {"image": "lun2", "pool": "rbd", "controls": {"max_data_area_mb": 128}}
+    ],
+    "clients": [
+        {
+            "luns": [{"image": "lun1", "pool": "rbd"}],
+            "client_iqn": "iqn.1994-05.com.redhat:rh7-client",
+            "auth": {
+                "password": "myiscsipassword1",
+                "user": "myiscsiusername1",
+                "mutual_password": "myiscsipassword2",
+                "mutual_user": "myiscsiusername2"}
+        },
+        {
+            "luns": [],
+            "client_iqn": "iqn.1994-05.com.redhat:rh7-client2",
+            "auth": {
+                "password": "myiscsipassword3",
+                "user": "myiscsiusername3",
+                "mutual_password": "myiscsipassword4",
+                "mutual_user": "myiscsiusername4"
+            }
+        }
+    ],
+    "target_controls": {},
+    "groups": [
+        {
+            "group_id": "mygroup",
+            "disks": [{"pool": "rbd", "image": "lun2"}],
+            "members": ["iqn.1994-05.com.redhat:rh7-client2"]
+        }
+    ]
+}
+
+iscsi_target_response = {
+    'target_iqn': 'iqn.2003-01.com.redhat.iscsi-gw:iscsi-igw',
+    'portals': [
+        {'host': 'node2', 'ip': '10.0.2.15'},
+        {'host': 'node2', 'ip': '192.168.100.202'},
+        {'host': 'node3', 'ip': '192.168.100.203'}
+    ],
+    'disks': [
+        {'pool': 'rbd', 'image': 'lun1', 'controls': {'max_data_area_mb': 128}},
+        {'pool': 'rbd', 'image': 'lun2', 'controls': {'max_data_area_mb': 128}}
+    ],
+    'clients': [
+        {
+            'client_iqn': 'iqn.1994-05.com.redhat:rh7-client',
+            'luns': [{'pool': 'rbd', 'image': 'lun1'}],
+            'auth': {
+                'user': 'myiscsiusername1',
+                'password': 'myiscsipassword1',
+                'mutual_password': 'myiscsipassword2',
+                'mutual_user': 'myiscsiusername2'
+            }
+        },
+        {
+            'client_iqn': 'iqn.1994-05.com.redhat:rh7-client2',
+            'luns': [],
+            'auth': {
+                'user': 'myiscsiusername3',
+                'password': 'myiscsipassword3',
+                'mutual_password': 'myiscsipassword4',
+                'mutual_user': 'myiscsiusername4'
+            }
+        }
+    ],
+    'groups': [
+        {
+            'group_id': 'mygroup',
+            'disks': [{'pool': 'rbd', 'image': 'lun2'}],
+            'members': ['iqn.1994-05.com.redhat:rh7-client2']
+        }
+    ],
+    'target_controls': {}
+}
+
+
+class IscsiClientMock(object):
+
+    _instance = None
+
+    def __init__(self):
+        self.gateway_name = None
+        self.config = {
+            "created": "2019/01/17 08:57:16",
+            "discovery_auth": {
+                "chap": "",
+                "chap_mutual": ""
+            },
+            "disks": {},
+            "epoch": 0,
+            "gateways": {},
+            "targets": {},
+            "updated": "",
+            "version": 4
+        }
+
+    @classmethod
+    def instance(cls, gateway_name=None):
+        cls._instance.gateway_name = gateway_name
+        # pylint: disable=unused-argument
+        return cls._instance
+
+    def ping(self):
+        return {
+            "message": "pong"
+        }
+
+    def get_settings(self):
+        return {
+            "config": {
+                "minimum_gateways": 2
+            },
+            "disk_default_controls": {
+                "hw_max_sectors": 1024,
+                "max_data_area_mb": 8,
+                "osd_op_timeout": 30,
+                "qfull_timeout": 5
+            },
+            "target_default_controls": {
+                "cmdsn_depth": 128,
+                "dataout_timeout": 20,
+                "first_burst_length": 262144,
+                "immediate_data": "Yes",
+                "initial_r2t": "Yes",
+                "max_burst_length": 524288,
+                "max_outstanding_r2t": 1,
+                "max_recv_data_segment_length": 262144,
+                "max_xmit_data_segment_length": 262144,
+                "nopin_response_timeout": 5,
+                "nopin_timeout": 5
+            }
+        }
+
+    def get_config(self):
+        return self.config
+
+    def create_target(self, target_iqn, target_controls):
+        self.config['targets'][target_iqn] = {
+            "clients": {},
+            "controls": target_controls,
+            "created": "2019/01/17 09:22:34",
+            "disks": [],
+            "groups": {},
+            "portals": {}
+        }
+
+    def create_gateway(self, target_iqn, gateway_name, ip_address):
+        target_config = self.config['targets'][target_iqn]
+        if 'ip_list' not in target_config:
+            target_config['ip_list'] = []
+        target_config['ip_list'] += ip_address
+        target_config['portals'][gateway_name] = {
+            "portal_ip_address": ip_address[0]
+        }
+
+    def create_disk(self, image_id):
+        pool, image = image_id.split('.')
+        self.config['disks'][image_id] = {
+            "pool": pool,
+            "image": image,
+            "controls": {}
+        }
+
+    def create_target_lun(self, target_iqn, image_id):
+        target_config = self.config['targets'][target_iqn]
+        target_config['disks'].append(image_id)
+        self.config['disks'][image_id]['owner'] = list(target_config['portals'].keys())[0]
+
+    def reconfigure_disk(self, image_id, controls):
+        self.config['disks'][image_id]['controls'] = controls
+
+    def create_client(self, target_iqn, client_iqn):
+        target_config = self.config['targets'][target_iqn]
+        target_config['clients'][client_iqn] = {
+            "auth": {
+                "chap": "",
+                "chap_mutual": ""
+            },
+            "group_name": "",
+            "luns": {}
+        }
+
+    def create_client_lun(self, target_iqn, client_iqn, image_id):
+        target_config = self.config['targets'][target_iqn]
+        target_config['clients'][client_iqn]['luns'][image_id] = {}
+
+    def create_client_auth(self, target_iqn, client_iqn, chap, chap_mutual):
+        target_config = self.config['targets'][target_iqn]
+        target_config['clients'][client_iqn]['auth']['chap'] = chap
+        target_config['clients'][client_iqn]['auth']['chap_mutual'] = chap_mutual
+
+    def create_group(self, target_iqn, group_name, members, image_ids):
+        target_config = self.config['targets'][target_iqn]
+        target_config['groups'][group_name] = {
+            "disks": {},
+            "members": []
+        }
+        for image_id in image_ids:
+            target_config['groups'][group_name]['disks'][image_id] = {}
+        target_config['groups'][group_name]['members'] = members
+
+    def delete_group(self, target_iqn, group_name):
+        target_config = self.config['targets'][target_iqn]
+        del target_config['groups'][group_name]
+
+    def delete_client(self, target_iqn, client_iqn):
+        target_config = self.config['targets'][target_iqn]
+        del target_config['clients'][client_iqn]
+
+    def delete_target_lun(self, target_iqn, image_id):
+        target_config = self.config['targets'][target_iqn]
+        target_config['disks'].remove(image_id)
+        del self.config['disks'][image_id]['owner']
+
+    def delete_disk(self, image_id):
+        del self.config['disks'][image_id]
+
+    def delete_target(self, target_iqn):
+        del self.config['targets'][target_iqn]
+
+    def get_ip_addresses(self):
+        ips = {
+            'node1': ['192.168.100.201'],
+            'node2': ['192.168.100.202', '10.0.2.15'],
+            'node3': ['192.168.100.203']
+        }
+        return {'data': ips[self.gateway_name]}