Add connector for vmdk volumes

Change I743e676372703e74178c79683dd622d530981e04 removed volume
backend driver calls for creating and restoring volume backups.
The overridden methods for creating and restoring backups in
the VMDK driver is no longer called and this breaks the backup-
restore of volumes created by the VMDK driver. The Cinder backup
manager now uses initiator connectors for creating and restoring
backups for all volume backends. The patch adds a connector for
vmdk volumes to fix the backup-restore for the VMDK driver.

DocImpact

Change-Id: Ia1a20f93780593b1efbb74484c3fdd3ca3564290
Partial-bug: #1602660
This commit is contained in:
Vipin Balachandran 2016-07-13 17:17:06 +05:30
parent f38c6c87be
commit 9f70ace94c
6 changed files with 651 additions and 0 deletions

View File

@ -56,3 +56,4 @@ QUOBYTE = "QUOBYTE"
DISCO = "DISCO"
VZSTORAGE = "VZSTORAGE"
SHEEPDOG = "SHEEPDOG"
VMDK = "VMDK"

View File

@ -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

View File

@ -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

View File

@ -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()

View File

@ -0,0 +1,4 @@
---
features:
- Added initiator connector 'VmdkConnector' to support backup and
restore of vmdk volumes by Cinder backup service.

View File

@ -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