VMware Datastore storage backend

Customers using a VMware environment with OpenStack should be able to
store their Glance images in VMware datastores. This is a first step to solve
the problem where Nova needs to copy the bits over the network
from Glance to the datastore when spawning an instance.
Also, this give the ability to provide some optimizations for specific
image formats in the future (fast cloning for example).

This patch contains a 'glance/store/vmware/' folder with the code
to manage the connection with vCenter or an ESX(i) host.
This code will go away as soon as it is merged to Olso:
see review https://review.openstack.org/#/c/65075/

The current implementation give this ability to specify the vCenter or
ESX(i) IP. In case of a vCenter IP, there is no optimization to reduce
the datapath (no host selected).
Consequently, it is recommended to specify an ESX IP if the ESX host
API endpoint is accessible from Glance.

docImpact
Implements bp vmware-datastore-storage-backend

Change-Id: I3837912e0d1614b9c31a689f71c2e34d453e2dc3
This commit is contained in:
Arnaud Legendre 2013-11-27 10:14:00 -08:00
parent 4d8c3f1553
commit f9589bd010
17 changed files with 1693 additions and 3 deletions

View File

@ -367,7 +367,8 @@ Optional. Default: ``file``
Can only be specified in configuration files.
Sets the storage backend to use by default when storing images in Glance.
Available options for this option are (``file``, ``swift``, ``s3``, ``rbd``, or ``sheepdog``, or ``cinder``).
Available options for this option are (``file``, ``swift``, ``s3``, ``rbd``, ``sheepdog``,
``cinder`` or ``vsphere``).
Configuring Glance Image Size Limit
-----------------------------------

View File

@ -405,7 +405,7 @@ The list of metadata headers that Glance accepts are listed below.
* ``x-image-meta-store``
This header is optional. Valid values are one of ``file``, ``s3``, ``rbd``,
``swift``, ``cinder``, ``gridfs`` or ``sheepdog``
``swift``, ``cinder``, ``gridfs``, ``sheepdog`` or ``vsphere``
When present, Glance will attempt to store the disk image data in the
backing store indicated by the value of the header. If the Glance node

View File

@ -20,6 +20,7 @@ default_store = file
# glance.store.swift.Store,
# glance.store.sheepdog.Store,
# glance.store.cinder.Store,
# glance.store.vmware_datastore.Store,
# Maximum image size (in bytes) that may be uploaded through the
@ -457,6 +458,42 @@ sheepdog_store_chunk_size = 64
# Allow to perform insecure SSL requests to cinder (boolean value)
#cinder_api_insecure = False
# ============ VMware Datastore Store Options =====================
# ESX/ESXi or vCenter Server target system.
# The server value can be an IP address or a DNS name
# e.g. 127.0.0.1, 127.0.0.1:443, www.vmware-infra.com
#vmware_server_host = <None>
# Server username (string value)
#vmware_server_username = <None>
# Server password (string value)
#vmware_server_password = <None>
# Inventory path to a datacenter (string value)
# Value optional when vmware_server_ip is an ESX/ESXi host: if specified
# should be `ha-datacenter`.
#vmware_datacenter_path = <None>
# Datastore associated with the datacenter (string value)
#vmware_datastore_name = <None>
# The number of times we retry on failures
# e.g., socket error, etc (integer value)
#vmware_api_retry_count = 10
# The interval used for polling remote tasks
# invoked on VMware ESX/VC server in seconds (integer value)
#vmware_task_poll_interval = 5
# Absolute path of the folder containing the images in the datastore
# (string value)
#vmware_store_image_dir = /openstack_glance
# Allow to perform insecure SSL requests to the target system (boolean value)
#vmware_api_insecure = False
# ============ Delayed Delete Options =============================
# Turn on/off delayed delete

View File

@ -46,6 +46,7 @@ registry_port = 9191
# glance.store.swift.Store,
# glance.store.sheepdog.Store,
# glance.store.cinder.Store,
# glance.store.vmware_datastore.Store,
# ============ Filesystem Store Options ========================
@ -155,6 +156,42 @@ s3_store_create_bucket_on_put = False
# Allow to perform insecure SSL requests to cinder (boolean value)
#cinder_api_insecure = False
# ============ VMware Datastore Store Options =====================
# ESX/ESXi or vCenter Server target system.
# The server value can be an IP address or a DNS name
# e.g. 127.0.0.1, 127.0.0.1:443, www.vmware-infra.com
#vmware_server_host = <None>
# Server username (string value)
#vmware_server_username = <None>
# Server password (string value)
#vmware_server_password = <None>
# Inventory path to a datacenter (string value)
# Value optional when vmware_server_ip is an ESX/ESXi host: if specified
# should be `ha-datacenter`.
#vmware_datacenter_path = <None>
# Datastore associated with the datacenter (string value)
#vmware_datastore_name = <None>
# The number of times we retry on failures
# e.g., socket error, etc (integer value)
#vmware_api_retry_count = 10
# The interval used for polling remote tasks
# invoked on VMware ESX/VC server in seconds (integer value)
#vmware_task_poll_interval = 5
# Absolute path of the folder containing the images in the datastore
# (string value)
#vmware_store_image_dir = /openstack_glance
# Allow to perform insecure SSL requests to the target system (boolean value)
#vmware_api_insecure = False
# ================= Security Options ==========================
# AES key for encrypting store 'location' metadata, including

View File

@ -39,6 +39,7 @@ store_opts = [
'glance.store.swift.Store',
'glance.store.sheepdog.Store',
'glance.store.cinder.Store',
'glance.store.vmware_datastore.Store',
],
help=_('List of which store classes and store class locations '
'are currently known to glance at startup.')),

View File

