From c79dfa6facbd28d929d2f534b897810b475cbf14 Mon Sep 17 00:00:00 2001 From: Sean McGinnis Date: Thu, 9 Jan 2020 07:21:05 -0600 Subject: [PATCH] Revert "Remove Huawei FusionStorage Driver" This reverts commit 120e3f31ecd3925878c2f0c13988cddcd21729ab. Minor adjustments needed to account for import order enforcement introduced after driver removal. Change-Id: I02064f7911ef30a2ce1e0bd62fffc8777b4c988f --- cinder/opts.py | 3 + .../volume/drivers/fusionstorage/__init__.py | 0 .../drivers/fusionstorage/test_dsware.py | 479 ++++++++++++++++++ .../drivers/fusionstorage/test_fs_client.py | 273 ++++++++++ .../drivers/fusionstorage/test_fs_conf.py | 155 ++++++ .../drivers/fusionstorage/test_utils.py | 48 ++ .../volume/drivers/fusionstorage/__init__.py | 0 .../volume/drivers/fusionstorage/constants.py | 30 ++ cinder/volume/drivers/fusionstorage/dsware.py | 384 ++++++++++++++ .../volume/drivers/fusionstorage/fs_client.py | 256 ++++++++++ .../volume/drivers/fusionstorage/fs_conf.py | 127 +++++ doc/source/reference/support-matrix.ini | 14 + doc/source/reference/support-matrix.rst | 1 - ...orage-driver-removal-dedb8ea4d30df009.yaml | 6 - 14 files changed, 1769 insertions(+), 7 deletions(-) create mode 100644 cinder/tests/unit/volume/drivers/fusionstorage/__init__.py create mode 100644 cinder/tests/unit/volume/drivers/fusionstorage/test_dsware.py create mode 100644 cinder/tests/unit/volume/drivers/fusionstorage/test_fs_client.py create mode 100644 cinder/tests/unit/volume/drivers/fusionstorage/test_fs_conf.py create mode 100644 cinder/tests/unit/volume/drivers/fusionstorage/test_utils.py create mode 100644 cinder/volume/drivers/fusionstorage/__init__.py create mode 100644 cinder/volume/drivers/fusionstorage/constants.py create mode 100644 cinder/volume/drivers/fusionstorage/dsware.py create mode 100644 cinder/volume/drivers/fusionstorage/fs_client.py create mode 100644 cinder/volume/drivers/fusionstorage/fs_conf.py delete mode 100644 releasenotes/notes/huawei-fusionstorage-driver-removal-dedb8ea4d30df009.yaml diff --git a/cinder/opts.py b/cinder/opts.py index 8db24f8d11b..8e8e702673f 100644 --- a/cinder/opts.py +++ b/cinder/opts.py @@ -88,6 +88,8 @@ from cinder.volume.drivers.dell_emc import xtremio as \ cinder_volume_drivers_dell_emc_xtremio from cinder.volume.drivers.fujitsu.eternus_dx import eternus_dx_common as \ cinder_volume_drivers_fujitsu_eternus_dx_eternusdxcommon +from cinder.volume.drivers.fusionstorage import dsware as \ + cinder_volume_drivers_fusionstorage_dsware from cinder.volume.drivers.hpe import hpe_3par_common as \ cinder_volume_drivers_hpe_hpe3parcommon from cinder.volume.drivers.hpe import hpe_lefthand_iscsi as \ @@ -247,6 +249,7 @@ def list_opts(): cinder_volume_driver.scst_opts, cinder_volume_driver.backup_opts, cinder_volume_driver.image_opts, + cinder_volume_drivers_fusionstorage_dsware.volume_opts, cinder_volume_drivers_infortrend_raidcmd_cli_commoncli. infortrend_opts, cinder_volume_drivers_inspur_as13000_as13000driver. diff --git a/cinder/tests/unit/volume/drivers/fusionstorage/__init__.py b/cinder/tests/unit/volume/drivers/fusionstorage/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/cinder/tests/unit/volume/drivers/fusionstorage/test_dsware.py b/cinder/tests/unit/volume/drivers/fusionstorage/test_dsware.py new file mode 100644 index 00000000000..f4fbf5a32f1 --- /dev/null +++ b/cinder/tests/unit/volume/drivers/fusionstorage/test_dsware.py @@ -0,0 +1,479 @@ +# Copyright (c) 2018 Huawei Technologies 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. +import json +from unittest import mock +import uuid + +import ddt + +from cinder import exception +from cinder import objects +from cinder import test +from cinder.volume import configuration as config +from cinder.volume.drivers.fusionstorage import dsware +from cinder.volume.drivers.fusionstorage import fs_client +from cinder.volume.drivers.fusionstorage import fs_conf +from cinder.volume import volume_utils + + +class FakeDSWAREDriver(dsware.DSWAREDriver): + def __init__(self): + self.configuration = config.Configuration(None) + self.conf = fs_conf.FusionStorageConf(self.configuration, "cinder@fs") + self.client = None + + +@ddt.ddt +class TestDSWAREDriver(test.TestCase): + + def setUp(self): + super(TestDSWAREDriver, self).setUp() + self.fake_driver = FakeDSWAREDriver() + self.client = fs_client.RestCommon(None, None, None) + + def tearDown(self): + super(TestDSWAREDriver, self).tearDown() + + @mock.patch.object(fs_client.RestCommon, 'login') + def test_do_setup(self, mock_login): + self.fake_driver.client = fs_client.RestCommon( + 'https://fake_rest_site', 'user', 'password') + update_mocker = self.mock_object( + self.fake_driver.conf, 'update_config_value') + self.fake_driver.configuration.san_address = 'https://fake_rest_site' + self.fake_driver.configuration.san_user = 'fake_san_user' + self.fake_driver.configuration.san_password = 'fake_san_password' + + self.fake_driver.do_setup('context') + update_mocker.assert_called_once_with() + mock_login.assert_called_once_with() + + @mock.patch.object(fs_client.RestCommon, 'query_pool_info') + def test_check_for_setup_error(self, mock_query_pool_info): + self.fake_driver.configuration.pools_name = ['fake_pool_name'] + self.fake_driver.client = fs_client.RestCommon( + 'https://fake_rest_site', 'user', 'password') + result1 = [{'poolName': 'fake_pool_name'}, + {'poolName': 'fake_pool_name1'}] + result2 = [{'poolName': 'fake_pool_name1'}, + {'poolName': 'fake_pool_name2'}] + + mock_query_pool_info.return_value = result1 + retval = self.fake_driver.check_for_setup_error() + self.assertIsNone(retval) + + mock_query_pool_info.return_value = result2 + try: + self.fake_driver.check_for_setup_error() + except Exception as e: + self.assertEqual(exception.InvalidInput, type(e)) + + @mock.patch.object(fs_client.RestCommon, 'query_pool_info') + def test__update_pool_stats(self, mock_query_pool_info): + self.fake_driver.configuration.pools_name = ['fake_pool_name'] + self.fake_driver.client = fs_client.RestCommon( + 'https://fake_rest_site', 'user', 'password') + result = [{'poolName': 'fake_pool_name', + 'totalCapacity': 2048, 'usedCapacity': 1024}, + {'poolName': 'fake_pool_name1', + 'totalCapacity': 2048, 'usedCapacity': 1024}] + + mock_query_pool_info.return_value = result + retval = self.fake_driver._update_pool_stats() + self.assertDictEqual( + {"volume_backend_name": 'FakeDSWAREDriver', + "driver_version": "2.0.9", + "QoS_support": False, + "thin_provisioning_support": False, + "vendor_name": "Huawei", + "storage_protocol": "SCSI", + "pools": + [{"pool_name": 'fake_pool_name', "total_capacity_gb": 2.0, + "free_capacity_gb": 1.0}]}, retval) + mock_query_pool_info.assert_called_once_with() + + @mock.patch.object(fs_client.RestCommon, 'keep_alive') + @mock.patch.object(dsware.DSWAREDriver, '_update_pool_stats') + def test_get_volume_stats(self, mock__update_pool_stats, mock_keep_alive): + self.fake_driver.client = fs_client.RestCommon( + 'https://fake_rest_site', 'user', 'password') + result = {"success"} + mock__update_pool_stats.return_value = result + retval = self.fake_driver.get_volume_stats() + self.assertEqual(result, retval) + mock_keep_alive.assert_called_once_with() + + @mock.patch.object(fs_client.RestCommon, 'query_volume_by_name') + def test__check_volume_exist(self, mock_query_volume_by_name): + self.fake_driver.client = fs_client.RestCommon( + 'https://fake_rest_site', 'user', 'password') + volume = objects.Volume(_name_id=uuid.uuid4()) + result1 = {'volName': 'fake_name'} + result2 = None + + mock_query_volume_by_name.return_value = result1 + retval = self.fake_driver._check_volume_exist(volume) + self.assertEqual(retval, result1) + + mock_query_volume_by_name.return_value = result2 + retval = self.fake_driver._check_volume_exist(volume) + self.assertIsNone(retval) + + @mock.patch.object(volume_utils, 'extract_host') + @mock.patch.object(fs_client.RestCommon, 'query_pool_info') + def test__get_pool_id(self, mock_query_pool_info, mock_extract_host): + self.fake_driver.client = fs_client.RestCommon( + 'https://fake_rest_site', 'user', 'password') + volume = objects.Volume(host='host') + pool_name1 = 'fake_pool_name1' + pool_name2 = 'fake_pool_name2' + pool_info = [{'poolName': 'fake_pool_name', 'poolId': 'fake_id'}, + {'poolName': 'fake_pool_name1', 'poolId': 'fake_id1'}] + + mock_query_pool_info.return_value = pool_info + mock_extract_host.return_value = pool_name1 + retval = self.fake_driver._get_pool_id(volume) + self.assertEqual('fake_id1', retval) + + mock_extract_host.return_value = pool_name2 + try: + self.fake_driver._get_pool_id(volume) + except Exception as e: + self.assertEqual(exception.InvalidInput, type(e)) + + def test__get_vol_name(self): + volume1 = objects.Volume(_name_id=uuid.uuid4()) + volume1.update( + {"provider_location": json.dumps({"name": "fake_name"})}) + volume2 = objects.Volume(_name_id=uuid.uuid4()) + + retval = self.fake_driver._get_vol_name(volume1) + self.assertEqual("fake_name", retval) + + retval = self.fake_driver._get_vol_name(volume2) + self.assertEqual(volume2.name, retval) + + @mock.patch.object(fs_client.RestCommon, 'create_volume') + @mock.patch.object(dsware.DSWAREDriver, '_get_pool_id') + def test_create_volume(self, mock__get_pool_id, mock_create_volume): + self.fake_driver.client = fs_client.RestCommon( + 'https://fake_rest_site', 'user', 'password') + volume = objects.Volume(_name_id=uuid.uuid4(), size=1) + mock__get_pool_id.return_value = 'fake_poolID' + mock_create_volume.return_value = {'result': 0} + + retval = self.fake_driver.create_volume(volume) + self.assertIsNone(retval) + + @mock.patch.object(dsware.DSWAREDriver, '_check_volume_exist') + @mock.patch.object(fs_client.RestCommon, 'delete_volume') + def test_delete_volume(self, mock_delete_volume, mock__check_volume_exist): + result = True + self.fake_driver.client = fs_client.RestCommon( + 'https://fake_rest_site', 'user', 'password') + volume = objects.Volume(_name_id=uuid.uuid4()) + mock_delete_volume.return_value = {'result': 0} + + mock__check_volume_exist.return_value = result + retval = self.fake_driver.delete_volume(volume) + self.assertIsNone(retval) + + mock__check_volume_exist.return_value = False + retval = self.fake_driver.delete_volume(volume) + self.assertIsNone(retval) + + @mock.patch.object(dsware.DSWAREDriver, '_check_volume_exist') + @mock.patch.object(fs_client.RestCommon, 'expand_volume') + def test_extend_volume(self, mock_expand_volume, mock__check_volume_exist): + result1 = True + result2 = False + self.fake_driver.client = fs_client.RestCommon( + 'https://fake_rest_site', 'user', 'password') + volume = objects.Volume(_name_id=uuid.uuid4(), size=2) + mock_expand_volume.return_value = { + 'volName': 'fake_name', 'size': 'new_size'} + + mock__check_volume_exist.return_value = result1 + retval = self.fake_driver.extend_volume(volume=volume, new_size=3) + self.assertIsNone(retval) + + mock__check_volume_exist.return_value = result2 + try: + self.fake_driver.extend_volume(volume=volume, new_size=3) + except Exception as e: + self.assertEqual(exception.VolumeBackendAPIException, type(e)) + + @mock.patch.object(dsware.DSWAREDriver, '_check_volume_exist') + @mock.patch.object(dsware.DSWAREDriver, '_check_snapshot_exist') + @mock.patch.object(fs_client.RestCommon, 'create_volume_from_snapshot') + def test_create_volume_from_snapshot( + self, mock_create_volume_from_snapshot, + mock_check_snapshot_exist, mock_check_volume_exist): + result1 = True + result2 = False + self.fake_driver.client = fs_client.RestCommon( + 'https://fake_rest_site', 'user', 'password') + volume = objects.Volume(_name_id=uuid.uuid4()) + snapshot = objects.Snapshot( + id=uuid.uuid4(), volume_size=2, volume=volume) + + volume1 = objects.Volume(_name_id=uuid.uuid4(), size=2) + volume2 = objects.Volume(_name_id=uuid.uuid4(), size=1) + mock_create_volume_from_snapshot.return_value = {'result': 0} + + mock_check_volume_exist.return_value = result2 + mock_check_snapshot_exist.return_value = result1 + retval = self.fake_driver.create_volume_from_snapshot( + volume1, snapshot) + self.assertIsNone(retval) + + mock_check_volume_exist.return_value = result1 + try: + self.fake_driver.create_volume_from_snapshot(volume1, snapshot) + except Exception as e: + self.assertEqual(exception.VolumeBackendAPIException, type(e)) + + mock_check_volume_exist.return_value = result2 + mock_check_snapshot_exist.return_value = result2 + try: + self.fake_driver.create_volume_from_snapshot(volume1, snapshot) + except Exception as e: + self.assertEqual(exception.VolumeBackendAPIException, type(e)) + + mock_check_volume_exist.return_value = result2 + mock_check_snapshot_exist.return_value = result1 + try: + self.fake_driver.create_volume_from_snapshot(volume2, snapshot) + except Exception as e: + self.assertEqual(exception.VolumeBackendAPIException, type(e)) + + @mock.patch.object(dsware.DSWAREDriver, '_check_volume_exist') + @mock.patch.object(fs_client.RestCommon, 'create_volume_from_volume') + def test_cloned_volume( + self, mock_create_volume_from_volume, mock__check_volume_exist): + self.fake_driver.client = fs_client.RestCommon( + 'https://fake_rest_site', 'user', 'password') + volume = objects.Volume(_name_id=uuid.uuid4(), size=1) + src_volume = objects.Volume(_name_id=uuid.uuid4()) + result1 = True + result2 = False + + mock__check_volume_exist.return_value = result1 + retval = self.fake_driver.create_cloned_volume(volume, src_volume) + self.assertIsNone(retval) + mock_create_volume_from_volume.assert_called_once_with( + vol_name=volume.name, vol_size=volume.size * 1024, + src_vol_name=src_volume.name) + + mock__check_volume_exist.return_value = result2 + try: + self.fake_driver.create_cloned_volume(volume, src_volume) + except Exception as e: + self.assertEqual(exception.VolumeBackendAPIException, type(e)) + + def test__get_snapshot_name(self): + snapshot1 = objects.Snapshot(id=uuid.uuid4()) + snapshot1.update( + {"provider_location": json.dumps({"name": "fake_name"})}) + snapshot2 = objects.Snapshot(id=uuid.uuid4()) + + retval = self.fake_driver._get_snapshot_name(snapshot1) + self.assertEqual("fake_name", retval) + + retval = self.fake_driver._get_snapshot_name(snapshot2) + self.assertEqual(snapshot2.name, retval) + + @mock.patch.object(fs_client.RestCommon, 'query_snapshot_by_name') + @mock.patch.object(dsware.DSWAREDriver, '_get_pool_id') + def test__check_snapshot_exist( + self, mock_get_pool_id, mock_query_snapshot_by_name): + self.fake_driver.client = fs_client.RestCommon( + 'https://fake_rest_site', 'user', 'password') + volume = objects.Volume(_name_id=uuid.uuid4()) + snapshot = objects.Snapshot(id=uuid.uuid4()) + result1 = {'name': 'fake_name', 'totalNum': 1} + result2 = {'name': 'fake_name', 'totalNum': 0} + mock_get_pool_id.return_value = "fake_pool_id" + + mock_query_snapshot_by_name.return_value = result1 + retval = self.fake_driver._check_snapshot_exist(volume, snapshot) + self.assertEqual({'name': 'fake_name', 'totalNum': 1}, retval) + + mock_query_snapshot_by_name.return_value = result2 + retval = self.fake_driver._check_snapshot_exist(volume, snapshot) + self.assertIsNone(retval) + + @mock.patch.object(fs_client.RestCommon, 'create_snapshot') + def test_create_snapshot(self, mock_create_snapshot): + self.fake_driver.client = fs_client.RestCommon( + 'https://fake_rest_site', 'user', 'password') + volume = objects.Volume(_name_id=uuid.uuid4()) + snapshot = objects.Snapshot(id=uuid.uuid4(), + volume_id=uuid.uuid4(), volume=volume) + + retval = self.fake_driver.create_snapshot(snapshot) + self.assertIsNone(retval) + mock_create_snapshot.assert_called_once_with( + snapshot_name=snapshot.name, vol_name=volume.name) + + @mock.patch.object(dsware.DSWAREDriver, '_check_snapshot_exist') + @mock.patch.object(fs_client.RestCommon, 'delete_snapshot') + def test_delete_snapshot(self, mock_delete_snapshot, + mock_check_snapshot_exist): + self.fake_driver.client = fs_client.RestCommon( + 'https://fake_rest_site', 'user', 'password') + volume = objects.Volume(id=uuid.uuid4()) + snapshot = objects.Snapshot(id=uuid.uuid4(), volume=volume) + result = True + mock_delete_snapshot.return_valume = {'result': 0} + + mock_check_snapshot_exist.return_value = result + retval = self.fake_driver.delete_snapshot(snapshot) + self.assertIsNone(retval) + + mock_check_snapshot_exist.return_value = False + retval = self.fake_driver.delete_snapshot(snapshot) + self.assertIsNone(retval) + + def test__get_manager_ip(self): + context = {'host': 'host1'} + host1 = {'host1': '1.1.1.1'} + host2 = {'host2': '1.1.1.1'} + + self.fake_driver.configuration.manager_ips = host1 + retval = self.fake_driver._get_manager_ip(context) + self.assertEqual('1.1.1.1', retval) + + self.fake_driver.configuration.manager_ips = host2 + try: + self.fake_driver._get_manager_ip(context) + except Exception as e: + self.assertEqual(exception.VolumeBackendAPIException, type(e)) + + @mock.patch.object(dsware.DSWAREDriver, '_check_volume_exist') + @mock.patch.object(dsware.DSWAREDriver, '_get_manager_ip') + @mock.patch.object(fs_client.RestCommon, 'attach_volume') + def test__attach_volume(self, mock_attach_volume, + mock__get_manager_ip, mock__check_volume_exist): + self.fake_driver.client = fs_client.RestCommon( + 'https://fake_rest_site', 'user', 'password') + volume = objects.Volume(_name_id=uuid.uuid4()) + attach_result1 = {volume.name: [{'devName': 'fake_path'}]} + attach_result2 = {volume.name: [{'devName': ''}]} + result1 = True + result2 = False + mock__get_manager_ip.return_value = 'fake_ip' + + mock__check_volume_exist.return_value = result1 + mock_attach_volume.return_value = attach_result1 + retval, vol = self.fake_driver._attach_volume( + "context", volume, "properties") + self.assertEqual( + ({'device': {'path': 'fake_path'}}, volume), (retval, vol)) + mock__get_manager_ip.assert_called_once_with("properties") + mock__check_volume_exist.assert_called_once_with(volume) + mock_attach_volume.assert_called_once_with(volume.name, 'fake_ip') + + mock__check_volume_exist.return_value = result2 + try: + self.fake_driver._attach_volume("context", volume, "properties") + except Exception as e: + self.assertEqual(exception.VolumeBackendAPIException, type(e)) + + mock__check_volume_exist.return_value = result1 + mock_attach_volume.return_value = attach_result2 + try: + self.fake_driver._attach_volume("context", volume, "properties") + except Exception as e: + self.assertEqual(exception.VolumeBackendAPIException, type(e)) + + @mock.patch.object(dsware.DSWAREDriver, '_check_volume_exist') + @mock.patch.object(dsware.DSWAREDriver, '_get_manager_ip') + @mock.patch.object(fs_client.RestCommon, 'detach_volume') + def test__detach_volume(self, mock_detach_volume, + mock__get_manager_ip, mock__check_volume_exist): + self.fake_driver.client = fs_client.RestCommon( + 'https://fake_rest_site', 'user', 'password') + volume = objects.Volume(_name_id=uuid.uuid4()) + result1 = True + result2 = False + + mock__get_manager_ip.return_value = 'fake_ip' + mock_detach_volume.return_value = {'result': 0} + + mock__check_volume_exist.return_value = result1 + retval = self.fake_driver._detach_volume( + 'context', 'attach_info', volume, 'properties') + self.assertIsNone(retval) + + mock__check_volume_exist.return_value = result2 + retval = self.fake_driver._detach_volume( + 'context', 'attach_info', volume, 'properties') + self.assertIsNone(retval) + + @mock.patch.object(dsware.DSWAREDriver, '_check_volume_exist') + @mock.patch.object(dsware.DSWAREDriver, '_get_manager_ip') + @mock.patch.object(fs_client.RestCommon, 'attach_volume') + @mock.patch.object(fs_client.RestCommon, 'query_volume_by_name') + def test_initialize_connection(self, mock_query_volume_by_name, + mock_attach_volume, + mock__get_manager_ip, + mock__check_volume_exist): + self.fake_driver.client = fs_client.RestCommon( + 'https://fake_rest_site', 'user', 'password') + volume = objects.Volume(_name_id=uuid.uuid4()) + attach_result = {volume.name: [{'devName': 'fake_path'}]} + + result1 = True + result2 = False + mock__get_manager_ip.return_value = 'fake_ip' + mock_query_volume_by_name.return_value = {'wwn': 'fake_wwn', + 'volName': 'fake_name'} + mock_attach_volume.return_value = attach_result + + mock__check_volume_exist.return_value = result1 + retval = self.fake_driver.initialize_connection(volume, 'connector') + self.assertDictEqual( + {'driver_volume_type': 'local', + 'data': {'device_path': '/dev/disk/by-id/wwn-0xfake_wwn'}}, + retval) + + mock__check_volume_exist.return_value = result2 + try: + self.fake_driver.initialize_connection(volume, 'connector') + except Exception as e: + self.assertEqual(exception.VolumeBackendAPIException, type(e)) + + @mock.patch.object(dsware.DSWAREDriver, '_check_volume_exist') + @mock.patch.object(dsware.DSWAREDriver, '_get_manager_ip') + @mock.patch.object(fs_client.RestCommon, 'detach_volume') + def test_terminate_connection(self, mock_detach_volume, + mock__get_manager_ip, + mock__check_volume_exist): + self.fake_driver.client = fs_client.RestCommon( + 'https://fake_rest_site', 'user', 'password') + volume = objects.Volume(_name_id=uuid.uuid4()) + result1 = True + result2 = False + mock__get_manager_ip.return_value = 'fake_ip' + + mock__check_volume_exist.return_value = result1 + retval = self.fake_driver.terminate_connection(volume, 'connector') + self.assertIsNone(retval) + mock_detach_volume.assert_called_once_with(volume.name, 'fake_ip') + + mock__check_volume_exist.return_value = result2 + retval = self.fake_driver.terminate_connection('volume', 'connector') + self.assertIsNone(retval) diff --git a/cinder/tests/unit/volume/drivers/fusionstorage/test_fs_client.py b/cinder/tests/unit/volume/drivers/fusionstorage/test_fs_client.py new file mode 100644 index 00000000000..f278e251f9f --- /dev/null +++ b/cinder/tests/unit/volume/drivers/fusionstorage/test_fs_client.py @@ -0,0 +1,273 @@ +# Copyright (c) 2018 Huawei Technologies 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. + +import json +from unittest import mock + +import requests + +from cinder import test +from cinder.tests.unit.volume.drivers.fusionstorage import test_utils +from cinder.volume.drivers.fusionstorage import fs_client + + +class FakeSession(test_utils.FakeBaseSession): + method_map = { + 'get': { + 'rest/version': + {'currentVersion': 'fake_version'}, + '/storagePool$': + {'storagePools': [{'poolName': 'fake_pool_name', + 'poolId': 'fake_pool_id'}]}, + r'/storagePool\?poolId=0': + {'storagePools': [{'poolName': 'fake_pool_name1', + 'poolId': 0}]}, + r'/volume/queryByName\?volName=fake_name': + {'errorCode': 0, 'lunDetailInfo': + [{'volume_id': 'fake_id', + 'volume_name': 'fake_name'}]}, + r'/volume/queryById\?volId=fake_id': + {'errorCode': 0, 'lunDetailInfo': + [{'volume_id': 'fake_id', + 'volume_name': 'fake_name'}]}, + r'/lun/wwn/list\?wwn=fake_wwn': + {'errorCode': 0, 'lunDetailInfo': + [{'volume_id': 'fake_id', + 'volume_wwn': 'fake_wwn'}]}, + }, + 'post': { + '/sec/login': {}, + '/sec/logout': {'res': 'fake_logout'}, + '/sec/keepAlive': {'res': 'fake_keepAlive'}, + '/volume/list': {'errorCode': 0, 'volumeList': [ + {'volName': 'fake_name1', 'volId': 'fake_id1'}, + {'volName': 'fake_name2', 'volId': 'fake_id2'}]}, + '/volume/create': {'ID': 'fake_volume_create_id'}, + '/volume/delete': {'ID': 'fake_volume_delete_id'}, + '/volume/attach': + {'fake_name': [{'errorCode': '0', 'ip': 'fake_ip'}]}, + '/volume/detach/': {'ID': 'fake_volume_detach_id'}, + '/volume/expand': {'ID': 'fake_volume_expend_id'}, + '/volume/snapshot/list': + {"snapshotList": [{"snapshot": "fake_name", + "size": "fake_size"}]}, + '/snapshot/list': {'totalNum': 'fake_snapshot_num', + 'snapshotList': + [{'snapName': 'fake_snapName'}]}, + '/snapshot/create/': {'ID': 'fake_snapshot_create_id'}, + '/snapshot/delete/': {'ID': 'fake_snapshot_delete_id'}, + '/snapshot/rollback': {'ID': 'fake_snapshot_delete_id'}, + '/snapshot/volume/create/': {'ID': 'fake_vol_from_snap_id'}, + } + } + + +class TestFsclient(test.TestCase): + def setUp(self): + super(TestFsclient, self).setUp() + self.mock_object(requests, 'Session', FakeSession) + self.client = fs_client.RestCommon('https://fake_rest_site', + 'fake_user', + 'fake_password') + self.client.login() + + def tearDown(self): + super(TestFsclient, self).tearDown() + + def test_login(self): + self.assertEqual('fake_version', + self.client.version) + self.assertEqual('fake_token', + self.client.session.headers['X-Auth-Token']) + + def test_keep_alive(self): + retval = self.client.keep_alive() + self.assertIsNone(retval) + + def test_logout(self): + self.assertIsNone(self.client.logout()) + + def test_query_all_pool_info(self): + with mock.patch.object(self.client.session, 'get', + wraps=self.client.session.get) as mocker: + retval = self.client.query_pool_info() + mocker.assert_called_once_with( + 'https://fake_rest_site/dsware/service/' + 'fake_version/storagePool', timeout=50) + self.assertListEqual( + [{'poolName': 'fake_pool_name', + 'poolId': 'fake_pool_id'}], retval) + + def test_query_pool_info(self): + with mock.patch.object(self.client.session, 'get', + wraps=self.client.session.get) as mocker: + retval = self.client.query_pool_info(pool_id=0) + mocker.assert_called_once_with( + 'https://fake_rest_site/dsware/service/' + 'fake_version/storagePool?poolId=0', timeout=50) + self.assertListEqual( + [{'poolName': 'fake_pool_name1', 'poolId': 0}], retval) + + def test_query_volume_by_name(self): + with mock.patch.object(self.client.session, 'get', + wraps=self.client.session.get) as mocker: + retval = self.client.query_volume_by_name(vol_name='fake_name') + mocker.assert_called_once_with( + 'https://fake_rest_site/dsware/service/fake_version/' + 'volume/queryByName?volName=fake_name', timeout=50) + self.assertListEqual( + [{'volume_id': 'fake_id', 'volume_name': 'fake_name'}], retval) + + def test_query_volume_by_id(self): + with mock.patch.object(self.client.session, 'get', + wraps=self.client.session.get) as mocker: + retval = self.client.query_volume_by_id(vol_id='fake_id') + mocker.assert_called_once_with( + 'https://fake_rest_site/dsware/service/fake_version/' + 'volume/queryById?volId=fake_id', timeout=50) + self.assertListEqual( + [{'volume_id': 'fake_id', 'volume_name': 'fake_name'}], retval) + + def test_create_volume(self): + with mock.patch.object(self.client.session, 'post', + wraps=self.client.session.post) as mocker: + retval = self.client.create_volume( + vol_name='fake_name', vol_size=1, pool_id='fake_id') + except_data = json.dumps( + {"volName": "fake_name", "volSize": 1, "poolId": "fake_id"}) + mocker.assert_called_once_with( + 'https://fake_rest_site/dsware/service/fake_version/' + 'volume/create', data=except_data, timeout=50) + self.assertIsNone(retval) + + def test_delete_volume(self): + with mock.patch.object(self.client.session, 'post', + wraps=self.client.session.post) as mocker: + retval = self.client.delete_volume(vol_name='fake_name') + except_data = json.dumps({"volNames": ['fake_name']}) + mocker.assert_called_once_with( + 'https://fake_rest_site/dsware/service/fake_version/' + 'volume/delete', data=except_data, timeout=50) + self.assertIsNone(retval) + + def test_attach_volume(self): + with mock.patch.object(self.client.session, 'post', + wraps=self.client.session.post) as mocker: + retval = self.client.attach_volume( + vol_name='fake_name', manage_ip='fake_ip') + except_data = json.dumps( + {"volName": ['fake_name'], "ipList": ['fake_ip']}) + mocker.assert_called_once_with( + 'https://fake_rest_site/dsware/service/fake_version/' + 'volume/attach', data=except_data, timeout=50) + self.assertDictEqual( + {'result': 0, + 'fake_name': [{'errorCode': '0', 'ip': 'fake_ip'}]}, + retval) + + def test_detach_volume(self): + with mock.patch.object(self.client.session, 'post', + wraps=self.client.session.post) as mocker: + retval = self.client.detach_volume( + vol_name='fake_name', manage_ip='fake_ip') + except_data = json.dumps( + {"volName": ['fake_name'], "ipList": ['fake_ip']}) + mocker.assert_called_once_with( + 'https://fake_rest_site/dsware/service/fake_version/' + 'volume/detach/', data=except_data, timeout=50) + self.assertIsNone(retval) + + def test_expand_volume(self): + with mock.patch.object(self.client.session, 'post', + wraps=self.client.session.post) as mocker: + retval = self.client.expand_volume( + vol_name='fake_name', new_vol_size=2) + except_data = json.dumps({"volName": 'fake_name', "newVolSize": 2}) + mocker.assert_called_once_with( + 'https://fake_rest_site/dsware/service/fake_version/' + 'volume/expand', data=except_data, timeout=50) + self.assertIsNone(retval) + + def test_query_snapshot_by_name(self): + with mock.patch.object(self.client.session, 'post', + wraps=self.client.session.post) as mocker: + retval = self.client.query_snapshot_by_name( + pool_id='fake_id', snapshot_name='fake_name') + except_data = json.dumps( + {"poolId": 'fake_id', "pageNum": 1, + "pageSize": 1000, "filters": {"volumeName": 'fake_name'}}) + mocker.assert_called_once_with( + 'https://fake_rest_site/dsware/service/fake_version/' + 'snapshot/list', data=except_data, timeout=50) + self.assertDictEqual( + {'result': 0, 'totalNum': 'fake_snapshot_num', + 'snapshotList': [{'snapName': 'fake_snapName'}]}, retval) + + def test_create_snapshot(self): + with mock.patch.object(self.client.session, 'post', + wraps=self.client.session.post) as mocker: + retval = self.client.create_snapshot( + snapshot_name='fake_snap', vol_name='fake_name') + except_data = json.dumps( + {"volName": "fake_name", "snapshotName": "fake_snap"}) + mocker.assert_called_once_with( + 'https://fake_rest_site/dsware/service/fake_version/' + 'snapshot/create/', data=except_data, timeout=50) + self.assertIsNone(retval) + + def test_delete_snapshot(self): + with mock.patch.object(self.client.session, 'post', + wraps=self.client.session.post) as mocker: + retval = self.client.delete_snapshot(snapshot_name='fake_snap') + except_data = json.dumps({"snapshotName": "fake_snap"}) + mocker.assert_called_once_with( + 'https://fake_rest_site/dsware/service/fake_version/' + 'snapshot/delete/', data=except_data, timeout=50) + self.assertIsNone(retval) + + def test_create_volume_from_snapshot(self): + with mock.patch.object(self.client.session, 'post', + wraps=self.client.session.post) as mocker: + retval = self.client.create_volume_from_snapshot( + snapshot_name='fake_snap', vol_name='fake_name', vol_size=2) + except_data = json.dumps({"src": 'fake_snap', + "volName": 'fake_name', + "volSize": 2}) + mocker.assert_called_once_with( + 'https://fake_rest_site/dsware/service/fake_version/' + 'snapshot/volume/create/', data=except_data, timeout=50) + self.assertIsNone(retval) + + @mock.patch.object(fs_client.RestCommon, 'create_snapshot') + @mock.patch.object(fs_client.RestCommon, 'create_volume_from_snapshot') + @mock.patch.object(fs_client.RestCommon, 'delete_snapshot') + def test_create_volume_from_volume( + self, mock_delete_snapshot, mock_volume_from_snapshot, + mock_create_snapshot): + vol_name = 'fake_name' + vol_size = 3 + src_vol_name = 'src_fake_name' + temp_snapshot_name = "temp" + src_vol_name + "clone" + vol_name + + retval = self.client.create_volume_from_volume( + vol_name, vol_size, src_vol_name) + mock_create_snapshot.assert_called_once_with( + vol_name=src_vol_name, snapshot_name=temp_snapshot_name) + mock_volume_from_snapshot.assert_called_once_with( + snapshot_name=temp_snapshot_name, + vol_name=vol_name, vol_size=vol_size) + mock_delete_snapshot.assert_called_once_with( + snapshot_name=temp_snapshot_name) + self.assertIsNone(retval) diff --git a/cinder/tests/unit/volume/drivers/fusionstorage/test_fs_conf.py b/cinder/tests/unit/volume/drivers/fusionstorage/test_fs_conf.py new file mode 100644 index 00000000000..13f16c2c14a --- /dev/null +++ b/cinder/tests/unit/volume/drivers/fusionstorage/test_fs_conf.py @@ -0,0 +1,155 @@ +# Copyright (c) 2018 Huawei Technologies 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. +import os +import shutil +import tempfile +from unittest import mock + +import ddt +from six.moves import configparser + +from cinder import test +from cinder.volume.drivers.fusionstorage import fs_conf + + +@ddt.ddt +class FusionStorageConfTestCase(test.TestCase): + def setUp(self): + super(FusionStorageConfTestCase, self).setUp() + self.tmp_dir = tempfile.mkdtemp() + self.conf = mock.Mock() + self._create_fake_conf_file() + self.fusionstorage_conf = fs_conf.FusionStorageConf( + self.conf, "cinder@fs") + + def tearDown(self): + shutil.rmtree(self.tmp_dir) + super(FusionStorageConfTestCase, self).tearDown() + + def _create_fake_conf_file(self): + self.conf.cinder_fusionstorage_conf_file = ( + self.tmp_dir + '/cinder.conf') + + config = configparser.ConfigParser() + config.add_section('backend_name') + config.set('backend_name', 'dsware_rest_url', 'https://fake_rest_site') + config.set('backend_name', 'san_login', 'fake_user') + config.set('backend_name', 'san_password', 'fake_passwd') + config.set('backend_name', 'dsware_storage_pools', 'fake_pool') + + config.add_section('manager_ip') + config.set('manager_ip', 'fake_host', 'fake_ip') + config.write(open(self.conf.cinder_fusionstorage_conf_file, 'w')) + + @mock.patch.object(fs_conf.FusionStorageConf, '_encode_authentication') + @mock.patch.object(fs_conf.FusionStorageConf, '_pools_name') + @mock.patch.object(fs_conf.FusionStorageConf, '_san_address') + @mock.patch.object(fs_conf.FusionStorageConf, '_san_user') + @mock.patch.object(fs_conf.FusionStorageConf, '_san_password') + def test_update_config_value(self, mock_san_password, mock_san_user, + mock_san_address, mock_pools_name, + mock_encode_authentication): + self.fusionstorage_conf.update_config_value() + mock_encode_authentication.assert_called_once_with() + mock_pools_name.assert_called_once_with() + mock_san_address.assert_called_once_with() + mock_san_user.assert_called_once_with() + mock_san_password.assert_called_once_with() + + @mock.patch.object(os.path, 'exists') + def test__encode_authentication(self, mock_exists): + config = configparser.ConfigParser() + config.read(self.conf.cinder_fusionstorage_conf_file) + mock_exists.return_value = False + + user_name = 'fake_user' + self.mock_object( + self.fusionstorage_conf.configuration, 'safe_get', + return_value=user_name) + self.fusionstorage_conf._encode_authentication() + + password = 'fake_passwd' + self.mock_object( + self.fusionstorage_conf.configuration, 'safe_get', + return_value=password) + self.fusionstorage_conf._encode_authentication() + + @mock.patch.object(os.path, 'exists') + @mock.patch.object(configparser.ConfigParser, 'set') + def test__rewrite_conf(self, mock_set, mock_exists): + mock_exists.return_value = False + mock_set.return_value = "success" + self.fusionstorage_conf._rewrite_conf('fake_name', 'fake_pwd') + + def test__san_address(self): + address = 'https://fake_rest_site' + self.mock_object( + self.fusionstorage_conf.configuration, 'safe_get', + return_value=address) + self.fusionstorage_conf._san_address() + self.assertEqual('https://fake_rest_site', + self.fusionstorage_conf.configuration.san_address) + + def test__san_user(self): + user = '!&&&ZmFrZV91c2Vy' + self.mock_object( + self.fusionstorage_conf.configuration, 'safe_get', + return_value=user) + self.fusionstorage_conf._san_user() + self.assertEqual( + 'fake_user', self.fusionstorage_conf.configuration.san_user) + + user = 'fake_user_2' + self.mock_object( + self.fusionstorage_conf.configuration, 'safe_get', + return_value=user) + self.fusionstorage_conf._san_user() + self.assertEqual( + 'fake_user_2', self.fusionstorage_conf.configuration.san_user) + + def test__san_password(self): + password = '!&&&ZmFrZV9wYXNzd2Q=' + self.mock_object( + self.fusionstorage_conf.configuration, 'safe_get', + return_value=password) + self.fusionstorage_conf._san_password() + self.assertEqual( + 'fake_passwd', self.fusionstorage_conf.configuration.san_password) + + password = 'fake_passwd_2' + self.mock_object( + self.fusionstorage_conf.configuration, 'safe_get', + return_value=password) + self.fusionstorage_conf._san_password() + self.assertEqual('fake_passwd_2', + self.fusionstorage_conf.configuration.san_password) + + def test__pools_name(self): + pools_name = 'fake_pool' + self.mock_object( + self.fusionstorage_conf.configuration, 'safe_get', + return_value=pools_name) + self.fusionstorage_conf._pools_name() + self.assertListEqual( + ['fake_pool'], self.fusionstorage_conf.configuration.pools_name) + + def test__manager_ip(self): + manager_ips = {'fake_host': 'fake_ip'} + self.mock_object( + self.fusionstorage_conf.configuration, 'safe_get', + return_value=manager_ips) + self.fusionstorage_conf._manager_ip() + self.assertDictEqual({'fake_host': 'fake_ip'}, + self.fusionstorage_conf.configuration.manager_ips) diff --git a/cinder/tests/unit/volume/drivers/fusionstorage/test_utils.py b/cinder/tests/unit/volume/drivers/fusionstorage/test_utils.py new file mode 100644 index 00000000000..a22d5027035 --- /dev/null +++ b/cinder/tests/unit/volume/drivers/fusionstorage/test_utils.py @@ -0,0 +1,48 @@ +# Copyright (c) 2018 Huawei Technologies 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. +import json +import re + +import requests + + +class FakeBaseSession(requests.Session): + method_map = {} + + def _get_response(self, method, url): + url_map = self.method_map.get(method, {}) + tmp = None + data = {} + for k in url_map: + if re.search(k, url): + if not tmp or len(tmp) < len(k): + data = url_map[k] + tmp = k + + resp_content = {'result': 0} + resp_content.update(data) + resp = requests.Response() + resp.headers['X-Auth-Token'] = 'fake_token' + resp.status_code = 0 + resp.encoding = 'utf-8' + resp._content = json.dumps(resp_content).encode('utf-8') + + return resp + + def get(self, url, **kwargs): + return self._get_response('get', url) + + def post(self, url, **kwargs): + return self._get_response('post', url) diff --git a/cinder/volume/drivers/fusionstorage/__init__.py b/cinder/volume/drivers/fusionstorage/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/cinder/volume/drivers/fusionstorage/constants.py b/cinder/volume/drivers/fusionstorage/constants.py new file mode 100644 index 00000000000..71058793b18 --- /dev/null +++ b/cinder/volume/drivers/fusionstorage/constants.py @@ -0,0 +1,30 @@ +# Copyright (c) 2016 Huawei Technologies 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. + +DEFAULT_TIMEOUT = 50 +LOGIN_SOCKET_TIMEOUT = 32 + +CONNECT_ERROR = 403 +ERROR_UNAUTHORIZED = 10000003 +VOLUME_NOT_EXIST = (31000000, 50150005) + +BASIC_URI = '/dsware/service/' +CONF_PATH = "/etc/cinder/cinder.conf" + +CONF_ADDRESS = "dsware_rest_url" +CONF_MANAGER_IP = "manager_ips" +CONF_POOLS = "dsware_storage_pools" +CONF_PWD = "san_password" +CONF_USER = "san_login" diff --git a/cinder/volume/drivers/fusionstorage/dsware.py b/cinder/volume/drivers/fusionstorage/dsware.py new file mode 100644 index 00000000000..4191445d7f6 --- /dev/null +++ b/cinder/volume/drivers/fusionstorage/dsware.py @@ -0,0 +1,384 @@ +# Copyright (c) 2018 Huawei Technologies 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. + +import json + +from oslo_config import cfg +from oslo_log import log as logging +from oslo_utils import units + +from cinder import exception +from cinder.i18n import _ +from cinder import interface +from cinder.volume import driver +from cinder.volume.drivers.fusionstorage import fs_client +from cinder.volume.drivers.fusionstorage import fs_conf +from cinder.volume.drivers.san import san +from cinder.volume import volume_utils + +LOG = logging.getLogger(__name__) + +volume_opts = [ + cfg.BoolOpt("dsware_isthin", + default=False, + help='The flag of thin storage allocation.', + deprecated_for_removal=True, + deprecated_since='14.0.0', + deprecated_reason='FusionStorage cinder driver refactored the ' + 'code with Restful method and the old CLI ' + 'mode has been abandon. So those ' + 'configuration items are no longer used.'), + cfg.StrOpt("dsware_manager", + default='', + help='Fusionstorage manager ip addr for cinder-volume.', + deprecated_for_removal=True, + deprecated_since='14.0.0', + deprecated_reason='FusionStorage cinder driver refactored the ' + 'code with Restful method and the old CLI ' + 'mode has been abandon. So those ' + 'configuration items are no longer used.'), + cfg.StrOpt('fusionstorageagent', + default='', + help='Fusionstorage agent ip addr range', + deprecated_for_removal=True, + deprecated_since='14.0.0', + deprecated_reason='FusionStorage cinder driver refactored the ' + 'code with Restful method and the old CLI ' + 'mode has been abandon. So those ' + 'configuration items are no longer used.'), + cfg.StrOpt('pool_type', + default='default', + help='Pool type, like sata-2copy', + deprecated_for_removal=True, + deprecated_since='14.0.0', + deprecated_reason='FusionStorage cinder driver refactored the ' + 'code with Restful method and the old CLI ' + 'mode has been abandon. So those ' + 'configuration items are no longer used.'), + cfg.ListOpt('pool_id_filter', + default=[], + help='Pool id permit to use', + deprecated_for_removal=True, + deprecated_since='14.0.0', + deprecated_reason='FusionStorage cinder driver refactored the ' + 'code with Restful method and the old CLI ' + 'mode has been abandon. So those ' + 'configuration items are no longer used.'), + cfg.IntOpt('clone_volume_timeout', + default=680, + help='Create clone volume timeout', + deprecated_for_removal=True, + deprecated_since='14.0.0', + deprecated_reason='FusionStorage cinder driver refactored the ' + 'code with Restful method and the old CLI ' + 'mode has been abandon. So those ' + 'configuration items are no longer used.'), + cfg.DictOpt('manager_ips', + default={}, + help='This option is to support the FSA to mount across the ' + 'different nodes. The parameters takes the standard dict ' + 'config form, manager_ips = host1:ip1, host2:ip2...'), + cfg.StrOpt('dsware_rest_url', + default='', + help='The address of FusionStorage array. For example, ' + '"dsware_rest_url=xxx"'), + cfg.StrOpt('dsware_storage_pools', + default="", + help='The list of pools on the FusionStorage array, the ' + 'semicolon(;) was used to split the storage pools, ' + '"dsware_storage_pools = xxx1; xxx2; xxx3"') +] + +CONF = cfg.CONF +CONF.register_opts(volume_opts) + + +@interface.volumedriver +class DSWAREDriver(driver.VolumeDriver): + VERSION = '2.0' + CI_WIKI_NAME = 'Huawei_FusionStorage_CI' + + # TODO(jsbryant) Remove driver in the 'U' release due to no py37 support. + SUPPORTED = False + + def __init__(self, *args, **kwargs): + super(DSWAREDriver, self).__init__(*args, **kwargs) + + if not self.configuration: + msg = _('Configuration is not found.') + LOG.error(msg) + raise exception.InvalidInput(reason=msg) + + self.configuration.append_config_values(volume_opts) + self.configuration.append_config_values(san.san_opts) + self.conf = fs_conf.FusionStorageConf(self.configuration, self.host) + self.client = None + + @staticmethod + def get_driver_options(): + return volume_opts + + def do_setup(self, context): + self.conf.update_config_value() + url_str = self.configuration.san_address + url_user = self.configuration.san_user + url_password = self.configuration.san_password + + self.client = fs_client.RestCommon( + fs_address=url_str, fs_user=url_user, + fs_password=url_password) + self.client.login() + + def check_for_setup_error(self): + all_pools = self.client.query_pool_info() + all_pools_name = [p['poolName'] for p in all_pools + if p.get('poolName')] + + for pool in self.configuration.pools_name: + if pool not in all_pools_name: + msg = _('Storage pool %(pool)s does not exist ' + 'in the FusionStorage.') % {'pool': pool} + LOG.error(msg) + raise exception.InvalidInput(reason=msg) + + def _update_pool_stats(self): + backend_name = self.configuration.safe_get( + 'volume_backend_name') or self.__class__.__name__ + data = {"volume_backend_name": backend_name, + "driver_version": "2.0.9", + "QoS_support": False, + "thin_provisioning_support": False, + "pools": [], + "vendor_name": "Huawei", + "storage_protocol": "SCSI", + } + all_pools = self.client.query_pool_info() + + for pool in all_pools: + if pool['poolName'] in self.configuration.pools_name: + single_pool_info = self._update_single_pool_info_status(pool) + data['pools'].append(single_pool_info) + return data + + def _get_capacity(self, pool_info): + pool_capacity = {} + + total = float(pool_info['totalCapacity']) / units.Ki + free = (float(pool_info['totalCapacity']) - + float(pool_info['usedCapacity'])) / units.Ki + pool_capacity['total_capacity_gb'] = total + pool_capacity['free_capacity_gb'] = free + + return pool_capacity + + def _update_single_pool_info_status(self, pool_info): + status = {} + capacity = self._get_capacity(pool_info=pool_info) + status.update({ + "pool_name": pool_info['poolName'], + "total_capacity_gb": capacity['total_capacity_gb'], + "free_capacity_gb": capacity['free_capacity_gb'], + }) + return status + + def get_volume_stats(self, refresh=False): + self.client.keep_alive() + stats = self._update_pool_stats() + return stats + + def _check_volume_exist(self, volume): + vol_name = self._get_vol_name(volume) + result = self.client.query_volume_by_name(vol_name=vol_name) + if result: + return result + + def _raise_exception(self, msg): + LOG.error(msg) + raise exception.VolumeBackendAPIException(data=msg) + + def _get_pool_id(self, volume): + pool_id = None + pool_name = volume_utils.extract_host(volume.host, level='pool') + all_pools = self.client.query_pool_info() + for pool in all_pools: + if pool_name == pool['poolName']: + pool_id = pool['poolId'] + + if pool_id is None: + msg = _('Storage pool %(pool)s does not exist on the array. ' + 'Please check.') % {"pool": pool_id} + LOG.error(msg) + raise exception.InvalidInput(reason=msg) + return pool_id + + def _get_vol_name(self, volume): + provider_location = volume.get("provider_location", None) + if provider_location: + vol_name = json.loads(provider_location).get("name") + else: + vol_name = volume.name + return vol_name + + def create_volume(self, volume): + pool_id = self._get_pool_id(volume) + vol_name = volume.name + vol_size = volume.size + vol_size *= units.Ki + self.client.create_volume( + pool_id=pool_id, vol_name=vol_name, vol_size=vol_size) + + def delete_volume(self, volume): + vol_name = self._get_vol_name(volume) + if self._check_volume_exist(volume): + self.client.delete_volume(vol_name=vol_name) + + def extend_volume(self, volume, new_size): + vol_name = self._get_vol_name(volume) + if not self._check_volume_exist(volume): + msg = _("Volume: %(vol_name)s does not exist!" + ) % {"vol_name": vol_name} + self._raise_exception(msg) + else: + new_size *= units.Ki + self.client.expand_volume(vol_name, new_size) + + def _check_snapshot_exist(self, volume, snapshot): + pool_id = self._get_pool_id(volume) + snapshot_name = self._get_snapshot_name(snapshot) + result = self.client.query_snapshot_by_name( + pool_id=pool_id, snapshot_name=snapshot_name) + if result.get('totalNum'): + return result + + def _get_snapshot_name(self, snapshot): + provider_location = snapshot.get("provider_location", None) + if provider_location: + snapshot_name = json.loads(provider_location).get("name") + else: + snapshot_name = snapshot.name + return snapshot_name + + def create_volume_from_snapshot(self, volume, snapshot): + vol_name = self._get_vol_name(volume) + snapshot_name = self._get_snapshot_name(snapshot) + vol_size = volume.size + + if not self._check_snapshot_exist(snapshot.volume, snapshot): + msg = _("Snapshot: %(name)s does not exist!" + ) % {"name": snapshot_name} + self._raise_exception(msg) + elif self._check_volume_exist(volume): + msg = _("Volume: %(vol_name)s already exists!" + ) % {'vol_name': vol_name} + self._raise_exception(msg) + else: + vol_size *= units.Ki + self.client.create_volume_from_snapshot( + snapshot_name=snapshot_name, vol_name=vol_name, + vol_size=vol_size) + + def create_cloned_volume(self, volume, src_volume): + vol_name = self._get_vol_name(volume) + src_vol_name = self._get_vol_name(src_volume) + + vol_size = volume.size + vol_size *= units.Ki + + if not self._check_volume_exist(src_volume): + msg = _("Volume: %(vol_name)s does not exist!" + ) % {"vol_name": src_vol_name} + self._raise_exception(msg) + else: + self.client.create_volume_from_volume( + vol_name=vol_name, vol_size=vol_size, + src_vol_name=src_vol_name) + + def create_snapshot(self, snapshot): + snapshot_name = self._get_snapshot_name(snapshot) + vol_name = self._get_vol_name(snapshot.volume) + + self.client.create_snapshot( + snapshot_name=snapshot_name, vol_name=vol_name) + + def delete_snapshot(self, snapshot): + snapshot_name = self._get_snapshot_name(snapshot) + + if self._check_snapshot_exist(snapshot.volume, snapshot): + self.client.delete_snapshot(snapshot_name=snapshot_name) + + def _get_manager_ip(self, context): + if self.configuration.manager_ips.get(context['host']): + return self.configuration.manager_ips.get(context['host']) + else: + msg = _("The required host: %(host)s and its manager ip are not " + "included in the configuration file." + ) % {"host": context['host']} + LOG.error(msg) + raise exception.VolumeBackendAPIException(msg) + + def _attach_volume(self, context, volume, properties, remote=False): + vol_name = self._get_vol_name(volume) + if not self._check_volume_exist(volume): + msg = _("Volume: %(vol_name)s does not exist!" + ) % {"vol_name": vol_name} + self._raise_exception(msg) + manager_ip = self._get_manager_ip(properties) + result = self.client.attach_volume(vol_name, manager_ip) + attach_path = result[vol_name][0]['devName'] + attach_info = dict() + attach_info['device'] = dict() + attach_info['device']['path'] = attach_path + if attach_path == '': + msg = _("Host attach volume failed!") + LOG.error(msg) + raise exception.VolumeBackendAPIException(data=msg) + return attach_info, volume + + def _detach_volume(self, context, attach_info, volume, properties, + force=False, remote=False, ignore_errors=False): + vol_name = self._get_vol_name(volume) + if self._check_volume_exist(volume): + manager_ip = self._get_manager_ip(properties) + self.client.detach_volume(vol_name, manager_ip) + + def initialize_connection(self, volume, connector): + vol_name = self._get_vol_name(volume) + manager_ip = self._get_manager_ip(connector) + if not self._check_volume_exist(volume): + msg = _("Volume: %(vol_name)s does not exist!" + ) % {"vol_name": vol_name} + self._raise_exception(msg) + self.client.attach_volume(vol_name, manager_ip) + volume_info = self.client.query_volume_by_name(vol_name=vol_name) + vol_wwn = volume_info.get('wwn') + by_id_path = "/dev/disk/by-id/" + "wwn-0x%s" % vol_wwn + properties = {'device_path': by_id_path} + return {'driver_volume_type': 'local', + 'data': properties} + + def terminate_connection(self, volume, connector, **kwargs): + if self._check_volume_exist(volume): + manager_ip = self._get_manager_ip(connector) + vol_name = self._get_vol_name(volume) + self.client.detach_volume(vol_name, manager_ip) + + def create_export(self, context, volume, connector): + pass + + def ensure_export(self, context, volume): + pass + + def remove_export(self, context, volume): + pass diff --git a/cinder/volume/drivers/fusionstorage/fs_client.py b/cinder/volume/drivers/fusionstorage/fs_client.py new file mode 100644 index 00000000000..35fa6d33b4d --- /dev/null +++ b/cinder/volume/drivers/fusionstorage/fs_client.py @@ -0,0 +1,256 @@ +# Copyright (c) 2018 Huawei Technologies 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. + +import json + +from oslo_log import log as logging +import requests +import six + +from cinder import exception +from cinder.i18n import _ +from cinder.volume.drivers.fusionstorage import constants + +LOG = logging.getLogger(__name__) + + +class RestCommon(object): + def __init__(self, fs_address, fs_user, fs_password): + self.address = fs_address + self.user = fs_user + self.password = fs_password + + self.session = None + self.token = None + self.version = None + + self.init_http_head() + + LOG.warning("Suppressing requests library SSL Warnings") + requests.packages.urllib3.disable_warnings( + requests.packages.urllib3.exceptions.InsecureRequestWarning) + requests.packages.urllib3.disable_warnings( + requests.packages.urllib3.exceptions.InsecurePlatformWarning) + + def init_http_head(self): + self.session = requests.Session() + self.session.headers.update({ + "Content-Type": "application/json;charset=UTF-8", + }) + self.session.verify = False + + def call(self, url, method, data=None, + call_timeout=constants.DEFAULT_TIMEOUT, + get_version=False, filter_flag=False, json_flag=False): + kwargs = {'timeout': call_timeout} + if data: + kwargs['data'] = json.dumps(data) + + if not get_version: + call_url = self.address + constants.BASIC_URI + self.version + url + else: + call_url = self.address + constants.BASIC_URI + url + + func = getattr(self.session, method.lower()) + + try: + result = func(call_url, **kwargs) + except Exception as err: + LOG.error('Bad response from server: %(url)s. ' + 'Error: %(err)s'), {'url': url, 'err': err} + return {"error": { + "code": constants.CONNECT_ERROR, + "description": "Connect to server error."}} + + try: + result.raise_for_status() + except requests.HTTPError as exc: + return {"error": {"code": exc.response.status_code, + "description": six.text_type(exc)}} + + if not filter_flag: + LOG.info(''' + Request URL: %(url)s, + Call Method: %(method)s, + Request Data: %(data)s, + Response Data: %(res)s, + Result Data: %(res_json)s''', {'url': url, 'method': method, + 'data': data, 'res': result, + 'res_json': result.json()}) + + if json_flag: + return result + else: + return result.json() + + def _assert_rest_result(self, result, err_str): + if result.get('result') != 0: + msg = (_('%(err)s\nresult: %(res)s.') % {'err': err_str, + 'res': result}) + LOG.error(msg) + raise exception.VolumeBackendAPIException(data=msg) + + def get_version(self): + url = 'rest/version' + self.session.headers.update({ + "Referer": self.address + constants.BASIC_URI + }) + result = self.call(url=url, method='GET', get_version=True) + self._assert_rest_result(result, _('Get version session error.')) + if result.get("currentVersion"): + self.version = result["currentVersion"] + + def login(self): + self.get_version() + url = '/sec/login' + data = {"userName": self.user, "password": self.password} + result = self.call(url, 'POST', data=data, + call_timeout=constants.LOGIN_SOCKET_TIMEOUT, + filter_flag=True, json_flag=True) + self._assert_rest_result(result.json(), _('Login session error.')) + self.token = result.headers['X-Auth-Token'] + + self.session.headers.update({ + "x-auth-token": self.token + }) + + def logout(self): + url = '/sec/logout' + if self.address: + result = self.call(url, 'POST') + self._assert_rest_result(result, _('Logout session error.')) + + def keep_alive(self): + url = '/sec/keepAlive' + result = self.call(url, 'POST', filter_flag=True) + + if result.get('result') == constants.ERROR_UNAUTHORIZED: + try: + self.login() + except Exception: + LOG.error('The FusionStorage may have been powered off. ' + 'Power on the FusionStorage and then log in.') + raise + else: + self._assert_rest_result(result, _('Keep alive session error.')) + + def query_pool_info(self, pool_id=None): + pool_id = str(pool_id) + if pool_id != 'None': + url = '/storagePool' + '?poolId=' + pool_id + else: + url = '/storagePool' + result = self.call(url, 'GET', filter_flag=True) + self._assert_rest_result(result, _("Query pool session error.")) + return result['storagePools'] + + def query_volume_by_name(self, vol_name): + url = '/volume/queryByName?volName=' + vol_name + result = self.call(url, 'GET') + if result.get('errorCode') in constants.VOLUME_NOT_EXIST: + return None + self._assert_rest_result( + result, _("Query volume by name session error")) + return result.get('lunDetailInfo') + + def query_volume_by_id(self, vol_id): + url = '/volume/queryById?volId=' + vol_id + result = self.call(url, 'GET') + if result.get('errorCode') in constants.VOLUME_NOT_EXIST: + return None + self._assert_rest_result( + result, _("Query volume by ID session error")) + return result.get('lunDetailInfo') + + def create_volume(self, vol_name, vol_size, pool_id): + url = '/volume/create' + params = {"volName": vol_name, "volSize": vol_size, "poolId": pool_id} + result = self.call(url, "POST", params) + self._assert_rest_result(result, _('Create volume session error.')) + + def delete_volume(self, vol_name): + url = '/volume/delete' + params = {"volNames": [vol_name]} + result = self.call(url, "POST", params) + self._assert_rest_result(result, _('Delete volume session error.')) + + def attach_volume(self, vol_name, manage_ip): + url = '/volume/attach' + params = {"volName": [vol_name], "ipList": [manage_ip]} + result = self.call(url, "POST", params) + self._assert_rest_result(result, _('Attach volume session error.')) + + if int(result[vol_name][0]['errorCode']) != 0: + msg = _("Host attach volume failed!") + LOG.error(msg) + raise exception.VolumeBackendAPIException(data=msg) + return result + + def detach_volume(self, vol_name, manage_ip): + url = '/volume/detach/' + params = {"volName": [vol_name], "ipList": [manage_ip]} + result = self.call(url, "POST", params) + self._assert_rest_result(result, _('Detach volume session error.')) + + def expand_volume(self, vol_name, new_vol_size): + url = '/volume/expand' + params = {"volName": vol_name, "newVolSize": new_vol_size} + result = self.call(url, "POST", params) + self._assert_rest_result(result, _('Expand volume session error.')) + + def query_snapshot_by_name(self, pool_id, snapshot_name, page_num=1, + page_size=1000): + # Filter the snapshot according to the name, while the "page_num" and + # "page_size" must be set while using the interface. + url = '/snapshot/list' + params = {"poolId": pool_id, "pageNum": page_num, + "pageSize": page_size, + "filters": {"volumeName": snapshot_name}} + + result = self.call(url, "POST", params) + self._assert_rest_result( + result, _('query snapshot list session error.')) + return result + + def create_snapshot(self, snapshot_name, vol_name): + url = '/snapshot/create/' + params = {"volName": vol_name, "snapshotName": snapshot_name} + result = self.call(url, "POST", params) + self._assert_rest_result(result, _('Create snapshot error.')) + + def delete_snapshot(self, snapshot_name): + url = '/snapshot/delete/' + params = {"snapshotName": snapshot_name} + result = self.call(url, "POST", params) + self._assert_rest_result(result, _('Delete snapshot session error.')) + + def create_volume_from_snapshot(self, snapshot_name, vol_name, vol_size): + url = '/snapshot/volume/create/' + params = {"src": snapshot_name, "volName": vol_name, + "volSize": vol_size} + result = self.call(url, "POST", params) + self._assert_rest_result( + result, _('create volume from snapshot session error.')) + + def create_volume_from_volume(self, vol_name, vol_size, src_vol_name): + temp_snapshot_name = "temp" + src_vol_name + "clone" + vol_name + + self.create_snapshot(vol_name=src_vol_name, + snapshot_name=temp_snapshot_name) + + self.create_volume_from_snapshot(snapshot_name=temp_snapshot_name, + vol_name=vol_name, vol_size=vol_size) + + self.delete_snapshot(snapshot_name=temp_snapshot_name) diff --git a/cinder/volume/drivers/fusionstorage/fs_conf.py b/cinder/volume/drivers/fusionstorage/fs_conf.py new file mode 100644 index 00000000000..378fff57b5d --- /dev/null +++ b/cinder/volume/drivers/fusionstorage/fs_conf.py @@ -0,0 +1,127 @@ +# Copyright (c) 2018 Huawei Technologies 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. + +import base64 +import os + +from oslo_log import log as logging +import six +from six.moves import configparser + +from cinder import exception +from cinder.i18n import _ +from cinder import utils +from cinder.volume.drivers.fusionstorage import constants + + +LOG = logging.getLogger(__name__) + + +class FusionStorageConf(object): + def __init__(self, configuration, host): + self.configuration = configuration + self._check_host(host) + + def _check_host(self, host): + if host and len(host.split('@')) > 1: + self.host = host.split('@')[1] + else: + msg = _("The host %s is not reliable. Please check cinder-volume " + "backend.") % host + LOG.error(msg) + raise exception.InvalidInput(reason=msg) + + def update_config_value(self): + self._encode_authentication() + self._pools_name() + self._san_address() + self._san_user() + self._san_password() + + def _encode_authentication(self): + name_node = self.configuration.safe_get(constants.CONF_USER) + pwd_node = self.configuration.safe_get(constants.CONF_PWD) + + need_encode = False + if name_node is not None and not name_node.startswith('!&&&'): + encoded = base64.b64encode(six.b(name_node)).decode() + name_node = '!&&&' + encoded + need_encode = True + + if pwd_node is not None and not pwd_node.startswith('!&&&'): + encoded = base64.b64encode(six.b(pwd_node)).decode() + pwd_node = '!&&&' + encoded + need_encode = True + + if need_encode: + self._rewrite_conf(name_node, pwd_node) + + def _rewrite_conf(self, name_node, pwd_node): + if os.path.exists(constants.CONF_PATH): + utils.execute("chmod", "666", constants.CONF_PATH, + run_as_root=True) + conf = configparser.ConfigParser() + conf.read(constants.CONF_PATH) + if name_node: + conf.set(self.host, constants.CONF_USER, name_node) + if pwd_node: + conf.set(self.host, constants.CONF_PWD, pwd_node) + fh = open(constants.CONF_PATH, 'w') + conf.write(fh) + fh.close() + utils.execute("chmod", "644", constants.CONF_PATH, + run_as_root=True) + + def _assert_text_result(self, text, mess): + if not text: + msg = _("%s is not configured.") % mess + LOG.error(msg) + raise exception.InvalidInput(reason=msg) + + def _san_address(self): + address = self.configuration.safe_get(constants.CONF_ADDRESS) + self._assert_text_result(address, mess=constants.CONF_ADDRESS) + setattr(self.configuration, 'san_address', address) + + def _decode_text(self, text): + return (base64.b64decode(six.b(text[4:])).decode() if + text.startswith('!&&&') else text) + + def _san_user(self): + user_text = self.configuration.safe_get(constants.CONF_USER) + self._assert_text_result(user_text, mess=constants.CONF_USER) + user = self._decode_text(user_text) + setattr(self.configuration, 'san_user', user) + + def _san_password(self): + pwd_text = self.configuration.safe_get(constants.CONF_PWD) + self._assert_text_result(pwd_text, mess=constants.CONF_PWD) + pwd = self._decode_text(pwd_text) + setattr(self.configuration, 'san_password', pwd) + + def _pools_name(self): + pools_name = self.configuration.safe_get(constants.CONF_POOLS) + self._assert_text_result(pools_name, mess=constants.CONF_POOLS) + pools = set(x.strip() for x in pools_name.split(';') if x.strip()) + if not pools: + msg = _('No valid storage pool configured.') + LOG.error(msg) + raise exception.InvalidInput(msg) + setattr(self.configuration, 'pools_name', list(pools)) + + def _manager_ip(self): + manager_ips = self.configuration.safe_get(constants.CONF_MANAGER_IP) + self._assert_text_result(manager_ips, mess=constants.CONF_MANAGER_IP) + setattr(self.configuration, 'manager_ips', manager_ips) diff --git a/doc/source/reference/support-matrix.ini b/doc/source/reference/support-matrix.ini index a3867f4b670..a31053ebb66 100644 --- a/doc/source/reference/support-matrix.ini +++ b/doc/source/reference/support-matrix.ini @@ -81,6 +81,9 @@ title=Huawei 18000 Series Driver (iSCSI, FC) [driver.huawei_dorado] title=Huawei Dorado V3 Series Driver (iSCSI, FC) +[driver.huawei_fusionstorage] +title=Huawei FusionStorage Driver (dsware) + [driver.ibm_ds8k] title=IBM DS8k Storage Driver (FC) @@ -212,6 +215,7 @@ driver.huawei_v5=complete driver.huawei_f_v5=complete driver.huawei_18000=complete driver.huawei_dorado=complete +driver.huawei_fusionstorage=missing driver.infinidat=complete driver.ibm_ds8k=complete driver.ibm_flashsystem=missing @@ -273,6 +277,7 @@ driver.huawei_v5=complete driver.huawei_f_v5=complete driver.huawei_18000=complete driver.huawei_dorado=complete +driver.huawei_fusionstorage=complete driver.infinidat=complete driver.ibm_ds8k=missing driver.ibm_flashsystem=complete @@ -334,6 +339,7 @@ driver.huawei_v5=complete driver.huawei_f_v5=complete driver.huawei_18000=complete driver.huawei_dorado=complete +driver.huawei_fusionstorage=complete driver.infinidat=missing driver.ibm_ds8k=missing driver.ibm_flashsystem=missing @@ -398,6 +404,7 @@ driver.huawei_v5=complete driver.huawei_f_v5=complete driver.huawei_18000=complete driver.huawei_dorado=complete +driver.huawei_fusionstorage=missing driver.infinidat=complete driver.ibm_ds8k=missing driver.ibm_flashsystem=missing @@ -461,6 +468,7 @@ driver.huawei_v5=complete driver.huawei_f_v5=complete driver.huawei_18000=complete driver.huawei_dorado=complete +driver.huawei_fusionstorage=missing driver.infinidat=missing driver.ibm_ds8k=complete driver.ibm_flashsystem=missing @@ -525,6 +533,7 @@ driver.huawei_v5=complete driver.huawei_f_v5=complete driver.huawei_18000=complete driver.huawei_dorado=complete +driver.huawei_fusionstorage=missing driver.infinidat=missing driver.ibm_ds8k=complete driver.ibm_flashsystem=missing @@ -588,6 +597,7 @@ driver.huawei_v5=complete driver.huawei_f_v5=complete driver.huawei_18000=complete driver.huawei_dorado=complete +driver.huawei_fusionstorage=missing driver.infinidat=complete driver.ibm_ds8k=missing driver.ibm_flashsystem=missing @@ -652,6 +662,7 @@ driver.huawei_v5=complete driver.huawei_f_v5=complete driver.huawei_18000=complete driver.huawei_dorado=complete +driver.huawei_fusionstorage=missing driver.infinidat=missing driver.ibm_ds8k=missing driver.ibm_flashsystem=missing @@ -716,6 +727,7 @@ driver.huawei_v5=missing driver.huawei_f_v5=missing driver.huawei_18000=missing driver.huawei_dorado=missing +driver.huawei_fusionstorage=missing driver.infinidat=complete driver.ibm_ds8k=complete driver.ibm_flashsystem=missing @@ -777,6 +789,7 @@ driver.huawei_f_v5=missing driver.huawei_v5=missing driver.huawei_18000=missing driver.huawei_dorado=missing +driver.huawei_fusionstorage=missing driver.infinidat=missing driver.ibm_ds8k=missing driver.ibm_flashsystem=missing @@ -842,6 +855,7 @@ driver.huawei_f_v5=missing driver.huawei_v5=missing driver.huawei_18000=missing driver.huawei_dorado=missing +driver.huawei_fusionstorage=missing driver.infinidat=missing driver.ibm_ds8k=missing driver.ibm_flashsystem=missing diff --git a/doc/source/reference/support-matrix.rst b/doc/source/reference/support-matrix.rst index edfa25d15bd..07d9c49eeb4 100644 --- a/doc/source/reference/support-matrix.rst +++ b/doc/source/reference/support-matrix.rst @@ -87,7 +87,6 @@ release. * Nexenta Edge Storage Driver * Ussuri - * Huawei FusionStorage Driver * Nimble Storage Driver * ProphetStor Flexvisor Driver * Sheepdog Driver diff --git a/releasenotes/notes/huawei-fusionstorage-driver-removal-dedb8ea4d30df009.yaml b/releasenotes/notes/huawei-fusionstorage-driver-removal-dedb8ea4d30df009.yaml deleted file mode 100644 index a034bd19fcf..00000000000 --- a/releasenotes/notes/huawei-fusionstorage-driver-removal-dedb8ea4d30df009.yaml +++ /dev/null @@ -1,6 +0,0 @@ -upgrade: - - | - The Huawei FusionStorage driver was marked unsupported in the - Train release and has now been removed. All data on - FusionStorage backends should be migrated to a supported - storage backend before upgrading your Cinder installation.