diff --git a/cinder/opts.py b/cinder/opts.py index 9549c9e2a2c..02747fa70f6 100644 --- a/cinder/opts.py +++ b/cinder/opts.py @@ -185,6 +185,8 @@ from cinder.volume.drivers.synology import synology_common as \ cinder_volume_drivers_synology_synologycommon from cinder.volume.drivers.toyou.acs5000 import acs5000_common as \ cinder_volume_drivers_toyou_acs5000_acs5000common +from cinder.volume.drivers.toyou.tyds import tyds as \ + cinder_volume_drivers_toyou_tyds_tyds from cinder.volume.drivers.veritas_access import veritas_iscsi as \ cinder_volume_drivers_veritas_access_veritasiscsi from cinder.volume.drivers.vmware import vmdk as \ @@ -439,6 +441,7 @@ def list_opts(): cinder_volume_drivers_stx_common.common_opts, cinder_volume_drivers_stx_common.iscsi_opts, cinder_volume_drivers_synology_synologycommon.cinder_opts, + cinder_volume_drivers_toyou_tyds_tyds.tyds_opts, cinder_volume_drivers_vmware_vmdk.vmdk_opts, cinder_volume_drivers_vzstorage.vzstorage_opts, cinder_volume_drivers_windows_iscsi.windows_opts, diff --git a/cinder/tests/unit/volume/drivers/toyou/test_tyds.py b/cinder/tests/unit/volume/drivers/toyou/test_tyds.py new file mode 100644 index 00000000000..335c0d41aec --- /dev/null +++ b/cinder/tests/unit/volume/drivers/toyou/test_tyds.py @@ -0,0 +1,690 @@ +# Copyright 2023 toyou Corp. +# 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 unittest +from unittest import mock + +from cinder import exception +from cinder.tests.unit import fake_snapshot +from cinder.tests.unit import fake_volume +from cinder.volume import configuration as conf +from cinder.volume.drivers.toyou.tyds import tyds as driver + +POOLS_NAME = ['pool1', 'pool2'] + + +class TestTydsDriver(unittest.TestCase): + @mock.patch('cinder.volume.drivers.toyou.tyds.tyds_client.TydsClient', + autospec=True) + def setUp(self, mock_tyds_client): + """Set up the test case. + + - Creates a driver instance. + - Mocks the TydsClient and its methods. + - Initializes volumes and snapshots for testing. + """ + super().setUp() + self.mock_client = mock_tyds_client.return_value + self.mock_do_request = mock.MagicMock( + side_effect=self.mock_client.do_request) + self.mock_client.do_request = self.mock_do_request + + self.configuration = mock.Mock(spec=conf.Configuration) + self.configuration.tyds_pools = POOLS_NAME + self.configuration.san_ip = "23.44.56.78" + self.configuration.tyds_http_port = 80 + self.configuration.san_login = 'admin' + self.configuration.san_password = 'admin' + self.configuration.tyds_stripe_size = '4M' + self.configuration.tyds_clone_progress_interval = 3 + self.configuration.tyds_copy_progress_interval = 3 + self.driver = driver.TYDSDriver(configuration=self.configuration) + self.driver.do_setup(context=None) + self.driver.check_for_setup_error() + + self.volume = fake_volume.fake_volume_obj(None) + self.volume.host = 'host@backend#pool1' + self.snapshot = fake_snapshot.fake_snapshot_obj(None) + self.snapshot.volume = self.volume + self.snapshot.volume_id = self.volume.id + self.target_volume = fake_volume.fake_volume_obj(None) + self.target_volume.host = 'host@backend#pool2' + self.src_vref = self.volume + + def test_create_volume_success(self): + """Test case for successful volume creation. + + - Sets mock return value. + - Calls create_volume method. + - Verifies if the create_volume method is called with correct + arguments. + """ + self.mock_client.create_volume.return_value = self.volume + self.driver.create_volume(self.volume) + self.mock_client.create_volume.assert_called_once_with( + self.volume.name, self.volume.size * 1024, 'pool1', '4M') + + def test_create_volume_failure(self): + """Test case for volume creation failure. + + - Sets the mock return value to simulate a failure. + - Calls the create_volume method. + - Verifies if the create_volume method raises the expected exception. + """ + # Set the mock return value to simulate a failure + self.mock_client.create_volume.side_effect = \ + exception.VolumeBackendAPIException('API error') + + # Call the create_volume method and check the result + self.assertRaises( + exception.VolumeBackendAPIException, + self.driver.create_volume, + self.volume + ) + + def test_delete_volume_success(self): + """Test case for successful volume deletion. + + - Mocks the _get_volume_by_name method to return a volume. + - Calls the delete_volume method. + - Verifies if the delete_volume method is called with the correct + volume ID. + """ + # Mock the _get_volume_by_name method to return a volume + self.driver._get_volume_by_name = mock.Mock(return_value={'id': '13'}) + + # Call the delete_volume method + self.driver.delete_volume(self.volume) + + # Verify if the delete_volume method is called with the correct + # volume ID + self.mock_client.delete_volume.assert_called_once_with('13') + + def test_delete_volume_failure(self): + """Test case for volume deletion failure. + + - Mocks the _get_volume_by_name method to return a volume. + - Sets the mock return value for delete_volume method to raise an + exception. + - Calls the delete_volume method. + - Verifies if the delete_volume method raises the expected exception. + """ + # Mock the _get_volume_by_name method to return a volume + self.driver._get_volume_by_name = mock.Mock(return_value={'id': '13'}) + + # Set the mock return value for delete_volume method to raise an + # exception + self.mock_client.delete_volume.side_effect = \ + exception.VolumeBackendAPIException('API error') + + # Call the delete_volume method and verify if it raises the expected + # exception + self.assertRaises(exception.VolumeBackendAPIException, + self.driver.delete_volume, self.volume) + + def test_create_snapshot_success(self): + """Test case for successful snapshot creation. + + - Sets the mock return value for create_snapshot method. + - Mocks the _get_volume_by_name method to return a volume. + - Calls the create_snapshot method. + - Verifies if the create_snapshot method is called with the correct + arguments. + """ + # Set the mock return value for create_snapshot method + self.mock_client.create_snapshot.return_value = self.snapshot + + # Mock the _get_volume_by_name method to return a volume + self.driver._get_volume_by_name = mock.Mock(return_value={'id': '13'}) + + # Call the create_snapshot method + self.driver.create_snapshot(self.snapshot) + + # Verify if the create_snapshot method is called with the correct + # arguments + self.mock_client.create_snapshot.assert_called_once_with( + self.snapshot.name, '13', + '%s/%s' % (self.volume.name, self.snapshot.name) + ) + + def test_create_snapshot_failure_with_no_volume(self): + """Test case for snapshot creation failure when volume is not found. + + - Mocks the _get_volume_by_name method to return None. + - Calls the create_snapshot method. + - Verifies if the create_snapshot method is not called. + """ + # Mock the _get_volume_by_name method to return None + self.driver._get_volume_by_name = mock.Mock(return_value=None) + + # Call the create_snapshot method and check for exception + self.assertRaises(driver.TYDSDriverException, + self.driver.create_snapshot, self.snapshot) + + # Verify if the create_snapshot method is not called + self.mock_client.create_snapshot.assert_not_called() + + def test_create_snapshot_failure(self): + """Test case for snapshot creation failure. + + - Mocks the _get_volume_by_name method to return a volume. + - Sets the mock return value for create_snapshot to raise an exception. + - Calls the create_snapshot method. + - Verifies if the create_snapshot method is called with the correct + arguments. + """ + # Mock the _get_volume_by_name method to return a volume + self.driver._get_volume_by_name = mock.Mock(return_value={'id': '13'}) + + # Set the mock return value for create_snapshot to raise an exception + self.mock_client.create_snapshot.side_effect = \ + exception.VolumeBackendAPIException('API error') + + # Call the create_snapshot method and check for exception + self.assertRaises(exception.VolumeBackendAPIException, + self.driver.create_snapshot, self.snapshot) + + # Verify if the create_snapshot method is called with the correct + # arguments + self.mock_client.create_snapshot.assert_called_once_with( + self.snapshot.name, '13', + '%s/%s' % (self.volume.name, self.snapshot.name)) + + def test_delete_snapshot_success(self): + """Test case for successful snapshot deletion. + + - Mocks the _get_snapshot_by_name method to return a snapshot. + - Calls the delete_snapshot method. + - Verifies if the delete_snapshot method is called with the correct + arguments. + """ + # Mock the _get_snapshot_by_name method to return a snapshot + self.driver._get_snapshot_by_name = mock.Mock( + return_value={'id': 'volume_id'}) + + # Call the delete_snapshot method + self.driver.delete_snapshot(self.snapshot) + + # Verify if the delete_snapshot method is called with the correct + # arguments + self.mock_client.delete_snapshot.assert_called_once_with('volume_id') + + def test_delete_snapshot_failure(self): + """Test case for snapshot deletion failure. + + - Mocks the _get_snapshot_by_name method to return a snapshot. + - Sets the mock return value for delete_snapshot to raise an exception. + - Calls the delete_snapshot method. + - Verifies if the delete_snapshot method is called with the correct + arguments. + """ + # Mock the _get_snapshot_by_name method to return a snapshot + self.driver._get_snapshot_by_name = mock.Mock( + return_value={'id': 'volume_id'}) + + # Set the mock return value for delete_snapshot to raise an exception + self.mock_client.delete_snapshot.side_effect = \ + exception.VolumeBackendAPIException('API error') + + # Call the delete_snapshot method and check for exception + self.assertRaises(exception.VolumeBackendAPIException, + self.driver.delete_snapshot, + self.snapshot) + + # Verify if the delete_snapshot method is called once + self.mock_client.delete_snapshot.assert_called_once() + + @mock.patch('time.sleep') + @mock.patch('cinder.coordination.synchronized', new=mock.MagicMock()) + def test_create_volume_from_snapshot_success(self, mock_sleep): + """Test case for successful volume creation from snapshot. + + - Mocks the sleep function. + - Sets the mock return values for create_volume_from_snapshot, + _get_volume_by_name, and get_clone_progress. + - Calls the create_volume_from_snapshot method. + - Verifies if the create_volume_from_snapshot method is called with + the correct arguments. + - Verifies if the _get_volume_by_name method is called once. + """ + # Mock the sleep function + mock_sleep.return_value = None + + # Set the mock return values for create_volume_from_snapshot, + # _get_volume_by_name, and get_clone_progress + self.mock_client.create_volume_from_snapshot.return_value = self.volume + self.driver._get_volume_by_name = mock.Mock( + return_value={'poolName': 'pool1', + 'sizeMB': self.volume.size * 1024}) + self.mock_client.get_clone_progress.return_value = {'progress': '100%'} + + # Call the create_volume_from_snapshot method + self.driver.create_volume_from_snapshot(self.target_volume, + self.snapshot) + + # Verify if the create_volume_from_snapshot method is called with the + # correct arguments + self.mock_client.create_volume_from_snapshot.assert_called_once_with( + self.target_volume.name, 'pool2', self.snapshot.name, + self.volume.name, 'pool1') + + # Verify if the _get_volume_by_name method is called once + self.driver._get_volume_by_name.assert_called_once() + + def test_create_volume_from_snapshot_failure(self): + """Test case for volume creation from snapshot failure. + + - Sets the mock return value for _get_volume_by_name to return None. + - Calls the create_volume_from_snapshot method. + - Verifies if the create_volume_from_snapshot method raises a + driver.TYDSDriverException. + """ + # Set the mock return value for _get_volume_by_name to return None + self.driver._get_volume_by_name = mock.Mock(return_value=None) + + # Call the create_volume_from_snapshot method and check for exception + self.assertRaises(driver.TYDSDriverException, + self.driver.create_volume_from_snapshot, + self.volume, self.snapshot) + + @mock.patch('cinder.coordination.synchronized', new=mock.MagicMock()) + def test_create_cloned_volume_success(self): + """Test case for successful cloned volume creation. + + - Sets the mock return values for get_copy_progress, get_pools, + get_volumes, and _get_volume_by_name. + - Calls the create_cloned_volume method. + - Verifies if the create_clone_volume method is called with the correct + arguments. + """ + # Set the mock return values for get_copy_progress, get_pools, + # get_volumes, and _get_volume_by_name + self.mock_client.get_copy_progress.return_value = {'progress': '100%'} + self.driver.client.get_pools.return_value = [ + {'name': 'pool1', 'id': 'pool1_id'}, + {'name': 'pool2', 'id': 'pool2_id'} + ] + self.driver.client.get_volumes.return_value = [ + {'blockName': self.volume.name, 'poolName': 'pool1', + 'id': 'source_volume_id'} + ] + self.driver._get_volume_by_name = mock.Mock( + return_value={'name': self.volume.name, 'id': '13'}) + + # Call the create_cloned_volume method + self.driver.create_cloned_volume(self.target_volume, self.src_vref) + + # Verify if the create_clone_volume method is called with the correct + # arguments + self.driver.client.create_clone_volume.assert_called_once_with( + 'pool1', self.volume.name, 'source_volume_id', 'pool2', 'pool2_id', + self.target_volume.name + ) + + @mock.patch('cinder.coordination.synchronized', new=mock.MagicMock()) + def test_create_cloned_volume_failure(self): + """Test case for cloned volume creation failure. + + - Sets the mock return values for get_pools and get_volumes. + - Calls the create_cloned_volume method. + - Verifies if the create_cloned_volume method raises a + driver.TYDSDriverException. + """ + # Set the mock return values for get_pools and get_volumes + self.driver.client.get_pools.return_value = [ + {'name': 'pool1', 'id': 'pool1_id'}, + {'name': 'pool2', 'id': 'pool2_id'} + ] + self.driver.client.get_volumes.return_value = [ + {'blockName': self.volume.name, 'poolName': None, 'id': '14'} + ] + + # Call the create_cloned_volume method and check for exception + self.assertRaises(driver.TYDSDriverException, + self.driver.create_cloned_volume, + self.target_volume, + self.src_vref) + + def test_extend_volume_success(self): + """Test case for successful volume extension. + + - Sets the new size. + - Calls the extend_volume method. + - Verifies if the extend_volume method is called with the correct + arguments. + """ + new_size = 10 + + # Call the extend_volume method + self.driver.extend_volume(self.volume, new_size) + + # Verify if the extend_volume method is called with the correct + # arguments + self.mock_client.extend_volume.assert_called_once_with( + self.volume.name, 'pool1', new_size * 1024) + + def test_extend_volume_failure(self): + """Test case for volume extension failure. + + - Sets the new size and error message. + - Sets the mock side effect for extend_volume to raise an Exception. + - Calls the extend_volume method. + - Verifies if the extend_volume method raises the expected exception + and the error message matches. + - Verifies if the extend_volume method is called with the correct + arguments. + """ + new_size = 10 + + # Set the mock side effect for extend_volume to raise an Exception + self.mock_client.extend_volume.side_effect = \ + exception.VolumeBackendAPIException('API Error: Volume extend') + + # Call the extend_volume method and check for exception + self.assertRaises(exception.VolumeBackendAPIException, + self.driver.extend_volume, self.volume, new_size) + + # Verify if the extend_volume method is called with the correct + # arguments + self.mock_client.extend_volume.assert_called_once_with( + self.volume.name, 'pool1', new_size * 1024) + + def test_get_volume_stats(self): + """Test case for retrieving volume statistics. + + - Sets the mock side effect for safe_get to return the appropriate + values. + - Sets the mock return values for get_pools and get_volumes. + - Calls the get_volume_stats method. + - Verifies if the get_pools and get_volumes methods are called once. + - Verifies if the retrieved statistics match the expected statistics. + """ + + def safe_get_side_effect(param_name): + if param_name == 'volume_backend_name': + return 'toyou_backend' + + # Set the mock side effect for safe_get to return the appropriate + # values + self.configuration.safe_get.side_effect = safe_get_side_effect + + # Set the mock return values for get_pools and get_volumes + self.mock_client.get_pools.return_value = [ + {'name': 'pool1', + 'stats': {'max_avail': '107374182400', 'stored': '53687091200'}}, + {'name': 'pool2', + 'stats': {'max_avail': '214748364800', 'stored': '107374182400'}} + ] + self.mock_client.get_volumes.return_value = [ + {'poolName': 'pool1', 'sizeMB': '1024'}, + {'poolName': 'pool1', 'sizeMB': '2048'}, + {'poolName': 'pool2', 'sizeMB': '3072'} + ] + + # Call the get_volume_stats method + stats = self.driver.get_volume_stats() + + # Verify if the get_pools and get_volumes methods are called once + self.mock_client.get_pools.assert_called_once() + self.mock_client.get_volumes.assert_called_once() + + # Define the expected statistics + expected_stats = { + 'vendor_name': 'TOYOU', + 'driver_version': '1.0.0', + 'volume_backend_name': 'toyou_backend', + 'pools': [ + { + 'pool_name': 'pool1', + 'total_capacity_gb': 100.0, + 'free_capacity_gb': 50.0, + 'provisioned_capacity_gb': 3.0, + 'thin_provisioning_support': True, + 'QoS_support': False, + 'consistencygroup_support': False, + 'total_volumes': 2, + 'multiattach': False + }, + { + 'pool_name': 'pool2', + 'total_capacity_gb': 200.0, + 'free_capacity_gb': 100.0, + 'provisioned_capacity_gb': 3.0, + 'thin_provisioning_support': True, + 'QoS_support': False, + 'consistencygroup_support': False, + 'total_volumes': 1, + 'multiattach': False + } + ], + 'storage_protocol': 'iSCSI', + } + + # Verify if the retrieved statistics match the expected statistics + self.assertEqual(stats, expected_stats) + + def test_get_volume_stats_pool_not_found(self): + """Test case for retrieving volume statistics when pool not found. + + - Sets the mock return value for get_pools to an empty list. + - Calls the get_volume_stats method. + - Verifies if the get_pools method is called once. + - Verifies if the get_volume_stats method raises a + driver.TYDSDriverException. + """ + # Set the mock return value for get_pools to an empty list + self.mock_client.get_pools.return_value = [] + + # Call the get_volume_stats method and check for exception + self.assertRaises(driver.TYDSDriverException, + self.driver.get_volume_stats) + + # Verify if the get_pools method is called once + self.mock_client.get_pools.assert_called_once() + + def test_initialize_connection_success(self): + """Test case for successful volume initialization. + + - Sets the connector information. + - Sets the mock return values for get_initiator_list and get_target. + - Sets the mock return values and assertions for create_initiator_group + , create_target, modify_target, and generate_config. + - Calls the initialize_connection method. + - Verifies the expected return value and method calls. + """ + # Set the connector information + connector = { + 'host': 'host1', + 'initiator': 'iqn.1234', + 'ip': '192.168.0.1', + 'uuid': 'uuid1' + } + + # Set the mock return values for get_initiator_list and get_target + self.mock_client.get_initiator_list.return_value = [] + self.mock_client.get_target.return_value = [ + {'name': 'iqn.2023-06.com.toyou:uuid1', 'ipAddr': '192.168.0.2'}] + + # Set the mock return values and assertions for create_initiator_group, + # create_target, modify_target, and generate_config + self.mock_client.create_initiator_group.return_value = None + self.mock_client.create_target.return_value = None + self.mock_client.modify_target.return_value = None + self.mock_client.generate_config.return_value = None + self.mock_client.get_initiator_target_connections.side_effect = [ + [], # First call returns an empty list + [{'target_name': 'iqn.2023-06.com.toyou:initiator-group-uuid1', + 'target_iqn': 'iqn1', + 'block': [{'name': 'volume1', 'lunid': 0}]}] + # Second call returns a non-empty dictionary + ] + + # Call the initialize_connection method + result = self.driver.initialize_connection(self.volume, connector) + + # Define the expected return value + expected_return = { + 'driver_volume_type': 'iscsi', + 'data': { + 'target_discovered': False, + 'target_portal': '192.168.0.2:3260', + 'target_lun': 0, + 'target_iqns': ['iqn.2023-06.com.toyou:initiator-group-uuid1'], + 'target_portals': ['192.168.0.2:3260'], + 'target_luns': [0] + } + } + + # Verify the method calls and return value + self.mock_client.get_initiator_list.assert_called_once() + self.mock_client.create_initiator_group.assert_called_once() + self.assertEqual( + self.mock_client.get_initiator_target_connections.call_count, 2) + self.assertEqual(self.mock_client.get_target.call_count, 2) + self.mock_client.modify_target.assert_not_called() + self.mock_client.create_target.assert_called_once() + self.mock_client.generate_config.assert_called_once() + + self.assertEqual(result, expected_return) + + def test_initialize_connection_failure(self): + """Test case for failed volume initialization. + + - Sets the connector information. + - Sets the mock return values for get_initiator_list and get_it. + - Calls the initialize_connection method. + - Verifies if the get_initiator_list method is called once. + - Verifies if the create_initiator_group method is not called. + - Verifies if the initialize_connection method raises an exception to + type exception.VolumeBackendAPIException. + """ + # Set the connector information + connector = { + 'host': 'host1', + 'initiator': 'iqn.1234', + 'ip': '192.168.0.1', + 'uuid': 'uuid1' + } + + # Set the mock return values for get_initiator_list and get_it + self.mock_client.get_initiator_list.return_value = [ + {'group_name': 'initiator-group-uuid1'}] + self.mock_client.get_initiator_target_connections.return_value = [] + + # Call the initialize_connection method and check for exception + self.assertRaises(exception.VolumeBackendAPIException, + self.driver.initialize_connection, self.volume, + connector) + + # Verify if the get_initiator_list method is called once + self.mock_client.get_initiator_list.assert_called_once() + + # Verify if the create_initiator_group method is not called + self.mock_client.create_initiator_group.assert_not_called() + + def test_terminate_connection_success(self): + """Test case for successful termination of volume connection. + + - Sets the connector information. + - Sets the mock return values for get_it and get_initiator_list. + - Calls the terminate_connection method with the required mock methods. + - Verifies the method calls using assertions. + """ + # Set the connector information + connector = { + 'host': 'host1', + 'initiator': 'iqn.1234', + 'ip': '192.168.0.1', + 'uuid': 'uuid1' + } + + # Set the mock return values for get_it and get_initiator_list + self.mock_client.get_initiator_target_connections.return_value = [ + {'target_iqn': 'target_iqn1', + 'target_name': 'target1', + 'hostName': ['host1'], + 'block': [{'name': 'volume1', 'lunid': 1}, + {'name': 'volume2', 'lunid': 2}]} + ] + self.mock_client.get_initiator_list.return_value = [ + {'group_name': 'initiator-group-uuid1', 'group_id': 'group_id1'} + ] + + # Call the terminate_connection method with the required mock methods + self.driver.terminate_connection( + self.volume, + connector, + mock_get_it=self.mock_client.get_initiator_target_connections, + mock_delete_target=self.mock_client.delete_target, + mock_get_initiator_list=self.mock_client.get_initiator_list, + mock_delete_initiator_group=self.mock_client + .delete_initiator_group, + mock_restart_service=self.mock_client.restart_service, + ) + + # Verify the method calls using assertions + self.mock_client.get_initiator_target_connections.assert_called_once() + self.mock_client.get_initiator_list.assert_not_called() + self.mock_client.delete_target.assert_not_called() + self.mock_client.delete_initiator_group.assert_not_called() + self.mock_client.restart_service.assert_not_called() + + def test_terminate_connection_failure(self): + """Test case for failed termination of volume connection. + + - Sets the connector information. + - Sets the mock return values for get_it and get_initiator_list. + - Sets the delete_target method to raise an exception. + - Calls the terminate_connection method. + - Verifies the method calls and assertions. + """ + # Set the connector information + connector = { + 'host': 'host1', + 'initiator': 'iqn.1234', + 'ip': '192.168.0.1', + 'uuid': 'uuid1' + } + + # Set the mock return values for get_it and get_initiator_list + self.mock_client.get_initiator_target_connections.return_value = [ + { + 'target_iqn': 'target_iqn1', + 'target_name': 'iqn.2023-06.com.toyou:initiator-group-uuid1', + 'hostName': ['host1'], + 'block': [{'name': self.volume.name, 'lunid': 1}] + } + ] + self.mock_client.get_initiator_list.return_value = [ + {'group_name': 'initiator-group-uuid1', 'group_id': 'group_id1'} + ] + + # Set the delete_target method to raise an exception + self.mock_client.delete_target.side_effect = \ + exception.VolumeBackendAPIException('API error') + + # Assert that an exception to type exception.VolumeBackendAPIException + # is raised + self.assertRaises(exception.VolumeBackendAPIException, + self.driver.terminate_connection, + self.volume, + connector) + + # Verify method calls and assertions + self.mock_client.get_initiator_target_connections.assert_called_once() + self.mock_client.get_initiator_list.assert_not_called() + self.mock_client.delete_target.assert_called_once_with('target_iqn1') + self.mock_client.delete_initiator_group.assert_not_called() + self.mock_client.restart_service.assert_not_called() diff --git a/cinder/volume/drivers/toyou/tyds/__init__.py b/cinder/volume/drivers/toyou/tyds/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/cinder/volume/drivers/toyou/tyds/tyds.py b/cinder/volume/drivers/toyou/tyds/tyds.py new file mode 100644 index 00000000000..3a08822aeae --- /dev/null +++ b/cinder/volume/drivers/toyou/tyds/tyds.py @@ -0,0 +1,666 @@ +# Copyright 2023 toyou Corp. +# 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 Toyou distributed storage. +""" + +import re +import time + +from oslo_config import cfg +from oslo_log import log as logging +from oslo_utils import units + +from cinder.common import constants +from cinder import coordination +from cinder import exception +from cinder.i18n import _ +from cinder import interface +from cinder import utils as cinder_utils +from cinder.volume import configuration +from cinder.volume import driver +from cinder.volume.drivers.san import san +from cinder.volume.drivers.toyou.tyds import tyds_client +from cinder.volume import volume_utils + +LOG = logging.getLogger(__name__) +tyds_opts = [ + cfg.ListOpt('tyds_pools', + default=['pool01'], + help='The pool name where volumes are stored.'), + cfg.PortOpt('tyds_http_port', + default=80, + help='The port that connects to the http api.'), + cfg.StrOpt('tyds_stripe_size', + default='4M', + help='Volume stripe size.'), + cfg.IntOpt('tyds_clone_progress_interval', + default=3, + help='Interval (in seconds) for retrieving clone progress.'), + cfg.IntOpt('tyds_copy_progress_interval', + default=3, + help='Interval (in seconds) for retrieving copy progress.') +] +CONF = cfg.CONF +CONF.register_opts(tyds_opts, group=configuration.SHARED_CONF_GROUP) + + +class TYDSDriverException(exception.VolumeDriverException): + message = _("TYDS Cinder toyou failure: %(reason)s") + + +CREATE_VOLUME_SUCCESS = ('[Success] Cinder: Create Block Device, ' + 'Block Name: %s, Size in MB: %s, Pool Name: %s, ' + 'Stripe Size: %s.') +CREATE_VOLUME_FAILED = ('[Failed] Cinder: Create Block Device, ' + 'Block Name: %s, Size in MB: %s, Pool Name: %s, ' + 'Stripe Size: %s.') +DELETE_VOLUME_SUCCESS = ('[Success] Cinder: Delete Block Device, Block Name: ' + '%s.') +DELETE_VOLUME_FAILED = ('[Failed] Cinder: delete failed, the volume: %s ' + 'has normal_snaps: %s, please delete ' + 'normal_snaps first.') +ATTACH_VOLUME_SUCCESS = ('[Success] Cinder: Attach Block Device, Block Name: ' + '%s, IP Address: %s, Host: %s.') +DETACH_VOLUME_SUCCESS = ('[Success] Cinder: Detach Block Device, Block Name: ' + '%s, IP Address: %s, Host: %s.') +EXTEND_VOLUME_SUCCESS = ('[Success] Cinder: Extend volume: %s from %sMB to ' + '%sMB.') +CREATE_SNAPSHOT_SUCCESS = '[Success] Cinder: Create snapshot: %s, volume: %s.' +DELETE_SNAPSHOT_SUCCESS = '[Success] Cinder: Delete snapshot: %s, volume: %s.' +CREATE_VOLUME_FROM_SNAPSHOT_SUCCESS = ('[Success] Cinder: Create volume: %s, ' + 'pool name: %s; from snapshot: %s ' + 'source volume: %s, source pool name: ' + '%s.') +CREATE_VOLUME_FROM_SNAPSHOT_DONE = ('[Success] Cinder: Create volume: %s ' + 'done, pool name: %s; from snapshot:' + ' %s source volume: %s, source pool ' + 'name: %s.') +COPY_VOLUME_DONE = ('[Success] Cinder: Copy volume done, ' + 'pool_name: %s; block_name: %s ' + 'target_pool_name: %s, target_block_name: %s.') +COPY_VOLUME_FAILED = ('[Failed] Cinder: Copy volume failed, ' + 'pool_name: %s; block_name: %s ' + 'target_pool_name: %s, target_block_name: %s.') + + +@interface.volumedriver +class TYDSDriver(driver.MigrateVD, driver.BaseVD): + """TOYOU distributed storage abstract common class. + + .. code-block:: none + + Version history: + 1.0.0 - Initial TOYOU NetStor TYDS Driver + + """ + VENDOR = 'TOYOU' + VERSION = '1.0.0' + CI_WIKI_NAME = 'TOYOU_TYDS_CI' + + def __init__(self, *args, **kwargs): + super(TYDSDriver, self).__init__(*args, **kwargs) + self.configuration.append_config_values(tyds_opts) + self.configuration.append_config_values(san.san_opts) + self.ip = self.configuration.san_ip + self.port = self.configuration.tyds_http_port + self.username = self.configuration.san_login + self.password = self.configuration.san_password + self.pools = self.configuration.tyds_pools + self.client = None + self.storage_protocol = constants.ISCSI + + @staticmethod + def get_driver_options(): + additional_opts = driver.BaseVD._get_oslo_driver_opts( + 'san_ip', 'san_login', 'san_password' + ) + return tyds_opts + additional_opts + + def do_setup(self, context): + LOG.debug("Start setup Tyds client") + self.client = tyds_client.TydsClient(self.ip, + self.port, + self.username, + self.password) + LOG.info("Initialized Tyds Driver Client.") + + def check_for_setup_error(self): + required = [ + 'san_ip', + 'san_login', + 'san_password', + 'tyds_pools' + ] + missing_params = [param for param in required if + not self.configuration.safe_get(param)] + if missing_params: + missing_params_str = ', '.join(missing_params) + msg = _("The following parameters are not set: %s" % + missing_params_str) + raise exception.InvalidInput( + reason=msg) + + def _update_volume_stats(self): + """Update the backend stats including TOYOU info and pools info.""" + backend_name = self.configuration.safe_get('volume_backend_name') + + self._stats = { + 'vendor_name': self.VENDOR, + 'driver_version': self.VERSION, + 'volume_backend_name': backend_name, + 'pools': self._get_pools_stats(), + 'storage_protocol': self.storage_protocol, + } + + LOG.debug('Update volume stats: %s.', self._stats) + + def _get_pools_stats(self): + """Get pools statistics.""" + pools_data = self.client.get_pools() + volumes_list = self.client.get_volumes() + pools_stats = [] + + for pool_name in self.pools: + pool_info = next( + (data for data in pools_data if data['name'] == pool_name), + None + ) + if pool_info: + max_avail = int(pool_info['stats']['max_avail']) + stored = int(pool_info['stats']['stored']) + free_capacity = self._convert_gb(max_avail - stored, "B") + total_capacity = self._convert_gb(max_avail, "B") + + allocated_capacity = 0 + total_volumes = 0 + for vol in volumes_list: + if vol['poolName'] == pool_name: + allocated_capacity += self._convert_gb( + int(vol['sizeMB']), "MB") + total_volumes += 1 + + pools_stats.append({ + 'pool_name': pool_name, + 'total_capacity_gb': total_capacity, + 'free_capacity_gb': free_capacity, + 'provisioned_capacity_gb': allocated_capacity, + 'thin_provisioning_support': True, + 'QoS_support': False, + 'consistencygroup_support': False, + 'total_volumes': total_volumes, + 'multiattach': False + }) + else: + raise TYDSDriverException( + reason=_( + 'Backend storage pool "%s" not found.') % pool_name + ) + + return pools_stats + + def _get_volume_by_name(self, volume_name): + """Get volume information by name.""" + volume_list = self.client.get_volumes() + + for vol in volume_list: + if vol.get('blockName') == volume_name: + return vol + # Returns an empty dictionary indicating that the volume with the + # corresponding name was not found + return {} + + def _get_snapshot_by_name(self, snapshot_name, volume_id=None): + """Get snapshot information by name and optional volume ID.""" + snapshot_list = self.client.get_snapshot(volume_id) + + for snap in snapshot_list: + if snap.get('snapShotName') == snapshot_name: + return snap + # Returns an empty dictionary indicating that a snapshot with the + # corresponding name was not found + return {} + + @staticmethod + def _convert_gb(size, unit): + """Convert size from the given unit to GB.""" + size_gb = 0 + if unit in ['B', '']: + size_gb = size / units.Gi + elif unit in ['M', 'MB']: + size_gb = size / units.Ki + return float('%.0f' % size_gb) + + def _clone_volume(self, pool_name, block_name, block_id, target_pool_name, + target_pool_id, target_block_name): + self.client.create_clone_volume( + pool_name, + block_name, + block_id, + target_pool_name, + target_pool_id, + target_block_name + ) + + @coordination.synchronized('tyds-copy-{lun_name}-progress') + def _wait_copy_progress(lun_id, lun_name, target_block): + try: + ret = False + while_exit = False + rescan = 0 + interval = self.configuration.tyds_copy_progress_interval + while True: + rescan += 1 + progress_data = self.client.get_copy_progress( + lun_id, lun_name, target_block) + progress = progress_data.get('progress') + # finished clone + if progress == '100%': + # check new volume existence + target = self._get_volume_by_name(target_block) + if not target: + LOG.info( + 'Clone rescan: %(rescan)s, target volume ' + 'completed delayed, from %(block_name)s to ' + '%(target_block_name)s.', + {'rescan': rescan, 'block_name': lun_name, + 'target_block_name': target_block}) + time.sleep(interval) + continue + LOG.info( + 'Clone rescan: %(rescan)s, task done from ' + '%(block_name)s to %(target_block_name)s.', + {'rescan': rescan, 'block_name': lun_name, + 'target_block_name': target_block}) + while_exit = True + ret = True + elif progress: + LOG.info( + "Clone rescan: %(rescan)s, progress: %(progress)s," + " block_name: %(block_name)s, target_block_name: " + "%(target_block_name)s", + {"rescan": rescan, "progress": progress, + "block_name": lun_name, + "target_block_name": target_block}) + else: + LOG.error( + 'Copy: rescan: %(rescan)s, task error from ' + '%(block_name)s to %(target_block_name)s.', + {'rescan': rescan, 'block_name': lun_name, + 'target_block_name': target_block_name}) + while_exit = True + if while_exit: + break + time.sleep(interval) + return ret + except Exception as err: + LOG.error('Copy volume failed reason: %s', err) + return False + + if _wait_copy_progress(block_id, block_name, target_block_name): + LOG.debug(COPY_VOLUME_DONE, pool_name, + block_name, target_pool_name, target_block_name) + else: + self._delete_volume_if_clone_failed(target_block_name, pool_name, + block_name, target_block_name) + msg = _("copy volume failed from %s to %s") % ( + block_name, target_block_name) + raise TYDSDriverException(reason=msg) + + def _delete_volume_if_clone_failed(self, target_block_name, pool_name, + block_name, target_pool_name): + target_volume = self._get_volume_by_name(target_block_name) + + if target_volume: + self.client.delete_volume(target_volume.get('id')) + + LOG.debug(COPY_VOLUME_FAILED, pool_name, block_name, + target_pool_name, target_block_name) + + def create_export(self, context, volume, connector): + pass + + def create_volume(self, volume): + LOG.info("Creating volume '%s'", volume.name) + vol_name = cinder_utils.convert_str(volume.name) + size = int(volume.size) * 1024 + pool_name = volume_utils.extract_host(volume.host, 'pool') + stripe_size = self.configuration.tyds_stripe_size + self.client.create_volume(vol_name, size, pool_name, stripe_size) + + LOG.debug(CREATE_VOLUME_SUCCESS, vol_name, size, pool_name, + stripe_size) + + def retype(self, context, volume, new_type, diff, host): + # success + return True, None + + def delete_volume(self, volume): + LOG.debug("deleting volume '%s'", volume.name) + vol_name = cinder_utils.convert_str(volume.name) + vol = self._get_volume_by_name(vol_name) + if vol and vol.get('id'): + self.client.delete_volume(vol.get('id')) + LOG.debug(DELETE_VOLUME_SUCCESS, vol_name) + else: + LOG.info('Delete volume %s not found.', vol_name) + + def ensure_export(self, context, volume): + pass + + def remove_export(self, context, volume): + pass + + def initialize_connection(self, volume, connector): + LOG.debug('initialize_connection: volume %(vol)s with connector ' + '%(conn)s', {'vol': volume.name, 'conn': connector}) + pool_name = volume_utils.extract_host(volume.host, 'pool') + volume_name = cinder_utils.convert_str(volume.name) + group_name = "initiator-group-" + cinder_utils.convert_str( + connector['uuid']) + vol_info = {"name": volume_name, "size": volume.size, + "pool": pool_name} + + # Check initiator existence + initiator_list = self.client.get_initiator_list() + initiator_existence = False + if initiator_list: + initiator_existence = any( + initiator['group_name'] == group_name for initiator in + initiator_list + ) + if not initiator_existence: + # Create initiator + client = [{"ip": connector["ip"], "iqn": connector["initiator"]}] + self.client.create_initiator_group(group_name=group_name, + client=client) + # Check Initiator-Target connection existence + # add new volume to existing Initiator-Target connection + it_list = self.client.get_initiator_target_connections() + it_info = None + if it_list: + it_info = next((it for it in it_list if group_name in + it['target_name']), None) + if it_info: + target_iqn = it_info['target_iqn'] + lun_info = next((lun for lun in it_info['block'] if + lun['name'] == volume_name), None) + + if not lun_info: + # Add new volume to existing Initiator-Target connection + target_name_list = it_info['hostName'] + vols_info = it_info['block'] + vols_info.append(vol_info) + self.client.modify_target(target_iqn, target_name_list, + vols_info) + else: + # Create new Initiator-Target connection + target_node_list = self.client.get_target() + target_name_list = [target['name'] for target in target_node_list] + self.client.create_target(group_name, target_name_list, [vol_info]) + + it_list = self.client.get_initiator_target_connections() + if it_list: + it_info = next( + (it for it in it_list if group_name in it['target_name']), + None) + if it_info: + target_name = it_info['target_name'] + target_iqn = it_info['target_iqn'] + lun_info = next((lun for lun in it_info['block'] if lun['name'] + == volume_name), None) + lun_id = lun_info['lunid'] if lun_info else 0 + + # Generate config + self.client.generate_config(target_iqn) + + # Generate return info + target_node_list = self.client.get_target() + target_node = target_node_list[0] + target_ip = target_node['ipAddr'] + target_portal = '[%s]:3260' % target_ip if ':' in target_ip \ + else '%s:3260' % target_ip + target_iqns = [target_name] * len(target_node_list) + target_portals = ['[%s]:3260' % p['ipAddr'] if ':' in p['ipAddr'] + else '%s:3260' % p['ipAddr'] + for p in target_node_list] + target_luns = [lun_id] * len(target_node_list) + + properties = { + 'target_discovered': False, + 'target_portal': target_portal, + 'target_lun': lun_id, + 'target_iqns': target_iqns, + 'target_portals': target_portals, + 'target_luns': target_luns + } + + LOG.debug('connection properties: %s', properties) + LOG.debug(ATTACH_VOLUME_SUCCESS, volume_name, + connector.get('ip'), connector.get('host')) + + return {'driver_volume_type': 'iscsi', 'data': properties} + else: + raise exception.VolumeBackendAPIException( + data=_('initialize_connection: Failed to create IT ' + 'connection for volume %s') % volume_name) + + def terminate_connection(self, volume, connector, **kwargs): + if not connector: + # If the connector is empty, the info log is recorded and + # returned directly, without subsequent separation operations + LOG.info( + "Connector is None. Skipping termination for volume %s.", + volume.name) + return + volume_name = cinder_utils.convert_str(volume.name) + group_name = "initiator-group-" + cinder_utils.convert_str( + connector['uuid']) + data = {} + # Check Initiator-Target connection existence and remove volume from + # Initiator-Target connection if it exists + it_list = self.client.get_initiator_target_connections() + it_info = next((it for it in it_list if group_name in + it['target_name']), None) + if it_info: + target_iqn = it_info['target_iqn'] + target_name_list = it_info['hostName'] + vols_info = it_info['block'] + vols_info = [vol for vol in vols_info if + vol['name'] != volume_name] + if not vols_info: + self.client.delete_target(it_info['target_iqn']) + initiator_list = self.client.get_initiator_list() + initiator_to_delete = None + if initiator_list: + initiator_to_delete = next( + (initiator for initiator in initiator_list if + initiator['group_name'] == group_name), None) + if initiator_to_delete: + self.client.delete_initiator_group( + initiator_to_delete['group_id']) + self.client.restart_service(host_name=it_info['hostName']) + else: + self.client.modify_target(target_iqn, target_name_list, + vols_info) + # record log + LOG.debug(DETACH_VOLUME_SUCCESS, volume_name, connector.get( + 'ip'), connector.get('host')) + LOG.info('Detach volume %s successfully', volume_name) + target_node_list = self.client.get_target() + target_portals = ['%s:3260' % p['ipAddr'] + for p in target_node_list] + data['ports'] = target_portals + return {'driver_volume_type': 'iscsi', 'data': data} + + def migrate_volume(self): + pass + + def extend_volume(self, volume, new_size): + volume_name = cinder_utils.convert_str(volume.name) + pool_name = volume_utils.extract_host(volume.host, 'pool') + size_mb = int(new_size) * 1024 + self.client.extend_volume(volume_name, pool_name, size_mb) + LOG.debug(EXTEND_VOLUME_SUCCESS, volume_name, volume.size * + 1024, size_mb) + + def create_cloned_volume(self, volume, src_vref): + """Clone a volume.""" + # find pool_id to create clone volume + try: + target_pool_name = volume_utils.extract_host(volume.host, 'pool') + except Exception as err: + msg = _('target_pool_name must be specified. ' + 'extra err msg was: %s') % err + raise TYDSDriverException(reason=msg) + target_pool_id = None + pool_list = self.client.get_pools() + for pool in pool_list: + if target_pool_name == pool.get('name'): + target_pool_id = pool.get('id') + break + if not target_pool_id: + msg = _('target_pool_id: must be specified.') + raise TYDSDriverException(reason=msg) + # find volume id to create + volume_list = self.client.get_volumes() + block_name = cinder_utils.convert_str(src_vref.name) + pool_name = None + block_id = None + for vol in volume_list: + if block_name == vol.get('blockName'): + pool_name = vol.get('poolName') + block_id = vol.get('id') + break + if (not pool_name) or (not block_id): + msg = _('block_name: %(block_name)s does not matched a ' + 'pool_name or a block_id.') % {'block_name': block_name} + raise TYDSDriverException(reason=msg) + # get a name from new volume + target_block_name = cinder_utils.convert_str(volume.name) + # ready to create clone volume + self._clone_volume(pool_name, block_name, block_id, target_pool_name, + target_pool_id, target_block_name) + # handle the case where the new volume size is larger than the source + if volume['size'] > src_vref.get('size'): + size_mb = int(volume['size']) * 1024 + self.client.extend_volume(target_block_name, target_pool_name, + size_mb) + LOG.debug(EXTEND_VOLUME_SUCCESS, target_block_name, + src_vref.get('size') * 1024, size_mb) + + def create_snapshot(self, snapshot): + """Creates a snapshot.""" + volume_name = cinder_utils.convert_str(snapshot.volume_name) + snapshot_name = cinder_utils.convert_str(snapshot.name) + vol = self._get_volume_by_name(volume_name) + if vol and vol.get('id'): + comment = '%s/%s' % (volume_name, snapshot_name) + self.client.create_snapshot(snapshot_name, vol.get('id'), comment) + LOG.debug(CREATE_SNAPSHOT_SUCCESS, snapshot_name, + volume_name) + else: + msg = _('Volume "%s" not found.') % volume_name + raise TYDSDriverException(reason=msg) + + def delete_snapshot(self, snapshot): + """Deletes a snapshot.""" + snapshot_name = cinder_utils.convert_str(snapshot.name) + volume_name = cinder_utils.convert_str(snapshot.volume_name) + snap = self._get_snapshot_by_name(snapshot_name) + if snap and snap.get('id'): + self.client.delete_snapshot(snap.get('id')) + LOG.debug(DELETE_SNAPSHOT_SUCCESS, snapshot_name, + volume_name) + else: + LOG.info('Delete snapshot %s not found.', snapshot_name) + + def create_volume_from_snapshot(self, volume, snapshot): + """Creates a volume from a snapshot.""" + snapshot_name = cinder_utils.convert_str(snapshot.name) + volume_name = cinder_utils.convert_str(volume.name) + pool_name = volume_utils.extract_host(volume.host, 'pool') + source_volume = cinder_utils.convert_str(snapshot.volume_name) + src_vol = self._get_volume_by_name(source_volume) + if not src_vol: + msg = _('Volume "%s" not found in ' + 'create_volume_from_snapshot.') % volume_name + raise TYDSDriverException(reason=msg) + + self.client.create_volume_from_snapshot(volume_name, pool_name, + snapshot_name, source_volume, + src_vol.get('poolName')) + LOG.debug(CREATE_VOLUME_FROM_SNAPSHOT_SUCCESS, volume_name, + pool_name, snapshot_name, source_volume, + src_vol.get('poolName')) + + @coordination.synchronized('tyds-clone-{source_name}-progress') + def _wait_clone_progress(task_id, source_name, target_name): + ret = False + while_exit = False + rescan = 0 + interval = self.configuration.tyds_clone_progress_interval + while True: + rescan += 1 + progress = self.client.get_clone_progress( + task_id, source_name).get('progress', '') + if progress == '100%': + target = self._get_volume_by_name(target_name) + if not target: + LOG.info('Clone: rescan: %(rescan)s, task not begin, ' + 'from %(source)s to %(target)s.', + {'rescan': rescan, + 'source': source_name, + 'target': target_name}) + time.sleep(interval) + continue + LOG.info('Clone: rescan: %(rescan)s, task done from ' + '%(source)s to %(target)s.', + {'rescan': rescan, + 'source': source_name, + 'target': target_name}) + while_exit = True + ret = True + elif re.fullmatch(r'^\d{1,2}%$', progress): + LOG.info('Clone: rescan: %(rescan)s, task progress: ' + '%(progress)s, from %(source)s to %(target)s.', + {'rescan': rescan, + 'progress': progress, + 'source': source_name, + 'target': target_name}) + else: + while_exit = True + LOG.error('Clone: rescan: %(rescan)s, task error from ' + '%(source)s to %(target)s.', + {'rescan': rescan, + 'source': source_name, + 'target': target_name}) + if while_exit: + break + time.sleep(interval) + return ret + + if _wait_clone_progress(src_vol.get('id'), source_volume, volume_name): + LOG.debug(CREATE_VOLUME_FROM_SNAPSHOT_DONE, + volume_name, pool_name, snapshot_name, source_volume, + src_vol.get('poolName')) + # handle the case where the new volume size is larger than the source + new_size = volume.size * 1024 + old_size = int(src_vol['sizeMB']) + if new_size > old_size: + self.client.extend_volume(volume_name, pool_name, new_size) + LOG.debug(EXTEND_VOLUME_SUCCESS, volume_name, old_size, + new_size) diff --git a/cinder/volume/drivers/toyou/tyds/tyds_client.py b/cinder/volume/drivers/toyou/tyds/tyds_client.py new file mode 100644 index 00000000000..e9141cf33bc --- /dev/null +++ b/cinder/volume/drivers/toyou/tyds/tyds_client.py @@ -0,0 +1,481 @@ +# Copyright 2023 toyou Corp. +# 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 json +import time + +from oslo_log import log as logging +from oslo_utils import netutils +import requests + +from cinder import exception +from cinder.i18n import _ + +LOG = logging.getLogger(__name__) + + +class TydsClient(object): + def __init__(self, hostname, port, username, password): + """Initializes a new instance of the TydsClient. + + :param hostname: IP address of the Toyou distributed storage system. + :param port: The port to connect to the Toyou distributed storage + system. + :param username: The username for authentication. + :param password: The password for authentication. + """ + self._username = username + self._password = base64.standard_b64encode(password.encode('utf-8') + ).decode('utf-8') + self._baseurl = f"http://{hostname}:{port}/api" + self._snapshot_count = 999 + self._token = None + self._token_expiration = 0 + self._ip = self._get_local_ip() + + def get_token(self): + if self._token and time.time() < self._token_expiration: + # Token is not expired, directly return the existing Token + return self._token + + # Token has expired or has not been obtained before, + # retrieving the Token again + self._token = self.login() + self._token_expiration = time.time() + 710 * 60 + return self._token + + def send_http_api(self, url, params=None, method='post'): + """Send an HTTP API request to the storage. + + :param url: The URL for the API request. + :param params: The parameters for the API request. + :param method: The HTTP method for the API request. Default is 'post'. + + :return: The response from the API request. + + :raises VolumeBackendAPIException: If the API request fails. + + """ + if params: + params = json.dumps(params) + + url = f"{self._baseurl}/{url}" + header = { + 'Authorization': self.get_token(), + 'Content-Type': 'application/json' + } + + LOG.debug( + "Toyou Cinder Driver Requests: http_process header: %(header)s " + "url: %(url)s method: %(method)s", + {'header': header, 'url': url, 'method': method} + ) + + response = self.do_request(method, url, header, params) + return response + + @staticmethod + def _get_local_ip(): + """Get the local IP address. + + :return: The local IP address. + + """ + return netutils.get_my_ipv4() + + def login(self): + """Perform login to obtain an authentication token. + + :return: The authentication token. + + :raises VolumeBackendAPIException: If the login request fails or the + authentication token cannot be + obtained. + + """ + params = { + 'REMOTE_ADDR': self._ip, + 'username': self._username, + 'password': self._password + } + data = json.dumps(params) + url = f"{self._baseurl}/auth/login/" + response = self.do_request(method='post', + url=url, + header={'Content-Type': 'application/json'}, + data=data) + self._token = response.get('token') + return self._token + + @staticmethod + def do_request(method, url, header, data): + """Send request to the storage and handle the response. + + :param method: The HTTP method to use for the request. Valid methods + are 'post', 'get', 'put', and 'delete'. + :param url: The URL to send the request to. + :param header: The headers to include in the request. + :param data: The data to send in the request body. + + :return: The response data returned by the storage system. + + :raises VolumeBackendAPIException: If the request fails or the response + from the storage system is not as + expected. + + """ + valid_methods = ['post', 'get', 'put', 'delete'] + if method not in valid_methods: + raise exception.VolumeBackendAPIException( + data=_('Unsupported request type: %s.') % method + ) + + try: + req = getattr(requests, method)(url, data=data, headers=header) + req.raise_for_status() + response = req.json() + except requests.exceptions.RequestException as e: + msg = (_('Request to %(url)s failed: %(error)s') % + {'url': url, 'error': str(e)}) + raise exception.VolumeBackendAPIException(data=msg) + except ValueError as e: + msg = (_('Failed to parse response from %(url)s: %(error)s') % + {'url': url, 'error': str(e)}) + raise exception.VolumeBackendAPIException(data=msg) + + LOG.debug('URL: %(url)s, TYPE: %(type)s, CODE: %(code)s, ' + 'RESPONSE: %(response)s.', + {'url': req.url, + 'type': method, + 'code': req.status_code, + 'response': response}) + # Response Error + if response.get('code') != '0000': + msg = (_('ERROR RESPONSE: %(response)s URL: %(url)s PARAMS: ' + '%(params)s.') % + {'response': response, 'url': url, 'params': data}) + raise exception.VolumeBackendAPIException(data=msg) + # return result + return response.get('data') + + def get_pools(self): + """Query pool information. + + :return: A list of pool information. + + """ + url = 'pool/pool/' + response = self.send_http_api(url=url, method='get') + pool_list = response.get('poolList', []) + return pool_list + + def get_volumes(self): + """Query volume information. + + :return: A list of volume information. + + """ + url = 'block/blocks' + vol_list = self.send_http_api(url=url, method='get').get('blockList') + return vol_list + + def create_volume(self, vol_name, size, pool_name, stripe_size): + """Create a volume. + + :param vol_name: The name of the volume. + :param size: The size of the volume in MB. + :param pool_name: The name of the pool to create the volume in. + :param stripe_size: The stripe size of the volume. + :return: The response from the API call. + + """ + url = 'block/blocks/' + params = {'blockName': vol_name, + 'sizeMB': size, + 'poolName': pool_name, + 'stripSize': stripe_size} + return self.send_http_api(url=url, method='post', params=params) + + def delete_volume(self, vol_id): + """Delete a volume. + + :param vol_id: The ID of the volume to delete. + + """ + url = 'block/recycle/forceCreate/' + params = {'id': [vol_id]} + self.send_http_api(url=url, method='post', params=params) + + def extend_volume(self, vol_name, pool_name, size_mb): + """Extend the size of a volume. + + :param vol_name: The name of the volume to extend. + :param pool_name: The name of the pool where the volume resides. + :param size_mb: The new size of the volume in MB. + + """ + url = 'block/blocks/%s/' % vol_name + params = {'blockName': vol_name, + 'sizeMB': size_mb, + 'poolName': pool_name} + self.send_http_api(url=url, method='put', params=params) + + def create_clone_volume(self, *args): + """Create a clone of a volume. + + :param args: The arguments needed for cloning a volume. + Args: + - pool_name: The name of the source pool. + - block_name: The name of the source block. + - block_id: The ID of the source block. + - target_pool_name: The name of the target pool. + - target_pool_id: The ID of the target pool. + - target_block_name: The name of the target block. + + """ + pool_name, block_name, block_id, target_pool_name, target_pool_id,\ + target_block_name = args + params = { + 'poolName': pool_name, + 'blockName': block_name, + 'blockId': block_id, + 'copyType': 0, # 0 means shallow copy, currently copy volume first + # default shallow copy, 1 means deep copy + 'metapoolName': 'NULL', + 'targetMetapoolName': 'NULL', + 'targetPoolName': target_pool_name, + 'targetPoolId': target_pool_id, + 'targetBlockName': target_block_name + } + url = 'block/block/copy/' + self.send_http_api(url=url, params=params) + + def get_snapshot(self, volume_id=None): + """Get a list of snapshots. + + :param volume_id: The ID of the volume to filter snapshots (default: + None). + :return: The list of snapshots. + + """ + url = 'block/snapshot?pageNumber=1' + if volume_id: + url += '&blockId=%s' % volume_id + url += '&pageSize=%s' + response = self.send_http_api( + url=url % self._snapshot_count, method='get') + if self._snapshot_count < int(response.get('total')): + self._snapshot_count = int(response.get('total')) + response = self.send_http_api( + url=url % self._snapshot_count, method='get') + snapshot_list = response.get('snapShotList') + return snapshot_list + + def create_snapshot(self, name, volume_id, comment=''): + """Create a snapshot of a volume. + + :param name: The name of the snapshot. + :param volume_id: The ID of the volume to create a snapshot from. + :param comment: The optional comment for the snapshot (default: ''). + + """ + url = 'block/snapshot/' + params = {'sourceBlock': volume_id, + 'snapShotName': name, + 'remark': comment} + self.send_http_api(url=url, method='post', params=params) + + def delete_snapshot(self, snapshot_id): + """Delete a snapshot. + + :param snapshot_id: The ID of the snapshot to delete. + + """ + url = 'block/snapshot/%s/' % snapshot_id + self.send_http_api(url=url, method='delete') + + def create_volume_from_snapshot(self, volume_name, pool_name, + snapshot_name, source_volume_name, + source_pool_name): + """Create a volume from a snapshot. + + :param volume_name: The name of the new volume. + :param pool_name: The name of the pool for the new volume. + :param snapshot_name: The name of the snapshot to create the volume + from. + :param source_volume_name: The name of the source volume (snapshot's + origin). + :param source_pool_name: The name of the pool for the source volume. + + """ + url = 'block/clone/' + params = {'cloneBlockName': volume_name, + 'targetPoolName': pool_name, + 'snapName': snapshot_name, + 'blockName': source_volume_name, + 'poolName': source_pool_name, + 'targetMetapoolName': 'NULL'} + self.send_http_api(url=url, method='post', params=params) + + def get_clone_progress(self, volume_id, volume_name): + """Get the progress of a volume clone operation. + + :param volume_id: The ID of the volume being cloned. + :param volume_name: The name of the volume being cloned. + :return: The progress of the clone operation. + + """ + url = 'block/clone/progress/' + params = {'blockId': volume_id, + 'blockName': volume_name} + progress = self.send_http_api(url=url, method='post', params=params) + return progress + + def get_copy_progress(self, block_id, block_name, target_block_name): + """Get the progress of a block copy operation. + + :param block_id: The ID of the block being copied. + :param block_name: The name of the block being copied. + :param target_block_name: The name of the target block. + :return: The progress of the copy operation. + + """ + url = 'block/block/copyprogress/' + params = { + 'blockId': block_id, + 'blockName': block_name, + 'targetBlockName': target_block_name + } + + progress_data = self.send_http_api(url=url, params=params) + return progress_data + + def create_initiator_group(self, group_name, client): + """Create an initiator group. + + :param group_name: The name of the initiator group. + :param client: The client information for the initiator group. + """ + url = 'iscsi/client-group/' + params = { + 'group_name': group_name, + 'client': client, + 'chap_auth': 0, + 'mode': 'ISCSI' + } + self.send_http_api(url=url, params=params) + + def delete_initiator_group(self, group_id): + """Delete an initiator group. + + :param group_id: The ID of the initiator group. + :return: The response from the API call. + """ + url = 'iscsi/client-group/?group_id=%s' % group_id + return self.send_http_api(url=url, method='delete') + + def get_initiator_list(self): + """Get the list of initiators. + + :return: The list of initiators. + """ + url = 'iscsi/client-group/' + res = self.send_http_api(url=url, method='get') + initiator_list = res.get('client_group_list') + return initiator_list + + def get_target(self): + """Get the list of target hosts. + + :return: The list of target hosts. + """ + url = '/host/host/' + res = self.send_http_api(url=url, method='get') + target = res.get('hostList') + return target + + def create_target(self, group_name, target_list, vols_info): + """Create a target. + + :param group_name: The name of the initiator group. + :param target_list: The list of target hosts. + :param vols_info: The information of the volumes. + :return: The response from the API call. + """ + url = 'iscsi/target/' + params = {"group_name": group_name, + "chap_auth": 0, + "write_cache": 1, + "hostName": ",".join(target_list), + "block": vols_info} + return self.send_http_api(url=url, params=params, method='post') + + def delete_target(self, target_name): + """Delete a target. + + :param target_name: The name of the target. + :return: The response from the API call. + """ + url = 'iscsi/target/?targetIqn=%s' % target_name + return self.send_http_api(url=url, method='delete') + + def modify_target(self, target_name, target_list, vol_info): + """Modify a target. + + :param target_name: The name of the target. + :param target_list: The list of target hosts. + :param vol_info: The information of the volumes. + :return: The response from the API call. + """ + url = 'iscsi/target/' + params = { + "targetIqn": target_name, + "chap_auth": 0, + "hostName": target_list, + "block": vol_info + } + return self.send_http_api(url=url, params=params, method='put') + + def get_initiator_target_connections(self): + """Get the list of IT (Initiator-Target) connections. + + :return: The list of IT connections. + """ + url = 'iscsi/target/' + res = self.send_http_api(url=url, method='get') + target_list = res.get('target_list') + return target_list + + def generate_config(self, target_name): + """Generate configuration for a target. + + :param target_name: The name of the target. + """ + url = 'iscsi/target-config/' + params = { + 'targetName': target_name + } + self.send_http_api(url=url, params=params, method='post') + + def restart_service(self, host_name): + """Restart the iSCSI service on a host. + + :param host_name: The name of the host. + """ + url = 'iscsi/service/restart/' + params = { + "hostName": host_name + } + self.send_http_api(url=url, params=params, method='post') diff --git a/doc/source/configuration/block-storage/drivers/toyou-netstor-tyds-driver.rst b/doc/source/configuration/block-storage/drivers/toyou-netstor-tyds-driver.rst new file mode 100644 index 00000000000..2de1bac62b1 --- /dev/null +++ b/doc/source/configuration/block-storage/drivers/toyou-netstor-tyds-driver.rst @@ -0,0 +1,75 @@ +================================ +TOYOU NetStor TYDS Cinder driver +================================ + +TOYOU NetStor TYDS series volume driver provides OpenStack Compute instances +with access to TOYOU NetStor TYDS series storage systems. + +TOYOU NetStor TYDS storage can be used with iSCSI connection. + +This documentation explains how to configure and connect the block storage +nodes to TOYOU NetStor TYDS series storage. + +Driver options +~~~~~~~~~~~~~~ + +The following table contains the configuration options supported by the +TOYOU NetStor TYDS iSCSI driver. + +.. config-table:: + :config-target: TOYOU NetStor TYDS + + cinder.volume.drivers.toyou.tyds.tyds + +Supported operations +~~~~~~~~~~~~~~~~~~~~ + +- Create Volume. +- Delete Volume. +- Attach Volume. +- Detach Volume. +- Extend Volume +- Create Snapshot. +- Delete Snapshot. +- Create Volume from Snapshot. +- Create Volume from Volume (clone). +- Create lmage from Volume. +- Volume Migration (host assisted). + +Configure TOYOU NetStor TOYOU TYDS iSCSI backend +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This section details the steps required to configure the TOYOU NetStor +TYDS storage cinder driver. + +#. In the ``cinder.conf`` configuration file under the ``[DEFAULT]`` + section, set the enabled_backends parameter + with the iSCSI back-end group. + + .. code-block:: ini + + [DEFAULT] + enabled_backends = toyou-tyds-iscsi-1 + + +#. Add a backend group section for the backend group specified + in the enabled_backends parameter. + +#. In the newly created backend group section, set the + following configuration options: + + .. code-block:: ini + + [toyou-tyds-iscsi-1] + # The TOYOU NetStor TYDS driver path + volume_driver = cinder.volume.drivers.toyou.tyds.tyds.TYDSDriver + # Management http ip of TOYOU NetStor TYDS storage + san_ip = 10.0.0.10 + # Management http username of TOYOU NetStor TYDS storage + san_login = superuser + # Management http password of TOYOU NetStor TYDS storage + san_password = Toyou@123 + # The Pool used to allocated volumes + tyds_pools = pool01 + # Backend name + volume_backend_name = toyou-tyds-iscsi-1 diff --git a/doc/source/reference/support-matrix.ini b/doc/source/reference/support-matrix.ini index dc4da1bcbff..514e2c01609 100644 --- a/doc/source/reference/support-matrix.ini +++ b/doc/source/reference/support-matrix.ini @@ -204,6 +204,9 @@ title=Synology Storage Driver (iSCSI) [driver.toyou_netstor] title=TOYOU NetStor Storage Driver (iSCSI, FC) +[driver.toyou_netstor_tyds] +title=TOYOU NetStor TYDS Storage Driver (iSCSI) + [driver.vrtsaccess] title=Veritas Access iSCSI Driver (iSCSI) @@ -301,6 +304,7 @@ driver.seagate=complete driver.storpool=complete driver.synology=complete driver.toyou_netstor=complete +driver.toyou_netstor_tyds=complete driver.vrtsaccess=missing driver.vrtscnfs=missing driver.vzstorage=missing @@ -378,6 +382,7 @@ driver.seagate=complete driver.storpool=complete driver.synology=complete driver.toyou_netstor=complete +driver.toyou_netstor_tyds=complete driver.vrtsaccess=complete driver.vrtscnfs=complete driver.vzstorage=complete @@ -458,6 +463,7 @@ driver.seagate=missing driver.storpool=missing driver.synology=missing driver.toyou_netstor=missing +driver.toyou_netstor_tyds=missing driver.vrtsaccess=missing driver.vrtscnfs=missing driver.vzstorage=missing @@ -537,6 +543,7 @@ driver.seagate=missing driver.storpool=complete driver.synology=missing driver.toyou_netstor=missing +driver.toyou_netstor_tyds=missing driver.vrtsaccess=missing driver.vrtscnfs=missing driver.vzstorage=missing @@ -617,6 +624,7 @@ driver.seagate=missing driver.storpool=missing driver.synology=missing driver.toyou_netstor=missing +driver.toyou_netstor_tyds=missing driver.vrtsaccess=missing driver.vrtscnfs=missing driver.vzstorage=missing @@ -696,6 +704,7 @@ driver.seagate=missing driver.storpool=complete driver.synology=missing driver.toyou_netstor=complete +driver.toyou_netstor_tyds=complete driver.vrtsaccess=missing driver.vrtscnfs=missing driver.vzstorage=missing @@ -776,6 +785,7 @@ driver.seagate=missing driver.storpool=complete driver.synology=missing driver.toyou_netstor=complete +driver.toyou_netstor_tyds=missing driver.vrtsaccess=missing driver.vrtscnfs=missing driver.vzstorage=missing @@ -856,6 +866,7 @@ driver.seagate=complete driver.storpool=complete driver.synology=missing driver.toyou_netstor=complete +driver.toyou_netstor_tyds=missing driver.vrtsaccess=missing driver.vrtscnfs=missing driver.vzstorage=missing @@ -933,6 +944,7 @@ driver.seagate=missing driver.storpool=missing driver.synology=missing driver.toyou_netstor=complete +driver.toyou_netstor_tyds=missing driver.vrtsaccess=missing driver.vrtscnfs=missing driver.vzstorage=missing @@ -1014,6 +1026,7 @@ driver.seagate=missing driver.storpool=missing driver.synology=missing driver.toyou_netstor=missing +driver.toyou_netstor_tyds=missing driver.vrtsaccess=missing driver.vrtscnfs=missing driver.vzstorage=missing diff --git a/releasenotes/notes/toyou-netstor-storage-tyds-iscsi-driver-798da24653d8cd0d.yaml b/releasenotes/notes/toyou-netstor-storage-tyds-iscsi-driver-798da24653d8cd0d.yaml new file mode 100644 index 00000000000..0487fada8c3 --- /dev/null +++ b/releasenotes/notes/toyou-netstor-storage-tyds-iscsi-driver-798da24653d8cd0d.yaml @@ -0,0 +1,4 @@ +--- +features: + - | + New ISCSI cinder volume driver for TOYOU NetStor TYDS Storage.