@ -65,6 +65,7 @@ def get_location_from_uri(uri):
s3+https://accesskey:secretkey@s3.amazonaws.com/bucket/key-id
file:///var/lib/glance/images/1
cinder://volume-id
vsphere://server_host/folder/file_path?dcPath=dc_path&dsName=ds_name
"""
pieces = urlparse.urlparse(uri)
if pieces.scheme not in SCHEME_TO_CLS_MAP.keys():

View File

273
glance/store/vmware/api.py Normal file
View File

@ -0,0 +1,273 @@
# Copyright (c) 2014 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.
"""
Session and API call management for VMware ESX/VC server.
Provides abstraction over glance.vmware.vim.Vim SOAP calls.
"""
from eventlet import event
import glance.openstack.common.log as logging
from glance.openstack.common import loopingcall
from glance.store.vmware import error_util
from glance.store.vmware import vim
from glance.store.vmware import vim_util
LOG = logging.getLogger(__name__)
class Retry(object):
"""Decorator for retrying a function upon suggested exceptions.
The method retries for given number of times and the sleep
time increments till the max sleep time is reached.
If max retries is set to -1, then the decorated function is
invoked indefinitely till no exception is thrown or if
the caught exception is not in the list of suggested exceptions.
"""
def __init__(self, max_retry_count=-1, inc_sleep_time=10,
max_sleep_time=60, exceptions=()):
"""Initialize retry object based on input params.
:param max_retry_count: Max number of times, a function must be
retried when one of input 'exceptions'
is caught. The default -1 will always
retry the function till a non-exception
case, or an un-wanted error case arises.
:param inc_sleep_time: Incremental time in seconds for sleep time
between retrial
:param max_sleep_time: Max sleep time beyond which the sleep time will
not be incremented using param inc_sleep_time
and max_sleep_time will be used as sleep time
:param exceptions: Suggested exceptions for which the function must be
retried
"""
self._max_retry_count = max_retry_count
self._inc_sleep_time = inc_sleep_time
self._max_sleep_time = max_sleep_time
self._exceptions = exceptions
self._retry_count = 0
self._sleep_time = 0
def __call__(self, f):
def _func(done, *args, **kwargs):
try:
result = f(*args, **kwargs)
done.send(result)
except self._exceptions as excep:
LOG.exception(_("Failure while invoking function: "
"%(func)s. Error: %(excep)s.") %
{'func': f.__name__, 'excep': excep})
if (self._max_retry_count != -1 and
self._retry_count >= self._max_retry_count):
done.send_exception(excep)
else:
self._retry_count += 1
self._sleep_time += self._inc_sleep_time
return self._sleep_time
except Exception as excep:
done.send_exception(excep)
return 0
def func(*args, **kwargs):
done = event.Event()
loop = loopingcall.DynamicLoopingCall(_func, done, *args, **kwargs)
loop.start(periodic_interval_max=self._max_sleep_time)
result = done.wait()
loop.stop()
return result
return func
class VMwareAPISession(object):
"""Sets up a session with the server and handles all calls made to it."""
def __init__(self, server_ip, server_username, server_password,
api_retry_count, task_poll_interval=5.0,
scheme='https', create_session=True,
wsdl_loc=None):
"""Constructs session object.
:param server_ip: IP address of ESX/VC server
:param server_username: Username of ESX/VC server admin user
:param server_password: Password for param server_username
:param api_retry_count: Number of times an API must be retried upon
session/connection related errors
:param scheme: http or https protocol
:param create_session: Boolean whether to set up connection at the
time of instance creation
:param wsdl_loc: WSDL file location for invoking SOAP calls on server
using suds
"""
self._server_ip = server_ip
self._server_username = server_username
self._server_password = server_password
self._wsdl_loc = wsdl_loc
self._api_retry_count = api_retry_count
self._task_poll_interval = task_poll_interval
self._scheme = scheme
self._session_id = None
self._vim = None
if create_session:
self.create_session()
@property
def vim(self):
if not self._vim:
self._vim = vim.Vim(protocol=self._scheme, host=self._server_ip,
wsdl_loc=self._wsdl_loc)
return self._vim
@Retry(exceptions=(Exception))
def create_session(self):
"""Establish session with the server."""
# Login and setup the session with the server for making
# API calls
session_manager = self.vim.service_content.sessionManager
session = self.vim.Login(session_manager,
userName=self._server_username,
password=self._server_password)
# Terminate the earlier session, if possible (For the sake of
# preserving sessions as there is a limit to the number of
# sessions we can have)
if self._session_id:
try:
self.vim.TerminateSession(session_manager,
sessionId=[self._session_id])
except Exception as excep:
# This exception is something we can live with. It is
# just an extra caution on our side. The session may
# have been cleared. We could have made a call to
# SessionIsActive, but that is an overhead because we
# anyway would have to call TerminateSession.
LOG.exception(_("Error while terminating session: %s.") %
excep)
self._session_id = session.key
LOG.info(_("Successfully established connection to the server."))
def __del__(self):
"""Logs-out the session."""
try:
self.vim.Logout(self.vim.service_content.sessionManager)
except Exception as excep:
LOG.exception(_("Error while logging out the user: %s.") %
excep)
def invoke_api(self, module, method, *args, **kwargs):
"""Wrapper method for invoking APIs.
Here we retry the API calls for exceptions which may come because
of session overload.
Make sure if a Vim instance is being passed here, this session's
Vim (self.vim) instance is used, as we retry establishing session
in case of session timedout.
:param module: Module invoking the VI SDK calls
:param method: Method in the module that invokes the VI SDK call
:param args: Arguments to the method
:param kwargs: Keyword arguments to the method
:return: Response of the API call
"""
@Retry(max_retry_count=self._api_retry_count,
exceptions=(error_util.VimException))
def _invoke_api(module, method, *args, **kwargs):
last_fault_list = []
while True:
try:
api_method = getattr(module, method)
return api_method(*args, **kwargs)
except error_util.VimFaultException as excep:
if error_util.NOT_AUTHENTICATED not in excep.fault_list:
raise excep
# If it is a not-authenticated fault, we re-authenticate
# the user and retry the API invocation.
# Because of the idle session returning an empty
# RetrieveProperties response and also the same is
# returned when there is an empty answer to a query
# (e.g. no VMs on the host), we have no way to
# differentiate.
# So if the previous response was also an empty
# response and after creating a new session, we get
# the same empty response, then we are sure of the
# response being an empty response.
if error_util.NOT_AUTHENTICATED in last_fault_list:
return []
last_fault_list = excep.fault_list
LOG.warn(_("Not authenticated error occurred. "
"Will create session and try "
"API call again: %s.") % excep)
self.create_session()
return _invoke_api(module, method, *args, **kwargs)
def _stop_loop(self, loop):
loop.stop()
def wait_for_task(self, task):
"""Return a deferred that will give the result of the given task.
The task is polled until it completes. The method returns the task
information upon successful completion.
:param task: Managed object reference of the task
:return: Task info upon successful completion of the task
"""
done = event.Event()
loop = loopingcall.FixedIntervalLoopingCall(self._poll_task,
task, done)
loop.start(self._task_poll_interval)
task_info = done.wait()
loop.stop()
return task_info
def _poll_task(self, task, done):
"""Poll the given task.
If the task completes successfully then returns task info.
In case of error sends back appropriate error.
:param task: Managed object reference of the task
:param done: Event that captures task status
"""
try:
task_info = self.invoke_api(vim_util, 'get_object_property',
self.vim, task, 'info')
if task_info.state in ['queued', 'running']:
# If task already completed on server, it will not return
# the progress.
if hasattr(task_info, 'progress'):
LOG.debug(_("Task: %(task)s progress: %(prog)s.") %
{'task': task, 'prog': task_info.progress})
return
elif task_info.state == 'success':
LOG.debug(_("Task %s status: success.") % task)
done.send(task_info)
else:
error_msg = str(task_info.error.localizedMessage)
LOG.exception(_("Task: %(task)s failed with error: %(err)s.") %
{'task': task, 'err': error_msg})
done.send_exception(error_util.VimFaultException([],
error_msg))
except Exception as excep:
LOG.exception(_("Task: %(task)s failed with error: %(err)s.") %
{'task': task, 'err': excep})
done.send_exception(excep)

View File

@ -0,0 +1,48 @@
# Copyright (c) 2014 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.
"""
Exception classes and SOAP response error checking module.
"""
from glance.common import exception
NOT_AUTHENTICATED = 'NotAuthenticated'
class VimException(exception.GlanceException):
"""The VIM Exception class."""
def __init__(self, msg):
exception.GlanceException.__init__(self, msg)
class SessionOverLoadException(VimException):
"""Session Overload Exception."""
pass
class VimAttributeException(VimException):
"""VI Attribute Error."""
pass
class VimFaultException(VimException):
"""The VIM Fault exception class."""
def __init__(self, fault_list, msg):
super(VimFaultException, self).__init__(msg)
self.fault_list = fault_list

241
glance/store/vmware/vim.py Normal file
View File

@ -0,0 +1,241 @@
# Copyright (c) 2014 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.
"""
Classes for making VMware VI SOAP calls.
"""
import httplib
import logging
import suds
from glance.store.vmware import error_util
logging.getLogger('suds').setLevel(logging.INFO)
RESP_NOT_XML_ERROR = "Response is 'text/html', not 'text/xml'"
CONN_ABORT_ERROR = 'Software caused connection abort'
ADDRESS_IN_USE_ERROR = 'Address already in use'
def get_moref(value, type):
"""Get managed object reference.
:param value: value for the managed object
:param type: type of the managed object
:return: Managed object reference with with input value and type
"""
moref = suds.sudsobject.Property(value)
moref._type = type
return moref
class VIMMessagePlugin(suds.plugin.MessagePlugin):
def addAttributeForValue(self, node):
"""Helper to handle AnyType.
suds does not handle AnyType properly.
VI SDK requires type attribute to be set when AnyType is used
:param node: XML value node
"""
if node.name == 'value':
node.set('xsi:type', 'xsd:string')
def marshalled(self, context):
"""Marshal soap context.
Provides the plugin with the opportunity to prune empty
nodes and fixup nodes before sending it to the server.
:param context: SOAP context
"""
# suds builds the entire request object based on the wsdl schema.
# VI SDK throws server errors if optional SOAP nodes are sent
# without values, e.g. <test/> as opposed to <test>test</test>
context.envelope.prune()
context.envelope.walk(self.addAttributeForValue)
class Vim(object):
"""The VIM Object."""
def __init__(self, protocol='https', host='localhost', wsdl_loc=None):
"""Create communication interfaces for initiating SOAP transactions.
:param protocol: http or https
:param host: Server IPAddress[:port] or Hostname[:port]
:param wsdl_loc: Optional location of the VIM WSDL
"""
self._protocol = protocol
self._host_name = host
if not wsdl_loc:
wsdl_loc = Vim._get_wsdl_loc(protocol, host)
soap_url = Vim._get_soap_url(protocol, host)
self._client = suds.client.Client(wsdl_loc, location=soap_url,
plugins=[VIMMessagePlugin()])
self._service_content = self.RetrieveServiceContent('ServiceInstance')
@staticmethod
def _get_wsdl_loc(protocol, host_name):
"""Return default WSDL file location hosted at the server.
:param protocol: http or https
:param host_name: ESX/VC server host name
:return: Default WSDL file location hosted at the server
"""
return '%s://%s/sdk/vimService.wsdl' % (protocol, host_name)
@staticmethod
def _get_soap_url(protocol, host_name):
"""Return URL to SOAP services for ESX/VC server.
:param protocol: https or http
:param host_name: ESX/VC server host name
:return: URL to SOAP services for ESX/VC server
"""
return '%s://%s/sdk' % (protocol, host_name)
@property
def service_content(self):
return self._service_content
@property
def client(self):
return self._client
def __getattr__(self, attr_name):
"""Makes the API call and gets the result."""
def retrieve_properties_ex_fault_checker(response):
"""Checks the RetrievePropertiesEx response for errors.
Certain faults are sent as part of the SOAP body as property of
missingSet. For example NotAuthenticated fault. The method raises
appropriate VimFaultException when an error is found.
:param response: Response from RetrievePropertiesEx API call
"""
fault_list = []
if not response:
# This is the case when the session has timed out. ESX SOAP
# server sends an empty RetrievePropertiesExResponse. Normally
# missingSet in the returnval field has the specifics about
# the error, but that's not the case with a timed out idle
# session. It is as bad as a terminated session for we cannot
# use the session. So setting fault to NotAuthenticated fault.
fault_list = [error_util.NOT_AUTHENTICATED]
else:
for obj_cont in response:
if hasattr(obj_cont, 'missingSet'):
for missing_elem in obj_cont.missingSet:
fault_type = missing_elem.fault.fault.__class__
# Fault needs to be added to the type of fault
# for uniformity in error checking as SOAP faults
# define
fault_list.append(fault_type.__name__)
if fault_list:
exc_msg_list = ', '.join(fault_list)
raise error_util.VimFaultException(fault_list,
_("Error(s): %s occurred "
"in the call to "
"RetrievePropertiesEx.") %
exc_msg_list)
def vim_request_handler(managed_object, **kwargs):
"""Handler for VI SDK calls.
Builds the SOAP message and parses the response for fault
checking and other errors.
:param managed_object:Managed object reference
:param kwargs: Keyword arguments of the call
:return: Response of the API call
"""
try:
if isinstance(managed_object, str):
# For strings use string value for value and type
# of the managed object.
managed_object = get_moref(managed_object, managed_object)
request = getattr(self.client.service, attr_name)
response = request(managed_object, **kwargs)
if (attr_name.lower() == 'retrievepropertiesex'):
retrieve_properties_ex_fault_checker(response)
return response
except error_util.VimFaultException as excep:
raise
except suds.WebFault as excep:
doc = excep.document
detail = doc.childAtPath('/Envelope/Body/Fault/detail')
fault_list = []
for child in detail.getChildren():
fault_list.append(child.get('type'))
raise error_util.VimFaultException(fault_list, str(excep))
except AttributeError as excep:
raise error_util.VimAttributeException(_("No such SOAP method "
"%(attr)s. Detailed "
"error: %(excep)s.") %
{'attr': attr_name,
'excep': excep})
except (httplib.CannotSendRequest,
httplib.ResponseNotReady,
httplib.CannotSendHeader) as excep:
raise error_util.SessionOverLoadException(_("httplib error in "
"%(attr)s: "
"%(excep)s.") %
{'attr': attr_name,
'excep': excep})
except Exception as excep:
# Socket errors which need special handling for they
# might be caused by server API call overload
if (str(excep).find(ADDRESS_IN_USE_ERROR) != -1 or
str(excep).find(CONN_ABORT_ERROR)) != -1:
raise error_util.SessionOverLoadException(_("Socket error "
"in %(attr)s: "
"%(excep)s.") %
{'attr':
attr_name,
'excep': excep})
# Type error that needs special handling for it might be
# caused by server API call overload
elif str(excep).find(RESP_NOT_XML_ERROR) != -1:
raise error_util.SessionOverLoadException(_("Type error "
"in %(attr)s: "
"%(excep)s.") %
{'attr':
attr_name,
'excep': excep})
else:
raise error_util.VimException(_("Error in %(attr)s. "
"Detailed error: "
"%(excep)s.") %
{'attr': attr_name,
'excep': excep})
return vim_request_handler
def __repr__(self):
return "VIM Object."
def __str__(self):
return "VIM Object."

View File

@ -0,0 +1,301 @@
# Copyright (c) 2014 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.
"""
The VMware API utility module.
"""
def build_selection_spec(client_factory, name):
"""Builds the selection spec.
:param client_factory: Factory to get API input specs
:param name: Name for the selection spec
:return: Selection spec
"""
sel_spec = client_factory.create('ns0:SelectionSpec')
sel_spec.name = name
return sel_spec
def build_traversal_spec(client_factory, name, type, path, skip,
select_set):
"""Builds the traversal spec object.
:param client_factory: Factory to get API input specs
:param name: Name for the traversal spec
:param type: Type of the managed object reference
:param path: Property path of the managed object reference
:param skip: Whether or not to filter the object identified by param path
:param select_set: Set of selection specs specifying additional objects
to filter
:return: Traversal spec
"""
traversal_spec = client_factory.create('ns0:TraversalSpec')
traversal_spec.name = name
traversal_spec.type = type
traversal_spec.path = path
traversal_spec.skip = skip
traversal_spec.selectSet = select_set
return traversal_spec
def build_recursive_traversal_spec(client_factory):
"""Builds Recursive Traversal Spec to traverse managed object hierarchy.
:param client_factory: Factory to get API input specs
:return: Recursive traversal spec
"""
visit_folders_select_spec = build_selection_spec(client_factory,
'visitFolders')
# Next hop from Datacenter
dc_to_hf = build_traversal_spec(client_factory, 'dc_to_hf', 'Datacenter',
'hostFolder', False,
[visit_folders_select_spec])
dc_to_vmf = build_traversal_spec(client_factory, 'dc_to_vmf', 'Datacenter',
'vmFolder', False,
[visit_folders_select_spec])
# Next hop from HostSystem
h_to_vm = build_traversal_spec(client_factory, 'h_to_vm', 'HostSystem',
'vm', False,
[visit_folders_select_spec])
# Next hop from ComputeResource
cr_to_h = build_traversal_spec(client_factory, 'cr_to_h',
'ComputeResource', 'host', False, [])
cr_to_ds = build_traversal_spec(client_factory, 'cr_to_ds',
'ComputeResource', 'datastore', False, [])
rp_to_rp_select_spec = build_selection_spec(client_factory, 'rp_to_rp')
rp_to_vm_select_spec = build_selection_spec(client_factory, 'rp_to_vm')
cr_to_rp = build_traversal_spec(client_factory, 'cr_to_rp',
'ComputeResource', 'resourcePool', False,
[rp_to_rp_select_spec,
rp_to_vm_select_spec])
# Next hop from ClusterComputeResource
ccr_to_h = build_traversal_spec(client_factory, 'ccr_to_h',
'ClusterComputeResource', 'host',
False, [])
ccr_to_ds = build_traversal_spec(client_factory, 'ccr_to_ds',
'ClusterComputeResource', 'datastore',
False, [])
ccr_to_rp = build_traversal_spec(client_factory, 'ccr_to_rp',
'ClusterComputeResource', 'resourcePool',
False,
[rp_to_rp_select_spec,
rp_to_vm_select_spec])
# Next hop from ResourcePool
rp_to_rp = build_traversal_spec(client_factory, 'rp_to_rp', 'ResourcePool',
'resourcePool', False,
[rp_to_rp_select_spec,
rp_to_vm_select_spec])
rp_to_vm = build_traversal_spec(client_factory, 'rp_to_vm', 'ResourcePool',
'vm', False,
[rp_to_rp_select_spec,
rp_to_vm_select_spec])
# Get the assorted traversal spec which takes care of the objects to
# be searched for from the rootFolder
traversal_spec = build_traversal_spec(client_factory, 'visitFolders',
'Folder', 'childEntity', False,
[visit_folders_select_spec,
h_to_vm, dc_to_hf, dc_to_vmf,
cr_to_ds, cr_to_h, cr_to_rp,
ccr_to_h, ccr_to_ds, ccr_to_rp,
rp_to_rp, rp_to_vm])
return traversal_spec
def build_property_spec(client_factory, type='VirtualMachine',
properties_to_collect=None,
all_properties=False):
"""Builds the Property Spec.
:param client_factory: Factory to get API input specs
:param type: Type of the managed object reference property
:param properties_to_collect: Properties of the managed object reference
to be collected while traversal filtering
:param all_properties: Whether all the properties of managed object
reference needs to be collected
:return: Property spec
"""
if not properties_to_collect:
properties_to_collect = ['name']
property_spec = client_factory.create('ns0:PropertySpec')
property_spec.all = all_properties
property_spec.pathSet = properties_to_collect
property_spec.type = type
return property_spec
def build_object_spec(client_factory, root_folder, traversal_specs):
"""Builds the object Spec.
:param client_factory: Factory to get API input specs
:param root_folder: Root folder reference as the starting point for
traversal
:param traversal_specs: filter specs required for traversal
:return: Object spec
"""
object_spec = client_factory.create('ns0:ObjectSpec')
object_spec.obj = root_folder
object_spec.skip = False
object_spec.selectSet = traversal_specs
return object_spec
def build_property_filter_spec(client_factory, property_specs, object_specs):
"""Builds the Property Filter Spec.
:param client_factory: Factory to get API input specs
:param property_specs: Property specs to be collected for filtered objects
:param object_specs: Object specs to identify objects to be filtered
:return: Property filter spec
"""
property_filter_spec = client_factory.create('ns0:PropertyFilterSpec')
property_filter_spec.propSet = property_specs
property_filter_spec.objectSet = object_specs
return property_filter_spec
def get_objects(vim, type, max_objects, props_to_collect=None,
all_properties=False):
"""Gets all managed object references of a specified type.
It is caller's responsibility to continue or cancel retrieval.
:param vim: Vim object
:param type: Type of the managed object reference
:param max_objects: Maximum number of objects that should be returned in
a single call
:param props_to_collect: Properties of the managed object reference
to be collected
:param all_properties: Whether all properties of the managed object
reference are to be collected
:return: All managed object references of a specified type
"""
if not props_to_collect:
props_to_collect = ['name']
client_factory = vim.client.factory
recur_trav_spec = build_recursive_traversal_spec(client_factory)
object_spec = build_object_spec(client_factory,
vim.service_content.rootFolder,
[recur_trav_spec])
property_spec = build_property_spec(client_factory, type=type,
properties_to_collect=props_to_collect,
all_properties=all_properties)
property_filter_spec = build_property_filter_spec(client_factory,
[property_spec],
[object_spec])
options = client_factory.create('ns0:RetrieveOptions')
options.maxObjects = max_objects
return vim.RetrievePropertiesEx(vim.service_content.propertyCollector,
specSet=[property_filter_spec],
options=options)
def get_object_properties(vim, mobj, properties):
"""Gets properties of the managed object specified.
:param vim: Vim object
:param mobj: Reference to the managed object
:param properties: Properties of the managed object reference
to be retrieved
:return: Properties of the managed object specified
"""
client_factory = vim.client.factory
if mobj is None:
return None
collector = vim.service_content.propertyCollector
property_filter_spec = client_factory.create('ns0:PropertyFilterSpec')
property_spec = client_factory.create('ns0:PropertySpec')
property_spec.all = (properties is None or len(properties) == 0)
property_spec.pathSet = properties
property_spec.type = mobj._type
object_spec = client_factory.create('ns0:ObjectSpec')
object_spec.obj = mobj
object_spec.skip = False
property_filter_spec.propSet = [property_spec]
property_filter_spec.objectSet = [object_spec]
options = client_factory.create('ns0:RetrieveOptions')
options.maxObjects = 1
retrieve_result = vim.RetrievePropertiesEx(collector,
specSet=[property_filter_spec],
options=options)
cancel_retrieval(vim, retrieve_result)
return retrieve_result.objects
def _get_token(retrieve_result):
"""Get token from results to obtain next set of results.
:retrieve_result: Result from the RetrievePropertiesEx API
:return: Token to obtain next set of results. None if no more results.
"""
return getattr(retrieve_result, 'token', None)
def cancel_retrieval(vim, retrieve_result):
"""Cancels the retrieve operation if necessary.
:param vim: Vim object
:param retrieve_result: Result from the RetrievePropertiesEx API
"""
token = _get_token(retrieve_result)
if token:
collector = vim.service_content.propertyCollector
vim.CancelRetrievePropertiesEx(collector, token=token)
def continue_retrieval(vim, retrieve_result):
"""Continue retrieving results, if present.
:param vim: Vim object
:param retrieve_result: Result from the RetrievePropertiesEx API
"""
token = _get_token(retrieve_result)
if token:
collector = vim.service_content.propertyCollector
return vim.ContinueRetrievePropertiesEx(collector, token=token)
def get_object_property(vim, mobj, property_name):
"""Gets property of the managed object specified.
:param vim: Vim object
:param mobj: Reference to the managed object
:param property_name: Name of the property to be retrieved
:return: Property of the managed object specified
"""
props = get_object_properties(vim, mobj, [property_name])
prop_val = None
if props:
prop = None
if hasattr(props[0], 'propSet'):
# propSet will be set only if the server provides value
# for the field
prop = props[0].propSet
if prop:
prop_val = prop[0].val
return prop_val

View File

@ -0,0 +1,372 @@
# Copyright 2014 OpenStack, LLC
# 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.
"""Storage backend for VMware Datastore"""
import hashlib
import httplib
import urllib
import urlparse
import netaddr
from oslo.config import cfg
from glance.common import exception
import glance.openstack.common.log as logging
import glance.store
import glance.store.base
import glance.store.location
from glance.store.vmware import api
LOG = logging.getLogger(__name__)
MAX_REDIRECTS = 5
DEFAULT_STORE_IMAGE_DIR = '/openstack_glance'
DEFAULT_ESX_DATACENTER_PATH = 'ha-datacenter'
DS_URL_PREFIX = '/folder'
# check that datacenter/datastore combination is valid
_datastore_info_valid = False
vmware_opts = [
cfg.StrOpt('vmware_server_host',
help=_('ESX/ESXi or vCenter Server target system. '
'The server value can be an IP address or a DNS name.')),
cfg.StrOpt('vmware_server_username',
help=_('Username for authenticating with '
'VMware ESX/VC server.')),
cfg.StrOpt('vmware_server_password',
help=_('Password for authenticating with '
'VMware ESX/VC server.'),
secret=True),
cfg.StrOpt('vmware_datacenter_path',
default=DEFAULT_ESX_DATACENTER_PATH,
help=_('Inventory path to a datacenter. '
'If the vmware_server_host specified is an ESX/ESXi, '
'the vmware_datacenter_path is optional. If specified, '
'it should be "ha-datacenter".')),
cfg.StrOpt('vmware_datastore_name',
help=_('Datastore associated with the datacenter.')),
cfg.IntOpt('vmware_api_retry_count',
default=10,
help=_('Number of times VMware ESX/VC server API must be '
'retried upon connection related issues.')),
cfg.IntOpt('vmware_task_poll_interval',
default=5,
help=_('The interval used for polling remote tasks '
'invoked on VMware ESX/VC server.')),
cfg.StrOpt('vmware_store_image_dir',
default=DEFAULT_STORE_IMAGE_DIR,
help=_('The name of the directory where the glance images '
'will be stored in the VMware datastore.')),
cfg.BoolOpt('vmware_api_insecure',
default=False,
help=_('Allow to perform insecure SSL requests to ESX/VC')),
]
CONF = cfg.CONF
CONF.register_opts(vmware_opts)
def is_valid_ipv6(address):
try:
return netaddr.valid_ipv6(address)
except Exception:
return False
def http_response_iterator(conn, response, size):
"""Return an iterator for a file-like object.
:param conn: HTTP(S) Connection
:param response: httplib.HTTPResponse object
:param size: Chunk size to iterate with
"""
try:
chunk = response.read(size)
while chunk:
yield chunk
chunk = response.read(size)
finally:
conn.close()
class _Reader(object):
def __init__(self, data, checksum):
self.data = data
self.checksum = checksum
def read(self, len):
result = self.data.read(len)
self.checksum.update(result)
return result
class StoreLocation(glance.store.location.StoreLocation):
"""Class describing an VMware URI.
An VMware URI can look like any of the following:
vsphere://server_host/folder/file_path?dcPath=dc_path&dsName=ds_name
"""
def process_specs(self):
self.scheme = self.specs.get('scheme', 'vsphere')
self.server_host = self.specs.get('server_host')
self.path = (DS_URL_PREFIX + self.specs.get('folder_name')
+ '/' + self.specs.get('image_id'))
dc_path = self.specs.get('datacenter_path')
if dc_path is not None:
param_list = {'dcPath': self.specs.get('datacenter_path'),
'dsName': self.specs.get('datastore_name')}
else:
param_list = {'dsName': self.specs.get('datastore_name')}
self.query = urllib.urlencode(param_list)
def get_uri(self):
if is_valid_ipv6(self.server_host):
base_url = '%s://[%s]%s' % (self.scheme,
self.server_host, self.path)
else:
base_url = '%s://%s%s' % (self.scheme,
self.server_host, self.path)
return base_url + '?' + self.query
def _is_valid_path(self, path):
return path.startswith(DS_URL_PREFIX + CONF.vmware_store_image_dir)
def parse_uri(self, uri):
(self.scheme, self.server_host,
path, params, query, fragment) = urlparse.urlparse(uri)
if not query:
path = path.split('?')
if self._is_valid_path(path[0]):
self.path = path[0]
self.query = path[1]
return
elif self._is_valid_path(path):
self.path = path
self.query = query
return
reason = (_('Badly formed VMware datastore URI %(uri)s.')
% {'uri': uri})
LOG.debug(reason)
raise exception.BadStoreUri(reason)
class Store(glance.store.base.Store):
"""An implementation of the VMware datastore adapter."""
def get_schemes(self):
return ('vsphere',)
def configure(self):
self.scheme = 'vsphere'
self.server_host = self._option_get('vmware_server_host')
self.server_username = self._option_get('vmware_server_username')
self.server_password = self._option_get('vmware_server_password')
self.api_retry_count = CONF.vmware_api_retry_count
self.task_poll_interval = CONF.vmware_task_poll_interval
self.api_insecure = CONF.vmware_api_insecure
self._session = api.VMwareAPISession(self.server_host,
self.server_username,
self.server_password,
self.api_retry_count,
self.task_poll_interval)
self._service_content = self._session.vim.service_content
def configure_add(self):
self.datacenter_path = CONF.vmware_datacenter_path
self.datastore_name = self._option_get('vmware_datastore_name')
global _datastore_info_valid
if not _datastore_info_valid:
search_index_moref = self._service_content.searchIndex
inventory_path = ('%s/datastore/%s'
% (self.datacenter_path, self.datastore_name))
ds_moref = self._session.invoke_api(self._session.vim,
'FindByInventoryPath',
search_index_moref,
inventoryPath=inventory_path)
if ds_moref is None:
reason = (_("Could not find datastore %(ds_name)s "
"in datacenter %(dc_path)s")
% {'ds_name': self.datastore_name,
'dc_path': self.datacenter_path})
raise exception.BadStoreConfiguration(
store_name='vmware_datastore', reason=reason)
else:
ds_validated = True
self.store_image_dir = CONF.vmware_store_image_dir
def _option_get(self, param):
result = getattr(CONF, param)
if not result:
reason = (_("Could not find %(param)s in configuration "
"options.") % {'param': param})
raise exception.BadStoreConfiguration(
store_name='vmware_datastore', reason=reason)
return result
def _build_vim_cookie_header(self, vim_cookies):
"""Build ESX host session cookie header."""
if len(list(vim_cookies)) > 0:
cookie = list(vim_cookies)[0]
return cookie.name + '=' + cookie.value
def add(self, image_id, image_file, image_size):
"""Stores an image file with supplied identifier to the backend
storage system and returns a tuple containing information
about the stored image.
:param image_id: The opaque image identifier
:param image_file: The image data to write, as a file-like object
:param image_size: The size of the image data to write, in bytes
:retval tuple of URL in backing store, bytes written, checksum
and a dictionary with storage system specific information
:raises `glance.common.exception.Duplicate` if the image already
existed
"""
checksum = hashlib.md5()
image_file = _Reader(image_file, checksum)
loc = StoreLocation({'scheme': self.scheme,
'server_host': self.server_host,
'folder_name': self.store_image_dir,
'datacenter_path': self.datacenter_path,
'datastore_name': self.datastore_name,
'image_id': image_id})
cookie = self._build_vim_cookie_header(
self._session.vim.client.options.transport.cookiejar)
headers = {'Cookie': cookie, 'Content-Length': image_size}
conn = self._get_http_conn('PUT', loc, headers,
content=image_file)
res = conn.getresponse()
if res.status == httplib.CONFLICT:
raise exception.Duplicate(_("Image file %(image_id)s already "
"exists!") % {'image_id': image_id})
return (loc.get_uri(), image_size, checksum.hexdigest(), {})
def get(self, location):
"""Takes a `glance.store.location.Location` object that indicates
where to find the image file, and returns a tuple of generator
(for reading the image file) and image_size
:param location: `glance.store.location.Location` object, supplied
from glance.store.location.get_location_from_uri()
"""
cookie = self._build_vim_cookie_header(
self._session.vim.client.options.transport.cookiejar)
conn, resp, content_length = self._query(location,
'GET',
headers={'Cookie': cookie})
iterator = http_response_iterator(conn, resp, self.CHUNKSIZE)
class ResponseIndexable(glance.store.Indexable):
def another(self):
try:
return self.wrapped.next()
except StopIteration:
return ''
return (ResponseIndexable(iterator, content_length), content_length)
def get_size(self, location):
"""Takes a `glance.store.location.Location` object that indicates
where to find the image file, and returns the size
:param location: `glance.store.location.Location` object, supplied
from glance.store.location.get_location_from_uri()
"""
cookie = self._build_vim_cookie_header(
self._session.vim.client.options.transport.cookiejar)
return self._query(location, 'HEAD', headers={'Cookie': cookie})[2]
def delete(self, location):
"""Takes a `glance.store.location.Location` object that indicates
where to find the image file to delete
:location `glance.store.location.Location` object, supplied
from glance.store.location.get_location_from_uri()
:raises NotFound if image does not exist
"""
file_path = '[%s] %s' % (
self.datastore_name,
location.store_location.path[len(DS_URL_PREFIX):])
search_index_moref = self._service_content.searchIndex
dc_moref = self._session.invoke_api(self._session.vim,
'FindByInventoryPath',
search_index_moref,
inventoryPath=self.datacenter_path)
delete_task = self._session.invoke_api(
self._session.vim,
'DeleteDatastoreFile_Task',
self._service_content.fileManager,
name=file_path,
datacenter=dc_moref)
self._session.wait_for_task(delete_task)
def _query(self, location, method, headers, depth=0):
if depth > MAX_REDIRECTS:
msg = (_("The HTTP URL exceeded %(max_redirects)s maximum "
"redirects.") % {'max_redirects': MAX_REDIRECTS})
LOG.debug(msg)
raise exception.MaxRedirectsExceeded(redirects=MAX_REDIRECTS)
loc = location.store_location
conn = self._get_http_conn(method, loc, headers)
resp = conn.getresponse()
if resp.status >= 400:
if resp.status == httplib.NOT_FOUND:
msg = _('VMware datastore could not find image at URI.')
LOG.debug(msg)
raise exception.NotFound(msg)
msg = (_('HTTP URL %(url)s returned a %(status)s status code.')
% {'url': loc.get_uri(), 'status': resp.status})
LOG.debug(msg)
raise exception.BadStoreUri(msg)
location_header = resp.getheader('location')
if location_header:
if resp.status not in (301, 302):
msg = (_("The HTTP URL %(path)s attempted to redirect "
"with an invalid %(status)s status code.")
% {'path': loc.path, 'status': resp.status})
LOG.debug(msg)
raise exception.BadStoreUri(msg)
location_class = glance.store.location.Location
new_loc = location_class(location.store_name,
location.store_location.__class__,
uri=location_header,
image_id=location.image_id,
store_specs=location.store_specs)
return self._query(new_loc, method, depth + 1)
content_length = int(resp.getheader('content-length', 0))
return (conn, resp, content_length)
def _get_http_conn(self, method, loc, headers, content=None):
conn_class = self._get_http_conn_class()
conn = conn_class(loc.server_host)
conn.request(method, '%s?%s' % (loc.path, loc.query), content, headers)
return conn
def _get_http_conn_class(self):
if self.api_insecure:
return httplib.HTTPConnection
return httplib.HTTPSConnection

View File

@ -0,0 +1,138 @@
# Copyright 2014 OpenStack Foundation
# 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.
"""
Functional tests for the VMware Datastore store interface
Set the GLANCE_TEST_VMWARE_CONF environment variable to the location
of a Glance config that defines how to connect to a functional
VMware Datastore backend
"""
import ConfigParser
import httplib
import os
import urllib
import oslo.config.cfg
import testtools
from glance.store.vmware import api
import glance.store.vmware_datastore
import glance.tests.functional.store as store_tests
def read_config(path):
cp = ConfigParser.RawConfigParser()
cp.read(path)
return cp
def parse_config(config):
out = {}
options = [
'vmware_server_host',
'vmware_server_username',
'vmware_server_password',
'vmware_api_retry_count',
'vmware_store_image_dir',
'vmware_datacenter_path',
'vmware_datastore_name',
'vmware_api_insecure',
]
for option in options:
out[option] = config.defaults()[option]
return out
class VMwareDatastoreStoreError(RuntimeError):
pass
def vsphere_connect(server_ip, server_username, server_password,
api_retry_count, scheme='https',
create_session=True, wsdl_loc=None):
try:
return api.VMwareAPISession(server_ip,
server_username,
server_password,
api_retry_count,
scheme=scheme,
create_session=create_session,
wsdl_loc=wsdl_loc)
except AttributeError:
raise VMwareDatastoreStoreError(
'Could not find VMware datastore module')
class TestVMwareDatastoreStore(store_tests.BaseTestCase, testtools.TestCase):
store_cls_path = 'glance.store.vmware_datastore.Store'
store_cls = glance.store.vmware_datastore.Store
store_name = 'vmware_datastore'
def _build_vim_cookie_header(self, vim_cookies):
"""Build ESX host session cookie header."""
if len(list(vim_cookies)) > 0:
cookie = list(vim_cookies)[0]
return cookie.name + '=' + cookie.value
def setUp(self):
config_path = os.environ.get('GLANCE_TEST_VMWARE_CONF')
if not config_path:
msg = 'GLANCE_TEST_VMWARE_CONF environ not set.'
self.skipTest(msg)
oslo.config.cfg.CONF(args=[], default_config_files=[config_path])
raw_config = read_config(config_path)
config = parse_config(raw_config)
scheme = 'http' if config['vmware_api_insecure'] == 'True' else 'https'
self.vsphere = vsphere_connect(config['vmware_server_host'],
config['vmware_server_username'],
config['vmware_server_password'],
config['vmware_api_retry_count'],
scheme=scheme)
self.vmware_config = config
super(TestVMwareDatastoreStore, self).setUp()
def get_store(self, **kwargs):
store = glance.store.vmware_datastore.Store(
context=kwargs.get('context'))
store.configure()
store.configure_add()
return store
def stash_image(self, image_id, image_data):
server_ip = self.vmware_config['vmware_server_host']
path = ('/folder' + self.vmware_config['vmware_store_image_dir']
+ '/' + image_id)
dc_path = self.vmware_config.get('vmware_datacenter_path',
'ha-datacenter')
param_list = {'dcPath': dc_path,
'dsName': self.vmware_config['vmware_datastore_name']}
query = urllib.urlencode(param_list)
conn = (httplib.HTTPConnection(server_ip)
if self.vmware_config['vmware_api_insecure'] == 'True'
else httplib.HTTPSConnection(server_ip))
cookie = self._build_vim_cookie_header(
self.vsphere.vim.client.options.transport.cookiejar)
headers = {'Cookie': cookie, 'Content-Length': len(image_data)}
conn.request('PUT', '%s%s%s' % (path, '?', query), image_data, headers)
conn.getresponse()
return 'vsphere://%s%s?%s' % (server_ip, path, query)

View File

@ -23,6 +23,7 @@ from glance.openstack.common import jsonutils
from glance import store
from glance.store import location
from glance.store import sheepdog
from glance.store import vmware_datastore
from glance.tests import stubs
from glance.tests import utils as test_utils
@ -47,6 +48,8 @@ class StoreClearingUnitTest(test_utils.BaseTestCase):
on collie.
"""
self.stubs.Set(sheepdog.Store, 'configure_add', lambda x: None)
self.stubs.Set(vmware_datastore.Store, 'configure', lambda x: None)
self.stubs.Set(vmware_datastore.Store, 'configure_add', lambda x: None)
store.create_stores()

