Nexenta Edge iSCSI backend driver

Nexenta Edge project supports iSCSI block level storage.
This patch implements a driver for Nexenta Edge iSCSI backend.

DocImpact
Implements: blueprint nexentaedge-iscsi-volume-driver
Change-Id: I82c215ba85e9d49723e792d88d86553b3a75d3ac
This commit is contained in:
mikhail 2015-02-24 08:00:43 -08:00 committed by Sean McGinnis
parent cdc645e2f9
commit ee352dcf03
7 changed files with 577 additions and 0 deletions

View File

@ -119,6 +119,8 @@ from cinder.volume.drivers.lenovo import lenovo_common as \
from cinder.volume.drivers import lvm as cinder_volume_drivers_lvm
from cinder.volume.drivers.netapp import options as \
cinder_volume_drivers_netapp_options
from cinder.volume.drivers.nexenta.nexentaedge import iscsi as \
cinder_volume_drivers_nexenta_nexentaedge_iscsi
from cinder.volume.drivers import nfs as cinder_volume_drivers_nfs
from cinder.volume.drivers import nimble as cinder_volume_drivers_nimble
from cinder.volume.drivers.prophetstor import options as \
@ -287,6 +289,8 @@ def list_opts():
cinder_volume_drivers_prophetstor_options.DPL_OPTS,
cinder_volume_drivers_hitachi_hbsdiscsi.volume_opts,
cinder_volume_manager.volume_manager_opts,
cinder_volume_drivers_nexenta_nexentaedge_iscsi.
nexenta_edge_opts,
cinder_volume_drivers_ibm_flashsystemiscsi.
flashsystem_iscsi_opts,
cinder_volume_drivers_ibm_flashsystemcommon.flashsystem_opts,

View File

