From 78d124dee28e83a4718a455c456605b8127eab09 Mon Sep 17 00:00:00 2001 From: arsenc Date: Thu, 23 Jun 2016 16:06:01 +0800 Subject: [PATCH] Add Synology DSM storage driver This driver supports below features: - Volume Create/Delete - Volume Attach/Detach - Snapshot Create/Delete - Create Volume from Snapshot - Get Volume Stats - Copy Image to Volume - Copy Volume to Image - Clone Volume - Extend Volume DocImpact Implements: blueprint synology-cinder-driver Co-Authored-By: Taylor Huang Change-Id: I18d47f5d4cf10e4f481722406c6a5c071a521616 --- cinder/exception.py | 13 + cinder/opts.py | 3 + cinder/tests/unit/test_synology_common.py | 1611 +++++++++++++++++ cinder/tests/unit/test_synology_iscsi.py | 358 ++++ cinder/volume/drivers/synology/__init__.py | 0 .../drivers/synology/synology_common.py | 1236 +++++++++++++ .../volume/drivers/synology/synology_iscsi.py | 168 ++ ...nology-volume-driver-c5e0f655b04390ce.yaml | 3 + 8 files changed, 3392 insertions(+) create mode 100644 cinder/tests/unit/test_synology_common.py create mode 100644 cinder/tests/unit/test_synology_iscsi.py create mode 100644 cinder/volume/drivers/synology/__init__.py create mode 100644 cinder/volume/drivers/synology/synology_common.py create mode 100644 cinder/volume/drivers/synology/synology_iscsi.py create mode 100644 releasenotes/notes/synology-volume-driver-c5e0f655b04390ce.yaml diff --git a/cinder/exception.py b/cinder/exception.py index da5668b6d..bb07b0a2c 100644 --- a/cinder/exception.py +++ b/cinder/exception.py @@ -1181,3 +1181,16 @@ class GCSOAuth2Failure(BackupDriverException): # Kaminario K2 class KaminarioCinderDriverException(VolumeDriverException): message = _("KaminarioCinderDriver failure: %(reason)s") + + +# Synology driver +class SynoAPIHTTPError(CinderException): + message = _("HTTP exit code: [%(code)s]") + + +class SynoAuthError(CinderException): + pass + + +class SynoLUNNotExist(CinderException): + message = _("LUN not found by UUID: %(uuid)s.") diff --git a/cinder/opts.py b/cinder/opts.py index 5808d6b29..f2eeb704e 100644 --- a/cinder/opts.py +++ b/cinder/opts.py @@ -152,6 +152,8 @@ from cinder.volume.drivers import scality as cinder_volume_drivers_scality from cinder.volume.drivers import sheepdog as cinder_volume_drivers_sheepdog from cinder.volume.drivers import smbfs as cinder_volume_drivers_smbfs from cinder.volume.drivers import solidfire as cinder_volume_drivers_solidfire +from cinder.volume.drivers.synology import synology_common as \ + cinder_volume_drivers_synology_synologycommon from cinder.volume.drivers import tegile as cinder_volume_drivers_tegile from cinder.volume.drivers import tintri as cinder_volume_drivers_tintri from cinder.volume.drivers.violin import v7000_common as \ @@ -306,6 +308,7 @@ def list_opts(): cinder_volume_drivers_hitachi_hbsdfc.volume_opts, cinder_quota.quota_opts, cinder_volume_drivers_huawei_huaweidriver.huawei_opts, + cinder_volume_drivers_synology_synologycommon.cinder_opts, cinder_volume_drivers_dell_dellstoragecentercommon. common_opts, cinder_scheduler_hostmanager.host_manager_opts, diff --git a/cinder/tests/unit/test_synology_common.py b/cinder/tests/unit/test_synology_common.py new file mode 100644 index 000000000..4bbb6e728 --- /dev/null +++ b/cinder/tests/unit/test_synology_common.py @@ -0,0 +1,1611 @@ +# Copyright (c) 2016 Synology Co., Ltd. +# 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. + +"""Tests for the Synology iSCSI volume driver.""" + +import copy + +import mock +from oslo_utils import units +import requests +from six import string_types + +from cinder import context +from cinder import exception +from cinder import test +from cinder.tests.unit import fake_constants as fake +from cinder.tests.unit import fake_snapshot +from cinder.tests.unit import fake_volume +from cinder.volume import configuration as conf +from cinder.volume.drivers.synology import synology_common as common + +VOLUME_ID = fake.VOLUME_ID +TARGET_NAME_PREFIX = 'Cinder-Target-' +IP = '10.0.0.1' +IQN = 'iqn.2000-01.com.synology:' + TARGET_NAME_PREFIX + VOLUME_ID +TRG_ID = 1 +CHAP_AUTH_USERNAME = 'username' +CHAP_AUTH_PASSWORD = 'password' +VOLUME = { + '_name_id': '', + 'name': fake.VOLUME_NAME, + 'id': VOLUME_ID, + 'display_name': 'fake_volume', + 'size': 10, + 'provider_location': '%s:3260,%d %s 1' % (IP, TRG_ID, IQN), + 'provider_auth': 'CHAP %(user)s %(pass)s' % { + 'user': CHAP_AUTH_USERNAME, + 'pass': CHAP_AUTH_PASSWORD}, +} +NEW_VOLUME_ID = fake.VOLUME2_ID +IQN2 = 'iqn.2000-01.com.synology:' + TARGET_NAME_PREFIX + NEW_VOLUME_ID +NEW_TRG_ID = 2 +NEW_VOLUME = { + 'name': fake.VOLUME2_NAME, + 'id': NEW_VOLUME_ID, + 'display_name': 'new_fake_volume', + 'size': 10, + 'provider_location': '%s:3260,%d %s 1' % (IP, NEW_TRG_ID, IQN2), +} +SNAPSHOT_ID = fake.SNAPSHOT_ID +DS_SNAPSHOT_UUID = 'ca86a56a-40d8-4210-974c-ef15dbf01cba' +SNAPSHOT_METADATA = { + 'metadata': { + 'ds_snapshot_UUID': DS_SNAPSHOT_UUID + } +} +SNAPSHOT = { + 'name': fake.SNAPSHOT_NAME, + 'id': SNAPSHOT_ID, + 'volume_id': VOLUME_ID, + 'volume_name': VOLUME['name'], + 'volume_size': 10, + 'display_name': 'fake_snapshot', + 'volume': VOLUME, + 'metadata': SNAPSHOT_METADATA, +} +SNAPSHOT_INFO = { + 'is_action_locked': False, + 'snapshot_id': 1, + 'status': 'Healthy', + 'uuid': DS_SNAPSHOT_UUID, +} +INITIATOR_IQN = 'iqn.1993-08.org.debian:01:604af6a341' +CONNECTOR = { + 'initiator': INITIATOR_IQN, +} +CONTEXT = { +} +LOCAL_PATH = '/dev/isda' +IMAGE_SERVICE = 'image_service' +IMAGE_ID = 1 +IMAGE_META = { + 'id': IMAGE_ID +} +POOL_NAME = 'volume1' +NODE_UUID = '72003c93-2db2-4f00-a169-67c5eae86bb1' +NODE_UUID2 = '8e1e8b82-1ef9-4157-a4bf-e069355386c2' +HOST = { + 'capabilities': { + 'pool_name': 'volume2', + 'backend_info': 'Synology:iscsi:' + NODE_UUID, + }, +} +POOL_INFO = { + 'display_name': 'Volume 1', + 'raid_type': 'raid_1', + 'readonly': False, + 'fs_type': 'ext4', + 'location': 'internal', + 'size_total_byte': '487262806016', + 'volume_id': 1, + 'size_free_byte': '486521139200', + 'container': 'internal', + 'volume_path': '/volume1', + 'single_volume': True +} +LUN_UUID = 'e1315f33-ba35-42c3-a3e7-5a06958eca30' +LUN_INFO = { + 'status': '', + 'is_action_locked': False, + 'name': VOLUME['name'], + 'extent_size': 0, + 'allocated_size': 0, + 'uuid': LUN_UUID, + 'is_mapped': True, + 'lun_id': 3, + 'location': '/volume2', + 'restored_time': 0, + 'type': 143, + 'size': 1073741824 +} +FAKE_API = 'SYNO.Fake.API' +FAKE_METHOD = 'fake' +FAKE_PATH = 'fake.cgi' + + +class MockResponse(object): + def __init__(self, json_data, status_code): + self.json_data = json_data + self.status_code = status_code + + def json(self): + return self.json_data + + +class SynoSessionTestCase(test.TestCase): + @mock.patch('requests.post', return_value= + MockResponse({'data': {'sid': 'sid'}, 'success': True}, 200)) + def setUp(self, _mock_post): + super(SynoSessionTestCase, self).setUp() + + self.host = '127.0.0.1' + self.port = 5001 + self.username = 'admin' + self.password = 'admin' + self.https = True + self.ssl_verify = False + self.one_time_pass = None + self.device_id = None + self.session = common.Session(self.host, + self.port, + self.username, + self.password, + self.https, + self.ssl_verify, + self.one_time_pass, + self.device_id) + self.session.__class__.__del__ = lambda x: x + + def tearDown(self): + super(SynoSessionTestCase, self).tearDown() + + def test_query(self): + out = { + 'maxVersion': 3, + 'minVersion': 1, + 'path': FAKE_PATH, + 'requestFormat': 'JSON' + } + data = { + 'api': 'SYNO.API.Info', + 'version': 1, + 'method': 'query', + 'query': FAKE_API + } + requests.post = mock.Mock(side_effect=[ + MockResponse({ + 'data': { + FAKE_API: out + }, + 'success': True + }, 200), + MockResponse({ + 'data': { + FAKE_API: out + } + }, 200), + ]) + + result = self.session.query(FAKE_API) + requests.post.assert_called_once_with( + 'https://127.0.0.1:5001/webapi/query.cgi', + data=data, + verify=self.ssl_verify) + self.assertDictMatch(out, result) + + result = self.session.query(FAKE_API) + self.assertIsNone(result) + + +class SynoAPIRequestTestCase(test.TestCase): + @mock.patch('requests.post') + def setUp(self, _mock_post): + super(SynoAPIRequestTestCase, self).setUp() + + self.host = '127.0.0.1' + self.port = 5001 + self.username = 'admin' + self.password = 'admin' + self.https = True + self.ssl_verify = False + self.one_time_pass = None + self.device_id = None + self.request = common.APIRequest(self.host, + self.port, + self.username, + self.password, + self.https, + self.ssl_verify, + self.one_time_pass, + self.device_id) + self.request._APIRequest__session._sid = 'sid' + self.request._APIRequest__session.__class__.__del__ = lambda x: x + + def tearDown(self): + super(SynoAPIRequestTestCase, self).tearDown() + + @mock.patch.object(common, 'Session') + def test_new_session(self, _mock_session): + self.device_id = 'did' + self.request = common.APIRequest(self.host, + self.port, + self.username, + self.password, + self.https, + self.ssl_verify, + self.one_time_pass, + self.device_id) + + result = self.request.new_session() + self.assertIsNone(result) + + def test__start(self): + out = { + 'maxVersion': 3, + 'minVersion': 1, + 'path': FAKE_PATH, + 'requestFormat': 'JSON' + } + self.request._APIRequest__session.query = mock.Mock(return_value=out) + + result = self.request._start(FAKE_API, 3) + (self.request._APIRequest__session.query. + assert_called_once_with(FAKE_API)) + self.assertEqual(FAKE_PATH, result) + + out.update(maxVersion=2) + self.assertRaises(exception.APIException, + self.request._start, + FAKE_API, + 3) + + def test__encode_param(self): + param = { + 'api': FAKE_API, + 'method': FAKE_METHOD, + 'version': 1, + '_sid': 'sid' + } + self.request._jsonFormat = True + result = self.request._encode_param(param) + self.assertIsInstance(result, string_types) + + def test_request(self): + version = 1 + + self.request._start = mock.Mock(return_value='fake.cgi') + self.request._encode_param = mock.Mock(side_effect=lambda x: x) + self.request.new_session = mock.Mock() + requests.post = mock.Mock(side_effect=[ + MockResponse({'success': True}, 200), + MockResponse({'error': {'code': 101}, 'success': False}, 200), + MockResponse({'error': {'code': 101}}, 200), + MockResponse({}, 500) + ]) + + result = self.request.request(FAKE_API, FAKE_METHOD, version) + self.assertDictMatch({'success': True}, result) + + result = self.request.request(FAKE_API, FAKE_METHOD, version) + self.assertDictMatch({'error': {'code': 101}, 'success': False}, + result) + + self.assertRaises(exception.MalformedResponse, + self.request.request, + FAKE_API, + FAKE_METHOD, + version) + + result = self.request.request(FAKE_API, FAKE_METHOD, version) + self.assertDictMatch({'http_status': 500}, result) + + @mock.patch.object(common.LOG, 'debug') + def test_request_auth_error(self, _log): + version = 1 + + self.request._start = mock.Mock(return_value='fake.cgi') + self.request._encode_param = mock.Mock(side_effect=lambda x: x) + self.request.new_session = mock.Mock() + requests.post = mock.Mock(return_value= + MockResponse({ + 'error': {'code': 105}, + 'success': False + }, 200)) + + self.assertRaises(exception.SynoAuthError, + self.request.request, + FAKE_API, + FAKE_METHOD, + version) + + +class SynoCommonTestCase(test.TestCase): + + @mock.patch.object(common.SynoCommon, + '_get_node_uuid', + return_value=NODE_UUID) + @mock.patch.object(common, 'APIRequest') + def setUp(self, _request, _get_node_uuid): + super(SynoCommonTestCase, self).setUp() + + self.conf = self.setup_configuration() + self.common = common.SynoCommon(self.conf, 'iscsi') + self.common.vendor_name = 'Synology' + self.common.driver_type = 'iscsi' + self.common.volume_backend_name = 'DiskStation' + self.common.iscsi_port = 3260 + + def tearDown(self): + super(SynoCommonTestCase, self).tearDown() + + def setup_configuration(self): + config = mock.Mock(spec=conf.Configuration) + config.use_chap_auth = False + config.iscsi_protocol = 'iscsi' + config.iscsi_ip_address = IP + config.iscsi_port = 3260 + config.admin_port = 5000 + config.username = 'admin' + config.password = 'admin' + config.ssl_verify = True + config.one_time_pass = '123456' + config.volume_dd_blocksize = 1 + config.iscsi_target_prefix = 'iqn.2000-01.com.synology:' + config.pool_name = POOL_NAME + config.chap_username = 'abcd' + config.chap_password = 'qwerty' + + return config + + @mock.patch.object(common.SynoCommon, + '_get_node_uuid', + return_value=NODE_UUID) + @mock.patch.object(common, 'APIRequest') + def test___init__(self, _request, _get_node_uuid): + self.conf.safe_get = (mock.Mock(side_effect=[ + self.conf.iscsi_ip_address, + '', + ''])) + + self.assertRaises(exception.InvalidConfigurationValue, + self.common.__init__, + self.conf, + 'iscsi') + + self.assertRaises(exception.InvalidConfigurationValue, + self.common.__init__, + self.conf, + 'iscsi') + + def test__get_node_uuid(self): + out = { + 'data': { + 'nodes': [{ + 'uuid': NODE_UUID + }] + }, + 'success': True + } + self.common.exec_webapi = ( + mock.Mock(side_effect=[ + out, + out, + exception.SynoAuthError(message='dont care')])) + + result = self.common._get_node_uuid() + (self.common.exec_webapi. + assert_called_with('SYNO.Core.ISCSI.Node', + 'list', + mock.ANY)) + self.assertEqual(NODE_UUID, result) + + del out['data']['nodes'] + self.assertRaises(exception.VolumeDriverException, + self.common._get_node_uuid) + + self.assertRaises(exception.SynoAuthError, + self.common._get_node_uuid) + + def test__get_pool_info(self): + out = { + 'data': { + 'volume': POOL_INFO + }, + 'success': True + } + self.common.exec_webapi = ( + mock.Mock(side_effect=[ + out, + out, + exception.SynoAuthError(message='dont care')])) + result = self.common._get_pool_info() + (self.common.exec_webapi. + assert_called_with('SYNO.Core.Storage.Volume', + 'get', + mock.ANY, + volume_path='/' + POOL_NAME)) + self.assertDictMatch(POOL_INFO, result) + + del out['data']['volume'] + self.assertRaises(exception.MalformedResponse, + self.common._get_pool_info) + + self.assertRaises(exception.SynoAuthError, + self.common._get_pool_info) + + self.conf.pool_name = '' + self.assertRaises(exception.InvalidConfigurationValue, + self.common._get_pool_info) + + def test__get_pool_size(self): + pool_info = copy.deepcopy(POOL_INFO) + self.common._get_pool_info = mock.Mock(return_value=pool_info) + + result = self.common._get_pool_size() + + self.assertEqual((int(int(POOL_INFO['size_free_byte']) / units.Gi), + int(int(POOL_INFO['size_total_byte']) / units.Gi)), + result) + + del pool_info['size_free_byte'] + self.assertRaises(exception.MalformedResponse, + self.common._get_pool_size) + + def test__get_lun_info(self): + out = { + 'data': { + 'lun': LUN_INFO + }, + 'success': True + } + self.common.exec_webapi = ( + mock.Mock(side_effect=[ + out, + out, + exception.SynoAuthError(message='dont care')])) + result = self.common._get_lun_info(VOLUME['name'], + ['is_mapped']) + (self.common.exec_webapi. + assert_called_with('SYNO.Core.ISCSI.LUN', + 'get', + mock.ANY, + uuid=VOLUME['name'], + additional=['is_mapped'])) + self.assertDictMatch(LUN_INFO, result) + + del out['data']['lun'] + self.assertRaises(exception.MalformedResponse, + self.common._get_lun_info, + VOLUME['name']) + + self.assertRaises(exception.SynoAuthError, + self.common._get_lun_info, + VOLUME['name']) + + self.assertRaises(exception.InvalidParameterValue, + self.common._get_lun_info, + '') + + def test__get_lun_uuid(self): + lun_info = copy.deepcopy(LUN_INFO) + self.common._get_lun_info = ( + mock.Mock(side_effect=[ + lun_info, + lun_info, + exception.SynoAuthError(message='dont care')])) + + result = self.common._get_lun_uuid(VOLUME['name']) + self.assertEqual(LUN_UUID, result) + + del lun_info['uuid'] + self.assertRaises(exception.MalformedResponse, + self.common._get_lun_uuid, + VOLUME['name']) + + self.assertRaises(exception.SynoAuthError, + self.common._get_lun_uuid, + VOLUME['name']) + + self.assertRaises(exception.InvalidParameterValue, + self.common._get_lun_uuid, + '') + + def test__get_lun_status(self): + lun_info = copy.deepcopy(LUN_INFO) + self.common._get_lun_info = ( + mock.Mock(side_effect=[ + lun_info, + lun_info, + lun_info, + exception.SynoAuthError(message='dont care')])) + + result = self.common._get_lun_status(VOLUME['name']) + self.assertEqual((lun_info['status'], lun_info['is_action_locked']), + result) + + del lun_info['is_action_locked'] + self.assertRaises(exception.MalformedResponse, + self.common._get_lun_status, + VOLUME['name']) + + del lun_info['status'] + self.assertRaises(exception.MalformedResponse, + self.common._get_lun_status, + VOLUME['name']) + + self.assertRaises(exception.SynoAuthError, + self.common._get_lun_status, + VOLUME['name']) + + self.assertRaises(exception.InvalidParameterValue, + self.common._get_lun_status, + '') + + def test__get_snapshot_info(self): + out = { + 'data': { + 'snapshot': SNAPSHOT_INFO + }, + 'success': True + } + self.common.exec_webapi = ( + mock.Mock(side_effect=[ + out, + out, + exception.SynoAuthError(message='dont care')])) + result = self.common._get_snapshot_info(DS_SNAPSHOT_UUID, + additional=['status']) + (self.common.exec_webapi. + assert_called_with('SYNO.Core.ISCSI.LUN', + 'get_snapshot', + mock.ANY, + snapshot_uuid=DS_SNAPSHOT_UUID, + additional=['status'])) + self.assertDictMatch(SNAPSHOT_INFO, result) + + del out['data']['snapshot'] + self.assertRaises(exception.MalformedResponse, + self.common._get_snapshot_info, + DS_SNAPSHOT_UUID) + + self.assertRaises(exception.SynoAuthError, + self.common._get_snapshot_info, + DS_SNAPSHOT_UUID) + + self.assertRaises(exception.InvalidParameterValue, + self.common._get_snapshot_info, + '') + + def test__get_snapshot_status(self): + snapshot_info = copy.deepcopy(SNAPSHOT_INFO) + self.common._get_snapshot_info = ( + mock.Mock(side_effect=[ + snapshot_info, + snapshot_info, + snapshot_info, + exception.SynoAuthError(message='dont care')])) + + result = self.common._get_snapshot_status(DS_SNAPSHOT_UUID) + self.assertEqual((snapshot_info['status'], + snapshot_info['is_action_locked']), + result) + + del snapshot_info['is_action_locked'] + self.assertRaises(exception.MalformedResponse, + self.common._get_snapshot_status, + DS_SNAPSHOT_UUID) + + del snapshot_info['status'] + self.assertRaises(exception.MalformedResponse, + self.common._get_snapshot_status, + DS_SNAPSHOT_UUID) + + self.assertRaises(exception.SynoAuthError, + self.common._get_snapshot_status, + DS_SNAPSHOT_UUID) + + self.assertRaises(exception.InvalidParameterValue, + self.common._get_snapshot_status, + '') + + def test__get_metadata_value(self): + ctxt = context.get_admin_context() + fake_vol_obj = fake_volume.fake_volume_obj(ctxt) + self.assertRaises(exception.VolumeMetadataNotFound, + self.common._get_metadata_value, + fake_vol_obj, + 'no_such_key') + + fake_snap_obj = (fake_snapshot. + fake_snapshot_obj(ctxt, + expected_attrs=['metadata'])) + self.assertRaises(exception.SnapshotMetadataNotFound, + self.common._get_metadata_value, + fake_snap_obj, + 'no_such_key') + + meta = {'snapshot_metadata': [{'key': 'ds_snapshot_UUID', + 'value': DS_SNAPSHOT_UUID}], + 'expected_attrs': ['metadata']} + + fake_snap_obj = fake_snapshot.fake_snapshot_obj(ctxt, + **meta) + result = self.common._get_metadata_value(fake_snap_obj, + 'ds_snapshot_UUID') + self.assertEqual(DS_SNAPSHOT_UUID, result) + + self.assertRaises(exception.MetadataAbsent, + self.common._get_metadata_value, + SNAPSHOT, + 'no_such_key') + + def test__target_create_with_chap_auth(self): + out = { + 'data': { + 'target_id': TRG_ID + }, + 'success': True + } + trg_name = self.common.TARGET_NAME_PREFIX + VOLUME['id'] + iqn = self.conf.iscsi_target_prefix + trg_name + self.conf.use_chap_auth = True + self.common.exec_webapi = mock.Mock(return_value=out) + self.conf.safe_get = ( + mock.Mock(side_effect=[ + self.conf.use_chap_auth, + 'abcd', + 'qwerty', + self.conf.iscsi_target_prefix])) + result = self.common._target_create(VOLUME['id']) + (self.common.exec_webapi. + assert_called_with('SYNO.Core.ISCSI.Target', + 'create', + mock.ANY, + name=trg_name, + iqn=iqn, + auth_type=1, + user='abcd', + password='qwerty', + max_sessions=0)) + self.assertEqual((IQN, TRG_ID, 'CHAP abcd qwerty'), result) + + def test__target_create_without_chap_auth(self): + out = { + 'data': { + 'target_id': TRG_ID + }, + 'success': True + } + trg_name = self.common.TARGET_NAME_PREFIX + VOLUME['id'] + iqn = self.conf.iscsi_target_prefix + trg_name + self.common.exec_webapi = mock.Mock(return_value=out) + self.conf.safe_get = ( + mock.Mock(side_effect=[ + self.conf.use_chap_auth, + self.conf.iscsi_target_prefix])) + result = self.common._target_create(VOLUME['id']) + (self.common.exec_webapi. + assert_called_with('SYNO.Core.ISCSI.Target', + 'create', + mock.ANY, + name=trg_name, + iqn=iqn, + auth_type=0, + user='', + password='', + max_sessions=0)) + self.assertEqual((IQN, TRG_ID, ''), result) + + def test__target_create_error(self): + out = { + 'data': { + }, + 'success': True + } + self.common.exec_webapi = ( + mock.Mock(side_effect=[ + out, + exception.SynoAuthError(message='dont care')])) + self.conf.safe_get = ( + mock.Mock(side_effect=[ + self.conf.use_chap_auth, + self.conf.iscsi_target_prefix, + self.conf.use_chap_auth, + self.conf.iscsi_target_prefix])) + + self.assertRaises(exception.VolumeDriverException, + self.common._target_create, + VOLUME['id']) + + self.assertRaises(exception.SynoAuthError, + self.common._target_create, + VOLUME['id']) + + self.assertRaises(exception.InvalidParameterValue, + self.common._target_create, + '') + + def test__target_delete(self): + out = { + 'success': True + } + self.common.exec_webapi = ( + mock.Mock(side_effect=[ + out, + exception.SynoAuthError(message='dont care')])) + + result = self.common._target_delete(TRG_ID) + (self.common.exec_webapi. + assert_called_with('SYNO.Core.ISCSI.Target', + 'delete', + mock.ANY, + target_id=str(TRG_ID))) + self.assertIsNone(result) + + self.assertRaises(exception.SynoAuthError, + self.common._target_delete, + TRG_ID) + + self.assertRaises(exception.InvalidParameterValue, + self.common._target_delete, + -1) + + def test__lun_map_unmap_target(self): + out = { + 'success': True + } + self.common.exec_webapi = ( + mock.Mock(side_effect=[ + out, + out, + exception.SynoAuthError(message='dont care')])) + self.common._get_lun_uuid = mock.Mock(return_value=LUN_UUID) + + result = self.common._lun_map_unmap_target(VOLUME['name'], + True, + TRG_ID) + self.common._get_lun_uuid.assert_called_with(VOLUME['name']) + (self.common.exec_webapi. + assert_called_with('SYNO.Core.ISCSI.LUN', + 'map_target', + mock.ANY, + uuid=LUN_UUID, + target_ids=[str(TRG_ID)])) + self.assertIsNone(result) + + result = self.common._lun_map_unmap_target(VOLUME['name'], + False, + TRG_ID) + (self.common.exec_webapi. + assert_called_with('SYNO.Core.ISCSI.LUN', + 'unmap_target', + mock.ANY, + uuid=LUN_UUID, + target_ids=[str(TRG_ID)])) + self.assertIsNone(result) + + self.assertRaises(exception.SynoAuthError, + self.common._lun_map_unmap_target, + VOLUME['name'], + True, + TRG_ID) + + self.assertRaises(exception.InvalidParameterValue, + self.common._lun_map_unmap_target, + mock.ANY, + mock.ANY, + -1) + + def test__lun_map_target(self): + self.common._lun_map_unmap_target = mock.Mock() + + result = self.common._lun_map_target(VOLUME, TRG_ID) + + self.common._lun_map_unmap_target.assert_called_with(VOLUME, + True, + TRG_ID) + self.assertIsNone(result) + + def test__lun_ummap_target(self): + self.common._lun_map_unmap_target = mock.Mock() + + result = self.common._lun_unmap_target(VOLUME, TRG_ID) + + self.common._lun_map_unmap_target.assert_called_with(VOLUME, + False, + TRG_ID) + self.assertIsNone(result) + + def test__modify_lun_name(self): + out = { + 'success': True + } + self.common.exec_webapi = ( + mock.Mock(side_effect=[ + out, + exception.SynoAuthError(message='dont care')])) + + result = self.common._modify_lun_name(VOLUME['name'], + NEW_VOLUME['name']) + self.assertIsNone(result) + + self.assertRaises(exception.SynoAuthError, + self.common._modify_lun_name, + VOLUME['name'], + NEW_VOLUME['name']) + + @mock.patch('time.sleep') + def test__check_lun_status_normal(self, _patched_sleep): + self.common._get_lun_status = ( + mock.Mock(side_effect=[ + ('normal', True), + ('normal', False), + ('cloning', False), + exception.SynoLUNNotExist(message='dont care')])) + + result = self.common._check_lun_status_normal(VOLUME['name']) + self.assertEqual(1, _patched_sleep.call_count) + self.assertEqual([mock.call(2)], _patched_sleep.call_args_list) + self.common._get_lun_status.assert_called_with(VOLUME['name']) + self.assertTrue(result) + + result = self.common._check_lun_status_normal(VOLUME['name']) + self.assertFalse(result) + + self.assertRaises(exception.SynoLUNNotExist, + self.common._check_lun_status_normal, + VOLUME['name']) + + @mock.patch('time.sleep') + def test__check_snapshot_status_healthy(self, _patched_sleep): + self.common._get_snapshot_status = ( + mock.Mock(side_effect=[ + ('Healthy', True), + ('Healthy', False), + ('Unhealthy', False), + exception.SynoLUNNotExist(message='dont care')])) + + result = self.common._check_snapshot_status_healthy(DS_SNAPSHOT_UUID) + self.assertEqual(1, _patched_sleep.call_count) + self.assertEqual([mock.call(2)], _patched_sleep.call_args_list) + self.common._get_snapshot_status.assert_called_with(DS_SNAPSHOT_UUID) + self.assertTrue(result) + + result = self.common._check_snapshot_status_healthy(DS_SNAPSHOT_UUID) + self.assertFalse(result) + + self.assertRaises(exception.SynoLUNNotExist, + self.common._check_snapshot_status_healthy, + DS_SNAPSHOT_UUID) + + def test__check_storage_response(self): + out = { + 'success': False + } + result = self.common._check_storage_response(out) + self.assertEqual('Internal error', result[0]) + self.assertIsInstance(result[1], + (exception.VolumeBackendAPIException)) + + def test__check_iscsi_response(self): + out = { + 'success': False, + 'error': { + } + } + self.assertRaises(exception.MalformedResponse, + self.common._check_iscsi_response, + out) + + out['error'].update(code=18990505) + result = self.common._check_iscsi_response(out, uuid=LUN_UUID) + self.assertEqual('Bad LUN UUID [18990505]', result[0]) + self.assertIsInstance(result[1], + (exception.SynoLUNNotExist)) + + out['error'].update(code=18990532) + result = self.common._check_iscsi_response(out, + snapshot_id=SNAPSHOT_ID) + self.assertEqual('No such snapshot [18990532]', result[0]) + self.assertIsInstance(result[1], + (exception.SnapshotNotFound)) + + out['error'].update(code=12345678) + result = self.common._check_iscsi_response(out, uuid=LUN_UUID) + self.assertEqual('Internal error [12345678]', result[0]) + self.assertIsInstance(result[1], + (exception.VolumeBackendAPIException)) + + def test__check_ds_pool_status(self): + info = copy.deepcopy(POOL_INFO) + self.common._get_pool_info = mock.Mock(return_value=info) + + result = self.common._check_ds_pool_status() + self.assertIsNone(result) + + info['readonly'] = True + self.assertRaises(exception.VolumeDriverException, + self.common._check_ds_pool_status) + + del info['readonly'] + self.assertRaises(exception.MalformedResponse, + self.common._check_ds_pool_status) + + def test__check_ds_version(self): + ver1 = 'DSM 6.1-9999' + ver2 = 'DSM 6.0.2-9999' + ver3 = 'DSM 6.0.1-9999 Update 2' + ver4 = 'DSM 6.0-9999 Update 2' + ver5 = 'DSM 5.2-9999 ' + out = { + 'data': { + }, + 'success': True + } + self.common.exec_webapi = mock.Mock(return_value=out) + self.assertRaises(exception.MalformedResponse, + self.common._check_ds_version) + (self.common.exec_webapi. + assert_called_with('SYNO.Core.System', + 'info', + mock.ANY, + type='firmware')) + + out['data'].update(firmware_ver=ver1) + result = self.common._check_ds_version() + self.assertIsNone(result) + + out['data'].update(firmware_ver=ver2) + result = self.common._check_ds_version() + self.assertIsNone(result) + + out['data'].update(firmware_ver=ver3) + self.assertRaises(exception.VolumeDriverException, + self.common._check_ds_version) + + out['data'].update(firmware_ver=ver4) + self.assertRaises(exception.VolumeDriverException, + self.common._check_ds_version) + + out['data'].update(firmware_ver=ver5) + self.assertRaises(exception.VolumeDriverException, + self.common._check_ds_version) + + self.common.exec_webapi = ( + mock.Mock(side_effect= + exception.SynoAuthError(message='dont care'))) + self.assertRaises(exception.SynoAuthError, + self.common._check_ds_version) + + def test__check_ds_ability(self): + out = { + 'data': { + 'support_storage_mgr': 'yes', + 'support_iscsi_target': 'yes', + 'support_vaai': 'yes', + 'supportsnapshot': 'yes', + }, + 'success': True + } + self.common.exec_webapi = mock.Mock(return_value=out) + result = self.common._check_ds_ability() + self.assertIsNone(result) + (self.common.exec_webapi. + assert_called_with('SYNO.Core.System', + 'info', + mock.ANY, + type='define')) + + out['data'].update(supportsnapshot='no') + self.assertRaises(exception.VolumeDriverException, + self.common._check_ds_ability) + + out['data'].update(support_vaai='no') + self.assertRaises(exception.VolumeDriverException, + self.common._check_ds_ability) + + out['data'].update(support_iscsi_target='no') + self.assertRaises(exception.VolumeDriverException, + self.common._check_ds_ability) + + out['data'].update(support_storage_mgr='no') + self.assertRaises(exception.VolumeDriverException, + self.common._check_ds_ability) + + out['data'].update(usbstation='yes') + self.assertRaises(exception.VolumeDriverException, + self.common._check_ds_ability) + + del out['data'] + self.assertRaises(exception.MalformedResponse, + self.common._check_ds_ability) + + self.common.exec_webapi = ( + mock.Mock(side_effect= + exception.SynoAuthError(message='dont care'))) + self.assertRaises(exception.SynoAuthError, + self.common._check_ds_ability) + + @mock.patch.object(common.LOG, 'exception') + def test_check_response(self, _logexc): + out = { + 'success': True + } + bad_out1 = { + 'api_info': { + 'api': 'SYNO.Core.ISCSI.LUN', + 'method': 'create', + 'version': 1 + }, + 'success': False + } + bad_out2 = { + 'api_info': { + 'api': 'SYNO.Core.Storage.Volume', + 'method': 'get', + 'version': 1 + }, + 'success': False + } + bad_out3 = { + 'api_info': { + 'api': 'SYNO.Core.System', + 'method': 'info', + 'version': 1 + }, + 'success': False + } + self.common._check_iscsi_response = ( + mock.Mock(return_value= + ('Bad LUN UUID', + exception.SynoLUNNotExist(message='dont care')))) + self.common._check_storage_response = ( + mock.Mock(return_value= + ('Internal error', + exception. + VolumeBackendAPIException(message='dont care')))) + + result = self.common.check_response(out) + self.assertEqual(0, _logexc.call_count) + self.assertIsNone(result) + + self.assertRaises(exception.SynoLUNNotExist, + self.common.check_response, + bad_out1) + self.assertRaises(exception.VolumeBackendAPIException, + self.common.check_response, + bad_out2) + self.assertRaises(exception.VolumeBackendAPIException, + self.common.check_response, + bad_out3) + + def test_exec_webapi(self): + api = 'SYNO.Fake.WebAPI' + method = 'fake' + version = 1 + resp = {} + bad_resp = { + 'http_status': 500 + } + expected = copy.deepcopy(resp) + expected.update(api_info={'api': api, + 'method': method, + 'version': version}) + self.common.synoexec = mock.Mock(side_effect=[resp, bad_resp]) + + result = self.common.exec_webapi(api, + method, + version, + param1='value1', + param2='value2') + + self.common.synoexec.assert_called_once_with(api, + method, + version, + param1='value1', + param2='value2') + self.assertDictMatch(expected, result) + + self.assertRaises(exception.SynoAPIHTTPError, + self.common.exec_webapi, + api, + method, + version, + param1='value1', + param2='value2') + + def test_get_ip(self): + result = self.common.get_ip() + self.assertEqual(self.conf.iscsi_ip_address, result) + + def test_get_provider_location(self): + self.common.get_ip = ( + mock.Mock(return_value=self.conf.iscsi_ip_address)) + self.conf.safe_get = ( + mock.Mock(return_value=['10.0.0.2', '10.0.0.3'])) + expected = ('10.0.0.1:3260;10.0.0.2:3260;10.0.0.3:3260' + + ',%(tid)d %(iqn)s 0') % {'tid': TRG_ID, 'iqn': IQN} + + result = self.common.get_provider_location(IQN, TRG_ID) + + self.assertEqual(expected, result) + + def test_is_lun_mapped(self): + bad_lun_info = copy.deepcopy(LUN_INFO) + del bad_lun_info['is_mapped'] + self.common._get_lun_info = ( + mock.Mock(side_effect=[ + LUN_INFO, + exception.SynoAuthError(message='dont care'), + bad_lun_info])) + + result = self.common.is_lun_mapped(VOLUME['name']) + self.assertEqual(LUN_INFO['is_mapped'], result) + + self.assertRaises(exception.SynoAuthError, + self.common.is_lun_mapped, + VOLUME['name']) + + self.assertRaises(exception.MalformedResponse, + self.common.is_lun_mapped, + VOLUME['name']) + + self.assertRaises(exception.InvalidParameterValue, + self.common.is_lun_mapped, + '') + + def test_check_for_setup_error(self): + self.common._check_ds_pool_status = mock.Mock() + self.common._check_ds_version = mock.Mock() + self.common._check_ds_ability = mock.Mock() + + result = self.common.check_for_setup_error() + + self.common._check_ds_pool_status.assert_called_once_with() + self.common._check_ds_version.assert_called_once_with() + self.common._check_ds_ability.assert_called_once_with() + self.assertIsNone(result) + + def test_update_volume_stats(self): + self.common._get_pool_size = mock.Mock(return_value=(10, 100)) + + data = { + 'volume_backend_name': 'DiskStation', + 'vendor_name': 'Synology', + 'storage_protocol': 'iscsi', + 'consistencygroup_support': False, + 'QoS_support': False, + 'thin_provisioning_support': True, + 'thick_provisioning_support': False, + 'reserved_percentage': 0, + 'free_capacity_gb': 10, + 'total_capacity_gb': 100, + 'iscsi_ip_address': '10.0.0.1', + 'pool_name': 'volume1', + 'backend_info': + 'Synology:iscsi:72003c93-2db2-4f00-a169-67c5eae86bb1' + } + + result = self.common.update_volume_stats() + + self.assertDictMatch(data, result) + + def test_create_volume(self): + out = { + 'success': True + } + self.common.exec_webapi = ( + mock.Mock(side_effect=[ + out, + out, + exception.SynoAuthError(message='dont care')])) + self.common._check_lun_status_normal = ( + mock.Mock(side_effect=[True, False, True])) + + result = self.common.create_volume(VOLUME) + (self.common.exec_webapi. + assert_called_with('SYNO.Core.ISCSI.LUN', + 'create', + mock.ANY, + name=VOLUME['name'], + type=self.common.CINDER_LUN, + location='/' + self.conf.pool_name, + size=VOLUME['size'] * units.Gi)) + self.assertIsNone(result) + + self.assertRaises(exception.VolumeDriverException, + self.common.create_volume, + VOLUME) + + self.assertRaises(exception.SynoAuthError, + self.common.create_volume, + VOLUME) + + def test_delete_volume(self): + out = { + 'success': True + } + self.common._get_lun_uuid = mock.Mock(return_value=LUN_UUID) + self.common.exec_webapi = ( + mock.Mock(side_effect=[ + out, + exception.SynoLUNNotExist(message='dont care'), + exception.SynoAuthError(message='dont care')])) + + result = self.common.delete_volume(VOLUME) + self.common._get_lun_uuid.assert_called_with(VOLUME['name']) + (self.common.exec_webapi. + assert_called_with('SYNO.Core.ISCSI.LUN', + 'delete', + mock.ANY, + uuid=LUN_UUID)) + self.assertIsNone(result) + + result = self.common.delete_volume(VOLUME) + self.assertIsNone(result) + + self.assertRaises(exception.SynoAuthError, + self.common.delete_volume, + VOLUME) + + def test_create_cloned_volume(self): + out = { + 'success': True + } + new_volume = copy.deepcopy(NEW_VOLUME) + new_volume['size'] = 20 + self.common.exec_webapi = mock.Mock(return_value=out) + self.common._get_lun_uuid = ( + mock.Mock(side_effect=[ + LUN_UUID, + LUN_UUID, + LUN_UUID, + exception.InvalidParameterValue('dont care')])) + self.common.extend_volume = mock.Mock() + self.common._check_lun_status_normal = ( + mock.Mock(side_effect=[True, True, False, False])) + result = self.common.create_cloned_volume(new_volume, VOLUME) + self.common._get_lun_uuid.assert_called_with(VOLUME['name']) + (self.common.exec_webapi. + assert_called_with('SYNO.Core.ISCSI.LUN', + 'clone', + mock.ANY, + src_lun_uuid=LUN_UUID, + dst_lun_name=new_volume['name'], + is_same_pool=True, + clone_type='CINDER')) + (self.common._check_lun_status_normal. + assert_called_with(new_volume['name'])) + self.common.extend_volume.assert_called_once_with(new_volume, + new_volume['size']) + self.assertIsNone(result) + + new_volume['size'] = 10 + result = self.common.create_cloned_volume(new_volume, VOLUME) + self.assertIsNone(result) + + self.assertRaises(exception.VolumeDriverException, + self.common.create_cloned_volume, + new_volume, + VOLUME) + + self.assertRaises(exception.InvalidParameterValue, + self.common.create_cloned_volume, + new_volume, + VOLUME) + + def test_extend_volume(self): + new_size = 20 + out = { + 'success': True + } + self.common.exec_webapi = mock.Mock(return_value=out) + self.common._get_lun_uuid = ( + mock.Mock(side_effect=[ + LUN_UUID, + exception.InvalidParameterValue('dont care')])) + + result = self.common.extend_volume(VOLUME, new_size) + + (self.common.exec_webapi. + assert_called_with('SYNO.Core.ISCSI.LUN', + 'set', + mock.ANY, + uuid=LUN_UUID, + new_size=new_size * units.Gi)) + self.assertIsNone(result) + self.assertRaises(exception.ExtendVolumeError, + self.common.extend_volume, + VOLUME, + new_size) + + def test_update_migrated_volume(self): + expected = { + '_name_id': None + } + self.common._modify_lun_name = mock.Mock(side_effect=[None, Exception]) + + result = self.common.update_migrated_volume(VOLUME, + NEW_VOLUME) + + self.common._modify_lun_name.assert_called_with(NEW_VOLUME['name'], + VOLUME['name']) + self.assertDictMatch(expected, result) + + self.assertRaises(exception.VolumeMigrationFailed, + self.common.update_migrated_volume, + VOLUME, + NEW_VOLUME) + + def test_create_snapshot(self): + metadata = { + 'metadata': { + self.common.METADATA_DS_SNAPSHOT_UUID: DS_SNAPSHOT_UUID + } + } + out = { + 'data': { + 'snapshot_uuid': DS_SNAPSHOT_UUID, + 'snapshot_id': SNAPSHOT_ID + }, + 'success': True + } + self.common.exec_webapi = mock.Mock(return_value=out) + self.common._check_snapshot_status_healthy = ( + mock.Mock(side_effect=[True, False])) + + result = self.common.create_snapshot(SNAPSHOT) + + (self.common.exec_webapi. + assert_called_with('SYNO.Core.ISCSI.LUN', + 'take_snapshot', + mock.ANY, + src_lun_uuid=SNAPSHOT['volume']['name'], + is_app_consistent=False, + is_locked=False, + taken_by='Cinder', + description='(Cinder) ' + + SNAPSHOT['id'])) + self.assertDictMatch(metadata, result) + + self.assertRaises(exception.VolumeDriverException, + self.common.create_snapshot, + SNAPSHOT) + + def test_create_snapshot_error(self): + out = { + 'data': { + 'snapshot_uuid': 1, + 'snapshot_id': SNAPSHOT_ID + }, + 'success': True + } + self.common.exec_webapi = mock.Mock(return_value=out) + + self.assertRaises(exception.MalformedResponse, + self.common.create_snapshot, + SNAPSHOT) + + self.common.exec_webapi = ( + mock.Mock(side_effect=exception.SynoAuthError)) + + self.assertRaises(exception.SynoAuthError, + self.common.create_snapshot, + SNAPSHOT) + + def test_delete_snapshot(self): + out = { + 'success': True + } + self.common.exec_webapi = mock.Mock(return_value=out) + self.common._get_metadata_value = ( + mock.Mock(side_effect=[ + DS_SNAPSHOT_UUID, + exception.SnapshotMetadataNotFound(message='dont care'), + exception.MetadataAbsent])) + + result = self.common.delete_snapshot(SNAPSHOT) + (self.common._get_metadata_value. + assert_called_with(SNAPSHOT, + self.common.METADATA_DS_SNAPSHOT_UUID)) + (self.common.exec_webapi. + assert_called_with('SYNO.Core.ISCSI.LUN', + 'delete_snapshot', + mock.ANY, + snapshot_uuid=DS_SNAPSHOT_UUID, + delete_by='Cinder')) + self.assertIsNone(result) + + result = self.common.delete_snapshot(SNAPSHOT) + self.assertIsNone(result) + + self.assertRaises(exception.MetadataAbsent, + self.common.delete_snapshot, + SNAPSHOT) + + def test_create_volume_from_snapshot(self): + out = { + 'success': True + } + new_volume = copy.deepcopy(NEW_VOLUME) + new_volume['size'] = 20 + self.common.exec_webapi = mock.Mock(return_value=out) + self.common._get_metadata_value = ( + mock.Mock(side_effect=[ + DS_SNAPSHOT_UUID, + DS_SNAPSHOT_UUID, + exception.SnapshotMetadataNotFound(message='dont care'), + exception.SynoAuthError(message='dont care')])) + self.common._check_lun_status_normal = ( + mock.Mock(side_effect=[True, False, True, True])) + self.common.extend_volume = mock.Mock() + + result = self.common.create_volume_from_snapshot(new_volume, SNAPSHOT) + + (self.common._get_metadata_value. + assert_called_with(SNAPSHOT, + self.common.METADATA_DS_SNAPSHOT_UUID)) + (self.common.exec_webapi. + assert_called_with('SYNO.Core.ISCSI.LUN', + 'clone_snapshot', + mock.ANY, + src_lun_uuid=SNAPSHOT['volume']['name'], + snapshot_uuid=DS_SNAPSHOT_UUID, + cloned_lun_name=new_volume['name'], + clone_type='CINDER')) + self.common.extend_volume.assert_called_once_with(new_volume, + new_volume['size']) + self.assertIsNone(result) + + self.assertRaises(exception.VolumeDriverException, + self.common.create_volume_from_snapshot, + new_volume, + SNAPSHOT) + + self.assertRaises(exception.SnapshotMetadataNotFound, + self.common.create_volume_from_snapshot, + new_volume, + SNAPSHOT) + + self.assertRaises(exception.SynoAuthError, + self.common.create_volume_from_snapshot, + new_volume, + SNAPSHOT) + + def test_get_iqn_and_trgid(self): + location = '%s:3260,%d %s 1' % (IP, 1, IQN) + + result = self.common.get_iqn_and_trgid(location) + + self.assertEqual((IQN, 1), result) + + location = '' + self.assertRaises(exception.InvalidParameterValue, + self.common.get_iqn_and_trgid, + location) + + location = 'BADINPUT' + self.assertRaises(exception.InvalidInput, + self.common.get_iqn_and_trgid, + location) + + location = '%s:3260 %s 1' % (IP, IQN) + self.assertRaises(exception.InvalidInput, + self.common.get_iqn_and_trgid, + location) + + def test_get_iscsi_properties(self): + volume = copy.deepcopy(VOLUME) + iscsi_properties = { + 'target_discovered': False, + 'target_iqn': IQN, + 'target_portal': '%s:3260' % IP, + 'volume_id': VOLUME['id'], + 'access_mode': 'rw', + 'discard': False, + 'auth_method': 'CHAP', + 'auth_username': CHAP_AUTH_USERNAME, + 'auth_password': CHAP_AUTH_PASSWORD + } + self.common.get_ip = mock.Mock(return_value=IP) + self.conf.safe_get = mock.Mock(return_value=[]) + + result = self.common.get_iscsi_properties(volume) + self.assertDictMatch(iscsi_properties, result) + + volume['provider_location'] = '' + self.assertRaises(exception.InvalidParameterValue, + self.common.get_iscsi_properties, + volume) + + def test_get_iscsi_properties_multipath(self): + volume = copy.deepcopy(VOLUME) + iscsi_properties = { + 'target_discovered': False, + 'target_iqn': IQN, + 'target_iqns': [IQN] * 3, + 'target_lun': 0, + 'target_luns': [0] * 3, + 'target_portal': '%s:3260' % IP, + 'target_portals': + ['%s:3260' % IP, '10.0.0.2:3260', '10.0.0.3:3260'], + 'volume_id': VOLUME['id'], + 'access_mode': 'rw', + 'discard': False, + 'auth_method': 'CHAP', + 'auth_username': CHAP_AUTH_USERNAME, + 'auth_password': CHAP_AUTH_PASSWORD + } + self.common.get_ip = mock.Mock(return_value=IP) + self.conf.safe_get = mock.Mock(return_value=['10.0.0.2', '10.0.0.3']) + + result = self.common.get_iscsi_properties(volume) + self.assertDictMatch(iscsi_properties, result) + + volume['provider_location'] = '' + self.assertRaises(exception.InvalidParameterValue, + self.common.get_iscsi_properties, + volume) + + def test_get_iscsi_properties_without_chap(self): + volume = copy.deepcopy(VOLUME) + iscsi_properties = { + 'target_discovered': False, + 'target_iqn': IQN, + 'target_portal': '%s:3260' % IP, + 'volume_id': VOLUME['id'], + 'access_mode': 'rw', + 'discard': False + } + self.common.get_ip = mock.Mock(return_value=IP) + self.conf.safe_get = mock.Mock(return_value=[]) + + volume['provider_auth'] = 'abcde' + result = self.common.get_iscsi_properties(volume) + self.assertDictMatch(iscsi_properties, result) + + volume['provider_auth'] = '' + result = self.common.get_iscsi_properties(volume) + self.assertDictMatch(iscsi_properties, result) + + del volume['provider_auth'] + result = self.common.get_iscsi_properties(volume) + self.assertDictMatch(iscsi_properties, result) + + def test_create_iscsi_export(self): + self.common._target_create = ( + mock.Mock(return_value=(IQN, TRG_ID, VOLUME['provider_auth']))) + self.common._lun_map_target = mock.Mock() + + iqn, trg_id, provider_auth = ( + self.common.create_iscsi_export(VOLUME['name'], VOLUME['id'])) + + self.common._target_create.assert_called_with(VOLUME['id']) + self.common._lun_map_target.assert_called_with(VOLUME['name'], trg_id) + self.assertEqual((IQN, TRG_ID, VOLUME['provider_auth']), + (iqn, trg_id, provider_auth)) + + def test_remove_iscsi_export(self): + trg_id = TRG_ID + self.common._lun_unmap_target = mock.Mock() + self.common._target_delete = mock.Mock() + + result = self.common.remove_iscsi_export(VOLUME['name'], trg_id) + + self.assertIsNone(result) + self.common._lun_unmap_target.assert_called_with(VOLUME['name'], + TRG_ID) + self.common._target_delete.assert_called_with(TRG_ID) diff --git a/cinder/tests/unit/test_synology_iscsi.py b/cinder/tests/unit/test_synology_iscsi.py new file mode 100644 index 000000000..87791ea30 --- /dev/null +++ b/cinder/tests/unit/test_synology_iscsi.py @@ -0,0 +1,358 @@ +# Copyright (c) 2016 Synology Co., Ltd. +# 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. + +"""Tests for the Synology iSCSI volume driver.""" + +import mock + +from cinder import exception +from cinder import test +from cinder.tests.unit import fake_constants as fake +from cinder.volume import configuration as conf +from cinder.volume.drivers.synology import synology_common as common +from cinder.volume.drivers.synology import synology_iscsi + +VOLUME_ID = fake.VOLUME_ID +TARGET_NAME_PREFIX = 'Cinder-Target-' +IP = '10.10.10.10' +IQN = 'iqn.2000-01.com.synology:' + TARGET_NAME_PREFIX + VOLUME_ID +TRG_ID = 1 +VOLUME = { + 'name': fake.VOLUME_NAME, + 'id': VOLUME_ID, + 'display_name': 'fake_volume', + 'size': 10, + 'provider_location': '%s:3260,%d %s 1' % (IP, TRG_ID, IQN), +} +NEW_VOLUME_ID = fake.VOLUME2_ID +IQN2 = 'iqn.2000-01.com.synology:' + TARGET_NAME_PREFIX + NEW_VOLUME_ID +NEW_TRG_ID = 2 +NEW_VOLUME = { + 'name': fake.VOLUME2_NAME, + 'id': NEW_VOLUME_ID, + 'display_name': 'new_fake_volume', + 'size': 10, + 'provider_location': '%s:3260,%d %s 1' % (IP, NEW_TRG_ID, IQN2), +} +SNAPSHOT_ID = fake.SNAPSHOT_ID +SNAPSHOT = { + 'name': fake.SNAPSHOT_NAME, + 'id': SNAPSHOT_ID, + 'volume_id': VOLUME_ID, + 'volume_name': VOLUME['name'], + 'volume_size': 10, + 'display_name': 'fake_snapshot', +} +DS_SNAPSHOT_UUID = 'ca86a56a-40d8-4210-974c-ef15dbf01cba' +SNAPSHOT_METADATA = { + 'metadata': { + 'ds_snapshot_UUID': DS_SNAPSHOT_UUID + } +} +INITIATOR_IQN = 'iqn.1993-08.org.debian:01:604af6a341' +CONNECTOR = { + 'initiator': INITIATOR_IQN, +} +CONTEXT = { +} +LOCAL_PATH = '/dev/isda' +IMAGE_SERVICE = 'image_service' +IMAGE_ID = 1 +IMAGE_META = { + 'id': IMAGE_ID +} +NODE_UUID = '72003c93-2db2-4f00-a169-67c5eae86bb1' +HOST = { +} + + +class SynoISCSIDriverTestCase(test.TestCase): + + @mock.patch.object(common.SynoCommon, + '_get_node_uuid', + return_value=NODE_UUID) + @mock.patch.object(common, 'APIRequest') + def setUp(self, _request, _get_node_uuid): + super(SynoISCSIDriverTestCase, self).setUp() + + self.conf = self.setup_configuration() + self.driver = synology_iscsi.SynoISCSIDriver(configuration=self.conf) + self.driver.common = common.SynoCommon(self.conf, 'iscsi') + + def tearDown(self): + super(SynoISCSIDriverTestCase, self).tearDown() + + def setup_configuration(self): + config = mock.Mock(spec=conf.Configuration) + config.use_chap_auth = False + config.iscsi_protocol = 'iscsi' + config.iscsi_ip_address = IP + config.admin_port = 5000 + config.username = 'admin' + config.password = 'admin' + config.ssl_verify = True + config.one_time_pass = '123456' + config.volume_dd_blocksize = 1 + + return config + + def test_check_for_setup_error(self): + self.driver.common.check_for_setup_error = mock.Mock() + + result = self.driver.check_for_setup_error() + + self.driver.common.check_for_setup_error.assert_called_with() + self.assertIsNone(result) + + def test_create_volume(self): + self.driver.common.create_volume = mock.Mock() + + result = self.driver.create_volume(VOLUME) + + self.driver.common.create_volume.assert_called_with(VOLUME) + self.assertIsNone(result) + + def test_delete_volume(self): + self.driver.common.delete_volume = mock.Mock() + + result = self.driver.delete_volume(VOLUME) + + self.driver.common.delete_volume.assert_called_with(VOLUME) + self.assertIsNone(result) + + def test_create_cloned_volume(self): + self.driver.common.create_cloned_volume = mock.Mock() + + result = self.driver.create_cloned_volume(VOLUME, NEW_VOLUME) + + self.driver.common.create_cloned_volume.assert_called_with( + VOLUME, NEW_VOLUME) + self.assertIsNone(result) + + def test_extend_volume(self): + new_size = 20 + + self.driver.common.extend_volume = mock.Mock() + + result = self.driver.extend_volume(VOLUME, new_size) + + self.driver.common.extend_volume.assert_called_with( + VOLUME, new_size) + self.assertIsNone(result) + + def test_extend_volume_wrong_size(self): + wrong_new_size = 1 + + self.driver.common.extend_volume = mock.Mock() + + result = self.driver.extend_volume(VOLUME, wrong_new_size) + + self.driver.common.extend_volume.assert_not_called() + self.assertIsNone(result) + + def test_create_volume_from_snapshot(self): + self.driver.common.create_volume_from_snapshot = mock.Mock() + + result = self.driver.create_volume_from_snapshot(VOLUME, SNAPSHOT) + + (self.driver.common. + create_volume_from_snapshot.assert_called_with(VOLUME, SNAPSHOT)) + self.assertIsNone(result) + + def test_update_migrated_volume(self): + fake_ret = {'_name_id': VOLUME['id']} + status = '' + self.driver.common.update_migrated_volume = ( + mock.Mock(return_value=fake_ret)) + + result = self.driver.update_migrated_volume(CONTEXT, + VOLUME, + NEW_VOLUME, + status) + + (self.driver.common.update_migrated_volume. + assert_called_with(VOLUME, NEW_VOLUME)) + self.assertEqual(fake_ret, result) + + def test_create_snapshot(self): + self.driver.common.create_snapshot = ( + mock.Mock(return_value=SNAPSHOT_METADATA)) + + result = self.driver.create_snapshot(SNAPSHOT) + + self.driver.common.create_snapshot.assert_called_with(SNAPSHOT) + self.assertDictMatch(SNAPSHOT_METADATA, result) + + def test_delete_snapshot(self): + self.driver.common.delete_snapshot = mock.Mock() + + result = self.driver.delete_snapshot(SNAPSHOT) + + self.driver.common.delete_snapshot.assert_called_with(SNAPSHOT) + self.assertIsNone(result) + + def test_get_volume_stats(self): + self.driver.common.update_volume_stats = mock.MagicMock() + + result = self.driver.get_volume_stats(True) + + self.driver.common.update_volume_stats.assert_called_with() + self.assertDictMatch(self.driver.stats, result) + + result = self.driver.get_volume_stats(False) + + self.driver.common.update_volume_stats.assert_called_with() + self.assertDictMatch(self.driver.stats, result) + + def test_get_volume_stats_error(self): + self.driver.common.update_volume_stats = ( + mock.MagicMock(side_effect=exception.VolumeDriverException( + message='dont care'))) + + self.assertRaises(exception.VolumeDriverException, + self.driver.get_volume_stats, + True) + + def test_create_export(self): + provider_auth = 'CHAP username password' + provider_location = '%s:3260,%d %s 1' % (IP, TRG_ID, IQN) + + self.driver.common.is_lun_mapped = mock.Mock(return_value=False) + self.driver.common.create_iscsi_export = ( + mock.Mock(return_value=(IQN, TRG_ID, provider_auth))) + self.driver.common.get_provider_location = ( + mock.Mock(return_value=provider_location)) + + result = self.driver.create_export(CONTEXT, VOLUME, CONNECTOR) + + self.driver.common.is_lun_mapped.assert_called_with(VOLUME['name']) + (self.driver.common.create_iscsi_export. + assert_called_with(VOLUME['name'], VOLUME['id'])) + self.driver.common.get_provider_location.assert_called_with(IQN, + TRG_ID) + self.assertEqual(provider_location, result['provider_location']) + self.assertEqual(provider_auth, result['provider_auth']) + + def test_create_export_is_mapped(self): + self.driver.common.is_lun_mapped = mock.Mock(return_value=True) + self.driver.common.create_iscsi_export = mock.Mock() + self.driver.common.get_provider_location = mock.Mock() + + result = self.driver.create_export(CONTEXT, VOLUME, CONNECTOR) + + self.driver.common.is_lun_mapped.assert_called_with(VOLUME['name']) + self.driver.common.create_iscsi_export.assert_not_called() + self.driver.common.get_provider_location.assert_not_called() + self.assertEqual({}, result) + + def test_create_export_error(self): + provider_location = '%s:3260,%d %s 1' % (IP, TRG_ID, IQN) + + self.driver.common.is_lun_mapped = mock.Mock(return_value=False) + self.driver.common.create_iscsi_export = ( + mock.Mock(side_effect=exception.InvalidInput(reason='dont care'))) + self.driver.common.get_provider_location = ( + mock.Mock(return_value=provider_location)) + + self.assertRaises(exception.ExportFailure, + self.driver.create_export, + CONTEXT, + VOLUME, + CONNECTOR) + self.driver.common.is_lun_mapped.assert_called_with(VOLUME['name']) + self.driver.common.get_provider_location.assert_not_called() + + def test_remove_export(self): + self.driver.common.is_lun_mapped = mock.Mock(return_value=True) + self.driver.common.remove_iscsi_export = mock.Mock() + self.driver.common.get_iqn_and_trgid = ( + mock.Mock(return_value=('', TRG_ID))) + + _, trg_id = (self.driver.common. + get_iqn_and_trgid(VOLUME['provider_location'])) + result = self.driver.remove_export(CONTEXT, VOLUME) + + self.driver.common.is_lun_mapped.assert_called_with(VOLUME['name']) + (self.driver.common.get_iqn_and_trgid. + assert_called_with(VOLUME['provider_location'])) + (self.driver.common.remove_iscsi_export. + assert_called_with(VOLUME['name'], trg_id)) + self.assertIsNone(result) + + def test_remove_export_not_mapped(self): + self.driver.common.is_lun_mapped = mock.Mock(return_value=False) + self.driver.common.remove_iscsi_export = mock.Mock() + self.driver.common.get_iqn_and_trgid = mock.Mock() + + result = self.driver.remove_export(CONTEXT, VOLUME) + + self.driver.common.is_lun_mapped.assert_called_with(VOLUME['name']) + self.driver.common.get_iqn_and_trgid.assert_not_called() + self.driver.common.remove_iscsi_export.assert_not_called() + self.assertIsNone(result) + + def test_remove_export_error(self): + self.driver.common.is_lun_mapped = mock.Mock(return_value=True) + self.driver.common.remove_iscsi_export = ( + mock.Mock(side_effect= exception.RemoveExportException( + volume=VOLUME, reason='dont care'))) + + self.assertRaises(exception.RemoveExportException, + self.driver.remove_export, + CONTEXT, + VOLUME) + + def test_remove_export_error_get_lun_mapped(self): + self.driver.common.remove_iscsi_export = mock.Mock() + self.driver.common.get_iqn_and_trgid = mock.Mock() + self.driver.common.is_lun_mapped = ( + mock.Mock(side_effect=exception.SynoLUNNotExist( + message='dont care'))) + + result = self.driver.remove_export(CONTEXT, VOLUME) + + self.assertIsNone(result) + self.driver.common.get_iqn_and_trgid.assert_not_called() + self.driver.common.remove_iscsi_export.assert_not_called() + + def test_initialize_connection(self): + iscsi_properties = { + 'target_discovered': False, + 'target_iqn': IQN, + 'target_portal': '%s:3260' % self.conf.iscsi_ip_address, + 'volume_id': VOLUME['id'], + 'access_mode': 'rw', + 'discard': False + } + + self.driver.common.get_iscsi_properties = ( + mock.Mock(return_value=iscsi_properties)) + self.conf.safe_get = mock.Mock(return_value='iscsi') + + result = self.driver.initialize_connection(VOLUME, CONNECTOR) + + self.driver.common.get_iscsi_properties.assert_called_with(VOLUME) + self.conf.safe_get.assert_called_with('iscsi_protocol') + self.assertEqual('iscsi', result['driver_volume_type']) + self.assertDictMatch(iscsi_properties, result['data']) + + def test_initialize_connection_error(self): + self.driver.common.get_iscsi_properties = ( + mock.Mock(side_effect=exception.InvalidInput(reason='dont care'))) + + self.assertRaises(exception.InvalidInput, + self.driver.initialize_connection, + VOLUME, + CONNECTOR) diff --git a/cinder/volume/drivers/synology/__init__.py b/cinder/volume/drivers/synology/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/cinder/volume/drivers/synology/synology_common.py b/cinder/volume/drivers/synology/synology_common.py new file mode 100644 index 000000000..7f6475ced --- /dev/null +++ b/cinder/volume/drivers/synology/synology_common.py @@ -0,0 +1,1236 @@ +# Copyright (c) 2016 Synology 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 base64 +import functools +from hashlib import md5 +import json +from random import randint +import string +import time + +from Crypto.Cipher import AES +from Crypto.Cipher import PKCS1_v1_5 +from Crypto.PublicKey import RSA +from Crypto import Random +from oslo_config import cfg +from oslo_log import log as logging +from oslo_utils import excutils +from oslo_utils import units +import requests +from six.moves import urllib +from six import string_types + +from cinder import exception +from cinder import utils +from cinder.i18n import _, _LE, _LW +from cinder.objects import snapshot +from cinder.objects import volume +from cinder.volume import utils as volutils + + +cinder_opts = [ + cfg.StrOpt('pool_name', + default='', + help='Volume on Synology storage to be used for creating lun.'), + cfg.PortOpt('admin_port', + default=5000, + help='Management port for Synology storage.'), + cfg.StrOpt('username', + default='admin', + help='Administrator of Synology storage.'), + cfg.StrOpt('password', + default='', + help='Password of administator for logging in ' + 'Synology storage.', + secret=True), + cfg.BoolOpt('ssl_verify', + default=True, + help='Do certificate validation or not if ' + '$driver_use_ssl is True'), + cfg.StrOpt('one_time_pass', + default=None, + help='One time password of administator for logging in ' + 'Synology storage if OTP is enabled.', + secret=True), + cfg.StrOpt('device_id', + default=None, + help='Device id for skip one time password check for ' + 'logging in Synology storage if OTP is enabled.'), +] + +LOG = logging.getLogger(__name__) + +CONF = cfg.CONF +CONF.register_opts(cinder_opts) + + +class AESCipher(object): + """Encrypt with OpenSSL-compatible way""" + + SALT_MAGIC = 'Salted__' + + def __init__(self, password, key_length=32): + self._bs = AES.block_size + self._salt = Random.new().read(self._bs - len(self.SALT_MAGIC)) + + self._key, self._iv = self._derive_key_and_iv(password, + self._salt, + key_length, + self._bs) + + def _pad(self, s): + bs = self._bs + return s + (bs - len(s) % bs) * chr(bs - len(s) % bs) + + def _derive_key_and_iv(self, password, salt, key_length, iv_length): + d = d_i = '' + while len(d) < key_length + iv_length: + md5_str = d_i + password + salt + d_i = md5(md5_str).digest() + d += d_i + return d[:key_length], d[key_length:key_length + iv_length] + + def encrypt(self, text): + cipher = AES.new(self._key, AES.MODE_CBC, self._iv) + ciphertext = cipher.encrypt(self._pad(text)) + + return "%s%s%s" % (self.SALT_MAGIC, self._salt, ciphertext) + + +class Session(object): + def __init__(self, + host, + port, + username, + password, + https=False, + ssl_verify=True, + one_time_pass=None, + device_id=None): + self._proto = 'https' if https else 'http' + self._host = host + self._port = port + self._sess = 'dsm' + self._https = https + self._url_prefix = self._proto + '://' + host + ':' + str(port) + self._url = self._url_prefix + '/webapi/auth.cgi' + self._ssl_verify = ssl_verify + self._sid = None + self._did = device_id + + data = {'api': 'SYNO.API.Auth', + 'method': 'login', + 'version': 6} + + params = {'account': username, + 'passwd': password, + 'session': self._sess, + 'format': 'sid'} + + if one_time_pass: + if device_id: + params.update(device_id=device_id) + else: + params.update(otp_code=one_time_pass, + enable_device_token='yes') + + if not https: + params = self._encrypt_params(params) + + data.update(params) + + resp = requests.post(self._url, + data=data, + verify=self._ssl_verify) + result = resp.json() + + if result and result['success']: + self._sid = result['data']['sid'] + if one_time_pass and not device_id: + self._did = result['data']['did'] + else: + raise exception.SynoAuthError(_('Login failed.')) + + def _random_AES_passpharse(self, length): + available = ('0123456789' + 'abcdefghijklmnopqrstuvwxyz' + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' + '~!@#$%^&*()_+-/') + key = '' + + while length > 0: + key += available[randint(0, len(available) - 1)] + length -= 1 + + return key + + def _get_enc_info(self): + url = self.url_prefix() + '/webapi/encryption.cgi' + data = {"api": "SYNO.API.Encryption", + "method": "getinfo", + "version": 1, + "format": "module"} + + resp = requests.post(url, data=data, verify=self._ssl_verify) + result = resp.json() + + return result["data"] + + def _encrypt_RSA(self, modulus, passphrase, text): + key = RSA.construct((modulus, passphrase)) + cipher = PKCS1_v1_5.new(key) + ciphertext = cipher.encrypt(text) + + return ciphertext + + def _encrypt_AES(self, passphrase, text): + cipher = AESCipher(passphrase) + + return cipher.encrypt(text) + + def _encrypt_params(self, params): + enc_info = self._get_enc_info() + public_key = enc_info["public_key"] + cipher_key = enc_info["cipherkey"] + cipher_token = enc_info["ciphertoken"] + server_time = enc_info["server_time"] + random_passphrase = self._random_AES_passpharse(501) + + params[cipher_token] = server_time + + encrypted_passphrase = self._encrypt_RSA(string.atol(public_key, 16), + string.atol("10001", 16), + random_passphrase) + + encrypted_params = self._encrypt_AES(random_passphrase, + urllib.parse.urlencode(params)) + + enc_params = {"rsa": base64.b64encode(encrypted_passphrase), + "aes": base64.b64encode(encrypted_params)} + + return {cipher_key: json.dumps(enc_params)} + + def sid(self): + return self._sid + + def did(self): + return self._did + + def url_prefix(self): + return self._url_prefix + + def query(self, api): + url = self._url_prefix + '/webapi/query.cgi' + data = {'api': 'SYNO.API.Info', + 'version': 1, + 'method': 'query', + 'query': api} + + resp = requests.post(url, + data=data, + verify=self._ssl_verify) + result = resp.json() + + if 'success' in result and result['success']: + return result['data'][api] + else: + return None + + def __del__(self): + if not hasattr(self, '_sid'): + return + + data = {'api': 'SYNO.API.Auth', + 'version': 1, + 'method': 'logout', + 'session': self._sess, + '_sid': self._sid} + + requests.post(self._url, data=data, verify=self._ssl_verify) + + +def _connection_checker(func): + """Decorator to check session has expired or not.""" + @functools.wraps(func) + def inner_connection_checker(self, *args, **kwargs): + LOG.debug('in _connection_checker') + for attempts in range(2): + try: + return func(self, *args, **kwargs) + except exception.SynoAuthError as e: + if attempts < 1: + LOG.debug('Session might have expired.' + ' Trying to relogin') + self.new_session() + continue + else: + LOG.error(_LE('Try to renew session: [%s]'), e) + raise + return inner_connection_checker + + +class APIRequest(object): + def __init__(self, + host, + port, + username, + password, + https=False, + ssl_verify=True, + one_time_pass=None, + device_id=None): + self._host = host + self._port = port + self._username = username + self._password = password + self._https = https + self._ssl_verify = ssl_verify + self._one_time_pass = one_time_pass + self._device_id = device_id + + self.new_session() + + def new_session(self): + self.__session = Session(self._host, + self._port, + self._username, + self._password, + self._https, + self._ssl_verify, + self._one_time_pass, + self._device_id) + if not self._device_id: + self._device_id = self.__session.did() + + def _start(self, api, version): + apiInfo = self.__session.query(api) + self._jsonFormat = apiInfo['requestFormat'] == 'JSON' + if (apiInfo and (apiInfo['minVersion'] <= version) + and (apiInfo['maxVersion'] >= version)): + return apiInfo['path'] + else: + raise exception.APIException(service=api) + + def _encode_param(self, params): + # Json encode + if self._jsonFormat: + for key, value in params.items(): + params[key] = json.dumps(value) + # url encode + return urllib.parse.urlencode(params) + + @utils.synchronized('Synology') + @_connection_checker + def request(self, api, method, version, **params): + cgi_path = self._start(api, version) + s = self.__session + url = s.url_prefix() + '/webapi/' + cgi_path + data = {'api': api, + 'version': version, + 'method': method, + '_sid': s.sid() + } + + data.update(params) + + LOG.debug('[%s]', url) + LOG.debug('%s', json.dumps(data, indent=4)) + + # Send HTTP Post Request + resp = requests.post(url, + data=self._encode_param(data), + verify=self._ssl_verify) + + http_status = resp.status_code + result = resp.json() + + LOG.debug('%s', json.dumps(result, indent=4)) + + # Check for status code + if (200 != http_status): + result = {'http_status': http_status} + elif 'success' not in result: + reason = _("'success' not found") + raise exception.MalformedResponse(cmd=json.dumps(data, indent=4), + reason=reason) + + if ('error' in result and 'code' in result["error"] + and result['error']['code'] == 105): + raise exception.SynoAuthError(_('Session might have expired.')) + + return result + + +class SynoCommon(object): + """Manage Cinder volumes on Synology storage""" + + TARGET_NAME_PREFIX = 'Cinder-Target-' + CINDER_LUN = 'CINDER' + METADATA_DS_SNAPSHOT_UUID = 'ds_snapshot_UUID' + + def __init__(self, config, driver_type): + if not config.safe_get('iscsi_ip_address'): + raise exception.InvalidConfigurationValue( + option='iscsi_ip_address', + value='') + if not config.safe_get('pool_name'): + raise exception.InvalidConfigurationValue( + option='pool_name', + value='') + + self.config = config + self.vendor_name = 'Synology' + self.driver_type = driver_type + self.volume_backend_name = self._get_backend_name() + self.iscsi_port = self.config.safe_get('iscsi_port') + + api = APIRequest(self.config.iscsi_ip_address, + self.config.admin_port, + self.config.username, + self.config.password, + self.config.safe_get('driver_use_ssl'), + self.config.safe_get('ssl_verify'), + self.config.safe_get('one_time_pass'), + self.config.safe_get('device_id'),) + self.synoexec = api.request + self.host_uuid = self._get_node_uuid() + + def _get_node_uuid(self): + try: + out = self.exec_webapi('SYNO.Core.ISCSI.Node', + 'list', + 1) + + self.check_response(out) + + except Exception: + with excutils.save_and_reraise_exception(): + LOG.exception(_LE('Failed to _get_node_uuid.')) + + if (not self.check_value_valid(out, ['data', 'nodes'], list) + or 0 >= len(out['data']['nodes']) + or not self.check_value_valid(out['data']['nodes'][0], + ['uuid'], + string_types)): + msg = _('Failed to _get_node_uuid.') + raise exception.VolumeDriverException(message=msg) + + return out['data']['nodes'][0]['uuid'] + + def _get_pool_info(self): + pool_name = self.config.pool_name + if not pool_name: + raise exception.InvalidConfigurationValue(option='pool_name', + value='') + try: + out = self.exec_webapi('SYNO.Core.Storage.Volume', + 'get', + 1, + volume_path='/' + pool_name) + + self.check_response(out) + + except Exception: + with excutils.save_and_reraise_exception(): + LOG.exception(_LE('Failed to _get_pool_status.')) + + if not self.check_value_valid(out, ['data', 'volume'], object): + raise exception.MalformedResponse(cmd='_get_pool_info', + reason=_('no data found')) + + return out['data']['volume'] + + def _get_pool_size(self): + info = self._get_pool_info() + + if 'size_free_byte' not in info or 'size_total_byte' not in info: + raise exception.MalformedResponse(cmd='_get_pool_size', + reason=_('size not found')) + + free_capacity_gb = int(int(info['size_free_byte']) / units.Gi) + total_capacity_gb = int(int(info['size_total_byte']) / units.Gi) + + return free_capacity_gb, total_capacity_gb + + def _get_lun_info(self, lun_name, additional=None): + if not lun_name: + err = _('Param [lun_name] is invalid.') + raise exception.InvalidParameterValue(err=err) + + params = {'uuid': lun_name} + if additional is not None: + params['additional'] = additional + + try: + out = self.exec_webapi('SYNO.Core.ISCSI.LUN', + 'get', + 1, + **params) + + self.check_response(out, uuid=lun_name) + except Exception: + with excutils.save_and_reraise_exception(): + LOG.exception(_LE('Failed to _get_lun_info. [%s]'), lun_name) + + if not self.check_value_valid(out, ['data', 'lun'], object): + raise exception.MalformedResponse(cmd='_get_lun_info', + reason=_('lun info not found')) + + return out['data']['lun'] + + def _get_lun_uuid(self, lun_name): + if not lun_name: + err = _('Param [lun_name] is invalid.') + raise exception.InvalidParameterValue(err=err) + + try: + lun_info = self._get_lun_info(lun_name) + except Exception: + with excutils.save_and_reraise_exception(): + LOG.exception(_LE('Failed to _get_lun_uuid. [%s]'), lun_name) + + if not self.check_value_valid(lun_info, ['uuid'], string_types): + raise exception.MalformedResponse(cmd='_get_lun_info', + reason=_('uuid not found')) + + return lun_info['uuid'] + + def _get_lun_status(self, lun_name): + if not lun_name: + err = _('Param [lun_name] is invalid.') + raise exception.InvalidParameterValue(err=err) + + try: + lun_info = self._get_lun_info(lun_name, + ['status', 'is_action_locked']) + except Exception: + with excutils.save_and_reraise_exception(): + LOG.exception(_LE('Failed to _get_lun_status. [%s]'), + lun_name) + + if not self.check_value_valid(lun_info, ['status'], string_types): + raise exception.MalformedResponse(cmd='_get_lun_status', + reason=_('status not found')) + if not self.check_value_valid(lun_info, ['is_action_locked'], bool): + raise exception.MalformedResponse(cmd='_get_lun_status', + reason=_('action_locked ' + 'not found')) + + return lun_info['status'], lun_info['is_action_locked'] + + def _get_snapshot_info(self, snapshot_uuid, additional=None): + if not snapshot_uuid: + err = _('Param [snapshot_uuid] is invalid.') + raise exception.InvalidParameterValue(err=err) + + params = {'snapshot_uuid': snapshot_uuid} + if additional is not None: + params['additional'] = additional + + try: + out = self.exec_webapi('SYNO.Core.ISCSI.LUN', + 'get_snapshot', + 1, + **params) + + self.check_response(out, snapshot_id=snapshot_uuid) + except Exception: + with excutils.save_and_reraise_exception(): + LOG.exception(_LE('Failed to _get_snapshot_info. [%s]'), + snapshot_uuid) + + if not self.check_value_valid(out, ['data', 'snapshot'], object): + raise exception.MalformedResponse(cmd='_get_snapshot_info', + reason=_('snapshot info not ' + 'found')) + + return out['data']['snapshot'] + + def _get_snapshot_status(self, snapshot_uuid): + if not snapshot_uuid: + err = _('Param [snapshot_uuid] is invalid.') + raise exception.InvalidParameterValue(err=err) + + try: + snapshot_info = self._get_snapshot_info(snapshot_uuid, + ['status', + 'is_action_locked']) + except Exception: + with excutils.save_and_reraise_exception(): + LOG.exception(_LE('Failed to _get_snapshot_info. [%s]'), + snapshot_uuid) + + if not self.check_value_valid(snapshot_info, ['status'], string_types): + raise exception.MalformedResponse(cmd='_get_snapshot_status', + reason=_('status not found')) + if not self.check_value_valid(snapshot_info, + ['is_action_locked'], + bool): + raise exception.MalformedResponse(cmd='_get_snapshot_status', + reason=_('action_locked ' + 'not found')) + + return snapshot_info['status'], snapshot_info['is_action_locked'] + + def _get_metadata_value(self, obj, key): + if key not in obj['metadata']: + if isinstance(obj, volume.Volume): + raise exception.VolumeMetadataNotFound( + volume_id=obj['id'], + metadata_key=key) + elif isinstance(obj, snapshot.Snapshot): + raise exception.SnapshotMetadataNotFound( + snapshot_id=obj['id'], + metadata_key=key) + else: + raise exception.MetadataAbsent() + + return obj['metadata'][key] + + def _get_backend_name(self): + return self.config.safe_get('volume_backend_name') or 'Synology' + + def _target_create(self, identifier): + if not identifier: + err = _('Param [identifier] is invalid.') + raise exception.InvalidParameterValue(err=err) + + # 0 for no auth, 1 for single chap, 2 for mutual chap + auth_type = 0 + chap_username = '' + chap_password = '' + provider_auth = '' + if self.config.safe_get('use_chap_auth') and self.config.use_chap_auth: + auth_type = 1 + chap_username = (self.config.safe_get('chap_username') or + volutils.generate_username(12)) + chap_password = (self.config.safe_get('chap_password') or + volutils.generate_password()) + provider_auth = ' '.join(('CHAP', chap_username, chap_password)) + + trg_prefix = self.config.safe_get('iscsi_target_prefix') + trg_name = (self.TARGET_NAME_PREFIX + '%s') % identifier + iqn = trg_prefix + trg_name + + try: + out = self.exec_webapi('SYNO.Core.ISCSI.Target', + 'create', + 1, + name=trg_name, + iqn=iqn, + auth_type=auth_type, + user=chap_username, + password=chap_password, + max_sessions=0) + + self.check_response(out) + + except Exception: + with excutils.save_and_reraise_exception(): + LOG.exception(_LE('Failed to _target_create. [%s]'), + identifier) + + if not self.check_value_valid(out, ['data', 'target_id']): + msg = _('Failed to get target_id of target [%s]') % trg_name + raise exception.VolumeDriverException(message=msg) + + trg_id = out['data']['target_id'] + + return iqn, trg_id, provider_auth + + def _target_delete(self, trg_id): + if 0 > trg_id: + err = _('trg_id is invalid: %d.') % trg_id + raise exception.InvalidParameterValue(err=err) + + try: + out = self.exec_webapi('SYNO.Core.ISCSI.Target', + 'delete', + 1, + target_id=('%d' % trg_id)) + + self.check_response(out) + + except Exception: + with excutils.save_and_reraise_exception(): + LOG.exception(_LE('Failed to _target_delete. [%d]'), trg_id) + + # is_map True for map, False for ummap + def _lun_map_unmap_target(self, volume_name, is_map, trg_id): + if 0 > trg_id: + err = _('trg_id is invalid: %d.') % trg_id + raise exception.InvalidParameterValue(err=err) + + try: + lun_uuid = self._get_lun_uuid(volume_name) + out = self.exec_webapi('SYNO.Core.ISCSI.LUN', + 'map_target' if is_map else 'unmap_target', + 1, + uuid=lun_uuid, + target_ids=['%d' % trg_id]) + + self.check_response(out) + + except Exception: + with excutils.save_and_reraise_exception(): + LOG.exception(_LE('Failed to _lun_map_unmap_target.' + '[%(action)s][%(vol)s].'), + {'action': ('map_target' if is_map + else 'unmap_target'), + 'vol': volume_name}) + + def _lun_map_target(self, volume_name, trg_id): + self._lun_map_unmap_target(volume_name, True, trg_id) + + def _lun_unmap_target(self, volume_name, trg_id): + self._lun_map_unmap_target(volume_name, False, trg_id) + + def _modify_lun_name(self, name, new_name): + try: + out = self.exec_webapi('SYNO.Core.ISCSI.LUN', + 'set', + 1, + uuid=name, + new_name=new_name) + + self.check_response(out) + + except Exception: + with excutils.save_and_reraise_exception(): + LOG.exception(_LE('Failed to _modify_lun_name [%s].'), name) + + def _check_lun_status_normal(self, volume_name): + status = '' + try: + while True: + status, locked = self._get_lun_status(volume_name) + if not locked: + break + time.sleep(2) + except Exception: + with excutils.save_and_reraise_exception(): + LOG.exception(_LE('Failed to get lun status. [%s]'), + volume_name) + + LOG.debug(_LE('Lun [%(vol)s], status [%(status)s].'), + {'vol': volume_name, + 'status': status}) + return status == 'normal' + + def _check_snapshot_status_healthy(self, snapshot_uuid): + status = '' + try: + while True: + status, locked = self._get_snapshot_status(snapshot_uuid) + if not locked: + break + time.sleep(2) + except Exception: + with excutils.save_and_reraise_exception(): + LOG.exception(_LE('Failed to get snapshot status. [%s]'), + snapshot_uuid) + + LOG.debug(_LE('Lun [%(snapshot)s], status [%(status)s].'), + {'snapshot': snapshot_uuid, + 'status': status}) + return status == 'Healthy' + + def _check_storage_response(self, out, **kwargs): + data = 'internal error' + exc = exception.VolumeBackendAPIException(data=data) + message = 'Internal error' + + return (message, exc) + + def _check_iscsi_response(self, out, **kwargs): + LUN_BAD_LUN_UUID = 18990505 + LUN_NO_SUCH_SNAPSHOT = 18990532 + + if not self.check_value_valid(out, ['error', 'code'], int): + raise exception.MalformedResponse(cmd='exec_webapi', + reason=_('no error code found')) + + code = out['error']['code'] + exc = None + message = '' + + if code == LUN_BAD_LUN_UUID: + exc = exception.SynoLUNNotExist(**kwargs) + message = 'Bad LUN UUID' + elif code == LUN_NO_SUCH_SNAPSHOT: + exc = exception.SnapshotNotFound(**kwargs) + message = 'No such snapshot' + else: + data = 'internal error' + exc = exception.VolumeBackendAPIException(data=data) + message = 'Internal error' + + message = '%s [%d]' % (message, code) + + return (message, exc) + + def _check_ds_pool_status(self): + pool_info = self._get_pool_info() + if not self.check_value_valid(pool_info, ['readonly'], bool): + raise exception.MalformedResponse(cmd='check_for_setup_error', + reason=_('no readonly found')) + + if pool_info['readonly']: + message = _('pool [%s] is not writable') % self.config.pool_name + raise exception.VolumeDriverException(message=message) + + def _check_ds_version(self): + try: + out = self.exec_webapi('SYNO.Core.System', + 'info', + 1, + type='firmware') + + self.check_response(out) + except Exception: + with excutils.save_and_reraise_exception(): + LOG.exception(_LE('Failed to _check_ds_version')) + + if not self.check_value_valid(out, + ['data', 'firmware_ver'], + string_types): + raise exception.MalformedResponse(cmd='_check_ds_version', + reason=_('data not found')) + firmware_version = out['data']['firmware_ver'] + + # e.g. 'DSM 6.1-7610', 'DSM 6.0.1-7370', 'DSM 6.0-7321 update 3' + version = firmware_version.split()[1].split('-')[0] + versions = version.split('.') + major, minor, hotfix = (versions[0], + versions[1], + versions[2] if len(versions) is 3 else '0') + + major, minor, hotfix = (int(major), int(minor), int(hotfix)) + + if (6 > major) or (major is 6 and minor is 0 and hotfix < 2): + m = (_('DS version %s is not supperted') % + firmware_version) + raise exception.VolumeDriverException(message=m) + + def _check_ds_ability(self): + try: + out = self.exec_webapi('SYNO.Core.System', + 'info', + 1, + type='define') + + self.check_response(out) + except Exception: + with excutils.save_and_reraise_exception(): + LOG.exception(_LE('Failed to _check_ds_ability')) + + if not self.check_value_valid(out, ['data'], dict): + raise exception.MalformedResponse(cmd='_check_ds_ability', + reason=_('data not found')) + define = out['data'] + + if 'usbstation' in define and define['usbstation'] == 'yes': + m = _('usbstation is not supported') + raise exception.VolumeDriverException(message=m) + + if ('support_storage_mgr' not in define + or define['support_storage_mgr'] != 'yes'): + m = _('Storage Manager is not supported in DS') + raise exception.VolumeDriverException(message=m) + + if ('support_iscsi_target' not in define + or define['support_iscsi_target'] != 'yes'): + m = _('iSCSI target feature is not supported in DS') + raise exception.VolumeDriverException(message=m) + + if ('support_vaai' not in define + or define['support_vaai'] != 'yes'): + m = _('VAAI feature is not supported in DS') + raise exception.VolumeDriverException(message=m) + + if ('supportsnapshot' not in define + or define['supportsnapshot'] != 'yes'): + m = _('Snapshot feature is not supported in DS') + raise exception.VolumeDriverException(message=m) + + def check_response(self, out, **kwargs): + if out['success']: + return + + data = 'internal error' + exc = exception.VolumeBackendAPIException(data=data) + message = 'Internal error' + + api = out['api_info']['api'] + + if (api.startswith('SYNO.Core.ISCSI.')): + message, exc = self._check_iscsi_response(out, **kwargs) + elif (api.startswith('SYNO.Core.Storage.')): + message, exc = self._check_storage_response(out, **kwargs) + + LOG.exception(_LE('%(message)s'), {'message': message}) + + raise exc + + def exec_webapi(self, api, method, version, **kwargs): + result = self.synoexec(api, method, version, **kwargs) + + if 'http_status' in result and 200 != result['http_status']: + raise exception.SynoAPIHTTPError(code=result['http_status']) + + result['api_info'] = {'api': api, + 'method': method, + 'version': version} + return result + + def check_value_valid(self, obj, key_array, value_type=None): + curr_obj = obj + for key in key_array: + if key not in curr_obj: + LOG.error(_LE('key [%(key)s] is not in %(obj)s'), + {'key': key, + 'obj': curr_obj}) + return False + curr_obj = curr_obj[key] + + if value_type and not isinstance(curr_obj, value_type): + LOG.error(_LE('[%(obj)s] is %(type)s, not %(value_type)s'), + {'obj': curr_obj, + 'type': type(curr_obj), + 'value_type': value_type}) + return False + + return True + + def get_ip(self): + return self.config.iscsi_ip_address + + def get_provider_location(self, iqn, trg_id): + portals = ['%(ip)s:%(port)d' % {'ip': self.get_ip(), + 'port': self.iscsi_port}] + sec_ips = self.config.safe_get('iscsi_secondary_ip_addresses') + for ip in sec_ips: + portals.append('%(ip)s:%(port)d' % + {'ip': ip, + 'port': self.iscsi_port}) + + return '%s,%d %s 0' % ( + ';'.join(portals), + trg_id, + iqn) + + def is_lun_mapped(self, lun_name): + if not lun_name: + err = _('Param [lun_name] is invalid.') + raise exception.InvalidParameterValue(err=err) + + try: + lun_info = self._get_lun_info(lun_name, ['is_mapped']) + except Exception: + with excutils.save_and_reraise_exception(): + LOG.exception(_LE('Failed to _is_lun_mapped. [%s]'), lun_name) + + if not self.check_value_valid(lun_info, ['is_mapped'], bool): + raise exception.MalformedResponse(cmd='_is_lun_mapped', + reason=_('is_mapped not found')) + + return lun_info['is_mapped'] + + def check_for_setup_error(self): + self._check_ds_pool_status() + self._check_ds_version() + self._check_ds_ability() + + def update_volume_stats(self): + """Update volume statistics.""" + + free_capacity_gb, total_capacity_gb = self._get_pool_size() + + data = {} + data['volume_backend_name'] = self.volume_backend_name + data['vendor_name'] = self.vendor_name + data['storage_protocol'] = self.config.iscsi_protocol + data['consistencygroup_support'] = False + data['QoS_support'] = False + data['thin_provisioning_support'] = True + data['thick_provisioning_support'] = False + data['reserved_percentage'] = 0 + + data['free_capacity_gb'] = free_capacity_gb + data['total_capacity_gb'] = total_capacity_gb + + data['iscsi_ip_address'] = self.config.iscsi_ip_address + data['pool_name'] = self.config.pool_name + data['backend_info'] = ('%s:%s:%s' % + (self.vendor_name, + self.driver_type, + self.host_uuid)) + + return data + + def create_volume(self, volume): + try: + out = self.exec_webapi('SYNO.Core.ISCSI.LUN', + 'create', + 1, + name=volume['name'], + type=self.CINDER_LUN, + location='/' + self.config.pool_name, + size=volume['size'] * units.Gi) + + self.check_response(out) + + except Exception: + with excutils.save_and_reraise_exception(): + LOG.exception(_LE('Failed to create_volume. [%s]'), + volume['name']) + + if not self._check_lun_status_normal(volume['name']): + message = _('Lun [%s] status is not normal') % volume['name'] + raise exception.VolumeDriverException(message=message) + + def delete_volume(self, volume): + try: + lun_uuid = self._get_lun_uuid(volume['name']) + out = self.exec_webapi('SYNO.Core.ISCSI.LUN', + 'delete', + 1, + uuid=lun_uuid) + + self.check_response(out) + + except exception.SynoLUNNotExist: + LOG.warning(_LW('LUN does not exist')) + except Exception: + with excutils.save_and_reraise_exception(): + LOG.exception(_LE('Failed to delete_volume. [%s]'), + volume['name']) + + def create_cloned_volume(self, volume, src_vref): + try: + src_lun_uuid = self._get_lun_uuid(src_vref['name']) + out = self.exec_webapi('SYNO.Core.ISCSI.LUN', + 'clone', + 1, + src_lun_uuid=src_lun_uuid, + dst_lun_name=volume['name'], + is_same_pool=True, + clone_type='CINDER') + self.check_response(out) + + except Exception: + with excutils.save_and_reraise_exception(): + LOG.exception(_LE('Failed to create_cloned_volume. [%s]'), + volume['name']) + + if not self._check_lun_status_normal(volume['name']): + message = _('Lun [%s] status is not normal.') % volume['name'] + raise exception.VolumeDriverException(message=message) + + if src_vref['size'] < volume['size']: + self.extend_volume(volume, volume['size']) + + def extend_volume(self, volume, new_size): + try: + lun_uuid = self._get_lun_uuid(volume['name']) + out = self.exec_webapi('SYNO.Core.ISCSI.LUN', + 'set', + 1, + uuid=lun_uuid, + new_size=new_size * units.Gi) + + self.check_response(out) + + except Exception as e: + LOG.exception(_LE('Failed to extend_volume. [%s]'), + volume['name']) + raise exception.ExtendVolumeError(reason=e.msg) + + def update_migrated_volume(self, volume, new_volume): + try: + self._modify_lun_name(new_volume['name'], volume['name']) + except Exception: + reason = _('Failed to _modify_lun_name [%s].') % new_volume['name'] + raise exception.VolumeMigrationFailed(reason=reason) + + return {'_name_id': None} + + def create_snapshot(self, snapshot): + desc = '(Cinder) ' + (snapshot['id'] or '') + + try: + resp = self.exec_webapi('SYNO.Core.ISCSI.LUN', + 'take_snapshot', + 1, + src_lun_uuid=snapshot['volume']['name'], + is_app_consistent=False, + is_locked=False, + taken_by='Cinder', + description=desc) + + self.check_response(resp) + + except Exception: + with excutils.save_and_reraise_exception(): + LOG.exception(_LE('Failed to create_snapshot. [%s]'), + snapshot['volume']['name']) + + if not self.check_value_valid(resp, + ['data', 'snapshot_uuid'], + string_types): + raise exception.MalformedResponse(cmd='take_snapshot', + reason=_('uuid not found')) + + snapshot_uuid = resp['data']['snapshot_uuid'] + if not self._check_snapshot_status_healthy(snapshot_uuid): + message = (_('Volume [%(vol)s] snapshot [%(snapshot)s] status ' + 'is not healthy.') % + {'vol': snapshot['volume']['name'], + 'snapshot': snapshot_uuid}) + raise exception.VolumeDriverException(message=message) + + return { + 'metadata': { + self.METADATA_DS_SNAPSHOT_UUID: snapshot_uuid + }} + + def delete_snapshot(self, snapshot): + try: + ds_snapshot_uuid = (self._get_metadata_value + (snapshot, self.METADATA_DS_SNAPSHOT_UUID)) + + out = self.exec_webapi('SYNO.Core.ISCSI.LUN', + 'delete_snapshot', + 1, + snapshot_uuid=ds_snapshot_uuid, + delete_by='Cinder') + + self.check_response(out, snapshot_id=snapshot['id']) + + except (exception.SnapshotNotFound, + exception.SnapshotMetadataNotFound): + return + except Exception: + with excutils.save_and_reraise_exception(): + LOG.exception(_LE('Failed to delete_snapshot. [%s]'), + snapshot['id']) + + def create_volume_from_snapshot(self, volume, snapshot): + try: + ds_snapshot_uuid = (self._get_metadata_value + (snapshot, self.METADATA_DS_SNAPSHOT_UUID)) + + out = self.exec_webapi('SYNO.Core.ISCSI.LUN', + 'clone_snapshot', + 1, + src_lun_uuid=snapshot['volume']['name'], + snapshot_uuid=ds_snapshot_uuid, + cloned_lun_name=volume['name'], + clone_type='CINDER') + + self.check_response(out) + + except exception.SnapshotMetadataNotFound: + with excutils.save_and_reraise_exception(): + LOG.exception(_LE('Failed to get snapshot UUID. [%s]'), + snapshot['id']) + except Exception: + with excutils.save_and_reraise_exception(): + LOG.exception(_LE('Failed to create_volume_from_snapshot. ' + '[%s]'), + snapshot['id']) + + if not self._check_lun_status_normal(volume['name']): + message = (_('Volume [%(vol)s] snapshot [%(snapshot)s] status ' + 'is not healthy.') % + {'vol': snapshot['volume']['name'], + 'snapshot': ds_snapshot_uuid}) + raise exception.VolumeDriverException(message=message) + + if snapshot['volume_size'] < volume['size']: + self.extend_volume(volume, volume['size']) + + def get_iqn_and_trgid(self, location): + if not location: + err = _('Param [location] is invalid.') + raise exception.InvalidParameterValue(err=err) + + result = location.split(' ') + if len(result) < 2: + raise exception.InvalidInput(reason=location) + + data = result[0].split(',') + if len(data) < 2: + raise exception.InvalidInput(reason=location) + + iqn = result[1] + trg_id = data[1] + + return iqn, int(trg_id, 10) + + def get_iscsi_properties(self, volume): + if not volume['provider_location']: + err = _("Param volume['provider_location'] is invalid.") + raise exception.InvalidParameterValue(err=err) + + iqn, trg_id = self.get_iqn_and_trgid(volume['provider_location']) + + iscsi_properties = { + 'target_discovered': False, + 'target_iqn': iqn, + 'target_portal': '%(ip)s:%(port)d' % {'ip': self.get_ip(), + 'port': self.iscsi_port}, + 'volume_id': volume['id'], + 'access_mode': 'rw', + 'discard': False + } + ips = self.config.safe_get('iscsi_secondary_ip_addresses') + if ips: + target_portals = [iscsi_properties['target_portal']] + for ip in ips: + target_portals.append('%(ip)s:%(port)d' % + {'ip': ip, + 'port': self.iscsi_port}) + iscsi_properties.update(target_portals=target_portals) + count = len(target_portals) + iscsi_properties.update(target_iqns= + [iscsi_properties['target_iqn']] * count) + iscsi_properties.update(target_lun=0) + iscsi_properties.update(target_luns= + [iscsi_properties['target_lun']] * count) + + if 'provider_auth' in volume: + auth = volume['provider_auth'] + if auth: + try: + (auth_method, auth_username, auth_password) = auth.split() + iscsi_properties['auth_method'] = auth_method + iscsi_properties['auth_username'] = auth_username + iscsi_properties['auth_password'] = auth_password + except Exception: + LOG.error(_LE('Invalid provider_auth: %s'), auth) + + return iscsi_properties + + def create_iscsi_export(self, volume_name, identifier): + iqn, trg_id, provider_auth = self._target_create(identifier) + self._lun_map_target(volume_name, trg_id) + + return iqn, trg_id, provider_auth + + def remove_iscsi_export(self, volume_name, trg_id): + self._lun_unmap_target(volume_name, trg_id) + self._target_delete(trg_id) diff --git a/cinder/volume/drivers/synology/synology_iscsi.py b/cinder/volume/drivers/synology/synology_iscsi.py new file mode 100644 index 000000000..e59a3dcf4 --- /dev/null +++ b/cinder/volume/drivers/synology/synology_iscsi.py @@ -0,0 +1,168 @@ +# Copyright (c) 2016 Synology 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. + +from oslo_log import log as logging +from oslo_utils import excutils + +from cinder import exception +from cinder import interface +from cinder.i18n import _LE, _LW +from cinder.volume import driver +from cinder.volume.drivers.synology import synology_common as common + +LOG = logging.getLogger(__name__) + + +@interface.volumedriver +class SynoISCSIDriver(driver.ISCSIDriver): + """Openstack Cinder drivers for Synology storage. + + Version history: + 1.0.0 - Initial driver. Provide Cinder minimum features + """ + + VERSION = '1.0.0' + + def __init__(self, *args, **kwargs): + super(SynoISCSIDriver, self).__init__(*args, **kwargs) + + self.common = None + self.configuration.append_config_values(common.cinder_opts) + self.stats = {} + + def do_setup(self, context): + self.common = common.SynoCommon(self.configuration, 'iscsi') + + def check_for_setup_error(self): + self.common.check_for_setup_error() + + def create_volume(self, volume): + """Creates a logical volume.""" + + self.common.create_volume(volume) + + def delete_volume(self, volume): + """Deletes a logical volume.""" + + self.common.delete_volume(volume) + + def create_cloned_volume(self, volume, src_vref): + """Creates a clone of the specified volume.""" + + self.common.create_cloned_volume(volume, src_vref) + + def extend_volume(self, volume, new_size): + """Extend an existing volume's size.""" + + if volume['size'] >= new_size: + LOG.error(_LE('New size is smaller than original size. ' + 'New: [%(new)d] Old: [%(old)d]'), + {'new': new_size, + 'old': volume['size']}) + return + + self.common.extend_volume(volume, new_size) + + def create_volume_from_snapshot(self, volume, snapshot): + """Creates a volume from a snapshot.""" + + self.common.create_volume_from_snapshot(volume, snapshot) + + def update_migrated_volume(self, ctxt, volume, new_volume, status): + """Return model update for migrated volume.""" + + return self.common.update_migrated_volume(volume, new_volume) + + def create_snapshot(self, snapshot): + """Creates a snapshot.""" + + return self.common.create_snapshot(snapshot) + + def delete_snapshot(self, snapshot): + """Deletes a snapshot.""" + + self.common.delete_snapshot(snapshot) + + def get_volume_stats(self, refresh=False): + """Get volume status. + + If 'refresh' is True, run update the stats first. + """ + + try: + if refresh or not self.stats: + self.stats = self.common.update_volume_stats() + self.stats['driver_version'] = self.VERSION + except Exception: + with excutils.save_and_reraise_exception(): + LOG.exception(_LE('Failed to get_volume_stats.')) + + return self.stats + + def ensure_export(self, context, volume): + pass + + def create_export(self, context, volume, connector): + model_update = {} + + try: + if self.common.is_lun_mapped(volume['name']): + return model_update + iqn, trg_id, provider_auth = (self.common.create_iscsi_export + (volume['name'], volume['id'])) + except Exception as e: + LOG.exception(_LE('Failed to remove_export.')) + raise exception.ExportFailure(reason=e) + + model_update['provider_location'] = (self.common.get_provider_location + (iqn, trg_id)) + model_update['provider_auth'] = provider_auth + + return model_update + + def remove_export(self, context, volume): + try: + if not self.common.is_lun_mapped(volume['name']): + return + except exception.SynoLUNNotExist: + LOG.warning(_LW("Volume not exist")) + return + + try: + _, trg_id = (self.common.get_iqn_and_trgid + (volume['provider_location'])) + self.common.remove_iscsi_export(volume['name'], trg_id) + except Exception as e: + LOG.exception(_LE('Failed to remove_export.')) + raise exception.RemoveExportException(volume=volume, + reason=e.msg) + + def initialize_connection(self, volume, connector): + LOG.debug('iSCSI initiator: %s', connector['initiator']) + + try: + iscsi_properties = self.common.get_iscsi_properties(volume) + except Exception: + with excutils.save_and_reraise_exception(): + LOG.exception(_LE('Failed to initialize_connection.')) + + volume_type = self.configuration.safe_get('iscsi_protocol') or 'iscsi' + + return { + 'driver_volume_type': volume_type, + 'data': iscsi_properties + } + + def terminate_connection(self, volume, connector, **kwargs): + pass diff --git a/releasenotes/notes/synology-volume-driver-c5e0f655b04390ce.yaml b/releasenotes/notes/synology-volume-driver-c5e0f655b04390ce.yaml new file mode 100644 index 000000000..eae3729b1 --- /dev/null +++ b/releasenotes/notes/synology-volume-driver-c5e0f655b04390ce.yaml @@ -0,0 +1,3 @@ +--- +features: + - Added backend driver for Synology iSCSI-supported storage.