View File

@ -22,6 +22,7 @@ import glance.store.http
import glance.store.location as location
import glance.store.s3
import glance.store.swift
import glance.store.vmware_datastore
from glance.tests.unit import base
@ -53,6 +54,7 @@ class TestStoreLocation(base.StoreClearingUnitTest):
'rbd://%2F/%2F/%2F/%2F',
'sheepdog://imagename',
'cinder://12345678-9012-3455-6789-012345678901',
'vsphere://ip/folder/openstack_glance/2332298?dcPath=dc&dsName=ds',
]
for uri in good_store_uris:
@ -377,6 +379,27 @@ class TestStoreLocation(base.StoreClearingUnitTest):
bad_uri = 'http://image'
self.assertRaises(exception.BadStoreUri, loc.parse_uri, bad_uri)
def test_vmware_store_location(self):
"""
Test the specific StoreLocation for the VMware store
"""
uri = ('vsphere://127.0.0.1/folder/'
'openstack_glance/29038321?dcPath=my-dc&dsName=my-ds')
loc = glance.store.vmware_datastore.StoreLocation({})
loc.parse_uri(uri)
self.assertEqual("vsphere", loc.scheme)
self.assertEqual("127.0.0.1", loc.server_host)
self.assertEqual("/folder/openstack_glance/29038321", loc.path)
self.assertEqual("dcPath=my-dc&dsName=my-ds", loc.query)
self.assertEqual(uri, loc.get_uri())
bad_uri = 'vphere://'
self.assertRaises(exception.BadStoreUri, loc.parse_uri, bad_uri)
bad_uri = 'http://image'
self.assertRaises(exception.BadStoreUri, loc.parse_uri, bad_uri)
def test_cinder_store_good_location(self):
"""
Test the specific StoreLocation for the Cinder store
@ -412,7 +435,8 @@ class TestStoreLocation(base.StoreClearingUnitTest):
'https': glance.store.http.Store,
'rbd': glance.store.rbd.Store,
'sheepdog': glance.store.sheepdog.Store,
'cinder': glance.store.cinder.Store}
'cinder': glance.store.cinder.Store,
'vsphere': glance.store.vmware_datastore.Store}
ctx = context.RequestContext()
for scheme, store in good_results.items():

View File

@ -0,0 +1,212 @@
# Copyright 2014 OpenStack, LLC
# 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.
"""Tests the VMware Datastore backend store"""
import hashlib
import StringIO
import uuid
import mock
from glance.common import exception
from glance.openstack.common import units
from glance.store.location import get_location_from_uri
from glance.store.vmware_datastore import Store
from glance.tests.unit import base
from glance.tests import utils
FAKE_UUID = str(uuid.uuid4())
FIVE_KB = 5 * units.Ki
VMWARE_DATASTORE_CONF = {
'verbose': True,
'debug': True,
'known_stores': ['glance.store.vmware_datastore.Store'],
'default_store': 'vsphere',
'vmware_server_host': '127.0.0.1',
'vmware_server_username': 'username',
'vmware_server_password': 'password',
'vmware_datacenter_path': 'dc1',
'vmware_datastore_name': 'ds1',
'vmware_store_image_dir': '/openstack_glance',
'vmware_api_insecure': 'True'
}
def format_location(host_ip, folder_name,
image_id, datacenter_path, datastore_name):
"""
Helper method that returns a VMware Datastore store URI given
the component pieces.
"""
scheme = 'vsphere'
return ("%s://%s/folder%s/%s?dsName=%s&dcPath=%s"
% (scheme, host_ip, folder_name,
image_id, datastore_name, datacenter_path))
class FakeHTTPConnection(object):
def __init__(self, status=200, *args, **kwargs):
self.status = status
pass
def getresponse(self):
return utils.FakeHTTPResponse(status=self.status)
def request(self, *_args, **_kwargs):
pass
def close(self):
pass
class TestStore(base.StoreClearingUnitTest):
@mock.patch('glance.store.vmware.api.VMwareAPISession', autospec=True)
def setUp(self, mock_session):
"""Establish a clean test environment"""
super(TestStore, self).setUp()
Store.CHUNKSIZE = 2
self.store = Store()
class FakeSession:
def __init__(self):
self.vim = FakeVim()
class FakeVim:
def __init__(self):
self.client = FakeClient()
class FakeClient:
def __init__(self):
self.options = FakeOptions()
class FakeOptions:
def __init__(self):
self.transport = FakeTransport()
class FakeTransport:
def __init__(self):
self.cookiejar = FakeCookieJar()
class FakeCookieJar:
pass
with mock.patch.object(Store, 'configure') as store:
self.store.scheme = VMWARE_DATASTORE_CONF['default_store']
self.store.server_host = (
VMWARE_DATASTORE_CONF['vmware_server_host'])
self.store.datacenter_path = (
VMWARE_DATASTORE_CONF['vmware_datacenter_path'])
self.store.datastore_name = (
VMWARE_DATASTORE_CONF['vmware_datastore_name'])
self.store.api_insecure = (
VMWARE_DATASTORE_CONF['vmware_api_insecure'])
self.store._session = FakeSession()
self.store._session.invoke_api = mock.Mock()
self.store._session.wait_for_task = mock.Mock()
self.store.store_image_dir = (
VMWARE_DATASTORE_CONF['vmware_store_image_dir'])
Store._build_vim_cookie_header = mock.Mock()
self.addCleanup(self.stubs.UnsetAll)
def test_get(self):
"""Test a "normal" retrieval of an image in chunks"""
expected_image_size = 31
expected_returns = ['I ', 'am', ' a', ' t', 'ea', 'po', 't,', ' s',
'ho', 'rt', ' a', 'nd', ' s', 'to', 'ut', '\n']
loc = get_location_from_uri(
"vsphere://127.0.0.1/folder/openstack_glance/%s"
"?dsName=ds1&dcPath=dc1" % FAKE_UUID)
with mock.patch('httplib.HTTPConnection') as HttpConn:
HttpConn.return_value = FakeHTTPConnection()
(image_file, image_size) = self.store.get(loc)
self.assertEqual(image_size, expected_image_size)
chunks = [c for c in image_file]
self.assertEqual(chunks, expected_returns)
def test_get_non_existing(self):
"""
Test that trying to retrieve an image that doesn't exist
raises an error
"""
loc = get_location_from_uri("vsphere://127.0.0.1/folder/openstack_glan"
"ce/%s?dsName=ds1&dcPath=dc1" % FAKE_UUID)
with mock.patch('httplib.HTTPConnection') as HttpConn:
HttpConn.return_value = FakeHTTPConnection(status=404)
self.assertRaises(exception.NotFound, self.store.get, loc)
def test_add(self):
"""Test that we can add an image via the VMware backend"""
expected_image_id = str(uuid.uuid4())
expected_size = FIVE_KB
expected_contents = "*" * expected_size
hash_code = hashlib.md5(expected_contents)
expected_checksum = hash_code.hexdigest()
hashlib.md5 = mock.Mock(return_value=hash_code)
expected_location = format_location(
VMWARE_DATASTORE_CONF['vmware_server_host'],
VMWARE_DATASTORE_CONF['vmware_store_image_dir'],
expected_image_id,
VMWARE_DATASTORE_CONF['vmware_datacenter_path'],
VMWARE_DATASTORE_CONF['vmware_datastore_name'])
image = StringIO.StringIO(expected_contents)
with mock.patch('httplib.HTTPConnection') as HttpConn:
HttpConn.return_value = FakeHTTPConnection()
location, size, checksum, _ = self.store.add(expected_image_id,
image,
expected_size)
self.assertEqual(expected_location, location)
self.assertEqual(expected_size, size)
self.assertEqual(expected_checksum, checksum)
def test_delete(self):
"""Test we can delete an existing image in the VMware store"""
loc = get_location_from_uri(
"vsphere://127.0.0.1/folder/openstack_glance/%s?"
"dsName=ds1&dcPath=dc1" % FAKE_UUID)
with mock.patch('httplib.HTTPConnection') as HttpConn:
HttpConn.return_value = FakeHTTPConnection()
Store._service_content = mock.Mock()
self.store.delete(loc)
with mock.patch('httplib.HTTPConnection') as HttpConn:
HttpConn.return_value = FakeHTTPConnection(status=404)
self.assertRaises(exception.NotFound, self.store.get, loc)
def test_get_size(self):
"""Test we can get the size of an existing image in the VMware store"""
loc = get_location_from_uri(
"vsphere://127.0.0.1/folder/openstack_glance/%s"
"?dsName=ds1&dcPath=dc1" % FAKE_UUID)
with mock.patch('httplib.HTTPConnection') as HttpConn:
HttpConn.return_value = FakeHTTPConnection()
image_size = self.store.get_size(loc)
self.assertEqual(image_size, 31)
def test_get_size_non_existing(self):
"""
Test that trying to retrieve an image size that doesn't exist
raises an error
"""
loc = get_location_from_uri("vsphere://127.0.0.1/folder/openstack_glan"
"ce/%s?dsName=ds1&dcPath=dc1" % FAKE_UUID)
with mock.patch('httplib.HTTPConnection') as HttpConn:
HttpConn.return_value = FakeHTTPConnection(status=404)
self.assertRaises(exception.NotFound, self.store.get_size, loc)

View File

@ -22,6 +22,7 @@ iso8601>=0.1.8
ordereddict
oslo.config>=1.2.0
stevedore>=0.12
suds>=0.4
# For Swift storage backend.
python-swiftclient>=1.5