@ -0,0 +1,169 @@
#
# Copyright 2015 Nexenta Systems, Inc.
# All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import mock
from cinder import context
from cinder import test
from cinder.volume import configuration as conf
from cinder.volume.drivers.nexenta.nexentaedge import iscsi
NEDGE_URL = 'service/isc/iscsi'
NEDGE_BUCKET = 'c/t/bk'
NEDGE_SERVICE = 'isc'
NEDGE_BLOCKSIZE = 4096
NEDGE_CHUNKSIZE = 16384
MOCK_VOL = {
'id': 'vol1',
'name': 'vol1',
'size': 1
}
MOCK_VOL2 = {
'id': 'vol2',
'name': 'vol2',
'size': 1
}
MOCK_SNAP = {
'id': 'snap1',
'name': 'snap1',
'volume_name': 'vol1'
}
NEW_VOL_SIZE = 2
ISCSI_TARGET_NAME = 'iscsi_target_name'
ISCSI_TARGET_STATUS = 'Target 1: ' + ISCSI_TARGET_NAME
class TestNexentaEdgeISCSIDriver(test.TestCase):
def setUp(self):
super(TestNexentaEdgeISCSIDriver, self).setUp()
self.cfg = mock.Mock(spec=conf.Configuration)
self.cfg.nexenta_client_address = '0.0.0.0'
self.cfg.nexenta_rest_address = '0.0.0.0'
self.cfg.nexenta_rest_port = 8080
self.cfg.nexenta_rest_protocol = 'http'
self.cfg.nexenta_iscsi_target_portal_port = 3260
self.cfg.nexenta_rest_user = 'admin'
self.cfg.nexenta_rest_password = 'admin'
self.cfg.nexenta_lun_container = NEDGE_BUCKET
self.cfg.nexenta_iscsi_service = NEDGE_SERVICE
self.cfg.nexenta_blocksize = NEDGE_BLOCKSIZE
self.cfg.nexenta_chunksize = NEDGE_CHUNKSIZE
mock_exec = mock.Mock()
mock_exec.return_value = ('', '')
self.driver = iscsi.NexentaEdgeISCSIDriver(execute=mock_exec,
configuration=self.cfg)
self.api_patcher = mock.patch('cinder.volume.drivers.nexenta.'
'nexentaedge.jsonrpc.'
'NexentaEdgeJSONProxy.__call__')
self.mock_api = self.api_patcher.start()
self.mock_api.return_value = {
'data': {'value': ISCSI_TARGET_STATUS}
}
self.driver.do_setup(context.get_admin_context())
self.addCleanup(self.api_patcher.stop)
def test_check_do_setup(self):
self.assertEqual(ISCSI_TARGET_NAME, self.driver.target_name)
def test_create_volume(self):
self.driver.create_volume(MOCK_VOL)
self.mock_api.assert_called_with(NEDGE_URL, {
'objectPath': NEDGE_BUCKET + '/' + MOCK_VOL['id'],
'volSizeMB': MOCK_VOL['size'] * 1024,
'blockSize': NEDGE_BLOCKSIZE,
'chunkSize': NEDGE_CHUNKSIZE
})
def test_create_volume_fail(self):
self.mock_api.side_effect = RuntimeError
self.assertRaises(RuntimeError, self.driver.create_volume, MOCK_VOL)
def test_delete_volume(self):
self.driver.delete_volume(MOCK_VOL)
self.mock_api.assert_called_with(NEDGE_URL, {
'objectPath': NEDGE_BUCKET + '/' + MOCK_VOL['id']
})
def test_delete_volume_fail(self):
self.mock_api.side_effect = RuntimeError
self.assertRaises(RuntimeError, self.driver.delete_volume, MOCK_VOL)
def test_extend_volume(self):
self.driver.extend_volume(MOCK_VOL, NEW_VOL_SIZE)
self.mock_api.assert_called_with(NEDGE_URL + '/resize', {
'objectPath': NEDGE_BUCKET + '/' + MOCK_VOL['id'],
'newSizeMB': NEW_VOL_SIZE * 1024
})
def test_extend_volume_fail(self):
self.mock_api.side_effect = RuntimeError
self.assertRaises(RuntimeError, self.driver.extend_volume,
MOCK_VOL, NEW_VOL_SIZE)
def test_create_snapshot(self):
self.driver.create_snapshot(MOCK_SNAP)
self.mock_api.assert_called_with(NEDGE_URL + '/snapshot', {
'objectPath': NEDGE_BUCKET + '/' + MOCK_VOL['id'],
'snapName': MOCK_SNAP['id']
})
def test_create_snapshot_fail(self):
self.mock_api.side_effect = RuntimeError
self.assertRaises(RuntimeError, self.driver.create_snapshot, MOCK_SNAP)
def test_delete_snapshot(self):
self.driver.delete_snapshot(MOCK_SNAP)
self.mock_api.assert_called_with(NEDGE_URL + '/snapshot', {
'objectPath': NEDGE_BUCKET + '/' + MOCK_VOL['id'],
'snapName': MOCK_SNAP['id']
})
def test_delete_snapshot_fail(self):
self.mock_api.side_effect = RuntimeError
self.assertRaises(RuntimeError, self.driver.delete_snapshot, MOCK_SNAP)
def test_create_volume_from_snapshot(self):
self.driver.create_volume_from_snapshot(MOCK_VOL2, MOCK_SNAP)
self.mock_api.assert_called_with(NEDGE_URL + '/snapshot/clone', {
'objectPath': NEDGE_BUCKET + '/' + MOCK_SNAP['volume_name'],
'clonePath': NEDGE_BUCKET + '/' + MOCK_VOL2['id'],
'snapName': MOCK_SNAP['id']
})
def test_create_volume_from_snapshot_fail(self):
self.mock_api.side_effect = RuntimeError
self.assertRaises(RuntimeError,
self.driver.create_volume_from_snapshot,
MOCK_VOL2, MOCK_SNAP)
def test_create_cloned_volume(self):
self.driver.create_cloned_volume(MOCK_VOL2, MOCK_VOL)
self.mock_api.assert_called_with(NEDGE_URL, {
'objectPath': NEDGE_BUCKET + '/' + MOCK_VOL2['id'],
'volSizeMB': MOCK_VOL2['size'] * 1024,
'blockSize': NEDGE_BLOCKSIZE,
'chunkSize': NEDGE_CHUNKSIZE
})
def test_create_cloned_volume_fail(self):
self.mock_api.side_effect = RuntimeError
self.assertRaises(RuntimeError, self.driver.create_cloned_volume,
MOCK_VOL2, MOCK_VOL)

View File

