From 517cb6448b613b888476f5da75ae4ad28f60f744 Mon Sep 17 00:00:00 2001 From: Ivan Pchelintsev Date: Thu, 2 Jul 2020 18:00:54 +0300 Subject: [PATCH] Add Cinder driver for Dell EMC PowerStore * Supported Protocols - FC - iSCSI * Supported 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 - Revert Volume to Snapshot Implements: blueprint powerstore-cinder-driver Change-Id: Icef5b38ba39eec761c1cfa70e2a66bc28ddf4cd6 --- cinder/opts.py | 4 + .../drivers/dell_emc/powerstore/__init__.py | 80 ++ .../drivers/dell_emc/powerstore/test_base.py | 77 ++ .../test_snapshot_create_delete_revert.py | 89 ++ .../powerstore/test_volume_attach_detach.py | 157 ++++ .../test_volume_create_delete_extend.py | 152 ++++ .../test_volume_create_from_source.py | 114 +++ .../drivers/dell_emc/powerstore/__init__.py | 0 .../drivers/dell_emc/powerstore/adapter.py | 790 ++++++++++++++++++ .../drivers/dell_emc/powerstore/client.py | 427 ++++++++++ .../drivers/dell_emc/powerstore/driver.py | 119 +++ .../drivers/dell_emc/powerstore/options.py | 33 + .../drivers/dell_emc/powerstore/utils.py | 136 +++ .../drivers/dell-emc-powerstore-driver.rst | 79 ++ doc/source/reference/support-matrix.ini | 14 + ...rstore-cinder-driver-94f8c7f1371eafe7.yaml | 4 + 16 files changed, 2275 insertions(+) create mode 100644 cinder/tests/unit/volume/drivers/dell_emc/powerstore/__init__.py create mode 100644 cinder/tests/unit/volume/drivers/dell_emc/powerstore/test_base.py create mode 100644 cinder/tests/unit/volume/drivers/dell_emc/powerstore/test_snapshot_create_delete_revert.py create mode 100644 cinder/tests/unit/volume/drivers/dell_emc/powerstore/test_volume_attach_detach.py create mode 100644 cinder/tests/unit/volume/drivers/dell_emc/powerstore/test_volume_create_delete_extend.py create mode 100644 cinder/tests/unit/volume/drivers/dell_emc/powerstore/test_volume_create_from_source.py create mode 100644 cinder/volume/drivers/dell_emc/powerstore/__init__.py create mode 100644 cinder/volume/drivers/dell_emc/powerstore/adapter.py create mode 100644 cinder/volume/drivers/dell_emc/powerstore/client.py create mode 100644 cinder/volume/drivers/dell_emc/powerstore/driver.py create mode 100644 cinder/volume/drivers/dell_emc/powerstore/options.py create mode 100644 cinder/volume/drivers/dell_emc/powerstore/utils.py create mode 100644 doc/source/configuration/block-storage/drivers/dell-emc-powerstore-driver.rst create mode 100644 releasenotes/notes/bp-powerstore-cinder-driver-94f8c7f1371eafe7.yaml diff --git a/cinder/opts.py b/cinder/opts.py index 7e7e3571f6b..a496c4a3633 100644 --- a/cinder/opts.py +++ b/cinder/opts.py @@ -74,6 +74,8 @@ from cinder.volume.drivers.datera import datera_iscsi as \ cinder_volume_drivers_datera_dateraiscsi from cinder.volume.drivers.dell_emc.powermax import common as \ cinder_volume_drivers_dell_emc_powermax_common +from cinder.volume.drivers.dell_emc.powerstore import driver as \ + cinder_volume_drivers_dell_emc_powerstore_driver from cinder.volume.drivers.dell_emc.sc import storagecenter_common as \ cinder_volume_drivers_dell_emc_sc_storagecentercommon from cinder.volume.drivers.dell_emc.unity import driver as \ @@ -296,6 +298,8 @@ def list_opts(): cinder_volume_driver.image_opts, cinder_volume_driver.fqdn_opts, cinder_volume_drivers_dell_emc_powermax_common.powermax_opts, + cinder_volume_drivers_dell_emc_powerstore_driver. + POWERSTORE_OPTS, cinder_volume_drivers_dell_emc_sc_storagecentercommon. common_opts, cinder_volume_drivers_dell_emc_unity_driver.UNITY_OPTS, diff --git a/cinder/tests/unit/volume/drivers/dell_emc/powerstore/__init__.py b/cinder/tests/unit/volume/drivers/dell_emc/powerstore/__init__.py new file mode 100644 index 00000000000..7cb429812c7 --- /dev/null +++ b/cinder/tests/unit/volume/drivers/dell_emc/powerstore/__init__.py @@ -0,0 +1,80 @@ +# Copyright (c) 2020 Dell Inc. or its subsidiaries. +# 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.tests.unit import test +from cinder.volume import configuration +from cinder.volume.drivers.dell_emc.powerstore import driver +from cinder.volume.drivers.dell_emc.powerstore import options + + +class MockResponse(requests.Response): + def __init__(self, content=None, rc=200): + super(MockResponse, self).__init__() + + if content is None: + content = [] + if isinstance(content, str): + content = content.encode() + self._content = content + self.request = mock.MagicMock() + self.status_code = rc + + def json(self, **kwargs): + if isinstance(self._content, bytes): + return super(MockResponse, self).json(**kwargs) + return self._content + + @property + def text(self): + if not isinstance(self._content, bytes): + return json.dumps(self._content) + return super(MockResponse, self).text + + +class TestPowerStoreDriver(test.TestCase): + def setUp(self): + super(TestPowerStoreDriver, self).setUp() + self.configuration = configuration.Configuration( + options.POWERSTORE_OPTS, + configuration.SHARED_CONF_GROUP + ) + self._set_overrides() + self.driver = driver.PowerStoreDriver(configuration=self.configuration) + self.driver.do_setup({}) + self.iscsi_driver = self.driver + + self._override_shared_conf("storage_protocol", override="FC") + self.fc_driver = driver.PowerStoreDriver( + configuration=self.configuration + ) + self.fc_driver.do_setup({}) + + def _override_shared_conf(self, *args, **kwargs): + return self.override_config(*args, + **kwargs, + group=configuration.SHARED_CONF_GROUP) + + def _set_overrides(self): + # Override the defaults to fake values + self._override_shared_conf("san_ip", override="127.0.0.1") + self._override_shared_conf("san_login", override="test") + self._override_shared_conf("san_password", override="test") + self._override_shared_conf("powerstore_appliances", + override="test-appliance") diff --git a/cinder/tests/unit/volume/drivers/dell_emc/powerstore/test_base.py b/cinder/tests/unit/volume/drivers/dell_emc/powerstore/test_base.py new file mode 100644 index 00000000000..833a2bfa29c --- /dev/null +++ b/cinder/tests/unit/volume/drivers/dell_emc/powerstore/test_base.py @@ -0,0 +1,77 @@ +# Copyright (c) 2020 Dell Inc. or its subsidiaries. +# 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 unittest import mock + +from cinder import exception +from cinder.tests.unit.volume.drivers.dell_emc import powerstore + + +class TestBase(powerstore.TestPowerStoreDriver): + @mock.patch("cinder.volume.drivers.dell_emc.powerstore.client." + "PowerStoreClient.get_appliance_id_by_name") + def test_configuration(self, mock_appliance): + mock_appliance.return_value = "A1" + self.driver.check_for_setup_error() + + def test_configuration_rest_parameters_not_set(self): + self.driver.adapter.client.rest_ip = None + self.assertRaises(exception.VolumeBackendAPIException, + self.driver.check_for_setup_error) + + def test_configuration_appliances_not_set(self): + self.driver.adapter.appliances = {} + self.assertRaises(exception.VolumeBackendAPIException, + self.driver.check_for_setup_error) + + @mock.patch("requests.request") + def test_configuration_appliance_not_found(self, mock_get_request): + mock_get_request.return_value = powerstore.MockResponse() + error = self.assertRaises(exception.VolumeBackendAPIException, + self.driver.check_for_setup_error) + self.assertIn("not found", error.msg) + + @mock.patch("requests.request") + def test_configuration_appliance_bad_status(self, mock_get_request): + mock_get_request.return_value = powerstore.MockResponse(rc=400) + error = self.assertRaises(exception.VolumeBackendAPIException, + self.driver.check_for_setup_error) + self.assertIn("Failed to query PowerStore appliances.", error.msg) + + @mock.patch("cinder.volume.drivers.dell_emc.powerstore.client." + "PowerStoreClient.get_appliance_id_by_name") + @mock.patch("cinder.volume.drivers.dell_emc.powerstore.client." + "PowerStoreClient.get_appliance_metrics") + def test_update_volume_stats(self, mock_metrics, mock_appliance): + mock_appliance.return_value = "A1" + mock_metrics.return_value = { + "physical_total": 2147483648, + "physical_used": 1073741824, + } + self.driver.check_for_setup_error() + self.driver._update_volume_stats() + + @mock.patch("cinder.volume.drivers.dell_emc.powerstore.client." + "PowerStoreClient.get_appliance_id_by_name") + @mock.patch("requests.request") + def test_update_volume_stats_bad_status(self, + mock_metrics, + mock_appliance): + mock_appliance.return_value = "A1" + mock_metrics.return_value = powerstore.MockResponse(rc=400) + self.driver.check_for_setup_error() + error = self.assertRaises(exception.VolumeBackendAPIException, + self.driver._update_volume_stats) + self.assertIn("Failed to query metrics", error.msg) diff --git a/cinder/tests/unit/volume/drivers/dell_emc/powerstore/test_snapshot_create_delete_revert.py b/cinder/tests/unit/volume/drivers/dell_emc/powerstore/test_snapshot_create_delete_revert.py new file mode 100644 index 00000000000..728a90b4185 --- /dev/null +++ b/cinder/tests/unit/volume/drivers/dell_emc/powerstore/test_snapshot_create_delete_revert.py @@ -0,0 +1,89 @@ +# Copyright (c) 2020 Dell Inc. or its subsidiaries. +# 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 unittest import mock + +from cinder import exception +from cinder.tests.unit import fake_snapshot +from cinder.tests.unit import fake_volume +from cinder.tests.unit.volume.drivers.dell_emc import powerstore + + +class TestSnapshotCreateDelete(powerstore.TestPowerStoreDriver): + @mock.patch("cinder.volume.drivers.dell_emc.powerstore.client." + "PowerStoreClient.get_appliance_id_by_name") + def setUp(self, mock_appliance): + super(TestSnapshotCreateDelete, self).setUp() + mock_appliance.return_value = "A1" + self.driver.check_for_setup_error() + self.volume = fake_volume.fake_volume_obj( + {}, + host="host@backend#test-appliance", + provider_id="fake_id", + size=8 + ) + self.snapshot = fake_snapshot.fake_snapshot_obj( + {}, + provider_id="fake_id_1", + volume=self.volume + ) + + @mock.patch("cinder.volume.drivers.dell_emc.powerstore.client." + "PowerStoreClient.create_snapshot") + def test_create_snapshot(self, mock_create): + mock_create.return_value = self.snapshot.provider_id + self.driver.create_snapshot(self.snapshot) + + @mock.patch("requests.request") + def test_create_snapshot_bad_status(self, mock_create_request): + mock_create_request.return_value = powerstore.MockResponse(rc=400) + error = self.assertRaises( + exception.VolumeBackendAPIException, + self.driver.create_snapshot, + self.snapshot + ) + self.assertIn("Failed to create snapshot", error.msg) + + @mock.patch("cinder.volume.drivers.dell_emc.powerstore.client." + "PowerStoreClient.delete_volume_or_snapshot") + def test_delete_snapshot(self, mock_delete): + self.driver.delete_snapshot(self.snapshot) + + @mock.patch("requests.request") + def test_delete_snapshot_bad_status(self, mock_delete): + mock_delete.return_value = powerstore.MockResponse(rc=400) + error = self.assertRaises( + exception.VolumeBackendAPIException, + self.driver.delete_snapshot, + self.snapshot + ) + self.assertIn("Failed to delete PowerStore snapshot", error.msg) + + @mock.patch("cinder.volume.drivers.dell_emc.powerstore.client." + "PowerStoreClient.restore_from_snapshot") + def test_revert_to_snapshot(self, mock_revert): + self.driver.revert_to_snapshot({}, self.volume, self.snapshot) + + @mock.patch("requests.request") + def test_revert_to_snapshot_bad_status(self, mock_revert): + mock_revert.return_value = powerstore.MockResponse(rc=400) + error = self.assertRaises( + exception.VolumeBackendAPIException, + self.driver.revert_to_snapshot, + {}, + self.volume, + self.snapshot + ) + self.assertIn("Failed to restore PowerStore volume", error.msg) diff --git a/cinder/tests/unit/volume/drivers/dell_emc/powerstore/test_volume_attach_detach.py b/cinder/tests/unit/volume/drivers/dell_emc/powerstore/test_volume_attach_detach.py new file mode 100644 index 00000000000..c86ec4ebe6f --- /dev/null +++ b/cinder/tests/unit/volume/drivers/dell_emc/powerstore/test_volume_attach_detach.py @@ -0,0 +1,157 @@ +# Copyright (c) 2020 Dell Inc. or its subsidiaries. +# 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 unittest import mock + +from cinder import exception +from cinder.objects import fields +from cinder.objects import volume_attachment +from cinder.tests.unit import fake_volume +from cinder.tests.unit.volume.drivers.dell_emc import powerstore +from cinder.volume.drivers.dell_emc.powerstore import utils + + +class TestVolumeAttachDetach(powerstore.TestPowerStoreDriver): + @mock.patch("cinder.volume.drivers.dell_emc.powerstore.client." + "PowerStoreClient.get_appliance_id_by_name") + def setUp(self, mock_appliance): + super(TestVolumeAttachDetach, self).setUp() + mock_appliance.return_value = "A1" + self.iscsi_driver.check_for_setup_error() + self.fc_driver.check_for_setup_error() + self.volume = fake_volume.fake_volume_obj( + {}, + host="host@backend#test-appliance", + provider_id="fake_id", + size=8 + ) + self.volume.volume_attachment = ( + volume_attachment.VolumeAttachmentList() + ) + self.volume.volume_attachment.objects = [ + volume_attachment.VolumeAttachment( + attach_status=fields.VolumeAttachStatus.ATTACHED, + attached_host=self.volume.host + ), + volume_attachment.VolumeAttachment( + attach_status=fields.VolumeAttachStatus.ATTACHED, + attached_host=self.volume.host + ) + ] + self.fake_iscsi_targets_response = [ + { + "address": "1.2.3.4", + "ip_port": { + "target_iqn": + "iqn.2020-07.com.dell:dellemc-powerstore-test-iqn-1" + }, + }, + { + "address": "5.6.7.8", + "ip_port": { + "target_iqn": + "iqn.2020-07.com.dell:dellemc-powerstore-test-iqn-1" + }, + }, + ] + self.fake_fc_wwns_response = [ + { + "wwn": "58:cc:f0:98:49:21:07:02" + }, + { + "wwn": "58:cc:f0:98:49:23:07:02" + }, + ] + self.fake_connector = { + "host": self.volume.host, + "wwpns": ["58:cc:f0:98:49:21:07:02", "58:cc:f0:98:49:23:07:02"], + "initiator": "fake_initiator", + } + + @mock.patch("cinder.volume.drivers.dell_emc.powerstore.client." + "PowerStoreClient.get_fc_port") + def test_get_fc_targets(self, mock_get_ip_pool): + mock_get_ip_pool.return_value = self.fake_fc_wwns_response + wwns = self.fc_driver.adapter._get_fc_targets("A1") + self.assertEqual(2, len(wwns)) + + @mock.patch("cinder.volume.drivers.dell_emc.powerstore.client." + "PowerStoreClient.get_fc_port") + def test_get_fc_targets_filtered(self, mock_get_ip_pool): + mock_get_ip_pool.return_value = self.fake_fc_wwns_response + self.fc_driver.adapter.allowed_ports = ["58:cc:f0:98:49:23:07:02"] + wwns = self.fc_driver.adapter._get_fc_targets("A1") + self.assertEqual(1, len(wwns)) + self.assertFalse( + utils.fc_wwn_to_string("58:cc:f0:98:49:21:07:02") in wwns + ) + + @mock.patch("cinder.volume.drivers.dell_emc.powerstore.client." + "PowerStoreClient.get_fc_port") + def test_get_fc_targets_filtered_no_matched_ports(self, mock_get_ip_pool): + mock_get_ip_pool.return_value = self.fake_fc_wwns_response + self.fc_driver.adapter.allowed_ports = ["fc_wwn_1", "fc_wwn_2"] + error = self.assertRaises(exception.VolumeBackendAPIException, + self.fc_driver.adapter._get_fc_targets, + "A1") + self.assertIn("There are no accessible Fibre Channel targets on the " + "system.", error.msg) + + @mock.patch("cinder.volume.drivers.dell_emc.powerstore.client." + "PowerStoreClient.get_ip_pool_address") + def test_get_iscsi_targets(self, mock_get_ip_pool): + mock_get_ip_pool.return_value = self.fake_iscsi_targets_response + iqns, portals = self.iscsi_driver.adapter._get_iscsi_targets("A1") + self.assertTrue(len(iqns) == len(portals)) + self.assertEqual(2, len(portals)) + + @mock.patch("cinder.volume.drivers.dell_emc.powerstore.client." + "PowerStoreClient.get_ip_pool_address") + def test_get_iscsi_targets_filtered(self, mock_get_ip_pool): + mock_get_ip_pool.return_value = self.fake_iscsi_targets_response + self.iscsi_driver.adapter.allowed_ports = ["1.2.3.4"] + iqns, portals = self.iscsi_driver.adapter._get_iscsi_targets("A1") + self.assertTrue(len(iqns) == len(portals)) + self.assertEqual(1, len(portals)) + self.assertFalse( + "iqn.2020-07.com.dell:dellemc-powerstore-test-iqn-2" in iqns + ) + + @mock.patch("cinder.volume.drivers.dell_emc.powerstore.client." + "PowerStoreClient.get_ip_pool_address") + def test_get_iscsi_targets_filtered_no_matched_ports(self, + mock_get_ip_pool): + mock_get_ip_pool.return_value = self.fake_iscsi_targets_response + self.iscsi_driver.adapter.allowed_ports = ["1.1.1.1", "2.2.2.2"] + error = self.assertRaises(exception.VolumeBackendAPIException, + self.iscsi_driver.adapter._get_iscsi_targets, + "A1") + self.assertIn("There are no accessible iSCSI targets on the system.", + error.msg) + + @mock.patch("cinder.volume.drivers.dell_emc.powerstore.adapter." + "CommonAdapter._detach_volume_from_hosts") + @mock.patch("cinder.volume.drivers.dell_emc.powerstore.adapter." + "CommonAdapter._filter_hosts_by_initiators") + def test_detach_multiattached_volume(self, mock_filter_hosts, mock_detach): + self.iscsi_driver.terminate_connection(self.volume, + self.fake_connector) + mock_filter_hosts.assert_not_called() + mock_detach.assert_not_called() + self.volume.volume_attachment.objects.pop() + self.iscsi_driver.terminate_connection(self.volume, + self.fake_connector) + mock_filter_hosts.assert_called_once() + mock_detach.assert_called_once() diff --git a/cinder/tests/unit/volume/drivers/dell_emc/powerstore/test_volume_create_delete_extend.py b/cinder/tests/unit/volume/drivers/dell_emc/powerstore/test_volume_create_delete_extend.py new file mode 100644 index 00000000000..5143f2d4e2c --- /dev/null +++ b/cinder/tests/unit/volume/drivers/dell_emc/powerstore/test_volume_create_delete_extend.py @@ -0,0 +1,152 @@ +# Copyright (c) 2020 Dell Inc. or its subsidiaries. +# 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 unittest import mock + +from cinder import exception +from cinder.tests.unit import fake_volume +from cinder.tests.unit.volume.drivers.dell_emc import powerstore +from cinder.volume.drivers.dell_emc.powerstore import client + + +class TestVolumeCreateDeleteExtend(powerstore.TestPowerStoreDriver): + @mock.patch("cinder.volume.drivers.dell_emc.powerstore.client." + "PowerStoreClient.get_appliance_id_by_name") + def setUp(self, mock_appliance): + super(TestVolumeCreateDeleteExtend, self).setUp() + mock_appliance.return_value = "A1" + self.driver.check_for_setup_error() + self.volume = fake_volume.fake_volume_obj( + {}, + host="host@backend#test-appliance", + provider_id="fake_id", + size=8 + ) + + @mock.patch("cinder.volume.drivers.dell_emc.powerstore.client." + "PowerStoreClient.create_volume") + def test_create_volume(self, mock_create): + mock_create.return_value = "fake_id" + self.driver.create_volume(self.volume) + + @mock.patch("requests.request") + def test_create_volume_bad_status(self, mock_create_request): + mock_create_request.return_value = powerstore.MockResponse(rc=400) + error = self.assertRaises(exception.VolumeBackendAPIException, + self.driver.create_volume, + self.volume) + self.assertIn("Failed to create PowerStore volume", error.msg) + + @mock.patch("cinder.volume.drivers.dell_emc.powerstore.adapter." + "CommonAdapter._detach_volume_from_hosts") + @mock.patch("cinder.volume.drivers.dell_emc.powerstore.client." + "PowerStoreClient.delete_volume_or_snapshot") + def test_delete_volume(self, mock_delete, mock_detach): + self.driver.delete_volume(self.volume) + + @mock.patch("cinder.volume.drivers.dell_emc.powerstore.adapter." + "CommonAdapter._detach_volume_from_hosts") + @mock.patch("cinder.volume.drivers.dell_emc.powerstore.client." + "PowerStoreClient.delete_volume_or_snapshot") + def test_delete_volume_no_provider_id(self, mock_delete, mock_detach): + self.volume.provider_id = None + self.driver.delete_volume(self.volume) + mock_detach.assert_not_called() + mock_delete.assert_not_called() + + @mock.patch("cinder.volume.drivers.dell_emc.powerstore.adapter." + "CommonAdapter._detach_volume_from_hosts") + @mock.patch("requests.request") + def test_delete_volume_not_found(self, mock_delete_request, mock_detach): + mock_delete_request.return_value = powerstore.MockResponse(rc=404) + self.driver.delete_volume(self.volume) + + @mock.patch("requests.request") + @mock.patch("cinder.volume.drivers.dell_emc.powerstore.client." + "PowerStoreClient.get_volume_mapped_hosts") + @mock.patch("cinder.volume.drivers.dell_emc.powerstore.client." + "PowerStoreClient.delete_volume_or_snapshot") + def test_delete_volume_detach_not_found(self, + mock_delete, + mock_mapped_hosts, + mock_detach_request): + mock_mapped_hosts.return_value = ["fake_host_id"] + mock_detach_request.return_value = powerstore.MockResponse( + content={}, + rc=404 + ) + self.driver.delete_volume(self.volume) + + @mock.patch("requests.request") + @mock.patch("cinder.volume.drivers.dell_emc.powerstore.client." + "PowerStoreClient.get_volume_mapped_hosts") + @mock.patch("cinder.volume.drivers.dell_emc.powerstore.client." + "PowerStoreClient.delete_volume_or_snapshot") + def test_delete_volume_detach_not_mapped(self, + mock_delete, + mock_mapped_hosts, + mock_detach_request): + mock_mapped_hosts.return_value = ["fake_host_id"] + mock_detach_request.return_value = powerstore.MockResponse( + content={ + "messages": [ + { + "code": client.VOLUME_NOT_MAPPED_ERROR, + }, + ], + }, + rc=422 + ) + self.driver.delete_volume(self.volume) + + @mock.patch("cinder.volume.drivers.dell_emc.powerstore.adapter." + "CommonAdapter._detach_volume_from_hosts") + @mock.patch("requests.request") + def test_delete_volume_bad_status(self, mock_delete, mock_detach): + mock_delete.return_value = powerstore.MockResponse(rc=400) + error = self.assertRaises(exception.VolumeBackendAPIException, + self.driver.delete_volume, + self.volume) + self.assertIn("Failed to delete PowerStore volume", error.msg) + + @mock.patch("requests.request") + @mock.patch("cinder.volume.drivers.dell_emc.powerstore.client." + "PowerStoreClient.get_volume_mapped_hosts") + @mock.patch("cinder.volume.drivers.dell_emc.powerstore.client." + "PowerStoreClient.delete_volume_or_snapshot") + def test_delete_volume_detach_bad_status(self, + mock_delete, + mock_mapped_hosts, + mock_detach_request): + mock_mapped_hosts.return_value = ["fake_host_id"] + mock_detach_request.return_value = powerstore.MockResponse(rc=400) + error = self.assertRaises(exception.VolumeBackendAPIException, + self.driver.delete_volume, + self.volume) + self.assertIn("Failed to detach PowerStore volume", error.msg) + + @mock.patch("cinder.volume.drivers.dell_emc.powerstore.client." + "PowerStoreClient.extend_volume") + def test_extend_volume(self, mock_extend): + self.driver.extend_volume(self.volume, 16) + + @mock.patch("requests.request") + def test_extend_volume_bad_status(self, mock_extend_request): + mock_extend_request.return_value = powerstore.MockResponse(rc=400) + error = self.assertRaises(exception.VolumeBackendAPIException, + self.driver.extend_volume, + self.volume, + 16) + self.assertIn("Failed to extend PowerStore volume", error.msg) diff --git a/cinder/tests/unit/volume/drivers/dell_emc/powerstore/test_volume_create_from_source.py b/cinder/tests/unit/volume/drivers/dell_emc/powerstore/test_volume_create_from_source.py new file mode 100644 index 00000000000..ec5983a6a23 --- /dev/null +++ b/cinder/tests/unit/volume/drivers/dell_emc/powerstore/test_volume_create_from_source.py @@ -0,0 +1,114 @@ +# Copyright (c) 2020 Dell Inc. or its subsidiaries. +# 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 unittest import mock + +from cinder import exception +from cinder.tests.unit import fake_snapshot +from cinder.tests.unit import fake_volume +from cinder.tests.unit.volume.drivers.dell_emc import powerstore + + +class TestVolumeCreateFromSource(powerstore.TestPowerStoreDriver): + @mock.patch("cinder.volume.drivers.dell_emc.powerstore.client." + "PowerStoreClient.get_appliance_id_by_name") + def setUp(self, mock_appliance): + super(TestVolumeCreateFromSource, self).setUp() + mock_appliance.return_value = "A1" + self.driver.check_for_setup_error() + self.volume = fake_volume.fake_volume_obj( + {}, + host="host@backend#test-appliance", + provider_id="fake_id", + size=8 + ) + self.source_volume = fake_volume.fake_volume_obj( + {}, + host="host@backend#test-appliance", + provider_id="fake_id_1", + size=8 + ) + self.source_snapshot = fake_snapshot.fake_snapshot_obj( + {}, + provider_id="fake_id_2", + volume_size=8 + ) + + @mock.patch("cinder.volume.drivers.dell_emc.powerstore.client." + "PowerStoreClient.clone_volume_or_snapshot") + def test_create_cloned_volume(self, mock_create_cloned): + mock_create_cloned.return_value = self.volume.provider_id + self.driver.create_cloned_volume(self.volume, self.source_volume) + + @mock.patch("cinder.volume.drivers.dell_emc.powerstore.client." + "PowerStoreClient.extend_volume") + @mock.patch("cinder.volume.drivers.dell_emc.powerstore.client." + "PowerStoreClient.clone_volume_or_snapshot") + def test_create_cloned_volume_extended(self, + mock_create_cloned, + mock_extend): + mock_create_cloned.return_value = self.volume.provider_id + self.volume.size = 16 + self.driver.create_cloned_volume(self.volume, self.source_volume) + mock_extend.assert_called_once() + + @mock.patch("cinder.volume.drivers.dell_emc.powerstore.client." + "PowerStoreClient.clone_volume_or_snapshot") + def test_create_volume_from_snapshot(self, mock_create_from_snap): + mock_create_from_snap.return_value = self.volume.provider_id + self.driver.create_volume_from_snapshot(self.volume, + self.source_snapshot) + + @mock.patch("cinder.volume.drivers.dell_emc.powerstore.client." + "PowerStoreClient.extend_volume") + @mock.patch("cinder.volume.drivers.dell_emc.powerstore.client." + "PowerStoreClient.clone_volume_or_snapshot") + def test_create_volume_from_snapshot_extended(self, + mock_create_from_snap, + mock_extend): + mock_create_from_snap.return_value = self.volume.provider_id + self.volume.size = 16 + self.driver.create_volume_from_snapshot(self.volume, + self.source_snapshot) + mock_extend.assert_called_once() + + @mock.patch("requests.request") + def test_create_volume_from_source_bad_status(self, mock_create_request): + mock_create_request.return_value = powerstore.MockResponse(rc=400) + error = self.assertRaises( + exception.VolumeBackendAPIException, + self.driver.adapter._create_volume_from_source, + self.volume, + self.source_volume + ) + self.assertIn("Failed to create clone", error.msg) + + @mock.patch("requests.request") + @mock.patch("cinder.volume.drivers.dell_emc.powerstore.client." + "PowerStoreClient.clone_volume_or_snapshot") + def test_create_volume_from_source_extende_bad_status( + self, + mock_create_from_source, + mock_extend_request + ): + mock_extend_request.return_value = powerstore.MockResponse(rc=400) + self.volume.size = 16 + error = self.assertRaises( + exception.VolumeBackendAPIException, + self.driver.adapter._create_volume_from_source, + self.volume, + self.source_volume + ) + self.assertIn("Failed to extend PowerStore volume", error.msg) diff --git a/cinder/volume/drivers/dell_emc/powerstore/__init__.py b/cinder/volume/drivers/dell_emc/powerstore/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/cinder/volume/drivers/dell_emc/powerstore/adapter.py b/cinder/volume/drivers/dell_emc/powerstore/adapter.py new file mode 100644 index 00000000000..3bab1147d14 --- /dev/null +++ b/cinder/volume/drivers/dell_emc/powerstore/adapter.py @@ -0,0 +1,790 @@ +# Copyright (c) 2020 Dell Inc. or its subsidiaries. +# 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. + +"""Adapter for Dell EMC PowerStore Cinder driver.""" + +from oslo_log import log as logging + +from cinder import coordination +from cinder import exception +from cinder.i18n import _ +from cinder.objects.snapshot import Snapshot +from cinder.volume.drivers.dell_emc.powerstore import client +from cinder.volume.drivers.dell_emc.powerstore import options +from cinder.volume.drivers.dell_emc.powerstore import utils +from cinder.volume import volume_utils + + +LOG = logging.getLogger(__name__) +PROTOCOL_FC = "FC" +PROTOCOL_ISCSI = "iSCSI" + + +class CommonAdapter(object): + def __init__(self, active_backend_id, configuration): + self.active_backend_id = active_backend_id + self.appliances = None + self.appliances_to_ids_map = {} + self.client = None + self.configuration = configuration + self.storage_protocol = None + self.allowed_ports = None + + @staticmethod + def initiators(connector): + raise NotImplementedError + + def _port_is_allowed(self, port): + """Check if port is in allowed ports list. + + If allowed ports are empty then all ports are allowed. + + :param port: iSCSI IP/FC WWN to check + :return: is port allowed + """ + + if not self.allowed_ports: + return True + return port.lower() in self.allowed_ports + + def _get_connection_properties(self, appliance_id, volume_lun): + raise NotImplementedError + + def do_setup(self): + self.appliances = ( + self.configuration.safe_get(options.POWERSTORE_APPLIANCES) + ) + self.allowed_ports = [ + port.strip().lower() for port in + self.configuration.safe_get(options.POWERSTORE_PORTS) + ] + self.client = client.PowerStoreClient(configuration=self.configuration) + self.client.do_setup() + + def check_for_setup_error(self): + self.client.check_for_setup_error() + if not self.appliances: + msg = _("PowerStore appliances must be set.") + raise exception.VolumeBackendAPIException(data=msg) + self.appliances_to_ids_map = {} + for appliance_name in self.appliances: + self.appliances_to_ids_map[appliance_name] = ( + self.client.get_appliance_id_by_name(appliance_name) + ) + LOG.debug("Successfully initialized PowerStore %(protocol)s adapter. " + "PowerStore appliances: %(appliances)s. " + "Allowed ports: %(allowed_ports)s.", + { + "protocol": self.storage_protocol, + "appliances": self.appliances, + "allowed_ports": self.allowed_ports, + }) + + def create_volume(self, volume): + appliance_name = volume_utils.extract_host(volume.host, "pool") + appliance_id = self.appliances_to_ids_map[appliance_name] + LOG.debug("Create PowerStore volume %(volume_name)s of size " + "%(volume_size)s GiB with id %(volume_id)s on appliance " + "%(appliance_name)s.", + { + "volume_name": volume.name, + "volume_size": volume.size, + "volume_id": volume.id, + "appliance_name": appliance_name, + }) + size_in_bytes = utils.gib_to_bytes(volume.size) + provider_id = self.client.create_volume(appliance_id, + volume.name, + size_in_bytes) + LOG.debug("Successfully created PowerStore volume %(volume_name)s of " + "size %(volume_size)s GiB with id %(volume_id)s on " + "appliance %(appliance_name)s. " + "PowerStore volume id: %(volume_provider_id)s.", + { + "volume_name": volume.name, + "volume_size": volume.size, + "volume_id": volume.id, + "appliance_name": appliance_name, + "volume_provider_id": provider_id, + }) + return { + "provider_id": provider_id, + } + + def delete_volume(self, volume): + if not volume.provider_id: + LOG.warning("Volume %(volume_name)s with id %(volume_id)s " + "does not have provider_id thus does not " + "map to PowerStore volume.", + { + "volume_name": volume.name, + "volume_id": volume.id, + }) + return + LOG.debug("Delete PowerStore volume %(volume_name)s with id " + "%(volume_id)s. PowerStore volume id: " + "%(volume_provider_id)s.", + { + "volume_name": volume.name, + "volume_id": volume.id, + "volume_provider_id": volume.provider_id, + }) + self._detach_volume_from_hosts(volume) + self.client.delete_volume_or_snapshot(volume.provider_id) + LOG.debug("Successfully deleted PowerStore volume %(volume_name)s " + "with id %(volume_id)s. PowerStore volume id: " + "%(volume_provider_id)s.", + { + "volume_name": volume.name, + "volume_id": volume.id, + "volume_provider_id": volume.provider_id, + }) + + def extend_volume(self, volume, new_size): + LOG.debug("Extend PowerStore volume %(volume_name)s of size " + "%(volume_size)s GiB with id %(volume_id)s to " + "%(volume_new_size)s GiB. " + "PowerStore volume id: %(volume_provider_id)s.", + { + "volume_name": volume.name, + "volume_size": volume.size, + "volume_id": volume.id, + "volume_new_size": new_size, + "volume_provider_id": volume.provider_id, + }) + size_in_bytes = utils.gib_to_bytes(new_size) + self.client.extend_volume(volume.provider_id, size_in_bytes) + LOG.debug("Successfully extended PowerStore volume %(volume_name)s " + "of size %(volume_size)s GiB with id " + "%(volume_id)s to %(volume_new_size)s GiB. " + "PowerStore volume id: %(volume_provider_id)s.", + { + "volume_name": volume.name, + "volume_size": volume.size, + "volume_id": volume.id, + "volume_new_size": new_size, + "volume_provider_id": volume.provider_id, + }) + + def create_snapshot(self, snapshot): + LOG.debug("Create PowerStore snapshot %(snapshot_name)s with id " + "%(snapshot_id)s of volume %(volume_name)s with id " + "%(volume_id)s. PowerStore volume id: " + "%(volume_provider_id)s.", + { + "snapshot_name": snapshot.name, + "snapshot_id": snapshot.id, + "volume_name": snapshot.volume.name, + "volume_id": snapshot.volume.id, + "volume_provider_id": snapshot.volume.provider_id, + }) + snapshot_provider_id = self.client.create_snapshot( + snapshot.volume.provider_id, + snapshot.name) + LOG.debug("Successfully created PowerStore snapshot %(snapshot_name)s " + "with id %(snapshot_id)s of volume %(volume_name)s with " + "id %(volume_id)s. PowerStore snapshot id: " + "%(snapshot_provider_id)s, volume id: " + "%(volume_provider_id)s.", + { + "snapshot_name": snapshot.name, + "snapshot_id": snapshot.id, + "volume_name": snapshot.volume.name, + "volume_id": snapshot.volume.id, + "snapshot_provider_id": snapshot_provider_id, + "volume_provider_id": snapshot.volume.provider_id, + }) + return { + "provider_id": snapshot_provider_id, + } + + def delete_snapshot(self, snapshot): + LOG.debug("Delete PowerStore snapshot %(snapshot_name)s with id " + "%(snapshot_id)s of volume %(volume_name)s with " + "id %(volume_id)s. PowerStore snapshot id: " + "%(snapshot_provider_id)s, volume id: " + "%(volume_provider_id)s.", + { + "snapshot_name": snapshot.name, + "snapshot_id": snapshot.id, + "volume_name": snapshot.volume.name, + "volume_id": snapshot.volume.id, + "snapshot_provider_id": snapshot.provider_id, + "volume_provider_id": snapshot.volume.provider_id, + }) + self.client.delete_volume_or_snapshot(snapshot.provider_id, + entity="snapshot") + LOG.debug("Successfully deleted PowerStore snapshot %(snapshot_name)s " + "with id %(snapshot_id)s of volume %(volume_name)s with " + "id %(volume_id)s. PowerStore snapshot id: " + "%(snapshot_provider_id)s, volume id: " + "%(volume_provider_id)s.", + { + "snapshot_name": snapshot.name, + "snapshot_id": snapshot.id, + "volume_name": snapshot.volume.name, + "volume_id": snapshot.volume.id, + "snapshot_provider_id": snapshot.provider_id, + "volume_provider_id": snapshot.volume.provider_id, + }) + + def create_cloned_volume(self, volume, src_vref): + LOG.debug("Clone PowerStore volume %(source_volume_name)s with id " + "%(source_volume_id)s to volume %(cloned_volume_name)s of " + "size %(cloned_volume_size)s GiB with id " + "%(cloned_volume_id)s. PowerStore source volume id: " + "%(source_volume_provider_id)s.", + { + "source_volume_name": src_vref.name, + "source_volume_id": src_vref.id, + "cloned_volume_name": volume.name, + "cloned_volume_size": volume.size, + "cloned_volume_id": volume.id, + "source_volume_provider_id": src_vref.provider_id, + }) + cloned_provider_id = self._create_volume_from_source(volume, src_vref) + LOG.debug("Successfully cloned PowerStore volume " + "%(source_volume_name)s with id %(source_volume_id)s to " + "volume %(cloned_volume_name)s of size " + "%(cloned_volume_size)s GiB with id %(cloned_volume_id)s. " + "PowerStore source volume id: " + "%(source_volume_provider_id)s, " + "cloned volume id: %(cloned_volume_provider_id)s.", + { + "source_volume_name": src_vref.name, + "source_volume_id": src_vref.id, + "cloned_volume_name": volume.name, + "cloned_volume_size": volume.size, + "cloned_volume_id": volume.id, + "source_volume_provider_id": src_vref.provider_id, + "cloned_volume_provider_id": cloned_provider_id, + }) + return { + "provider_id": cloned_provider_id, + } + + def create_volume_from_snapshot(self, volume, snapshot): + LOG.debug("Create PowerStore volume %(volume_name)s of size " + "%(volume_size)s GiB with id %(volume_id)s from snapshot " + "%(snapshot_name)s with id %(snapshot_id)s. PowerStore " + "snapshot id: %(snapshot_provider_id)s.", + { + "volume_name": volume.name, + "volume_id": volume.id, + "volume_size": volume.size, + "snapshot_name": snapshot.name, + "snapshot_id": snapshot.id, + "snapshot_provider_id": snapshot.provider_id, + }) + volume_provider_id = self._create_volume_from_source(volume, snapshot) + LOG.debug("Successfully created PowerStore volume %(volume_name)s " + "of size %(volume_size)s GiB with id %(volume_id)s from " + "snapshot %(snapshot_name)s with id %(snapshot_id)s. " + "PowerStore volume id: %(volume_provider_id)s, " + "snapshot id: %(snapshot_provider_id)s.", + { + "volume_name": volume.name, + "volume_id": volume.id, + "volume_size": volume.size, + "snapshot_name": snapshot.name, + "snapshot_id": snapshot.id, + "volume_provider_id": volume_provider_id, + "snapshot_provider_id": snapshot.provider_id, + }) + return { + "provider_id": volume_provider_id, + } + + def initialize_connection(self, volume, connector, **kwargs): + connection_properties = self._connect_volume(volume, connector) + LOG.debug("Connection properties for volume %(volume_name)s with id " + "%(volume_id)s: %(connection_properties)s.", + { + "volume_name": volume.name, + "volume_id": volume.id, + "connection_properties": connection_properties, + }) + return connection_properties + + def terminate_connection(self, volume, connector, **kwargs): + self._disconnect_volume(volume, connector) + return {} + + def update_volume_stats(self): + stats = { + "volume_backend_name": ( + self.configuration.safe_get("volume_backend_name") or + "powerstore" + ), + "storage_protocol": self.storage_protocol, + "thick_provisioning_support": False, + "thin_provisioning_support": True, + "compression_support": True, + "multiattach": True, + "pools": [], + } + backend_total_capacity = 0 + backend_free_capacity = 0 + for appliance_name in self.appliances: + appliance_stats = self.client.get_appliance_metrics( + self.appliances_to_ids_map[appliance_name] + ) + appliance_total_capacity = utils.bytes_to_gib( + appliance_stats["physical_total"] + ) + appliance_free_capacity = ( + appliance_total_capacity - + utils.bytes_to_gib(appliance_stats["physical_used"]) + ) + pool = { + "pool_name": appliance_name, + "total_capacity_gb": appliance_total_capacity, + "free_capacity_gb": appliance_free_capacity, + "thick_provisioning_support": False, + "thin_provisioning_support": True, + "compression_support": True, + "multiattach": True, + } + backend_total_capacity += appliance_total_capacity + backend_free_capacity += appliance_free_capacity + stats["pools"].append(pool) + stats["total_capacity_gb"] = backend_total_capacity + stats["free_capacity_gb"] = backend_free_capacity + LOG.debug("Free capacity for backend '%(backend)s': " + "%(free)s GiB, total capacity: %(total)s GiB.", + { + "backend": stats["volume_backend_name"], + "free": backend_free_capacity, + "total": backend_total_capacity, + }) + return stats + + def _create_volume_from_source(self, volume, source): + """Create PowerStore volume from source (snapshot or another volume). + + :param volume: OpenStack volume object + :param source: OpenStack source snapshot or volume + :return: newly created PowerStore volume id + """ + + if isinstance(source, Snapshot): + entity = "snapshot" + source_size = source.volume_size + else: + entity = "volume" + source_size = source.size + volume_provider_id = self.client.clone_volume_or_snapshot( + volume.name, + source.provider_id, + entity + ) + if volume.size > source_size: + size_in_bytes = utils.gib_to_bytes(volume.size) + self.client.extend_volume(volume_provider_id, size_in_bytes) + return volume_provider_id + + def _filter_hosts_by_initiators(self, initiators): + """Filter hosts by given list of initiators. + + If initiators are added to different hosts the exception will be + raised. In this case one of the hosts should be deleted. + + :param initiators: list of initiators + :return: PowerStore host object + """ + + LOG.debug("Query PowerStore %(protocol)s hosts.", + { + "protocol": self.storage_protocol, + }) + hosts = self.client.get_all_hosts(self.storage_protocol) + hosts_found = utils.filter_hosts_by_initiators(hosts, initiators) + if hosts_found: + if len(hosts_found) > 1: + hosts_names_found = [host["name"] for host in hosts_found] + msg = (_("Initiators are added to different PowerStore hosts: " + "%(hosts_names_found)s. Remove all of the hosts " + "except one to proceed. Initiators will be modified " + "during the next volume attach procedure.") + % {"hosts_names_found": hosts_names_found, }) + LOG.error(msg) + raise exception.VolumeBackendAPIException(data=msg) + else: + return hosts_found[0] + + @coordination.synchronized("powerstore-create-host") + def _create_host_if_not_exist(self, connector): + """Create PowerStore host if it does not exist. + + :param connector: connection properties + :return: PowerStore host object + """ + + initiators = self.initiators(connector) + host = self._filter_hosts_by_initiators(initiators) + if host: + self._modify_host_initiators(host, initiators) + else: + host_name = utils.powerstore_host_name( + connector, + self.storage_protocol + ) + LOG.debug("Create PowerStore host %(host_name)s. " + "Initiators: %(initiators)s.", + { + "host_name": host_name, + "initiators": initiators, + }) + ports = [ + { + "port_name": initiator, + "port_type": self.storage_protocol, + } for initiator in initiators + ] + host = self.client.create_host(host_name, ports) + host["name"] = host_name + LOG.debug("Successfully created PowerStore host %(host_name)s. " + "Initiators: %(initiators)s. PowerStore host id: " + "%(host_provider_id)s.", + { + "host_name": host["name"], + "initiators": initiators, + "host_provider_id": host["id"], + }) + return host + + def _modify_host_initiators(self, host, initiators): + """Update PowerStore host initiators if needed. + + :param host: PowerStore host object + :param initiators: list of initiators + :return: None + """ + + initiators_added = [ + initiator["port_name"] for initiator in host["host_initiators"] + ] + initiators_to_remove = [ + initiator for initiator in initiators_added + if initiator not in initiators + ] + initiators_to_add = [ + { + "port_name": initiator, + "port_type": self.storage_protocol, + } for initiator in initiators + if initiator not in initiators_added + ] + if initiators_to_remove: + LOG.debug("Remove initiators from PowerStore host %(host_name)s. " + "Initiators: %(initiators_to_remove)s. " + "PowerStore host id: %(host_provider_id)s.", + { + "host_name": host["name"], + "initiators_to_remove": initiators_to_remove, + "host_provider_id": host["id"], + }) + self.client.modify_host_initiators( + host["id"], + remove_initiators=initiators_to_remove + ) + LOG.debug("Successfully removed initiators from PowerStore host " + "%(host_name)s. Initiators: %(initiators_to_remove)s. " + "PowerStore host id: %(host_provider_id)s.", + { + "host_name": host["name"], + "initiators_to_remove": initiators_to_remove, + "host_provider_id": host["id"], + }) + if initiators_to_add: + LOG.debug("Add initiators to PowerStore host %(host_name)s. " + "Initiators: %(initiators_to_add)s. PowerStore host id: " + "%(host_provider_id)s.", + { + "host_name": host["name"], + "initiators_to_add": initiators_to_add, + "host_provider_id": host["id"], + }) + self.client.modify_host_initiators( + host["id"], + add_initiators=initiators_to_add + ) + LOG.debug("Successfully added initiators to PowerStore host " + "%(host_name)s. Initiators: %(initiators_to_add)s. " + "PowerStore host id: %(host_provider_id)s.", + { + "host_name": host["name"], + "initiators_to_add": initiators_to_add, + "host_provider_id": host["id"], + }) + + def _attach_volume_to_host(self, host, volume): + """Attach PowerStore volume to host. + + :param host: PowerStore host object + :param volume: OpenStack volume object + :return: attached volume logical number + """ + + LOG.debug("Attach PowerStore volume %(volume_name)s with id " + "%(volume_id)s to host %(host_name)s. PowerStore volume id: " + "%(volume_provider_id)s, host id: %(host_provider_id)s.", + { + "volume_name": volume.name, + "volume_id": volume.id, + "host_name": host["name"], + "volume_provider_id": volume.provider_id, + "host_provider_id": host["id"], + }) + self.client.attach_volume_to_host(host["id"], volume.provider_id) + volume_lun = self.client.get_volume_lun( + host["id"], volume.provider_id + ) + LOG.debug("Successfully attached PowerStore volume %(volume_name)s " + "with id %(volume_id)s to host %(host_name)s. " + "PowerStore volume id: %(volume_provider_id)s, " + "host id: %(host_provider_id)s. Volume LUN: " + "%(volume_lun)s.", + { + "volume_name": volume.name, + "volume_id": volume.id, + "host_name": host["name"], + "volume_provider_id": volume.provider_id, + "host_provider_id": host["id"], + "volume_lun": volume_lun, + }) + return volume_lun + + def _create_host_and_attach(self, connector, volume): + """Create PowerStore host and attach volume. + + :param connector: connection properties + :param volume: OpenStack volume object + :return: attached volume logical number + """ + + host = self._create_host_if_not_exist(connector) + return self._attach_volume_to_host(host, volume) + + def _connect_volume(self, volume, connector): + """Attach PowerStore volume and return it's connection properties. + + :param volume: OpenStack volume object + :param connector: connection properties + :return: volume connection properties + """ + + appliance_name = volume_utils.extract_host(volume.host, "pool") + appliance_id = self.appliances_to_ids_map[appliance_name] + volume_lun = self._create_host_and_attach( + connector, + volume + ) + return self._get_connection_properties(appliance_id, + volume_lun) + + def _detach_volume_from_hosts(self, volume, hosts_to_detach=None): + """Detach volume from PowerStore hosts. + + If hosts_to_detach is None, detach volume from all hosts. + + :param volume: OpenStack volume object + :param hosts_to_detach: list of hosts to detach from + :return: None + """ + + if hosts_to_detach is None: + # Force detach. Get all mapped hosts and detach. + hosts_to_detach = self.client.get_volume_mapped_hosts( + volume.provider_id + ) + if not hosts_to_detach: + # Volume is not attached to any host. + return + LOG.debug("Detach PowerStore volume %(volume_name)s with id " + "%(volume_id)s from hosts. PowerStore volume id: " + "%(volume_provider_id)s, hosts ids: %(hosts_provider_ids)s.", + { + "volume_name": volume.name, + "volume_id": volume.id, + "volume_provider_id": volume.provider_id, + "hosts_provider_ids": hosts_to_detach, + }) + for host_id in hosts_to_detach: + self.client.detach_volume_from_host(host_id, volume.provider_id) + LOG.debug("Successfully detached PowerStore volume " + "%(volume_name)s with id %(volume_id)s from hosts. " + "PowerStore volume id: %(volume_provider_id)s, " + "hosts ids: %(hosts_provider_ids)s.", + { + "volume_name": volume.name, + "volume_id": volume.id, + "volume_provider_id": volume.provider_id, + "hosts_provider_ids": hosts_to_detach, + }) + + def _disconnect_volume(self, volume, connector): + """Detach PowerStore volume. + + :param volume: OpenStack volume object + :param connector: connection properties + :return: None + """ + + if connector is None: + self._detach_volume_from_hosts(volume) + else: + is_multiattached = utils.is_multiattached_to_host( + volume.volume_attachment, + connector["host"] + ) + if is_multiattached: + # Do not detach volume until it is attached to more than one + # instance on the same host. + return + initiators = self.initiators(connector) + host = self._filter_hosts_by_initiators(initiators) + if host: + self._detach_volume_from_hosts(volume, [host["id"]]) + + def revert_to_snapshot(self, volume, snapshot): + LOG.debug("Restore PowerStore volume %(volume_name)s with id " + "%(volume_id)s from snapshot %(snapshot_name)s with id " + "%(snapshot_id)s. PowerStore volume id: " + "%(volume_provider_id)s, snapshot id: " + "%(snapshot_provider_id)s.", + { + "volume_name": volume.name, + "volume_id": volume.id, + "snapshot_name": snapshot.name, + "snapshot_id": snapshot.id, + "volume_provider_id": volume.provider_id, + "snapshot_provider_id": snapshot.provider_id, + }) + self.client.restore_from_snapshot(volume.provider_id, + snapshot.provider_id) + LOG.debug("Successfully restored PowerStore volume %(volume_name)s " + "with id %(volume_id)s from snapshot %(snapshot_name)s " + "with id %(snapshot_id)s. PowerStore volume id: " + "%(volume_provider_id)s, snapshot id: " + "%(snapshot_provider_id)s.", + { + "volume_name": volume.name, + "volume_id": volume.id, + "snapshot_name": snapshot.name, + "snapshot_id": snapshot.id, + "volume_provider_id": volume.provider_id, + "snapshot_provider_id": snapshot.provider_id, + }) + + +class FibreChannelAdapter(CommonAdapter): + def __init__(self, active_backend_id, configuration): + super(FibreChannelAdapter, self).__init__(active_backend_id, + configuration) + self.storage_protocol = PROTOCOL_FC + self.driver_volume_type = "fibre_channel" + + @staticmethod + def initiators(connector): + return utils.extract_fc_wwpns(connector) + + def _get_fc_targets(self, appliance_id): + """Get available FC WWNs for PowerStore appliance. + + :param appliance_id: PowerStore appliance id + :return: list of FC WWNs + """ + + wwns = [] + fc_ports = self.client.get_fc_port(appliance_id) + for port in fc_ports: + if self._port_is_allowed(port["wwn"]): + wwns.append(utils.fc_wwn_to_string(port["wwn"])) + if not wwns: + msg = _("There are no accessible Fibre Channel targets on the " + "system.") + raise exception.VolumeBackendAPIException(data=msg) + return wwns + + def _get_connection_properties(self, appliance_id, volume_lun): + """Fill connection properties dict with data to attach volume. + + :param appliance_id: PowerStore appliance id + :param volume_lun: attached volume logical unit number + :return: connection properties + """ + + target_wwns = self._get_fc_targets(appliance_id) + return { + "driver_volume_type": self.driver_volume_type, + "data": { + "target_discovered": True, + "target_lun": volume_lun, + "target_wwn": target_wwns, + } + } + + +class iSCSIAdapter(CommonAdapter): + def __init__(self, active_backend_id, configuration): + super(iSCSIAdapter, self).__init__(active_backend_id, configuration) + self.storage_protocol = PROTOCOL_ISCSI + self.driver_volume_type = "iscsi" + + @staticmethod + def initiators(connector): + return [connector["initiator"]] + + def _get_iscsi_targets(self, appliance_id): + """Get available iSCSI portals and IQNs for PowerStore appliance. + + :param appliance_id: PowerStore appliance id + :return: iSCSI portals and IQNs + """ + + iqns = [] + portals = [] + ip_pool_addresses = self.client.get_ip_pool_address(appliance_id) + for address in ip_pool_addresses: + if self._port_is_allowed(address["address"]): + portals.append( + utils.iscsi_portal_with_port(address["address"]) + ) + iqns.append(address["ip_port"]["target_iqn"]) + if not portals: + msg = _("There are no accessible iSCSI targets on the " + "system.") + raise exception.VolumeBackendAPIException(data=msg) + return iqns, portals + + def _get_connection_properties(self, appliance_id, volume_lun): + """Fill connection properties dict with data to attach volume. + + :param appliance_id: PowerStore appliance id + :param volume_lun: attached volume logical unit number + :return: connection properties + """ + + iqns, portals = self._get_iscsi_targets(appliance_id) + return { + "driver_volume_type": self.driver_volume_type, + "data": { + "target_discovered": True, + "target_portals": portals, + "target_iqns": iqns, + "target_luns": [volume_lun] * len(portals), + }, + } diff --git a/cinder/volume/drivers/dell_emc/powerstore/client.py b/cinder/volume/drivers/dell_emc/powerstore/client.py new file mode 100644 index 00000000000..3927a00c39d --- /dev/null +++ b/cinder/volume/drivers/dell_emc/powerstore/client.py @@ -0,0 +1,427 @@ +# Copyright (c) 2020 Dell Inc. or its subsidiaries. +# 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. + +"""REST client for Dell EMC PowerStore Cinder Driver.""" + +import functools +import json + +from oslo_log import log as logging +import requests + +from cinder import exception +from cinder.i18n import _ + + +LOG = logging.getLogger(__name__) +VOLUME_NOT_MAPPED_ERROR = "0xE0A08001000F" + + +class PowerStoreClient(object): + def __init__(self, configuration): + self.configuration = configuration + self.rest_ip = None + self.rest_username = None + self.rest_password = None + self.verify_certificate = None + self.certificate_path = None + self.base_url = None + self.ok_codes = [ + requests.codes.ok, + requests.codes.created, + requests.codes.no_content, + requests.codes.partial_content + ] + + @property + def _verify_cert(self): + verify_cert = self.verify_certificate + if self.verify_certificate and self.certificate_path: + verify_cert = self.certificate_path + return verify_cert + + def do_setup(self): + self.rest_ip = self.configuration.safe_get("san_ip") + self.rest_username = self.configuration.safe_get("san_login") + self.rest_password = self.configuration.safe_get("san_password") + self.base_url = "https://%s:/api/rest" % self.rest_ip + self.verify_certificate = self.configuration.safe_get( + "driver_ssl_cert_verify" + ) + if self.verify_certificate: + self.certificate_path = ( + self.configuration.safe_get("driver_ssl_cert_path") + ) + + def check_for_setup_error(self): + if not all([self.rest_ip, self.rest_username, self.rest_password]): + msg = _("REST server IP, username and password must be set.") + raise exception.VolumeBackendAPIException(data=msg) + + # log warning if not using certificates + if not self.verify_certificate: + LOG.warning("Verify certificate is not set, using default of " + "False.") + LOG.debug("Successfully initialized PowerStore REST client. " + "Server IP: %(ip)s, username: %(username)s. " + "Verify server's certificate: %(verify_cert)s.", + { + "ip": self.rest_ip, + "username": self.rest_username, + "verify_cert": self._verify_cert, + }) + + def _send_request(self, method, url, payload=None, params=None): + if not payload: + payload = {} + if not params: + params = {} + request_params = { + "auth": (self.rest_username, self.rest_password), + "verify": self._verify_cert, + } + if method == "GET": + request_params["params"] = params + else: + request_params["data"] = json.dumps(payload) + request_url = self.base_url + url + r = requests.request(method, request_url, **request_params) + + log_level = logging.DEBUG + if r.status_code not in self.ok_codes: + log_level = logging.ERROR + LOG.log(log_level, + "REST Request: %s %s with body %s", + r.request.method, + r.request.url, + r.request.body) + LOG.log(log_level, + "REST Response: %s with data %s", + r.status_code, + r.text) + + try: + response = r.json() + except ValueError: + response = None + return r, response + + _send_get_request = functools.partialmethod(_send_request, "GET") + _send_post_request = functools.partialmethod(_send_request, "POST") + _send_patch_request = functools.partialmethod(_send_request, "PATCH") + _send_delete_request = functools.partialmethod(_send_request, "DELETE") + + def get_appliance_id_by_name(self, appliance_name): + r, response = self._send_get_request( + "/appliance", + params={ + "name": "eq.%s" % appliance_name, + } + ) + if r.status_code not in self.ok_codes: + msg = _("Failed to query PowerStore appliances.") + LOG.error(msg) + raise exception.VolumeBackendAPIException(data=msg) + try: + appliance_id = response[0].get("id") + return appliance_id + except IndexError: + msg = _("PowerStore appliance %s is not found.") % appliance_name + LOG.error(msg) + raise exception.VolumeBackendAPIException(data=msg) + + def get_appliance_metrics(self, appliance_id): + r, response = self._send_post_request( + "/metrics/generate", + payload={ + "entity": "space_metrics_by_appliance", + "entity_id": appliance_id, + } + ) + if r.status_code not in self.ok_codes: + msg = (_("Failed to query metrics for " + "PowerStore appliance with id %s.") % appliance_id) + LOG.error(msg) + raise exception.VolumeBackendAPIException(data=msg) + try: + latest_metrics = response[-1] + return latest_metrics + except IndexError: + msg = (_("Failed to query metrics for " + "PowerStore appliance with id %s.") % appliance_id) + LOG.error(msg) + raise exception.VolumeBackendAPIException(data=msg) + + def create_volume(self, appliance_id, name, size): + r, response = self._send_post_request( + "/volume", + payload={ + "appliance_id": appliance_id, + "name": name, + "size": size, + } + ) + if r.status_code not in self.ok_codes: + msg = _("Failed to create PowerStore volume %s.") % name + LOG.error(msg) + raise exception.VolumeBackendAPIException(data=msg) + return response["id"] + + def delete_volume_or_snapshot(self, entity_id, entity="volume"): + r, response = self._send_delete_request("/volume/%s" % entity_id) + if r.status_code not in self.ok_codes: + if r.status_code == requests.codes.not_found: + LOG.warning("PowerStore %(entity)s with id %(entity_id)s is " + "not found. Ignoring error.", + { + "entity": entity, + "entity_id": entity_id, + }) + else: + msg = (_("Failed to delete PowerStore %(entity)s with id " + "%(entity_id)s.") + % {"entity": entity, + "entity_id": entity_id, }) + LOG.error(msg) + raise exception.VolumeBackendAPIException(data=msg) + + def extend_volume(self, volume_id, size): + r, response = self._send_patch_request( + "/volume/%s" % volume_id, + payload={ + "size": size, + } + ) + if r.status_code not in self.ok_codes: + msg = (_("Failed to extend PowerStore volume with id %s.") + % volume_id) + LOG.error(msg) + raise exception.VolumeBackendAPIException(data=msg) + + def create_snapshot(self, volume_id, name): + r, response = self._send_post_request( + "/volume/%s/snapshot" % volume_id, + payload={ + "name": name, + } + ) + if r.status_code not in self.ok_codes: + msg = (_("Failed to create snapshot %(snapshot_name)s for " + "PowerStore volume with id %(volume_id)s.") + % {"snapshot_name": name, + "volume_id": volume_id, }) + LOG.error(msg) + raise exception.VolumeBackendAPIException(data=msg) + return response["id"] + + def clone_volume_or_snapshot(self, + name, + entity_id, + entity="volume"): + r, response = self._send_post_request( + "/volume/%s/clone" % entity_id, + payload={ + "name": name, + } + ) + if r.status_code not in self.ok_codes: + msg = (_("Failed to create clone %(clone_name)s for " + "PowerStore %(entity)s with id %(entity_id)s.") + % {"clone_name": name, + "entity": entity, + "entity_id": entity_id, }) + LOG.error(msg) + raise exception.VolumeBackendAPIException(data=msg) + return response["id"] + + def get_all_hosts(self, protocol): + r, response = self._send_get_request( + "/host", + params={ + "select": "id,name,host_initiators", + "host_initiators->0->>port_type": "eq.%s" % protocol, + } + ) + if r.status_code not in self.ok_codes: + msg = _("Failed to query PowerStore hosts.") + LOG.error(msg) + raise exception.VolumeBackendAPIException(data=msg) + return response + + def create_host(self, name, ports): + r, response = self._send_post_request( + "/host", + payload={ + "name": name, + "os_type": "Linux", + "initiators": ports + } + ) + if r.status_code not in self.ok_codes: + msg = _("Failed to create PowerStore host %s.") % name + LOG.error(msg) + raise exception.VolumeBackendAPIException(data=msg) + return response + + def modify_host_initiators(self, host_id, **kwargs): + r, response = self._send_patch_request( + "/host/%s" % host_id, + payload={ + **kwargs, + } + ) + if r.status_code not in self.ok_codes: + msg = (_("Failed to modify initiators of PowerStore host " + "with id %s.") % host_id) + LOG.error(msg) + raise exception.VolumeBackendAPIException(data=msg) + + def attach_volume_to_host(self, host_id, volume_id): + r, response = self._send_post_request( + "/volume/%s/attach" % volume_id, + payload={ + "host_id": host_id, + } + ) + if r.status_code not in self.ok_codes: + msg = (_("Failed to attach PowerStore volume %(volume_id)s " + "to host %(host_id)s.") + % {"volume_id": volume_id, + "host_id": host_id, }) + LOG.error(msg) + raise exception.VolumeBackendAPIException(data=msg) + + def get_volume_mapped_hosts(self, volume_id): + r, response = self._send_get_request( + "/host_volume_mapping", + params={ + "volume_id": "eq.%s" % volume_id, + "select": "host_id" + } + ) + if r.status_code not in self.ok_codes: + msg = _("Failed to query PowerStore host volume mappings.") + LOG.error(msg) + raise exception.VolumeBackendAPIException(data=msg) + mapped_hosts = [mapped_host["host_id"] for mapped_host in response] + return mapped_hosts + + def get_volume_lun(self, host_id, volume_id): + r, response = self._send_get_request( + "/host_volume_mapping", + params={ + "host_id": "eq.%s" % host_id, + "volume_id": "eq.%s" % volume_id, + "select": "logical_unit_number" + } + ) + if r.status_code not in self.ok_codes: + msg = _("Failed to query PowerStore host volume mappings.") + LOG.error(msg) + raise exception.VolumeBackendAPIException(data=msg) + try: + logical_unit_number = response[0].get("logical_unit_number") + return logical_unit_number + except IndexError: + msg = (_("PowerStore mapping of volume with id %(volume_id)s " + "to host %(host_id)s is not found.") + % {"volume_id": volume_id, + "host_id": host_id, }) + LOG.error(msg) + raise exception.VolumeBackendAPIException(data=msg) + + def get_fc_port(self, appliance_id): + r, response = self._send_get_request( + "/fc_port", + params={ + "appliance_id": "eq.%s" % appliance_id, + "is_link_up": "eq.True", + "select": "wwn" + + } + ) + if r.status_code not in self.ok_codes: + msg = _("Failed to query PowerStore IP pool addresses.") + LOG.error(msg) + raise exception.VolumeBackendAPIException(data=msg) + return response + + def get_ip_pool_address(self, appliance_id): + r, response = self._send_get_request( + "/ip_pool_address", + params={ + "appliance_id": "eq.%s" % appliance_id, + "purposes": "eq.{Storage_Iscsi_Target}", + "select": "address,ip_port(target_iqn)" + + } + ) + if r.status_code not in self.ok_codes: + msg = _("Failed to query PowerStore IP pool addresses.") + LOG.error(msg) + raise exception.VolumeBackendAPIException(data=msg) + return response + + def detach_volume_from_host(self, host_id, volume_id): + r, response = self._send_post_request( + "/volume/%s/detach" % volume_id, + payload={ + "host_id": host_id, + } + ) + if r.status_code not in self.ok_codes: + if r.status_code == requests.codes.not_found: + LOG.warning("PowerStore volume with id %(volume_id)s is " + "not found. Ignoring error.", + { + "volume_id": volume_id, + }) + elif ( + r.status_code == requests.codes.unprocessable and + any([ + message["code"] == VOLUME_NOT_MAPPED_ERROR + for message in response["messages"] + ]) + ): + LOG.warning("PowerStore volume with id %(volume_id)s is " + "not mapped to host with id %(host_id)s. " + "Ignoring error.", + { + "volume_id": volume_id, + "host_id": host_id, + }) + else: + msg = (_("Failed to detach PowerStore volume %(volume_id)s " + "to host %(host_id)s.") + % {"volume_id": volume_id, + "host_id": host_id, }) + LOG.error(msg) + raise exception.VolumeBackendAPIException(data=msg) + + def restore_from_snapshot(self, volume_id, snapshot_id): + r, response = self._send_post_request( + "/volume/%s/restore" % volume_id, + payload={ + "from_snap_id": snapshot_id, + "create_backup_snap": False, + } + ) + if r.status_code not in self.ok_codes: + msg = (_("Failed to restore PowerStore volume with id " + "%(volume_id)s from snapshot with id %(snapshot_id)s.") + % {"volume_id": volume_id, + "snapshot_id": snapshot_id, }) + LOG.error(msg) + raise exception.VolumeBackendAPIException(data=msg) diff --git a/cinder/volume/drivers/dell_emc/powerstore/driver.py b/cinder/volume/drivers/dell_emc/powerstore/driver.py new file mode 100644 index 00000000000..67e8b5f8c75 --- /dev/null +++ b/cinder/volume/drivers/dell_emc/powerstore/driver.py @@ -0,0 +1,119 @@ +# Copyright (c) 2020 Dell Inc. or its subsidiaries. +# 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. + +"""Cinder driver for Dell EMC PowerStore.""" + +from oslo_config import cfg + +from cinder import interface +from cinder.volume import configuration +from cinder.volume import driver +from cinder.volume.drivers.dell_emc.powerstore import adapter +from cinder.volume.drivers.dell_emc.powerstore.options import POWERSTORE_OPTS +from cinder.volume.drivers.san import san + + +CONF = cfg.CONF +CONF.register_opts(POWERSTORE_OPTS, group=configuration.SHARED_CONF_GROUP) + + +@interface.volumedriver +class PowerStoreDriver(driver.VolumeDriver): + """Dell EMC PowerStore Driver. + + .. code-block:: none + + Version history: + 1.0.0 - Initial version + """ + + VERSION = "1.0.0" + VENDOR = "Dell EMC" + + # ThirdPartySystems wiki page + CI_WIKI_NAME = "DellEMC_PowerStore_CI" + + def __init__(self, *args, **kwargs): + super(PowerStoreDriver, self).__init__(*args, **kwargs) + + self.active_backend_id = kwargs.get("active_backend_id") + self.adapter = None + self.configuration.append_config_values(san.san_opts) + self.configuration.append_config_values(POWERSTORE_OPTS) + + @staticmethod + def get_driver_options(): + return POWERSTORE_OPTS + + def do_setup(self, context): + storage_protocol = self.configuration.safe_get("storage_protocol") + if ( + storage_protocol and + storage_protocol.lower() == adapter.PROTOCOL_FC.lower() + ): + self.adapter = adapter.FibreChannelAdapter(self.active_backend_id, + self.configuration) + else: + self.adapter = adapter.iSCSIAdapter(self.active_backend_id, + self.configuration) + self.adapter.do_setup() + + def check_for_setup_error(self): + self.adapter.check_for_setup_error() + + def create_volume(self, volume): + return self.adapter.create_volume(volume) + + def delete_volume(self, volume): + return self.adapter.delete_volume(volume) + + def extend_volume(self, volume, new_size): + return self.adapter.extend_volume(volume, new_size) + + def create_snapshot(self, snapshot): + return self.adapter.create_snapshot(snapshot) + + def delete_snapshot(self, snapshot): + return self.adapter.delete_snapshot(snapshot) + + def create_cloned_volume(self, volume, src_vref): + return self.adapter.create_cloned_volume(volume, src_vref) + + def create_volume_from_snapshot(self, volume, snapshot): + return self.adapter.create_volume_from_snapshot(volume, snapshot) + + def initialize_connection(self, volume, connector, **kwargs): + return self.adapter.initialize_connection(volume, connector, **kwargs) + + def terminate_connection(self, volume, connector, **kwargs): + return self.adapter.terminate_connection(volume, connector, **kwargs) + + def revert_to_snapshot(self, context, volume, snapshot): + return self.adapter.revert_to_snapshot(volume, snapshot) + + def _update_volume_stats(self): + stats = self.adapter.update_volume_stats() + stats["driver_version"] = self.VERSION + stats["vendor_name"] = self.VENDOR + self._stats = stats + + 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/dell_emc/powerstore/options.py b/cinder/volume/drivers/dell_emc/powerstore/options.py new file mode 100644 index 00000000000..4c0eeeaec6e --- /dev/null +++ b/cinder/volume/drivers/dell_emc/powerstore/options.py @@ -0,0 +1,33 @@ +# Copyright (c) 2020 Dell Inc. or its subsidiaries. +# 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. + +"""Configuration options for Dell EMC PowerStore Cinder driver.""" + +from oslo_config import cfg + +POWERSTORE_APPLIANCES = "powerstore_appliances" +POWERSTORE_PORTS = "powerstore_ports" + +POWERSTORE_OPTS = [ + cfg.ListOpt(POWERSTORE_APPLIANCES, + default=[], + help="Appliances names. Comma separated list of PowerStore " + "appliances names used to provision volumes. Required."), + cfg.ListOpt(POWERSTORE_PORTS, + default=[], + help="Allowed ports. Comma separated list of PowerStore " + "iSCSI IPs or FC WWNs (ex. 58:cc:f0:98:49:22:07:02) " + "to be used. If option is not set all ports are allowed.") +] diff --git a/cinder/volume/drivers/dell_emc/powerstore/utils.py b/cinder/volume/drivers/dell_emc/powerstore/utils.py new file mode 100644 index 00000000000..28e6566bff1 --- /dev/null +++ b/cinder/volume/drivers/dell_emc/powerstore/utils.py @@ -0,0 +1,136 @@ +# Copyright (c) 2020 Dell Inc. or its subsidiaries. +# 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. + +"""Utilities for Dell EMC PowerStore Cinder driver.""" + +import re + +from oslo_log import log as logging +from oslo_utils import units + +from cinder import exception +from cinder.i18n import _ +from cinder.objects import fields + + +LOG = logging.getLogger(__name__) + + +def bytes_to_gib(size_in_bytes): + """Convert size in bytes to GiB. + + :param size_in_bytes: size in bytes + :return: size in GiB + """ + + return size_in_bytes // units.Gi + + +def gib_to_bytes(size_in_gb): + """Convert size in GiB to bytes. + + :param size_in_gb: size in GiB + :return: size in bytes + """ + + return size_in_gb * units.Gi + + +def extract_fc_wwpns(connector): + """Convert connector FC ports to appropriate format with colons. + + :param connector: connection properties + :return: FC ports in appropriate format with colons + """ + + if "wwnns" not in connector or "wwpns" not in connector: + msg = _("Host %s does not have FC initiators.") % connector["host"] + LOG.error(msg) + raise exception.VolumeBackendAPIException(data=msg) + return [":".join(re.findall("..", wwpn)) for wwpn in connector["wwpns"]] + + +def fc_wwn_to_string(wwn): + """Convert FC WWN to string without colons. + + :param wwn: FC WWN + :return: FC WWN without colons + """ + + return wwn.replace(":", "") + + +def iscsi_portal_with_port(address): + """Add default port 3260 to iSCSI portal + + :param address: iSCSI portal without port + :return: iSCSI portal with default port 3260 + """ + + return "%(address)s:3260" % {"address": address} + + +def powerstore_host_name(connector, protocol): + """Generate PowerStore host name for connector. + + :param connector: connection properties + :param protocol: storage protocol (FC or iSCSI) + :return: unique host name + """ + + return ("%(host)s-%(protocol)s" % + {"host": connector["host"], + "protocol": protocol, }) + + +def filter_hosts_by_initiators(hosts, initiators): + """Filter hosts by given list of initiators. + + :param hosts: list of PowerStore host objects + :param initiators: list of initiators + :return: PowerStore hosts list + """ + + hosts_names_found = set() + for host in hosts: + for initiator in host["host_initiators"]: + if initiator["port_name"] in initiators: + hosts_names_found.add(host["name"]) + return list(filter(lambda host: host["name"] in hosts_names_found, hosts)) + + +def is_multiattached_to_host(volume_attachment, host_name): + """Check if volume is attached to multiple instances on one host. + + When multiattach is enabled, a volume could be attached to two or more + instances which are hosted on one nova host. + Because PowerStore cannot recognize the volume is attached to two or more + instances, we should keep the volume attached to the nova host until + the volume is detached from the last instance. + + :param volume_attachment: list of VolumeAttachment objects + :param host_name: OpenStack host name + :return: multiattach flag + """ + + if not volume_attachment: + return False + + attachments = [ + attachment for attachment in volume_attachment + if (attachment.attach_status == fields.VolumeAttachStatus.ATTACHED and + attachment.attached_host == host_name) + ] + return len(attachments) > 1 diff --git a/doc/source/configuration/block-storage/drivers/dell-emc-powerstore-driver.rst b/doc/source/configuration/block-storage/drivers/dell-emc-powerstore-driver.rst new file mode 100644 index 00000000000..f7a92eafac5 --- /dev/null +++ b/doc/source/configuration/block-storage/drivers/dell-emc-powerstore-driver.rst @@ -0,0 +1,79 @@ +========================== +Dell EMC PowerStore driver +========================== + +This section explains how to configure and connect the block +storage nodes to an PowerStore storage cluster. + +Supported operations +~~~~~~~~~~~~~~~~~~~~ + +- Create, delete, attach and detach volumes. +- Create, delete volume snapshots. +- Create a volume from a snapshot. +- Copy an image to a volume. +- Copy a volume to an image. +- Clone a volume. +- Extend a volume. +- Get volume statistics. +- Attach a volume to multiple servers simultaneously (multiattach). +- Revert a volume to a snapshot. + +Driver configuration +~~~~~~~~~~~~~~~~~~~~ + +Add the following content into ``/etc/cinder/cinder.conf``: + +.. code-block:: ini + + [DEFAULT] + enabled_backends = powerstore + + [powerstore] + # PowerStore REST IP + san_ip = + # PowerStore REST username and password + san_login = + san_password = + # Storage protocol + storage_protocol = # FC or iSCSI + # Volume driver name + volume_driver = cinder.volume.drivers.dell_emc.powerstore.driver.PowerStoreDriver + # Backend name + volume_backend_name = + # PowerStore appliances + powerstore_appliances = # Ex. Appliance-1,Appliance-2 + # PowerStore allowed ports + powerstore_ports = # Ex. 58:cc:f0:98:49:22:07:02,58:cc:f0:98:49:23:07:02 + +Driver options +~~~~~~~~~~~~~~ + +The driver supports the following configuration options: + +.. config-table:: + :config-target: PowerStore + + cinder.volume.drivers.dell_emc.powerstore.driver + +SSL support +~~~~~~~~~~~ + +To enable the SSL certificate verification, modify the following options in the +``cinder.conf`` file: + +.. code-block:: ini + + driver_ssl_cert_verify = True + driver_ssl_cert_path = + +By default, the SSL certificate validation is disabled. + +If the ``driver_ssl_cert_path`` option is omitted, the system default CA will +be used. + +Thin provisioning and compression +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The driver creates thin provisioned compressed volumes by default. +Thick provisioning is not supported. diff --git a/doc/source/reference/support-matrix.ini b/doc/source/reference/support-matrix.ini index e4db5438d8f..4a8d2ebfe30 100644 --- a/doc/source/reference/support-matrix.ini +++ b/doc/source/reference/support-matrix.ini @@ -24,6 +24,9 @@ title=Dell EMC XtremeIO Storage Driver (FC, iSCSI) [driver.dell_emc_powermax] title=Dell EMC PowerMax (2000, 8000) Storage Driver (iSCSI, FC) +[driver.dell_emc_powerstore] +title="Dell EMC PowerStore Storage Driver (iSCSI, FC)" + [driver.dell_emc_sc] title=Dell EMC SC Series Storage Driver (iSCSI, FC) @@ -198,6 +201,7 @@ notes=A vendor driver is considered supported if the vendor is isn't resolved before the end of the subsequent release. driver.datera=complete driver.dell_emc_powermax=complete +driver.dell_emc_powerstore=complete driver.dell_emc_sc=complete driver.dell_emc_unity=complete driver.dell_emc_vmax_af=complete @@ -261,6 +265,7 @@ notes=Cinder supports the ability to extend a volume that is attached to an instance, but not all drivers are able to do this. driver.datera=complete driver.dell_emc_powermax=complete +driver.dell_emc_powerstore=missing driver.dell_emc_sc=complete driver.dell_emc_unity=complete driver.dell_emc_vmax_af=complete @@ -324,6 +329,7 @@ notes=This is the ability to directly attach a snapshot to an instance like a volume. driver.datera=missing driver.dell_emc_powermax=missing +driver.dell_emc_powerstore=missing driver.dell_emc_sc=missing driver.dell_emc_unity=complete driver.dell_emc_vmax_af=missing @@ -390,6 +396,7 @@ notes=Vendor drivers that support Quality of Service (QoS) at the utilize frontend QoS via libvirt. driver.datera=complete driver.dell_emc_powermax=complete +driver.dell_emc_powerstore=missing driver.dell_emc_sc=complete driver.dell_emc_unity=complete driver.dell_emc_vmax_af=complete @@ -455,6 +462,7 @@ notes=Vendor drivers that support volume replication can report this to take advantage of Cinder's failover and failback commands. driver.datera=missing driver.dell_emc_powermax=complete +driver.dell_emc_powerstore=missing driver.dell_emc_sc=complete driver.dell_emc_unity=complete driver.dell_emc_vmax_af=complete @@ -521,6 +529,7 @@ notes=Vendor drivers that support consistency groups are able to creation of consistent snapshots across a group. driver.datera=missing driver.dell_emc_powermax=complete +driver.dell_emc_powerstore=missing driver.dell_emc_sc=complete driver.dell_emc_unity=complete driver.dell_emc_vmax_af=complete @@ -586,6 +595,7 @@ notes=If a volume driver supports thin provisioning it means that it 'oversubscription'. driver.datera=missing driver.dell_emc_powermax=complete +driver.dell_emc_powerstore=complete driver.dell_emc_sc=complete driver.dell_emc_unity=complete driver.dell_emc_vmax_af=complete @@ -652,6 +662,7 @@ notes=Storage assisted volume migration is like host assisted volume functionality. driver.datera=missing driver.dell_emc_powermax=complete +driver.dell_emc_powerstore=missing driver.dell_emc_sc=missing driver.dell_emc_unity=complete driver.dell_emc_vmax_af=complete @@ -718,6 +729,7 @@ notes=Vendor drivers that report multi-attach support are able attach functionality otherwise data corruption may occur. driver.datera=missing driver.dell_emc_powermax=complete +driver.dell_emc_powerstore=complete driver.dell_emc_sc=complete driver.dell_emc_unity=complete driver.dell_emc_vmax_af=complete @@ -781,6 +793,7 @@ notes=Vendor drivers that implement the driver assisted function to revert a volume to the last snapshot taken. driver.datera=missing driver.dell_emc_powermax=complete +driver.dell_emc_powerstore=complete driver.dell_emc_sc=missing driver.dell_emc_unity=complete driver.dell_emc_vmax_af=complete @@ -848,6 +861,7 @@ notes=Vendor drivers that support running in an active/active a configuration. driver.datera=missing driver.dell_emc_powermax=missing +driver.dell_emc_powerstore=missing driver.dell_emc_sc=missing driver.dell_emc_unity=missing driver.dell_emc_vmax_af=missing diff --git a/releasenotes/notes/bp-powerstore-cinder-driver-94f8c7f1371eafe7.yaml b/releasenotes/notes/bp-powerstore-cinder-driver-94f8c7f1371eafe7.yaml new file mode 100644 index 00000000000..eb9052a8a7e --- /dev/null +++ b/releasenotes/notes/bp-powerstore-cinder-driver-94f8c7f1371eafe7.yaml @@ -0,0 +1,4 @@ +--- +features: + - | + Add Dell EMC PowerStore Storage Driver (iSCSI, FC).