diff --git a/os_brick/initiator/__init__.py b/os_brick/initiator/__init__.py index 5d45239c3..5cd418d33 100644 --- a/os_brick/initiator/__init__.py +++ b/os_brick/initiator/__init__.py @@ -56,3 +56,4 @@ QUOBYTE = "QUOBYTE" DISCO = "DISCO" VZSTORAGE = "VZSTORAGE" SHEEPDOG = "SHEEPDOG" +VMDK = "VMDK" diff --git a/os_brick/initiator/connector.py b/os_brick/initiator/connector.py index 131845c55..3922e7d58 100644 --- a/os_brick/initiator/connector.py +++ b/os_brick/initiator/connector.py @@ -90,6 +90,7 @@ connector_list = [ 'os_brick.initiator.connectors.hgst.HGSTConnector', 'os_brick.initiator.connectors.scaleio.ScaleIOConnector', 'os_brick.initiator.connectors.disco.DISCOConnector', + 'os_brick.initiator.connectors.vmware.VmdkConnector', 'os_brick.initiator.windows.base.BaseWindowsConnector', 'os_brick.initiator.windows.iscsi.WindowsISCSIConnector', 'os_brick.initiator.windows.fibre_channel.WindowsFCConnector', @@ -135,6 +136,8 @@ _connector_mapping_linux = { 'os_brick.initiator.connectors.disco.DISCOConnector', initiator.SHEEPDOG: 'os_brick.initiator.connectors.sheepdog.SheepdogConnector', + initiator.VMDK: + 'os_brick.initiator.connectors.vmware.VmdkConnector', } # Mapping for the S390X platform diff --git a/os_brick/initiator/connectors/vmware.py b/os_brick/initiator/connectors/vmware.py new file mode 100644 index 000000000..0f83593a2 --- /dev/null +++ b/os_brick/initiator/connectors/vmware.py @@ -0,0 +1,276 @@ +# Copyright (c) 2016 VMware, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import os +import tempfile + +from oslo_log import log as logging +from oslo_utils import fileutils +try: + from oslo_vmware import api + from oslo_vmware import exceptions as oslo_vmw_exceptions + from oslo_vmware import image_transfer + from oslo_vmware.objects import datastore + from oslo_vmware import rw_handles + from oslo_vmware import vim_util +except ImportError: + vim_util = None +import six + +from os_brick import exception +from os_brick.i18n import _ +from os_brick.initiator import initiator_connector + +LOG = logging.getLogger(__name__) + + +class VmdkConnector(initiator_connector.InitiatorConnector): + """Connector for volumes created by the VMDK driver. + + This connector is only used for backup and restore of Cinder volumes. + """ + + TMP_IMAGES_DATASTORE_FOLDER_PATH = "cinder_temp" + + def __init__(self, *args, **kwargs): + # Check if oslo.vmware library is available. + if vim_util is None: + message = _("Missing oslo_vmware python module, ensure oslo.vmware" + " library is installed and available.") + raise exception.BrickException(message=message) + + super(VmdkConnector, self).__init__(*args, **kwargs) + + self._ip = None + self._port = None + self._username = None + self._password = None + self._api_retry_count = None + self._task_poll_interval = None + self._ca_file = None + self._insecure = None + self._tmp_dir = None + self._timeout = None + + @staticmethod + def get_connector_properties(root_helper, *args, **kwargs): + return {} + + def check_valid_device(self, path, run_as_root=True): + pass + + def get_volume_paths(self, connection_properties): + return [] + + def get_search_path(self): + return None + + def get_all_available_volumes(self, connection_properties=None): + pass + + def _load_config(self, connection_properties): + config = connection_properties['config'] + self._ip = config['vmware_host_ip'] + self._port = config['vmware_host_port'] + self._username = config['vmware_host_username'] + self._password = config['vmware_host_password'] + self._api_retry_count = config['vmware_api_retry_count'] + self._task_poll_interval = config['vmware_task_poll_interval'] + self._ca_file = config['vmware_ca_file'] + self._insecure = config['vmware_insecure'] + self._tmp_dir = config['vmware_tmp_dir'] + self._timeout = config['vmware_image_transfer_timeout_secs'] + + def _create_session(self): + return api.VMwareAPISession(self._ip, + self._username, + self._password, + self._api_retry_count, + self._task_poll_interval, + port=self._port, + cacert=self._ca_file, + insecure=self._insecure) + + def _create_temp_file(self, *args, **kwargs): + fileutils.ensure_tree(self._tmp_dir) + fd, tmp = tempfile.mkstemp(dir=self._tmp_dir, *args, **kwargs) + os.close(fd) + return tmp + + def _download_vmdk( + self, tmp_file_path, session, backing, vmdk_path, vmdk_size): + with open(tmp_file_path, "wb") as tmp_file: + image_transfer.copy_stream_optimized_disk( + None, + self._timeout, + tmp_file, + session=session, + host=self._ip, + port=self._port, + vm=backing, + vmdk_file_path=vmdk_path, + vmdk_size=vmdk_size) + + def connect_volume(self, connection_properties): + # Download the volume vmdk from vCenter server to a temporary file + # and return its path. + self._load_config(connection_properties) + session = self._create_session() + + tmp_file_path = self._create_temp_file( + suffix=".vmdk", prefix=connection_properties['volume_id']) + backing = vim_util.get_moref(connection_properties['volume'], + "VirtualMachine") + vmdk_path = connection_properties['vmdk_path'] + vmdk_size = connection_properties['vmdk_size'] + try: + self._download_vmdk( + tmp_file_path, session, backing, vmdk_path, vmdk_size) + finally: + session.logout() + + # Save the last modified time of the temporary so that we can decide + # whether to upload the file back to vCenter server during disconnect. + last_modified = os.path.getmtime(tmp_file_path) + return {'path': tmp_file_path, 'last_modified': last_modified} + + def _snapshot_exists(self, session, backing): + snapshot = session.invoke_api(vim_util, + 'get_object_property', + session.vim, + backing, + 'snapshot') + if snapshot is None or snapshot.rootSnapshotList is None: + return False + return len(snapshot.rootSnapshotList) != 0 + + def _create_temp_ds_folder(self, session, ds_folder_path, dc_ref): + fileManager = session.vim.service_content.fileManager + try: + session.invoke_api(session.vim, + 'MakeDirectory', + fileManager, + name=ds_folder_path, + datacenter=dc_ref) + except oslo_vmw_exceptions.FileAlreadyExistsException: + pass + + # Note(vbala) remove this method when we implement it in oslo.vmware + def _upload_vmdk( + self, read_handle, host, port, dc_name, ds_name, cookies, + upload_file_path, file_size, cacerts, timeout_secs): + write_handle = rw_handles.FileWriteHandle(host, + port, + dc_name, + ds_name, + cookies, + upload_file_path, + file_size, + cacerts=cacerts) + image_transfer._start_transfer(read_handle, write_handle, timeout_secs) + + def _disconnect(self, tmp_file_path, session, ds_ref, dc_ref, vmdk_path): + # The restored volume is in compressed (streamOptimized) format. + # So we upload it to a temporary location in vCenter datastore and copy + # the compressed vmdk to the volume vmdk. The copy operation + # decompresses the disk to a format suitable for attaching to Nova + # instances in vCenter. + dstore = datastore.get_datastore_by_ref(session, ds_ref) + ds_path = dstore.build_path( + VmdkConnector.TMP_IMAGES_DATASTORE_FOLDER_PATH, + os.path.basename(tmp_file_path)) + self._create_temp_ds_folder( + session, six.text_type(ds_path.parent), dc_ref) + + with open(tmp_file_path, "rb") as tmp_file: + dc_name = session.invoke_api( + vim_util, 'get_object_property', session.vim, dc_ref, 'name') + cookies = session.vim.client.options.transport.cookiejar + cacerts = self._ca_file if self._ca_file else not self._insecure + self._upload_vmdk( + tmp_file, self._ip, self._port, dc_name, dstore.name, cookies, + ds_path.rel_path, os.path.getsize(tmp_file_path), cacerts, + self._timeout) + + # Delete the current volume vmdk because the copy operation does not + # overwrite. + LOG.debug("Deleting %s", vmdk_path) + disk_mgr = session.vim.service_content.virtualDiskManager + task = session.invoke_api(session.vim, + 'DeleteVirtualDisk_Task', + disk_mgr, + name=vmdk_path, + datacenter=dc_ref) + session.wait_for_task(task) + + src = six.text_type(ds_path) + LOG.debug("Copying %(src)s to %(dest)s", {'src': src, + 'dest': vmdk_path}) + task = session.invoke_api(session.vim, + 'CopyVirtualDisk_Task', + disk_mgr, + sourceName=src, + sourceDatacenter=dc_ref, + destName=vmdk_path, + destDatacenter=dc_ref) + session.wait_for_task(task) + + # Delete the compressed vmdk at the temporary location. + LOG.debug("Deleting %s", src) + file_mgr = session.vim.service_content.fileManager + task = session.invoke_api(session.vim, + 'DeleteDatastoreFile_Task', + file_mgr, + name=src, + datacenter=dc_ref) + session.wait_for_task(task) + + def disconnect_volume(self, connection_properties, device_info): + tmp_file_path = device_info['path'] + if not os.path.exists(tmp_file_path): + msg = _("Vmdk: %s not found.") % tmp_file_path + raise exception.NotFound(message=msg) + + session = None + try: + # We upload the temporary file to vCenter server only if it is + # modified after connect_volume. + if os.path.getmtime(tmp_file_path) > device_info['last_modified']: + self._load_config(connection_properties) + session = self._create_session() + backing = vim_util.get_moref(connection_properties['volume'], + "VirtualMachine") + # Currently there is no way we can restore the volume if it + # contains redo-log based snapshots (bug 1599026). + if self._snapshot_exists(session, backing): + msg = (_("Backing of volume: %s contains one or more " + "snapshots; cannot disconnect.") % + connection_properties['volume_id']) + raise exception.BrickException(message=msg) + + ds_ref = vim_util.get_moref( + connection_properties['datastore'], "Datastore") + dc_ref = vim_util.get_moref( + connection_properties['datacenter'], "Datacenter") + vmdk_path = connection_properties['vmdk_path'] + self._disconnect( + tmp_file_path, session, ds_ref, dc_ref, vmdk_path) + finally: + os.remove(tmp_file_path) + if session: + session.logout() + + def extend_volume(self, connection_properties): + raise NotImplementedError diff --git a/os_brick/tests/initiator/connectors/test_vmware.py b/os_brick/tests/initiator/connectors/test_vmware.py new file mode 100644 index 000000000..539d7b5f1 --- /dev/null +++ b/os_brick/tests/initiator/connectors/test_vmware.py @@ -0,0 +1,366 @@ +# Copyright (c) 2016 VMware, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import ddt +import mock +from oslo_utils import units +from oslo_vmware.objects import datastore +from oslo_vmware import vim_util + +from os_brick import exception +from os_brick.initiator.connectors import vmware +from os_brick.tests.initiator import test_connector + + +@ddt.ddt +class VmdkConnectorTestCase(test_connector.ConnectorTestCase): + + IP = '127.0.0.1' + PORT = 443 + USERNAME = 'username' + PASSWORD = 'password' + API_RETRY_COUNT = 3 + TASK_POLL_INTERVAL = 5.0 + CA_FILE = "/etc/ssl/rui-ca-cert.pem" + TMP_DIR = "/vmware-tmp" + IMG_TX_TIMEOUT = 10 + + VMDK_CONNECTOR = vmware.VmdkConnector + + def setUp(self): + super(VmdkConnectorTestCase, self).setUp() + + self._connector = vmware.VmdkConnector(None) + self._connector._ip = self.IP + self._connector._port = self.PORT + self._connector._username = self.USERNAME + self._connector._password = self.PASSWORD + self._connector._api_retry_count = self.API_RETRY_COUNT + self._connector._task_poll_interval = self.TASK_POLL_INTERVAL + self._connector._ca_file = self.CA_FILE + self._connector._insecure = True + self._connector._tmp_dir = self.TMP_DIR + self._connector._timeout = self.IMG_TX_TIMEOUT + + def test_load_config(self): + config = { + 'vmware_host_ip': 'localhost', + 'vmware_host_port': 1234, + 'vmware_host_username': 'root', + 'vmware_host_password': 'pswd', + 'vmware_api_retry_count': 1, + 'vmware_task_poll_interval': 1.0, + 'vmware_ca_file': None, + 'vmware_insecure': False, + 'vmware_tmp_dir': '/tmp', + 'vmware_image_transfer_timeout_secs': 5, + } + self._connector._load_config({'config': config}) + + self.assertEqual('localhost', self._connector._ip) + self.assertEqual(1234, self._connector._port) + self.assertEqual('root', self._connector._username) + self.assertEqual('pswd', self._connector._password) + self.assertEqual(1, self._connector._api_retry_count) + self.assertEqual(1.0, self._connector._task_poll_interval) + self.assertIsNone(self._connector._ca_file) + self.assertFalse(self._connector._insecure) + self.assertEqual('/tmp', self._connector._tmp_dir) + self.assertEqual(5, self._connector._timeout) + + @mock.patch('oslo_vmware.api.VMwareAPISession') + def test_create_session(self, session): + session.return_value = mock.sentinel.session + + ret = self._connector._create_session() + + self.assertEqual(mock.sentinel.session, ret) + session.assert_called_once_with( + self._connector._ip, + self._connector._username, + self._connector._password, + self._connector._api_retry_count, + self._connector._task_poll_interval, + port=self._connector._port, + cacert=self._connector._ca_file, + insecure=self._connector._insecure) + + @mock.patch('oslo_utils.fileutils.ensure_tree') + @mock.patch('tempfile.mkstemp') + @mock.patch('os.close') + def test_create_temp_file( + self, close, mkstemp, ensure_tree): + fd = mock.sentinel.fd + tmp = mock.sentinel.tmp + mkstemp.return_value = (fd, tmp) + + prefix = ".vmdk" + suffix = "test" + ret = self._connector._create_temp_file(prefix=prefix, suffix=suffix) + + self.assertEqual(tmp, ret) + ensure_tree.assert_called_once_with(self._connector._tmp_dir) + mkstemp.assert_called_once_with(dir=self._connector._tmp_dir, + prefix=prefix, + suffix=suffix) + close.assert_called_once_with(fd) + + @mock.patch('os_brick.initiator.connectors.vmware.open', create=True) + @mock.patch('oslo_vmware.image_transfer.copy_stream_optimized_disk') + def test_download_vmdk(self, copy_disk, file_open): + file_open_ret = mock.Mock() + tmp_file = mock.sentinel.tmp_file + file_open_ret.__enter__ = mock.Mock(return_value=tmp_file) + file_open_ret.__exit__ = mock.Mock(return_value=None) + file_open.return_value = file_open_ret + + tmp_file_path = mock.sentinel.tmp_file_path + session = mock.sentinel.session + backing = mock.sentinel.backing + vmdk_path = mock.sentinel.vmdk_path + vmdk_size = mock.sentinel.vmdk_size + self._connector._download_vmdk( + tmp_file_path, session, backing, vmdk_path, vmdk_size) + + file_open.assert_called_once_with(tmp_file_path, 'wb') + copy_disk.assert_called_once_with(None, + self._connector._timeout, + tmp_file, + session=session, + host=self._connector._ip, + port=self._connector._port, + vm=backing, + vmdk_file_path=vmdk_path, + vmdk_size=vmdk_size) + + def _create_connection_properties(self): + return {'volume_id': 'ed083474-d325-4a99-b301-269111654f0d', + 'volume': 'ref-1', + 'vmdk_path': '[ds] foo/bar.vmdk', + 'vmdk_size': units.Gi, + 'datastore': 'ds-1', + 'datacenter': 'dc-1', + } + + @mock.patch.object(VMDK_CONNECTOR, '_load_config') + @mock.patch.object(VMDK_CONNECTOR, '_create_session') + @mock.patch.object(VMDK_CONNECTOR, '_create_temp_file') + @mock.patch('oslo_vmware.vim_util.get_moref') + @mock.patch.object(VMDK_CONNECTOR, '_download_vmdk') + @mock.patch('os.path.getmtime') + def test_connect_volume( + self, getmtime, download_vmdk, get_moref, create_temp_file, + create_session, load_config): + session = mock.Mock() + create_session.return_value = session + + tmp_file_path = mock.sentinel.tmp_file_path + create_temp_file.return_value = tmp_file_path + + backing = mock.sentinel.backing + get_moref.return_value = backing + + last_modified = mock.sentinel.last_modified + getmtime.return_value = last_modified + + props = self._create_connection_properties() + ret = self._connector.connect_volume(props) + + self.assertEqual(tmp_file_path, ret['path']) + self.assertEqual(last_modified, ret['last_modified']) + load_config.assert_called_once_with(props) + create_session.assert_called_once_with() + create_temp_file.assert_called_once_with( + suffix=".vmdk", prefix=props['volume_id']) + download_vmdk.assert_called_once_with( + tmp_file_path, session, backing, props['vmdk_path'], + props['vmdk_size']) + session.logout.assert_called_once_with() + + @ddt.data((None, False), ([mock.sentinel.snap], True)) + @ddt.unpack + def test_snapshot_exists(self, snap_list, exp_return_value): + snapshot = mock.Mock(rootSnapshotList=snap_list) + session = mock.Mock() + session.invoke_api.return_value = snapshot + + backing = mock.sentinel.backing + ret = self._connector._snapshot_exists(session, backing) + + self.assertEqual(exp_return_value, ret) + session.invoke_api.assert_called_once_with( + vim_util, 'get_object_property', session.vim, backing, 'snapshot') + + def test_create_temp_ds_folder(self): + session = mock.Mock() + ds_folder_path = mock.sentinel.ds_folder_path + dc_ref = mock.sentinel.dc_ref + self._connector._create_temp_ds_folder(session, ds_folder_path, dc_ref) + + session.invoke_api.assert_called_once_with( + session.vim, + 'MakeDirectory', + session.vim.service_content.fileManager, + name=ds_folder_path, + datacenter=dc_ref) + + @mock.patch('oslo_vmware.objects.datastore.get_datastore_by_ref') + @mock.patch.object(VMDK_CONNECTOR, '_create_temp_ds_folder') + @mock.patch('os_brick.initiator.connectors.vmware.open', create=True) + @mock.patch.object(VMDK_CONNECTOR, '_upload_vmdk') + @mock.patch('os.path.getsize') + def test_disconnect( + self, getsize, upload_vmdk, file_open, create_temp_ds_folder, + get_ds_by_ref): + ds_ref = mock.sentinel.ds_ref + ds_name = 'datastore-1' + dstore = datastore.Datastore(ds_ref, ds_name) + get_ds_by_ref.return_value = dstore + + file_open_ret = mock.Mock() + tmp_file = mock.sentinel.tmp_file + file_open_ret.__enter__ = mock.Mock(return_value=tmp_file) + file_open_ret.__exit__ = mock.Mock(return_value=None) + file_open.return_value = file_open_ret + + dc_name = mock.sentinel.dc_name + delete_task = mock.sentinel.delete_vdisk_task + copy_task = mock.sentinel.copy_vdisk_task + delete_file_task = mock.sentinel.delete_file_task + session = mock.Mock() + session.invoke_api.side_effect = [ + dc_name, delete_task, copy_task, delete_file_task] + + getsize.return_value = units.Gi + + tmp_file_path = '/tmp/foo.vmdk' + dc_ref = mock.sentinel.dc_ref + vmdk_path = mock.sentinel.vmdk_path + self._connector._disconnect( + tmp_file_path, session, ds_ref, dc_ref, vmdk_path) + + tmp_folder_path = self._connector.TMP_IMAGES_DATASTORE_FOLDER_PATH + ds_folder_path = '[%s] %s' % (ds_name, tmp_folder_path) + create_temp_ds_folder.assert_called_once_with( + session, ds_folder_path, dc_ref) + file_open.assert_called_once_with(tmp_file_path, "rb") + + self.assertEqual( + mock.call(vim_util, 'get_object_property', session.vim, dc_ref, + 'name'), session.invoke_api.call_args_list[0]) + + exp_rel_path = '%s/foo.vmdk' % tmp_folder_path + upload_vmdk.assert_called_once_with( + tmp_file, self._connector._ip, self._connector._port, dc_name, + ds_name, session.vim.client.options.transport.cookiejar, + exp_rel_path, units.Gi, self._connector._ca_file, + self._connector._timeout) + + disk_mgr = session.vim.service_content.virtualDiskManager + self.assertEqual( + mock.call(session.vim, 'DeleteVirtualDisk_Task', disk_mgr, + name=vmdk_path, datacenter=dc_ref), + session.invoke_api.call_args_list[1]) + self.assertEqual(mock.call(delete_task), + session.wait_for_task.call_args_list[0]) + + src = '[%s] %s' % (ds_name, exp_rel_path) + self.assertEqual( + mock.call(session.vim, 'CopyVirtualDisk_Task', disk_mgr, + sourceName=src, sourceDatacenter=dc_ref, + destName=vmdk_path, destDatacenter=dc_ref), + session.invoke_api.call_args_list[2]) + self.assertEqual(mock.call(copy_task), + session.wait_for_task.call_args_list[1]) + + file_mgr = session.vim.service_content.fileManager + self.assertEqual( + mock.call(session.vim, 'DeleteDatastoreFile_Task', file_mgr, + name=src, datacenter=dc_ref), + session.invoke_api.call_args_list[3]) + self.assertEqual(mock.call(delete_file_task), + session.wait_for_task.call_args_list[2]) + + @mock.patch('os.path.exists') + def test_disconnect_volume_with_missing_temp_file(self, path_exists): + path_exists.return_value = False + + path = mock.sentinel.path + self.assertRaises(exception.NotFound, + self._connector.disconnect_volume, + mock.ANY, + {'path': path}) + path_exists.assert_called_once_with(path) + + @mock.patch('os.path.exists') + @mock.patch('os.path.getmtime') + @mock.patch.object(VMDK_CONNECTOR, '_disconnect') + @mock.patch('os.remove') + def test_disconnect_volume_with_unmodified_file( + self, remove, disconnect, getmtime, path_exists): + path_exists.return_value = True + + mtime = 1467802060 + getmtime.return_value = mtime + + path = mock.sentinel.path + self._connector.disconnect_volume(mock.ANY, {'path': path, + 'last_modified': mtime}) + + path_exists.assert_called_once_with(path) + getmtime.assert_called_once_with(path) + disconnect.assert_not_called() + remove.assert_called_once_with(path) + + @mock.patch('os.path.exists') + @mock.patch('os.path.getmtime') + @mock.patch.object(VMDK_CONNECTOR, '_load_config') + @mock.patch.object(VMDK_CONNECTOR, '_create_session') + @mock.patch('oslo_vmware.vim_util.get_moref') + @mock.patch.object(VMDK_CONNECTOR, '_snapshot_exists') + @mock.patch.object(VMDK_CONNECTOR, '_disconnect') + @mock.patch('os.remove') + def test_disconnect_volume( + self, remove, disconnect, snapshot_exists, get_moref, + create_session, load_config, getmtime, path_exists): + path_exists.return_value = True + + mtime = 1467802060 + getmtime.return_value = mtime + + session = mock.Mock() + create_session.return_value = session + + snapshot_exists.return_value = False + + backing = mock.sentinel.backing + ds_ref = mock.sentinel.ds_ref + dc_ref = mock.sentinel.dc_ref + get_moref.side_effect = [backing, ds_ref, dc_ref] + + props = self._create_connection_properties() + path = mock.sentinel.path + self._connector.disconnect_volume(props, {'path': path, + 'last_modified': mtime - 1}) + + path_exists.assert_called_once_with(path) + getmtime.assert_called_once_with(path) + load_config.assert_called_once_with(props) + create_session.assert_called_once_with() + snapshot_exists.assert_called_once_with(session, backing) + disconnect.assert_called_once_with( + path, session, ds_ref, dc_ref, props['vmdk_path']) + remove.assert_called_once_with(path) + session.logout.assert_called_once_with() diff --git a/releasenotes/notes/vmware-vmdk-connector-19e6999e6cae43cd.yaml b/releasenotes/notes/vmware-vmdk-connector-19e6999e6cae43cd.yaml new file mode 100644 index 000000000..dae4d252d --- /dev/null +++ b/releasenotes/notes/vmware-vmdk-connector-19e6999e6cae43cd.yaml @@ -0,0 +1,4 @@ +--- +features: + - Added initiator connector 'VmdkConnector' to support backup and + restore of vmdk volumes by Cinder backup service. diff --git a/test-requirements.txt b/test-requirements.txt index b0c1dd249..b3d465c60 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -14,3 +14,4 @@ testrepository>=0.0.18 # Apache-2.0/BSD testscenarios>=0.4 # Apache-2.0/BSD testtools>=1.4.0 # MIT os-testr>=0.7.0 # Apache-2.0 +oslo.vmware>=2.11.0 # Apache-2.0