@ -0,0 +1,303 @@
# Copyright 2015 Nexenta Systems, Inc.
# All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import json
from oslo_config import cfg
from oslo_log import log as logging
from oslo_utils import excutils
from oslo_utils import units
from cinder import exception
from cinder.i18n import _, _LE
from cinder.volume import driver
from cinder.volume.drivers.nexenta.nexentaedge import jsonrpc
nexenta_edge_opts = [
cfg.StrOpt('nexenta_rest_address',
default='',
help='IP address of NexentaEdge management REST API endpoint'),
cfg.IntOpt('nexenta_rest_port',
default=8080,
help='HTTP port to connect to NexentaEdge REST API endpoint'),
cfg.StrOpt('nexenta_rest_protocol',
default='auto',
help='Use http or https for REST connection (default auto)'),
cfg.IntOpt('nexenta_iscsi_target_portal_port',
default=3260,
help='NexentaEdge target portal port'),
cfg.StrOpt('nexenta_rest_user',
default='admin',
help='User name to connect to NexentaEdge'),
cfg.StrOpt('nexenta_rest_password',
default='nexenta',
help='Password to connect to NexentaEdge',
secret=True),
cfg.StrOpt('nexenta_lun_container',
default='',
help='NexentaEdge logical path of bucket for LUNs'),
cfg.StrOpt('nexenta_iscsi_service',
default='',
help='NexentaEdge iSCSI service name'),
cfg.StrOpt('nexenta_client_address',
default='',
help='NexentaEdge iSCSI Gateway client '
'address for non-VIP service'),
cfg.StrOpt('nexenta_blocksize',
default=4096,
help='NexentaEdge iSCSI LUN block size'),
cfg.StrOpt('nexenta_chunksize',
default=16384,
help='NexentaEdge iSCSI LUN object chunk size')
]
CONF = cfg.CONF
CONF.register_opts(nexenta_edge_opts)
LOG = logging.getLogger(__name__)
class NexentaEdgeISCSIDriver(driver.ISCSIDriver):
"""Executes volume driver commands on NexentaEdge cluster.
Version history:
1.0.0 - Initial driver version.
"""
VERSION = '1.0.0'
def __init__(self, *args, **kwargs):
super(NexentaEdgeISCSIDriver, self).__init__(*args, **kwargs)
if self.configuration:
self.configuration.append_config_values(nexenta_edge_opts)
self.restapi_protocol = self.configuration.nexenta_rest_protocol
self.restapi_host = self.configuration.nexenta_rest_address
self.restapi_port = self.configuration.nexenta_rest_port
self.restapi_user = self.configuration.nexenta_rest_user
self.restapi_password = self.configuration.nexenta_rest_password
self.iscsi_service = self.configuration.nexenta_iscsi_service
self.bucket_path = self.configuration.nexenta_lun_container
self.blocksize = self.configuration.nexenta_blocksize
self.chunksize = self.configuration.nexenta_chunksize
self.cluster, self.tenant, self.bucket = self.bucket_path.split('/')
self.bucket_url = ('clusters/' + self.cluster + '/tenants/' +
self.tenant + '/buckets/' + self.bucket)
self.iscsi_target_port = (self.configuration.
nexenta_iscsi_target_portal_port)
self.target_vip = None
@property
def backend_name(self):
backend_name = None
if self.configuration:
backend_name = self.configuration.safe_get('volume_backend_name')
if not backend_name:
backend_name = self.__class__.__name__
return backend_name
def do_setup(self, context):
if self.restapi_protocol == 'auto':
protocol, auto = 'http', True
else:
protocol, auto = self.restapi_protocol, False
try:
self.restapi = jsonrpc.NexentaEdgeJSONProxy(
protocol, self.restapi_host, self.restapi_port, '/',
self.restapi_user, self.restapi_password, auto=auto)
rsp = self.restapi.get('service/'
+ self.iscsi_service + '/iscsi/status')
data_keys = rsp['data'][list(rsp['data'].keys())[0]]
self.target_name = data_keys.split('\n', 1)[0].split(' ')[2]
rsp = self.restapi.get('service/' + self.iscsi_service)
if 'X-VIPS' in rsp['data']:
vips = json.loads(rsp['data']['X-VIPS'])
if len(vips[0]) == 1:
self.target_vip = vips[0][0]['ip'].split('/', 1)[0]
else:
self.target_vip = vips[0][1]['ip'].split('/', 1)[0]
else:
self.target_vip = self.configuration.safe_get(
'nexenta_client_address')
if not self.target_vip:
LOG.error(_LE('No VIP configured for service %s'),
self.iscsi_service)
raise exception.VolumeBackendAPIException(
_('No service VIP configured and '
'no nexenta_client_address'))
except exception.VolumeBackendAPIException:
with excutils.save_and_reraise_exception():
LOG.exception(_LE('Error verifying iSCSI service %(serv)s on '
'host %(hst)s'), {'serv': self.iscsi_service,
'hst': self.restapi_host})
def check_for_setup_error(self):
try:
self.restapi.get(self.bucket_url + '/objects/')
except exception.VolumeBackendAPIException:
with excutils.save_and_reraise_exception():
LOG.exception(_LE('Error verifying LUN container %(bkt)s'),
{'bkt': self.bucket_path})
def _get_lun_number(self, volname):
try:
rsp = self.restapi.put(
'service/' + self.iscsi_service + '/iscsi/number',
{
'objectPath': self.bucket_path + '/' + volname
})
except exception.VolumeBackendAPIException:
with excutils.save_and_reraise_exception():
LOG.exception(_LE('Error retrieving LUN %(vol)s number'),
{'vol': volname})
return rsp['data']
def _get_target_address(self, volname):
return self.target_vip
def _get_provider_location(self, volume):
return '%(host)s:%(port)s,1 %(name)s %(number)s' % {
'host': self._get_target_address(volume['name']),
'port': self.iscsi_target_port,
'name': self.target_name,
'number': self._get_lun_number(volume['name'])
}
def create_volume(self, volume):
try:
self.restapi.post('service/' + self.iscsi_service + '/iscsi', {
'objectPath': self.bucket_path + '/' + volume['name'],
'volSizeMB': int(volume['size']) * units.Ki,
'blockSize': self.blocksize,
'chunkSize': self.chunksize
})
except exception.VolumeBackendAPIException:
with excutils.save_and_reraise_exception():
LOG.exception(_LE('Error creating volume'))
def delete_volume(self, volume):
try:
self.restapi.delete('service/' + self.iscsi_service +
'/iscsi', {'objectPath': self.bucket_path +
'/' + volume['name']})
except exception.VolumeBackendAPIException:
with excutils.save_and_reraise_exception():
LOG.exception(_LE('Error deleting volume'))
def extend_volume(self, volume, new_size):
try:
self.restapi.put('service/' + self.iscsi_service + '/iscsi/resize',
{'objectPath': self.bucket_path +
'/' + volume['name'],
'newSizeMB': new_size * units.Ki})
except exception.VolumeBackendAPIException:
with excutils.save_and_reraise_exception():
LOG.exception(_LE('Error extending volume'))
def create_volume_from_snapshot(self, volume, snapshot):
try:
self.restapi.put(
'service/' + self.iscsi_service + '/iscsi/snapshot/clone',
{
'objectPath': self.bucket_path + '/' +
snapshot['volume_name'],
'clonePath': self.bucket_path + '/' + volume['name'],
'snapName': snapshot['name']
})
except exception.VolumeBackendAPIException:
with excutils.save_and_reraise_exception():
LOG.exception(_LE('Error cloning volume'))
def create_snapshot(self, snapshot):
try:
self.restapi.post(
'service/' + self.iscsi_service + '/iscsi/snapshot',
{
'objectPath': self.bucket_path + '/' +
snapshot['volume_name'],
'snapName': snapshot['name']
})
except exception.VolumeBackendAPIException:
with excutils.save_and_reraise_exception():
LOG.exception(_LE('Error creating snapshot'))
def delete_snapshot(self, snapshot):
try:
self.restapi.delete(
'service/' + self.iscsi_service + '/iscsi/snapshot',
{
'objectPath': self.bucket_path + '/' +
snapshot['volume_name'],
'snapName': snapshot['name']
})
except exception.VolumeBackendAPIException:
with excutils.save_and_reraise_exception():
LOG.exception(_LE('Error deleting snapshot'))
def create_cloned_volume(self, volume, src_vref):
vol_url = (self.bucket_url + '/objects/' +
src_vref['name'] + '/clone')
clone_body = {
'tenant_name': self.tenant,
'bucket_name': self.bucket,
'object_name': volume['name']
}
try:
self.restapi.post(vol_url, clone_body)
self.restapi.post('service/' + self.iscsi_service + '/iscsi', {
'objectPath': self.bucket_path + '/' + volume['name'],
'volSizeMB': int(src_vref['size']) * units.Ki,
'blockSize': self.blocksize,
'chunkSize': self.chunksize
})
except exception.VolumeBackendAPIException:
with excutils.save_and_reraise_exception():
LOG.exception(_LE('Error creating cloned volume'))
def create_export(self, context, volume, connector=None):
return {'provider_location': self._get_provider_location(volume)}
def ensure_export(self, context, volume):
pass
def remove_export(self, context, volume):
pass
def local_path(self, volume):
raise NotImplementedError
def get_volume_stats(self, refresh=False):
location_info = '%(driver)s:%(host)s:%(bucket)s' % {
'driver': self.__class__.__name__,
'host': self._get_target_address(None),
'bucket': self.bucket_path
}
return {
'vendor_name': 'Nexenta',
'driver_version': self.VERSION,
'storage_protocol': 'iSCSI',
'reserved_percentage': 0,
'total_capacity_gb': 'unknown',
'free_capacity_gb': 'unknown',
'QoS_support': False,
'volume_backend_name': self.backend_name,
'location_info': location_info,
'iscsi_target_portal_port': self.iscsi_target_port,
'restapi_url': self.restapi.url
}

View File

@ -0,0 +1,100 @@
# Copyright 2015 Nexenta Systems, Inc.
# All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import json
import requests
import socket
from oslo_log import log as logging
from cinder import exception
from cinder.i18n import _
from cinder.utils import retry
LOG = logging.getLogger(__name__)
socket.setdefaulttimeout(100)
class NexentaEdgeJSONProxy(object):
retry_exc_tuple = (
requests.exceptions.ConnectionError,
requests.exceptions.ConnectTimeout
)
def __init__(self, protocol, host, port, path, user, password, auto=False,
method=None):
self.protocol = protocol.lower()
self.host = host
self.port = port
self.path = path
self.user = user
self.password = password
self.auto = auto
self.method = method
@property
def url(self):
return '%s://%s:%s%s' % (self.protocol,
self.host, self.port, self.path)
def __getattr__(self, name):
if not self.method:
method = name
else:
raise exception.VolumeDriverException(
_("Wrong resource call syntax"))
return NexentaEdgeJSONProxy(
self.protocol, self.host, self.port, self.path,
self.user, self.password, self.auto, method)
def __hash__(self):
return self.url.__hash___()
def __repr__(self):
return 'HTTP JSON proxy: %s' % self.url
@retry(retry_exc_tuple, interval=1, retries=6)
def __call__(self, *args):
self.path += args[0]
data = None
if len(args) > 1:
data = json.dumps(args[1])
auth = ('%s:%s' % (self.user, self.password)).encode('base64')[:-1]
headers = {
'Content-Type': 'application/json',
'Authorization': 'Basic %s' % auth
}
LOG.debug('Sending JSON data: %s', self.url)
if self.method == 'get':
req = requests.get(self.url, headers=headers)
if self.method == 'post':
req = requests.post(self.url, data=data, headers=headers)
if self.method == 'put':
req = requests.put(self.url, data=data, headers=headers)
if self.method == 'delete':
req = requests.delete(self.url, data=data, headers=headers)
rsp = req.json()
req.close()
LOG.debug('Got response: %s', rsp)
if rsp.get('response') is None:
raise exception.VolumeBackendAPIException(
_('Error response: %s') % rsp)
return rsp.get('response')

View File

@ -92,6 +92,7 @@ cinder.tests.unit.test_misc
cinder.tests.unit.test_netapp
cinder.tests.unit.test_netapp_nfs
cinder.tests.unit.test_netapp_ssc
cinder.tests.unit.test_nexenta_edge
cinder.tests.unit.test_nfs
cinder.tests.unit.test_nimble
cinder.tests.unit.test_prophetstor_dpl