Continue refactoring of the image
Next refactoring monster with: - switch bunch of cloud._image methods to using image.proxy methods - Add new image.Image attribute - Fix image properties by adding global support for keeping "unknown" attributes under properties attr - turn back function lost in openstackcloud split (sadly not found by unittests) - add ability to create image without data required by OSC to switch to SDK - use the proxy logger Change-Id: I9d36d3a52370d6a1040362e1d6e762146df86258
This commit is contained in:
parent
f0c56488cb
commit
1e810595c6
|
@ -15,13 +15,9 @@
|
|||
# openstack.resource.Resource.list and openstack.resource2.Resource.list
|
||||
import types # noqa
|
||||
|
||||
import keystoneauth1.exceptions
|
||||
|
||||
from openstack.cloud import exc
|
||||
from openstack.cloud import meta
|
||||
from openstack.cloud import _normalize
|
||||
from openstack.cloud import _utils
|
||||
from openstack import proxy
|
||||
from openstack import utils
|
||||
|
||||
|
||||
|
@ -73,35 +69,10 @@ class ImageCloudMixin(_normalize.Normalizer):
|
|||
images = []
|
||||
params = {}
|
||||
image_list = []
|
||||
try:
|
||||
if self._is_client_version('image', 2):
|
||||
endpoint = '/images'
|
||||
if show_all:
|
||||
params['member_status'] = 'all'
|
||||
else:
|
||||
endpoint = '/images/detail'
|
||||
|
||||
response = self._image_client.get(endpoint, params=params)
|
||||
|
||||
except keystoneauth1.exceptions.catalog.EndpointNotFound:
|
||||
# We didn't have glance, let's try nova
|
||||
# If this doesn't work - we just let the exception propagate
|
||||
response = proxy._json_response(
|
||||
self.compute.get('/images/detail'))
|
||||
while 'next' in response:
|
||||
image_list.extend(meta.obj_list_to_munch(response['images']))
|
||||
endpoint = response['next']
|
||||
# next links from glance have the version prefix. If the catalog
|
||||
# has a versioned endpoint, then we can't append the next link to
|
||||
# it. Strip the absolute prefix (/v1/ or /v2/ to turn it into
|
||||
# a proper relative link.
|
||||
if endpoint.startswith('/v'):
|
||||
endpoint = endpoint[4:]
|
||||
response = self._image_client.get(endpoint)
|
||||
if 'images' in response:
|
||||
image_list.extend(meta.obj_list_to_munch(response['images']))
|
||||
else:
|
||||
image_list.extend(response)
|
||||
if self._is_client_version('image', 2):
|
||||
if show_all:
|
||||
params['member_status'] = 'all'
|
||||
image_list = list(self.image.images(**params))
|
||||
|
||||
for image in image_list:
|
||||
# The cloud might return DELETED for invalid images.
|
||||
|
@ -143,13 +114,8 @@ class ImageCloudMixin(_normalize.Normalizer):
|
|||
:param id: ID of the image.
|
||||
:returns: An image ``munch.Munch``.
|
||||
"""
|
||||
data = self._image_client.get(
|
||||
'/images/{id}'.format(id=id),
|
||||
error_message="Error getting image with ID {id}".format(id=id)
|
||||
)
|
||||
key = 'image' if 'image' in data else None
|
||||
image = self._normalize_image(
|
||||
self._get_and_munchify(key, data))
|
||||
self.image.get_image(image={'id': id}))
|
||||
|
||||
return image
|
||||
|
||||
|
@ -181,27 +147,14 @@ class ImageCloudMixin(_normalize.Normalizer):
|
|||
'Both an output path and file object were provided,'
|
||||
' however only one can be used at once')
|
||||
|
||||
image = self.search_images(name_or_id)
|
||||
if len(image) == 0:
|
||||
image = self.image.find_image(name_or_id)
|
||||
if not image:
|
||||
raise exc.OpenStackCloudResourceNotFound(
|
||||
"No images with name or ID %s were found" % name_or_id, None)
|
||||
if self._is_client_version('image', 2):
|
||||
endpoint = '/images/{id}/file'.format(id=image[0]['id'])
|
||||
else:
|
||||
endpoint = '/images/{id}'.format(id=image[0]['id'])
|
||||
|
||||
response = self._image_client.get(endpoint, stream=True)
|
||||
|
||||
with _utils.shade_exceptions("Unable to download image"):
|
||||
if output_path:
|
||||
with open(output_path, 'wb') as fd:
|
||||
for chunk in response.iter_content(chunk_size=chunk_size):
|
||||
fd.write(chunk)
|
||||
return
|
||||
elif output_file:
|
||||
for chunk in response.iter_content(chunk_size=chunk_size):
|
||||
output_file.write(chunk)
|
||||
return
|
||||
return self.image.download_image(
|
||||
image, output=output_file or output_path,
|
||||
chunk_size=chunk_size)
|
||||
|
||||
def get_image_exclude(self, name_or_id, exclude):
|
||||
for image in self.search_images(name_or_id):
|
||||
|
@ -254,12 +207,11 @@ class ImageCloudMixin(_normalize.Normalizer):
|
|||
image = self.get_image(name_or_id)
|
||||
if not image:
|
||||
return False
|
||||
self._image_client.delete(
|
||||
'/images/{id}'.format(id=image.id),
|
||||
error_message="Error in deleting image")
|
||||
self.image.delete_image(image)
|
||||
self.list_images.invalidate(self)
|
||||
|
||||
# Task API means an image was uploaded to swift
|
||||
# TODO(gtema) does it make sense to move this into proxy?
|
||||
if self.image_api_use_tasks and (
|
||||
self._IMAGE_OBJECT_KEY in image
|
||||
or self._SHADE_IMAGE_OBJECT_KEY in image):
|
||||
|
|
|
@ -269,11 +269,18 @@ class Normalizer(object):
|
|||
return ret
|
||||
|
||||
def _normalize_image(self, image):
|
||||
new_image = munch.Munch(
|
||||
location=self._get_current_location(project_id=image.get('owner')))
|
||||
if isinstance(image, resource.Resource):
|
||||
image = image.to_dict(ignore_none=True, original_names=True)
|
||||
location = image.pop(
|
||||
'location',
|
||||
self._get_current_location(project_id=image.get('owner')))
|
||||
else:
|
||||
location = self._get_current_location(
|
||||
project_id=image.get('owner'))
|
||||
# This copy is to keep things from getting epically weird in tests
|
||||
image = image.copy()
|
||||
|
||||
# This copy is to keep things from getting epically weird in tests
|
||||
image = image.copy()
|
||||
new_image = munch.Munch(location=location)
|
||||
|
||||
# Discard noise
|
||||
self._remove_novaclient_artifacts(image)
|
||||
|
@ -321,7 +328,7 @@ class Normalizer(object):
|
|||
new_image['is_protected'] = protected
|
||||
new_image['locations'] = image.pop('locations', [])
|
||||
|
||||
metadata = image.pop('metadata', {})
|
||||
metadata = image.pop('metadata', {}) or {}
|
||||
for key, val in metadata.items():
|
||||
properties.setdefault(key, val)
|
||||
|
||||
|
|
|
@ -64,21 +64,12 @@ class _OpenStackCloudMixin(object):
|
|||
_OBJECT_AUTOCREATE_KEY = 'x-object-meta-x-sdk-autocreated'
|
||||
_OBJECT_AUTOCREATE_CONTAINER = 'images'
|
||||
|
||||
_IMAGE_MD5_KEY = 'owner_specified.openstack.md5'
|
||||
_IMAGE_SHA256_KEY = 'owner_specified.openstack.sha256'
|
||||
_IMAGE_OBJECT_KEY = 'owner_specified.openstack.object'
|
||||
# NOTE(shade) shade keys were x-object-meta-x-shade-md5 - we need to check
|
||||
# those in freshness checks so that a shade->sdk transition
|
||||
# doesn't result in a re-upload
|
||||
_SHADE_OBJECT_MD5_KEY = 'x-object-meta-x-shade-md5'
|
||||
_SHADE_OBJECT_SHA256_KEY = 'x-object-meta-x-shade-sha256'
|
||||
_SHADE_OBJECT_AUTOCREATE_KEY = 'x-object-meta-x-shade-autocreated'
|
||||
# NOTE(shade) shade keys were owner_specified.shade.md5 - we need to add
|
||||
# those to freshness checks so that a shade->sdk transition
|
||||
# doesn't result in a re-upload
|
||||
_SHADE_IMAGE_MD5_KEY = 'owner_specified.shade.md5'
|
||||
_SHADE_IMAGE_SHA256_KEY = 'owner_specified.shade.sha256'
|
||||
_SHADE_IMAGE_OBJECT_KEY = 'owner_specified.shade.object'
|
||||
|
||||
def __init__(self):
|
||||
|
||||
|
|
|
@ -10,6 +10,7 @@
|
|||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
import abc
|
||||
import os
|
||||
|
||||
import six
|
||||
|
||||
|
@ -20,6 +21,17 @@ class BaseImageProxy(six.with_metaclass(abc.ABCMeta, proxy.Proxy)):
|
|||
|
||||
retriable_status_codes = [503]
|
||||
|
||||
_IMAGE_MD5_KEY = 'owner_specified.openstack.md5'
|
||||
_IMAGE_SHA256_KEY = 'owner_specified.openstack.sha256'
|
||||
_IMAGE_OBJECT_KEY = 'owner_specified.openstack.object'
|
||||
|
||||
# NOTE(shade) shade keys were owner_specified.shade.md5 - we need to add
|
||||
# those to freshness checks so that a shade->sdk transition
|
||||
# doesn't result in a re-upload
|
||||
_SHADE_IMAGE_MD5_KEY = 'owner_specified.shade.md5'
|
||||
_SHADE_IMAGE_SHA256_KEY = 'owner_specified.shade.sha256'
|
||||
_SHADE_IMAGE_OBJECT_KEY = 'owner_specified.shade.object'
|
||||
|
||||
def create_image(
|
||||
self, name, filename=None,
|
||||
container=None,
|
||||
|
@ -28,42 +40,42 @@ class BaseImageProxy(six.with_metaclass(abc.ABCMeta, proxy.Proxy)):
|
|||
disable_vendor_agent=True,
|
||||
allow_duplicates=False, meta=None,
|
||||
wait=False, timeout=3600,
|
||||
validate_checksum=True,
|
||||
**kwargs):
|
||||
"""Upload an image.
|
||||
|
||||
:param str name: Name of the image to create. If it is a pathname
|
||||
of an image, the name will be constructed from the
|
||||
extensionless basename of the path.
|
||||
of an image, the name will be constructed from the extensionless
|
||||
basename of the path.
|
||||
:param str filename: The path to the file to upload, if needed.
|
||||
(optional, defaults to None)
|
||||
(optional, defaults to None)
|
||||
:param str container: Name of the container in swift where images
|
||||
should be uploaded for import if the cloud
|
||||
requires such a thing. (optiona, defaults to
|
||||
'images')
|
||||
should be uploaded for import if the cloud requires such a thing.
|
||||
(optional, defaults to 'images')
|
||||
:param str md5: md5 sum of the image file. If not given, an md5 will
|
||||
be calculated.
|
||||
be calculated.
|
||||
:param str sha256: sha256 sum of the image file. If not given, an md5
|
||||
will be calculated.
|
||||
will be calculated.
|
||||
:param str disk_format: The disk format the image is in. (optional,
|
||||
defaults to the os-client-config config value
|
||||
for this cloud)
|
||||
defaults to the os-client-config config value for this cloud)
|
||||
:param str container_format: The container format the image is in.
|
||||
(optional, defaults to the
|
||||
os-client-config config value for this
|
||||
cloud)
|
||||
(optional, defaults to the os-client-config config value for this
|
||||
cloud)
|
||||
:param bool disable_vendor_agent: Whether or not to append metadata
|
||||
flags to the image to inform the
|
||||
cloud in question to not expect a
|
||||
vendor agent to be runing.
|
||||
(optional, defaults to True)
|
||||
flags to the image to inform the cloud in question to not expect a
|
||||
vendor agent to be runing. (optional, defaults to True)
|
||||
:param allow_duplicates: If true, skips checks that enforce unique
|
||||
image name. (optional, defaults to False)
|
||||
image name. (optional, defaults to False)
|
||||
:param meta: A dict of key/value pairs to use for metadata that
|
||||
bypasses automatic type conversion.
|
||||
bypasses automatic type conversion.
|
||||
:param bool wait: If true, waits for image to be created. Defaults to
|
||||
true - however, be aware that one of the upload
|
||||
methods is always synchronous.
|
||||
true - however, be aware that one of the upload methods is always
|
||||
synchronous.
|
||||
:param timeout: Seconds to wait for image creation. None is forever.
|
||||
:param bool validate_checksum: If true and cloud returns checksum,
|
||||
compares return value with the one calculated or passed into this
|
||||
call. If value does not match - raises exception. Default is
|
||||
'false'
|
||||
|
||||
Additional kwargs will be passed to the image creation as additional
|
||||
metadata for the image and will have all values converted to string
|
||||
|
@ -78,7 +90,7 @@ class BaseImageProxy(six.with_metaclass(abc.ABCMeta, proxy.Proxy)):
|
|||
|
||||
:returns: A ``munch.Munch`` of the Image object
|
||||
|
||||
:raises: OpenStackCloudException if there are problems uploading
|
||||
:raises: SDKException if there are problems uploading
|
||||
"""
|
||||
if container is None:
|
||||
container = self._connection._OBJECT_AUTOCREATE_CONTAINER
|
||||
|
@ -93,7 +105,8 @@ class BaseImageProxy(six.with_metaclass(abc.ABCMeta, proxy.Proxy)):
|
|||
|
||||
# If there is no filename, see if name is actually the filename
|
||||
if not filename:
|
||||
name, filename = self._connection._get_name_and_filename(name)
|
||||
name, filename = self._get_name_and_filename(
|
||||
name, self._connection.config.config['image_format'])
|
||||
if not (md5 or sha256):
|
||||
(md5, sha256) = self._connection._get_file_hashes(filename)
|
||||
if allow_duplicates:
|
||||
|
@ -102,25 +115,19 @@ class BaseImageProxy(six.with_metaclass(abc.ABCMeta, proxy.Proxy)):
|
|||
current_image = self._connection.get_image(name)
|
||||
if current_image:
|
||||
md5_key = current_image.get(
|
||||
self._connection._IMAGE_MD5_KEY,
|
||||
current_image.get(
|
||||
self._connection._SHADE_IMAGE_MD5_KEY, ''))
|
||||
self._IMAGE_MD5_KEY,
|
||||
current_image.get(self._SHADE_IMAGE_MD5_KEY, ''))
|
||||
sha256_key = current_image.get(
|
||||
self._connection._IMAGE_SHA256_KEY,
|
||||
current_image.get(
|
||||
self._connection._SHADE_IMAGE_SHA256_KEY, ''))
|
||||
self._IMAGE_SHA256_KEY,
|
||||
current_image.get(self._SHADE_IMAGE_SHA256_KEY, ''))
|
||||
up_to_date = self._connection._hashes_up_to_date(
|
||||
md5=md5, sha256=sha256,
|
||||
md5_key=md5_key, sha256_key=sha256_key)
|
||||
if up_to_date:
|
||||
self._connection.log.debug(
|
||||
self.log.debug(
|
||||
"image %(name)s exists and is up to date",
|
||||
{'name': name})
|
||||
return current_image
|
||||
kwargs[self._connection._IMAGE_MD5_KEY] = md5 or ''
|
||||
kwargs[self._connection._IMAGE_SHA256_KEY] = sha256 or ''
|
||||
kwargs[self._connection._IMAGE_OBJECT_KEY] = '/'.join(
|
||||
[container, name])
|
||||
|
||||
if disable_vendor_agent:
|
||||
kwargs.update(
|
||||
|
@ -129,6 +136,10 @@ class BaseImageProxy(six.with_metaclass(abc.ABCMeta, proxy.Proxy)):
|
|||
# If a user used the v1 calling format, they will have
|
||||
# passed a dict called properties along
|
||||
properties = kwargs.pop('properties', {})
|
||||
properties[self._IMAGE_MD5_KEY] = md5 or ''
|
||||
properties[self._IMAGE_SHA256_KEY] = sha256 or ''
|
||||
properties[self._IMAGE_OBJECT_KEY] = '/'.join(
|
||||
[container, name])
|
||||
kwargs.update(properties)
|
||||
image_kwargs = dict(properties=kwargs)
|
||||
if disk_format:
|
||||
|
@ -136,15 +147,25 @@ class BaseImageProxy(six.with_metaclass(abc.ABCMeta, proxy.Proxy)):
|
|||
if container_format:
|
||||
image_kwargs['container_format'] = container_format
|
||||
|
||||
image = self._upload_image(
|
||||
name, filename,
|
||||
wait=wait, timeout=timeout,
|
||||
meta=meta, **image_kwargs)
|
||||
if filename:
|
||||
image = self._upload_image(
|
||||
name, filename=filename, meta=meta,
|
||||
wait=wait, timeout=timeout,
|
||||
validate_checksum=validate_checksum,
|
||||
**image_kwargs)
|
||||
else:
|
||||
image = self._create_image(**image_kwargs)
|
||||
self._connection._get_cache(None).invalidate()
|
||||
return image
|
||||
|
||||
@abc.abstractmethod
|
||||
def _upload_image(self, name, filename, meta, **image_kwargs):
|
||||
def _create_image(self, name, **image_kwargs):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _upload_image(self, name, filename, meta, wait, timeout,
|
||||
validate_checksum=True,
|
||||
**image_kwargs):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
|
@ -180,3 +201,16 @@ class BaseImageProxy(six.with_metaclass(abc.ABCMeta, proxy.Proxy)):
|
|||
img_props[k] = v
|
||||
|
||||
return self._update_image_properties(image, meta, img_props)
|
||||
|
||||
def _get_name_and_filename(self, name, image_format):
|
||||
# See if name points to an existing file
|
||||
if os.path.exists(name):
|
||||
# Neat. Easy enough
|
||||
return (os.path.splitext(os.path.basename(name))[0], name)
|
||||
|
||||
# Try appending the disk format
|
||||
name_with_ext = '.'.join((name, image_format))
|
||||
if os.path.exists(name_with_ext):
|
||||
return (os.path.basename(name), name_with_ext)
|
||||
|
||||
return (name, None)
|
||||
|
|
|
@ -18,6 +18,11 @@ from openstack.image.v1 import image as _image
|
|||
|
||||
class Proxy(_base_proxy.BaseImageProxy):
|
||||
|
||||
def _create_image(self, **kwargs):
|
||||
"""Create image resource from attributes
|
||||
"""
|
||||
return self._create(_image.Image, **kwargs)
|
||||
|
||||
def upload_image(self, **attrs):
|
||||
"""Upload a new image from attributes
|
||||
|
||||
|
@ -48,8 +53,7 @@ class Proxy(_base_proxy.BaseImageProxy):
|
|||
image = self._connection._get_and_munchify(
|
||||
'image',
|
||||
self.post('/images', json=image_kwargs))
|
||||
checksum = image_kwargs['properties'].get(
|
||||
self._connection._IMAGE_MD5_KEY, '')
|
||||
checksum = image_kwargs['properties'].get(self._IMAGE_MD5_KEY, '')
|
||||
|
||||
try:
|
||||
# Let us all take a brief moment to be grateful that this
|
||||
|
@ -67,13 +71,13 @@ class Proxy(_base_proxy.BaseImageProxy):
|
|||
headers=headers, data=image_data))
|
||||
|
||||
except exc.OpenStackCloudHTTPError:
|
||||
self._connection.log.debug(
|
||||
self.log.debug(
|
||||
"Deleting failed upload of image %s", name)
|
||||
try:
|
||||
self.delete('/images/{id}'.format(id=image.id))
|
||||
except exc.OpenStackCloudHTTPError:
|
||||
# We're just trying to clean up - if it doesn't work - shrug
|
||||
self._connection.log.warning(
|
||||
self.log.warning(
|
||||
"Failed deleting image after we failed uploading it.",
|
||||
exc_info=True)
|
||||
raise
|
||||
|
@ -145,7 +149,7 @@ class Proxy(_base_proxy.BaseImageProxy):
|
|||
:returns: A generator of image objects
|
||||
:rtype: :class:`~openstack.image.v1.image.Image`
|
||||
"""
|
||||
return self._list(_image.Image, **query)
|
||||
return self._list(_image.Image, base_path='/images/detail', **query)
|
||||
|
||||
def update_image(self, image, **attrs):
|
||||
"""Update a image
|
||||
|
@ -159,3 +163,44 @@ class Proxy(_base_proxy.BaseImageProxy):
|
|||
:rtype: :class:`~openstack.image.v1.image.Image`
|
||||
"""
|
||||
return self._update(_image.Image, image, **attrs)
|
||||
|
||||
def download_image(self, image, stream=False, output=None,
|
||||
chunk_size=1024):
|
||||
"""Download an image
|
||||
|
||||
This will download an image to memory when ``stream=False``, or allow
|
||||
streaming downloads using an iterator when ``stream=True``.
|
||||
For examples of working with streamed responses, see
|
||||
:ref:`download_image-stream-true`.
|
||||
|
||||
:param image: The value can be either the ID of an image or a
|
||||
:class:`~openstack.image.v2.image.Image` instance.
|
||||
|
||||
:param bool stream: When ``True``, return a :class:`requests.Response`
|
||||
instance allowing you to iterate over the
|
||||
response data stream instead of storing its entire
|
||||
contents in memory. See
|
||||
:meth:`requests.Response.iter_content` for more
|
||||
details. *NOTE*: If you do not consume
|
||||
the entirety of the response you must explicitly
|
||||
call :meth:`requests.Response.close` or otherwise
|
||||
risk inefficiencies with the ``requests``
|
||||
library's handling of connections.
|
||||
|
||||
|
||||
When ``False``, return the entire
|
||||
contents of the response.
|
||||
:param output: Either a file object or a path to store data into.
|
||||
:param int chunk_size: size in bytes to read from the wire and buffer
|
||||
at one time. Defaults to 1024
|
||||
|
||||
:returns: When output is not given - the bytes comprising the given
|
||||
Image when stream is False, otherwise a :class:`requests.Response`
|
||||
instance. When output is given - a
|
||||
:class:`~openstack.image.v2.image.Image` instance.
|
||||
"""
|
||||
|
||||
image = self._get_resource(_image.Image, image)
|
||||
|
||||
return image.download(
|
||||
self, stream=stream, output=output, chunk_size=chunk_size)
|
||||
|
|
|
@ -9,8 +9,13 @@
|
|||
# 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 hashlib
|
||||
import io
|
||||
import six
|
||||
|
||||
from openstack import exceptions
|
||||
from openstack import resource
|
||||
from openstack import utils
|
||||
|
||||
|
||||
class Image(resource.Resource):
|
||||
|
@ -25,6 +30,10 @@ class Image(resource.Resource):
|
|||
allow_delete = True
|
||||
allow_list = True
|
||||
|
||||
# Store all unknown attributes under 'properties' in the object.
|
||||
# Remotely they would be still in the resource root
|
||||
_store_unknown_attrs_as_properties = True
|
||||
|
||||
#: Hash of the image data used. The Image service uses this value
|
||||
#: for verification.
|
||||
checksum = resource.Body('checksum')
|
||||
|
@ -69,3 +78,58 @@ class Image(resource.Resource):
|
|||
status = resource.Body('status')
|
||||
#: The timestamp when this image was last updated.
|
||||
updated_at = resource.Body('updated_at')
|
||||
|
||||
def download(self, session, stream=False, output=None, chunk_size=1024):
|
||||
"""Download the data contained in an image"""
|
||||
# TODO(briancurtin): This method should probably offload the get
|
||||
# operation into another thread or something of that nature.
|
||||
url = utils.urljoin(self.base_path, self.id, 'file')
|
||||
resp = session.get(url, stream=stream)
|
||||
|
||||
# See the following bug report for details on why the checksum
|
||||
# code may sometimes depend on a second GET call.
|
||||
# https://storyboard.openstack.org/#!/story/1619675
|
||||
checksum = resp.headers.get("Content-MD5")
|
||||
|
||||
if checksum is None:
|
||||
# If we don't receive the Content-MD5 header with the download,
|
||||
# make an additional call to get the image details and look at
|
||||
# the checksum attribute.
|
||||
details = self.fetch(session)
|
||||
checksum = details.checksum
|
||||
|
||||
if output:
|
||||
try:
|
||||
# In python 2 we might get StringIO - delete it as soon as
|
||||
# py2 support is dropped
|
||||
if isinstance(output, io.IOBase) \
|
||||
or isinstance(output, six.StringIO):
|
||||
for chunk in resp.iter_content(chunk_size=chunk_size):
|
||||
output.write(chunk)
|
||||
else:
|
||||
with open(output, 'wb') as fd:
|
||||
for chunk in resp.iter_content(
|
||||
chunk_size=chunk_size):
|
||||
fd.write(chunk)
|
||||
return resp
|
||||
except Exception as e:
|
||||
raise exceptions.SDKException(
|
||||
"Unable to download image: %s" % e)
|
||||
# if we are returning the repsonse object, ensure that it
|
||||
# has the content-md5 header so that the caller doesn't
|
||||
# need to jump through the same hoops through which we
|
||||
# just jumped.
|
||||
if stream:
|
||||
resp.headers['content-md5'] = checksum
|
||||
return resp
|
||||
|
||||
if checksum is not None:
|
||||
digest = hashlib.md5(resp.content).hexdigest()
|
||||
if digest != checksum:
|
||||
raise exceptions.InvalidResponse(
|
||||
"checksum mismatch: %s != %s" % (checksum, digest))
|
||||
else:
|
||||
session.log.warn(
|
||||
"Unable to verify the integrity of image %s" % (self.id))
|
||||
|
||||
return resp
|
||||
|
|
|
@ -10,13 +10,9 @@
|
|||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
import json
|
||||
import jsonpatch
|
||||
import operator
|
||||
import time
|
||||
import warnings
|
||||
|
||||
from openstack.cloud import exc
|
||||
from openstack import exceptions
|
||||
from openstack.image import _base_proxy
|
||||
from openstack.image.v2 import image as _image
|
||||
|
@ -34,6 +30,11 @@ _INT_PROPERTIES = ('min_disk', 'min_ram', 'size', 'virtual_size')
|
|||
|
||||
class Proxy(_base_proxy.BaseImageProxy):
|
||||
|
||||
def _create_image(self, **kwargs):
|
||||
"""Create image resource from attributes
|
||||
"""
|
||||
return self._create(_image.Image, **kwargs)
|
||||
|
||||
def import_image(self, image, method='glance-direct', uri=None):
|
||||
"""Import data to an existing image
|
||||
|
||||
|
@ -73,14 +74,13 @@ class Proxy(_base_proxy.BaseImageProxy):
|
|||
`create_image`.
|
||||
|
||||
:param container_format: Format of the container.
|
||||
A valid value is ami, ari, aki, bare,
|
||||
ovf, ova, or docker.
|
||||
A valid value is ami, ari, aki, bare, ovf, ova, or docker.
|
||||
:param disk_format: The format of the disk. A valid value is ami,
|
||||
ari, aki, vhd, vmdk, raw, qcow2, vdi, or iso.
|
||||
ari, aki, vhd, vmdk, raw, qcow2, vdi, or iso.
|
||||
:param data: The data to be uploaded as an image.
|
||||
:param dict attrs: Keyword arguments which will be used to create
|
||||
a :class:`~openstack.image.v2.image.Image`,
|
||||
comprised of the properties on the Image class.
|
||||
a :class:`~openstack.image.v2.image.Image`, comprised of the
|
||||
properties on the Image class.
|
||||
|
||||
:returns: The results of image creation
|
||||
:rtype: :class:`~openstack.image.v2.image.Image`
|
||||
|
@ -110,9 +110,9 @@ class Proxy(_base_proxy.BaseImageProxy):
|
|||
|
||||
return img
|
||||
|
||||
def _upload_image(
|
||||
self, name, filename=None,
|
||||
meta=None, **kwargs):
|
||||
def _upload_image(self, name, filename=None, meta=None,
|
||||
wait=False, timeout=None, validate_checksum=True,
|
||||
**kwargs):
|
||||
# We can never have nice things. Glance v1 took "is_public" as a
|
||||
# boolean. Glance v2 takes "visibility". If the user gives us
|
||||
# is_public, we know what they mean. If they give us visibility, they
|
||||
|
@ -128,17 +128,18 @@ class Proxy(_base_proxy.BaseImageProxy):
|
|||
# This makes me want to die inside
|
||||
if self._connection.image_api_use_tasks:
|
||||
return self._upload_image_task(
|
||||
name, filename,
|
||||
meta=meta, **kwargs)
|
||||
name, filename, meta=meta,
|
||||
wait=wait, timeout=timeout, **kwargs)
|
||||
else:
|
||||
return self._upload_image_put(
|
||||
name, filename, meta=meta,
|
||||
validate_checksum=validate_checksum,
|
||||
**kwargs)
|
||||
except exc.OpenStackCloudException:
|
||||
self._connection.log.debug("Image creation failed", exc_info=True)
|
||||
except exceptions.SDKException:
|
||||
self.log.debug("Image creation failed", exc_info=True)
|
||||
raise
|
||||
except Exception as e:
|
||||
raise exc.OpenStackCloudException(
|
||||
raise exceptions.SDKException(
|
||||
"Image creation failed: {message}".format(message=str(e)))
|
||||
|
||||
def _make_v2_image_params(self, meta, properties):
|
||||
|
@ -157,7 +158,7 @@ class Proxy(_base_proxy.BaseImageProxy):
|
|||
return ret
|
||||
|
||||
def _upload_image_put(
|
||||
self, name, filename, meta, wait, timeout, **image_kwargs):
|
||||
self, name, filename, meta, validate_checksum, **image_kwargs):
|
||||
image_data = open(filename, 'rb')
|
||||
|
||||
properties = image_kwargs.pop('properties', {})
|
||||
|
@ -165,46 +166,46 @@ class Proxy(_base_proxy.BaseImageProxy):
|
|||
image_kwargs.update(self._make_v2_image_params(meta, properties))
|
||||
image_kwargs['name'] = name
|
||||
|
||||
data = self.post('/images', json=image_kwargs)
|
||||
image = self._connection._get_and_munchify(key=None, data=data)
|
||||
image = self._create(_image.Image, **image_kwargs)
|
||||
|
||||
image.data = image_data
|
||||
|
||||
try:
|
||||
response = self.put(
|
||||
'/images/{id}/file'.format(id=image.id),
|
||||
headers={'Content-Type': 'application/octet-stream'},
|
||||
data=image_data)
|
||||
response = image.upload(self)
|
||||
exceptions.raise_from_response(response)
|
||||
# image_kwargs are flat here
|
||||
md5 = image_kwargs.get(self._IMAGE_MD5_KEY)
|
||||
sha256 = image_kwargs.get(self._IMAGE_SHA256_KEY)
|
||||
if validate_checksum and (md5 or sha256):
|
||||
# Verify that the hash computed remotely matches the local
|
||||
# value
|
||||
data = image.fetch(self)
|
||||
checksum = data.get('checksum')
|
||||
if checksum:
|
||||
valid = (checksum == md5 or checksum == sha256)
|
||||
if not valid:
|
||||
raise Exception('Image checksum verification failed')
|
||||
except Exception:
|
||||
self._connection.log.debug(
|
||||
self.log.debug(
|
||||
"Deleting failed upload of image %s", name)
|
||||
try:
|
||||
response = self.delete(
|
||||
'/images/{id}'.format(id=image.id))
|
||||
exceptions.raise_from_response(response)
|
||||
except exc.OpenStackCloudHTTPError:
|
||||
# We're just trying to clean up - if it doesn't work - shrug
|
||||
self._connection.log.warning(
|
||||
"Failed deleting image after we failed uploading it.",
|
||||
exc_info=True)
|
||||
self.delete_image(image.id)
|
||||
raise
|
||||
|
||||
return self._connection._normalize_image(image)
|
||||
return image
|
||||
|
||||
def _upload_image_task(
|
||||
self, name, filename,
|
||||
wait, timeout, meta, **image_kwargs):
|
||||
|
||||
if not self._connection.has_service('object-store'):
|
||||
raise exc.OpenStackCloudException(
|
||||
raise exceptions.SDKException(
|
||||
"The cloud {cloud} is configured to use tasks for image"
|
||||
" upload, but no object-store service is available."
|
||||
" Aborting.".format(cloud=self._connection.config.name))
|
||||
properties = image_kwargs.pop('properties', {})
|
||||
md5 = properties[self._connection._IMAGE_MD5_KEY]
|
||||
sha256 = properties[self._connection._IMAGE_SHA256_KEY]
|
||||
container = properties[
|
||||
self._connection._IMAGE_OBJECT_KEY].split('/', 1)[0]
|
||||
image_kwargs.update(properties)
|
||||
properties = image_kwargs.get('properties', {})
|
||||
md5 = properties[self._IMAGE_MD5_KEY]
|
||||
sha256 = properties[self._IMAGE_SHA256_KEY]
|
||||
container = properties[self._IMAGE_OBJECT_KEY].split('/', 1)[0]
|
||||
image_kwargs.pop('disk_format', None)
|
||||
image_kwargs.pop('container_format', None)
|
||||
|
||||
|
@ -222,70 +223,61 @@ class Proxy(_base_proxy.BaseImageProxy):
|
|||
import_from='{container}/{name}'.format(
|
||||
container=container, name=name),
|
||||
image_properties=dict(name=name)))
|
||||
data = self.post('/tasks', json=task_args)
|
||||
glance_task = self._connection._get_and_munchify(key=None, data=data)
|
||||
|
||||
glance_task = self.create_task(**task_args)
|
||||
self._connection.list_images.invalidate(self)
|
||||
if wait:
|
||||
start = time.time()
|
||||
image_id = None
|
||||
for count in utils.iterate_timeout(
|
||||
timeout,
|
||||
"Timeout waiting for the image to import."):
|
||||
if image_id is None:
|
||||
response = self.get(
|
||||
'/tasks/{id}'.format(id=glance_task.id))
|
||||
status = self._connection._get_and_munchify(
|
||||
key=None, data=response)
|
||||
|
||||
if status['status'] == 'success':
|
||||
image_id = status['result']['image_id']
|
||||
image = self._connection.get_image(image_id)
|
||||
if image is None:
|
||||
continue
|
||||
self.update_image_properties(
|
||||
image=image, meta=meta, **image_kwargs)
|
||||
self._connection.log.debug(
|
||||
"Image Task %s imported %s in %s",
|
||||
glance_task.id, image_id, (time.time() - start))
|
||||
# Clean up after ourselves. The object we created is not
|
||||
# needed after the import is done.
|
||||
self._connection.delete_object(container, name)
|
||||
return self._connection.get_image(image_id)
|
||||
elif status['status'] == 'failure':
|
||||
if status['message'] == _IMAGE_ERROR_396:
|
||||
glance_task = self.post('/tasks', data=task_args)
|
||||
self._connection.list_images.invalidate(self)
|
||||
else:
|
||||
# Clean up after ourselves. The image did not import
|
||||
# and this isn't a 'just retry' error - glance didn't
|
||||
# like the content. So we don't want to keep it for
|
||||
# next time.
|
||||
self._connection.delete_object(container, name)
|
||||
raise exc.OpenStackCloudException(
|
||||
"Image creation failed: {message}".format(
|
||||
message=status['message']),
|
||||
extra_data=status)
|
||||
try:
|
||||
glance_task = self.wait_for_task(
|
||||
task=glance_task,
|
||||
status='success',
|
||||
wait=timeout)
|
||||
|
||||
image_id = glance_task.result['image_id']
|
||||
image = self.get_image(image_id)
|
||||
# NOTE(gtema): Since we might move unknown attributes of
|
||||
# the image under properties - merge current with update
|
||||
# properties not to end up removing "existing" properties
|
||||
props = image.properties.copy()
|
||||
props.update(image_kwargs.pop('properties', {}))
|
||||
image_kwargs['properties'] = props
|
||||
|
||||
image = self.update_image(image, **image_kwargs)
|
||||
self.log.debug(
|
||||
"Image Task %s imported %s in %s",
|
||||
glance_task.id, image_id, (time.time() - start))
|
||||
except exceptions.ResourceFailure as e:
|
||||
glance_task = self.get_task(glance_task)
|
||||
raise exceptions.SDKException(
|
||||
"Image creation failed: {message}".format(
|
||||
message=e.message),
|
||||
extra_data=glance_task)
|
||||
finally:
|
||||
# Clean up after ourselves. The object we created is not
|
||||
# needed after the import is done.
|
||||
self._connection.delete_object(container, name)
|
||||
self._connection.list_images.invalidate(self)
|
||||
return image
|
||||
else:
|
||||
return glance_task
|
||||
|
||||
def _update_image_properties(self, image, meta, properties):
|
||||
if not isinstance(image, _image.Image):
|
||||
# If we come here with a dict (cloud) - convert dict to real object
|
||||
# to properly consume all properties (to calculate the diff).
|
||||
# This currently happens from unittests.
|
||||
image = _image.Image.existing(**image)
|
||||
img_props = image.properties.copy()
|
||||
|
||||
for k, v in iter(self._make_v2_image_params(meta, properties).items()):
|
||||
if image.get(k, None) != v:
|
||||
img_props[k] = v
|
||||
if not img_props:
|
||||
return False
|
||||
headers = {
|
||||
'Content-Type': 'application/openstack-images-v2.1-json-patch'}
|
||||
patch = sorted(list(jsonpatch.JsonPatch.from_diff(
|
||||
image.properties, img_props)), key=operator.itemgetter('value'))
|
||||
|
||||
# No need to fire an API call if there is an empty patch
|
||||
if patch:
|
||||
self.patch(
|
||||
'/images/{id}'.format(id=image.id),
|
||||
headers=headers,
|
||||
data=json.dumps(patch))
|
||||
self.update_image(image, **img_props)
|
||||
|
||||
self._connection.list_images.invalidate(self._connection)
|
||||
return True
|
||||
|
@ -293,7 +285,8 @@ class Proxy(_base_proxy.BaseImageProxy):
|
|||
def _existing_image(self, **kwargs):
|
||||
return _image.Image.existing(connection=self._connection, **kwargs)
|
||||
|
||||
def download_image(self, image, stream=False):
|
||||
def download_image(self, image, stream=False, output=None,
|
||||
chunk_size=1024):
|
||||
"""Download an image
|
||||
|
||||
This will download an image to memory when ``stream=False``, or allow
|
||||
|
@ -318,14 +311,20 @@ class Proxy(_base_proxy.BaseImageProxy):
|
|||
|
||||
When ``False``, return the entire
|
||||
contents of the response.
|
||||
:param output: Either a file object or a path to store data into.
|
||||
:param int chunk_size: size in bytes to read from the wire and buffer
|
||||
at one time. Defaults to 1024
|
||||
|
||||
:returns: The bytes comprising the given Image when stream is
|
||||
False, otherwise a :class:`requests.Response`
|
||||
instance.
|
||||
:returns: When output is not given - the bytes comprising the given
|
||||
Image when stream is False, otherwise a :class:`requests.Response`
|
||||
instance. When output is given - a
|
||||
:class:`~openstack.image.v2.image.Image` instance.
|
||||
"""
|
||||
|
||||
image = self._get_resource(_image.Image, image)
|
||||
return image.download(self, stream=stream)
|
||||
|
||||
return image.download(
|
||||
self, stream=stream, output=output, chunk_size=chunk_size)
|
||||
|
||||
def delete_image(self, image, ignore_missing=True):
|
||||
"""Delete an image
|
||||
|
@ -638,9 +637,45 @@ class Proxy(_base_proxy.BaseImageProxy):
|
|||
:raises: :class:`~AttributeError` if the resource does not have a
|
||||
``status`` attribute.
|
||||
"""
|
||||
failures = ['failure'] if failures is None else failures
|
||||
return resource.wait_for_status(
|
||||
self, task, status, failures, interval, wait)
|
||||
if failures is None:
|
||||
failures = ['failure']
|
||||
else:
|
||||
failures = [f.lower() for f in failures]
|
||||
|
||||
if task.status.lower() == status.lower():
|
||||
return task
|
||||
|
||||
name = "{res}:{id}".format(res=task.__class__.__name__, id=task.id)
|
||||
msg = "Timeout waiting for {name} to transition to {status}".format(
|
||||
name=name, status=status)
|
||||
|
||||
for count in utils.iterate_timeout(
|
||||
timeout=wait,
|
||||
message=msg,
|
||||
wait=interval):
|
||||
task = task.fetch(self)
|
||||
|
||||
if not task:
|
||||
raise exceptions.ResourceFailure(
|
||||
"{name} went away while waiting for {status}".format(
|
||||
name=name, status=status))
|
||||
|
||||
new_status = task.status
|
||||
normalized_status = new_status.lower()
|
||||
if normalized_status == status.lower():
|
||||
return task
|
||||
elif normalized_status in failures:
|
||||
if task.message == _IMAGE_ERROR_396:
|
||||
task_args = dict(input=task.input, type=task.type)
|
||||
task = self.create_task(**task_args)
|
||||
self.log.debug('Got error 396. Recreating task %s' % task)
|
||||
else:
|
||||
raise exceptions.ResourceFailure(
|
||||
"{name} transitioned to failure state {status}".format(
|
||||
name=name, status=new_status))
|
||||
|
||||
self.log.debug('Still waiting for resource %s to reach state %s, '
|
||||
'current state is %s', name, status, new_status)
|
||||
|
||||
def get_tasks_schema(self):
|
||||
"""Get image tasks schema
|
||||
|
|
|
@ -11,14 +11,13 @@
|
|||
# under the License.
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import six
|
||||
|
||||
from openstack import _log
|
||||
from openstack import exceptions
|
||||
from openstack import resource
|
||||
from openstack import utils
|
||||
|
||||
_logger = _log.setup_logging('openstack')
|
||||
|
||||
|
||||
class Image(resource.Resource, resource.TagMixin):
|
||||
resources_key = 'images'
|
||||
|
@ -33,6 +32,10 @@ class Image(resource.Resource, resource.TagMixin):
|
|||
commit_method = 'PATCH'
|
||||
commit_jsonpatch = True
|
||||
|
||||
# Store all unknown attributes under 'properties' in the object.
|
||||
# Remotely they would be still in the resource root
|
||||
_store_unknown_attrs_as_properties = True
|
||||
|
||||
_query_mapping = resource.QueryParameters(
|
||||
"name", "visibility",
|
||||
"member_status", "owner",
|
||||
|
@ -135,7 +138,7 @@ class Image(resource.Resource, resource.TagMixin):
|
|||
architecture = resource.Body("architecture")
|
||||
#: The hypervisor type. Note that qemu is used for both QEMU and
|
||||
#: KVM hypervisor types.
|
||||
hypervisor_type = resource.Body("hypervisor-type")
|
||||
hypervisor_type = resource.Body("hypervisor_type")
|
||||
#: Optional property allows created servers to have a different bandwidth
|
||||
#: cap than that defined in the network they are attached to.
|
||||
instance_type_rxtx_factor = resource.Body(
|
||||
|
@ -157,6 +160,8 @@ class Image(resource.Resource, resource.TagMixin):
|
|||
#: Secure Boot first examines software such as firmware and OS by
|
||||
#: their signature and only allows them to run if the signatures are valid.
|
||||
needs_secure_boot = resource.Body('os_secure_boot')
|
||||
#: Time for graceful shutdown
|
||||
os_shutdown_timeout = resource.Body('os_shutdown_timeout', type=int)
|
||||
#: The ID of image stored in the Image service that should be used as
|
||||
#: the ramdisk when booting an AMI-style image.
|
||||
ramdisk_id = resource.Body('ramdisk_id')
|
||||
|
@ -172,6 +177,12 @@ class Image(resource.Resource, resource.TagMixin):
|
|||
#: Specifies the type of disk controller to attach disk devices to.
|
||||
#: One of scsi, virtio, uml, xen, ide, or usb.
|
||||
hw_disk_bus = resource.Body('hw_disk_bus')
|
||||
#: Used to pin the virtual CPUs (vCPUs) of instances to the
|
||||
#: host's physical CPU cores (pCPUs).
|
||||
hw_cpu_policy = resource.Body('hw_cpu_policy')
|
||||
#: Defines how hardware CPU threads in a simultaneous
|
||||
#: multithreading-based (SMT) architecture be used.
|
||||
hw_cpu_thread_policy = resource.Body('hw_cpu_thread_policy')
|
||||
#: Adds a random-number generator device to the image's instances.
|
||||
hw_rng_model = resource.Body('hw_rng_model')
|
||||
#: For libvirt: Enables booting an ARM system using the specified
|
||||
|
@ -212,7 +223,7 @@ class Image(resource.Resource, resource.TagMixin):
|
|||
vmware_ostype = resource.Body('vmware_ostype')
|
||||
#: If true, the root partition on the disk is automatically resized
|
||||
#: before the instance boots.
|
||||
has_auto_disk_config = resource.Body('auto_disk_config', type=bool)
|
||||
has_auto_disk_config = resource.Body('auto_disk_config')
|
||||
#: The operating system installed on the image.
|
||||
os_type = resource.Body('os_type')
|
||||
#: The operating system admin username.
|
||||
|
@ -221,6 +232,8 @@ class Image(resource.Resource, resource.TagMixin):
|
|||
hw_qemu_guest_agent = resource.Body('hw_qemu_guest_agent', type=bool)
|
||||
#: If true, require quiesce on snapshot via QEMU guest agent.
|
||||
os_require_quiesce = resource.Body('os_require_quiesce', type=bool)
|
||||
#: The URL for the schema describing a virtual machine image.
|
||||
schema = resource.Body('schema')
|
||||
|
||||
def _action(self, session, action):
|
||||
"""Call an action on an image ID."""
|
||||
|
@ -245,9 +258,9 @@ class Image(resource.Resource, resource.TagMixin):
|
|||
def upload(self, session):
|
||||
"""Upload data into an existing image"""
|
||||
url = utils.urljoin(self.base_path, self.id, 'file')
|
||||
session.put(url, data=self.data,
|
||||
headers={"Content-Type": "application/octet-stream",
|
||||
"Accept": ""})
|
||||
return session.put(url, data=self.data,
|
||||
headers={"Content-Type": "application/octet-stream",
|
||||
"Accept": ""})
|
||||
|
||||
def import_image(self, session, method='glance-direct', uri=None):
|
||||
"""Import Image via interoperable image import process"""
|
||||
|
@ -261,7 +274,7 @@ class Image(resource.Resource, resource.TagMixin):
|
|||
'method: "web-download"')
|
||||
session.post(url, json=json)
|
||||
|
||||
def download(self, session, stream=False):
|
||||
def download(self, session, stream=False, output=None, chunk_size=1024):
|
||||
"""Download the data contained in an image"""
|
||||
# TODO(briancurtin): This method should probably offload the get
|
||||
# operation into another thread or something of that nature.
|
||||
|
@ -271,7 +284,7 @@ class Image(resource.Resource, resource.TagMixin):
|
|||
# See the following bug report for details on why the checksum
|
||||
# code may sometimes depend on a second GET call.
|
||||
# https://storyboard.openstack.org/#!/story/1619675
|
||||
checksum = resp.headers.get("Content-MD5")
|
||||
checksum = resp.headers.get('Content-MD5')
|
||||
|
||||
if checksum is None:
|
||||
# If we don't receive the Content-MD5 header with the download,
|
||||
|
@ -280,6 +293,23 @@ class Image(resource.Resource, resource.TagMixin):
|
|||
details = self.fetch(session)
|
||||
checksum = details.checksum
|
||||
|
||||
if output:
|
||||
try:
|
||||
# In python 2 we might get StringIO - delete it as soon as
|
||||
# py2 support is dropped
|
||||
if isinstance(output, io.IOBase) \
|
||||
or isinstance(output, six.StringIO):
|
||||
for chunk in resp.iter_content(chunk_size=chunk_size):
|
||||
output.write(chunk)
|
||||
else:
|
||||
with open(output, 'wb') as fd:
|
||||
for chunk in resp.iter_content(
|
||||
chunk_size=chunk_size):
|
||||
fd.write(chunk)
|
||||
return resp
|
||||
except Exception as e:
|
||||
raise exceptions.SDKException(
|
||||
'Unable to download image: %s' % e)
|
||||
# if we are returning the repsonse object, ensure that it
|
||||
# has the content-md5 header so that the caller doesn't
|
||||
# need to jump through the same hoops through which we
|
||||
|
@ -294,10 +324,10 @@ class Image(resource.Resource, resource.TagMixin):
|
|||
raise exceptions.InvalidResponse(
|
||||
"checksum mismatch: %s != %s" % (checksum, digest))
|
||||
else:
|
||||
_logger.warn(
|
||||
session.log.warn(
|
||||
"Unable to verify the integrity of image %s" % (self.id))
|
||||
|
||||
return resp.content
|
||||
return resp
|
||||
|
||||
def _prepare_request(self, requires_id=None, prepend_key=False,
|
||||
patch=False, base_path=None):
|
||||
|
|
|
@ -35,6 +35,7 @@ import collections
|
|||
import itertools
|
||||
|
||||
import jsonpatch
|
||||
import operator
|
||||
from keystoneauth1 import adapter
|
||||
from keystoneauth1 import discover
|
||||
import munch
|
||||
|
@ -425,6 +426,7 @@ class Resource(dict):
|
|||
_computed = None
|
||||
_original_body = None
|
||||
_delete_response_class = None
|
||||
_store_unknown_attrs_as_properties = False
|
||||
|
||||
def __init__(self, _synchronized=False, connection=None, **attrs):
|
||||
"""The base resource
|
||||
|
@ -445,9 +447,6 @@ class Resource(dict):
|
|||
# items as they match up with any of the body, header,
|
||||
# or uri mappings.
|
||||
body, header, uri, computed = self._collect_attrs(attrs)
|
||||
# TODO(briancurtin): at this point if attrs has anything left
|
||||
# they're not being set anywhere. Log this? Raise exception?
|
||||
# How strict should we be here? Should strict be an option?
|
||||
|
||||
self._body = _ComponentManager(
|
||||
attributes=body,
|
||||
|
@ -472,6 +471,13 @@ class Resource(dict):
|
|||
}
|
||||
else:
|
||||
self._original_body = {}
|
||||
if self._store_unknown_attrs_as_properties:
|
||||
# When storing of unknown attributes is requested - ensure
|
||||
# we have properties attribute (with type=None)
|
||||
self._store_unknown_attrs_as_properties = (
|
||||
hasattr(self.__class__, 'properties')
|
||||
and self.__class__.properties.type is None
|
||||
)
|
||||
|
||||
self._update_location()
|
||||
|
||||
|
@ -504,7 +510,7 @@ class Resource(dict):
|
|||
self._body.attributes == comparand._body.attributes,
|
||||
self._header.attributes == comparand._header.attributes,
|
||||
self._uri.attributes == comparand._uri.attributes,
|
||||
self._computed.attributes == comparand._computed.attributes,
|
||||
self._computed.attributes == comparand._computed.attributes
|
||||
])
|
||||
|
||||
def __getattribute__(self, name):
|
||||
|
@ -602,6 +608,10 @@ class Resource(dict):
|
|||
header = self._consume_header_attrs(attrs)
|
||||
uri = self._consume_uri_attrs(attrs)
|
||||
|
||||
if attrs and self._store_unknown_attrs_as_properties:
|
||||
# Keep also remaining (unknown) attributes
|
||||
body = self._pack_attrs_under_properties(body, attrs)
|
||||
|
||||
if any([body, header, uri]):
|
||||
attrs = self._compute_attributes(body, header, uri)
|
||||
|
||||
|
@ -911,12 +921,55 @@ class Resource(dict):
|
|||
body=True, headers=False,
|
||||
original_names=original_names, _to_munch=True)
|
||||
|
||||
def _unpack_properties_to_resource_root(self, body):
|
||||
if not body:
|
||||
return
|
||||
# We do not want to modify caller
|
||||
body = body.copy()
|
||||
props = body.pop('properties', {})
|
||||
if props and isinstance(props, dict):
|
||||
# unpack dict of properties back to the root of the resource
|
||||
body.update(props)
|
||||
elif props and isinstance(props, str):
|
||||
# A string value only - bring it back
|
||||
body['properties'] = props
|
||||
return body
|
||||
|
||||
def _pack_attrs_under_properties(self, body, attrs):
|
||||
props = body.get('properties', {})
|
||||
if not isinstance(props, dict):
|
||||
props = {'properties': props}
|
||||
props.update(attrs)
|
||||
body['properties'] = props
|
||||
return body
|
||||
|
||||
def _prepare_request_body(self, patch, prepend_key):
|
||||
if patch:
|
||||
new = self._body.attributes
|
||||
body = jsonpatch.make_patch(self._original_body, new).patch
|
||||
if not self._store_unknown_attrs_as_properties:
|
||||
# Default case
|
||||
new = self._body.attributes
|
||||
original_body = self._original_body
|
||||
else:
|
||||
new = self._unpack_properties_to_resource_root(
|
||||
self._body.attributes)
|
||||
original_body = self._unpack_properties_to_resource_root(
|
||||
self._original_body)
|
||||
|
||||
# NOTE(gtema) sort result, since we might need validate it in tests
|
||||
body = sorted(
|
||||
list(jsonpatch.make_patch(
|
||||
original_body,
|
||||
new).patch),
|
||||
key=operator.itemgetter('path')
|
||||
)
|
||||
else:
|
||||
body = self._body.dirty
|
||||
if not self._store_unknown_attrs_as_properties:
|
||||
# Default case
|
||||
body = self._body.dirty
|
||||
else:
|
||||
body = self._unpack_properties_to_resource_root(
|
||||
self._body.dirty)
|
||||
|
||||
if prepend_key and self.resource_key is not None:
|
||||
body = {self.resource_key: body}
|
||||
return body
|
||||
|
@ -980,12 +1033,17 @@ class Resource(dict):
|
|||
if self.resource_key and self.resource_key in body:
|
||||
body = body[self.resource_key]
|
||||
|
||||
body = self._consume_body_attrs(body)
|
||||
self._body.attributes.update(body)
|
||||
body_attrs = self._consume_body_attrs(body)
|
||||
|
||||
if self._store_unknown_attrs_as_properties:
|
||||
body_attrs = self._pack_attrs_under_properties(
|
||||
body_attrs, body)
|
||||
|
||||
self._body.attributes.update(body_attrs)
|
||||
self._body.clean()
|
||||
if self.commit_jsonpatch or self.allow_patch:
|
||||
# We need the original body to compare against
|
||||
self._original_body = body.copy()
|
||||
self._original_body = body_attrs.copy()
|
||||
except ValueError:
|
||||
# Server returned not parse-able response (202, 204, etc)
|
||||
# Do simply nothing
|
||||
|
|
|
@ -225,7 +225,7 @@ def make_fake_image(
|
|||
u'image_state': u'available',
|
||||
u'container_format': u'bare',
|
||||
u'min_ram': 0,
|
||||
u'ramdisk_id': None,
|
||||
u'ramdisk_id': 'fake_ramdisk_id',
|
||||
u'updated_at': u'2016-02-10T05:05:02Z',
|
||||
u'file': '/v2/images/' + image_id + '/file',
|
||||
u'size': 3402170368,
|
||||
|
|
|
@ -83,7 +83,15 @@ class TestImage(BaseTestImage):
|
|||
def test_download_image_no_images_found(self):
|
||||
self.register_uris([
|
||||
dict(method='GET',
|
||||
uri='https://image.example.com/v2/images',
|
||||
uri='https://image.example.com/v2/images/{name}'.format(
|
||||
name=self.image_name),
|
||||
status_code=404),
|
||||
dict(method='GET',
|
||||
uri='https://image.example.com/v2/images?name={name}'.format(
|
||||
name=self.image_name),
|
||||
json=dict(images=[])),
|
||||
dict(method='GET',
|
||||
uri='https://image.example.com/v2/images?os_hidden=True',
|
||||
json=dict(images=[]))])
|
||||
self.assertRaises(exc.OpenStackCloudResourceNotFound,
|
||||
self.cloud.download_image, self.image_name,
|
||||
|
@ -93,13 +101,21 @@ class TestImage(BaseTestImage):
|
|||
def _register_image_mocks(self):
|
||||
self.register_uris([
|
||||
dict(method='GET',
|
||||
uri='https://image.example.com/v2/images',
|
||||
uri='https://image.example.com/v2/images/{name}'.format(
|
||||
name=self.image_name),
|
||||
status_code=404),
|
||||
dict(method='GET',
|
||||
uri='https://image.example.com/v2/images?name={name}'.format(
|
||||
name=self.image_name),
|
||||
json=self.fake_search_return),
|
||||
dict(method='GET',
|
||||
uri='https://image.example.com/v2/images/{id}/file'.format(
|
||||
id=self.image_id),
|
||||
content=self.output,
|
||||
headers={'Content-Type': 'application/octet-stream'})
|
||||
headers={
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Content-MD5': self.fake_image_dict['checksum']
|
||||
})
|
||||
])
|
||||
|
||||
def test_download_image_with_fd(self):
|
||||
|
@ -147,7 +163,7 @@ class TestImage(BaseTestImage):
|
|||
base_url_append='v2'),
|
||||
json=self.fake_image_dict)
|
||||
])
|
||||
self.assertEqual(
|
||||
self.assertDictEqual(
|
||||
self.cloud._normalize_image(self.fake_image_dict),
|
||||
self.cloud.get_image_by_id(self.image_id))
|
||||
self.assert_calls()
|
||||
|
@ -326,6 +342,12 @@ class TestImage(BaseTestImage):
|
|||
'image', append=['images', self.image_id, 'file'],
|
||||
base_url_append='v2'),
|
||||
request_headers={'Content-Type': 'application/octet-stream'}),
|
||||
dict(method='GET',
|
||||
uri=self.get_mock_url(
|
||||
'image', append=['images', self.fake_image_dict['id']],
|
||||
base_url_append='v2'
|
||||
),
|
||||
json=self.fake_image_dict),
|
||||
dict(method='GET',
|
||||
uri=self.get_mock_url(
|
||||
'image', append=['images'], base_url_append='v2'),
|
||||
|
@ -411,7 +433,7 @@ class TestImage(BaseTestImage):
|
|||
dict(method='POST',
|
||||
uri=self.get_mock_url(
|
||||
'image', append=['tasks'], base_url_append='v2'),
|
||||
json=args,
|
||||
json={'id': task_id, 'status': 'processing'},
|
||||
validate=dict(
|
||||
json=dict(
|
||||
type='import', input={
|
||||
|
@ -430,8 +452,9 @@ class TestImage(BaseTestImage):
|
|||
json=args),
|
||||
dict(method='GET',
|
||||
uri=self.get_mock_url(
|
||||
'image', append=['images'], base_url_append='v2'),
|
||||
json={'images': [image_no_checksums]}),
|
||||
'image', append=['images', self.image_id],
|
||||
base_url_append='v2'),
|
||||
json=image_no_checksums),
|
||||
dict(method='PATCH',
|
||||
uri=self.get_mock_url(
|
||||
'image', append=['images', self.image_id],
|
||||
|
@ -447,10 +470,11 @@ class TestImage(BaseTestImage):
|
|||
u'path': u'/owner_specified.openstack.md5'},
|
||||
{u'op': u'add', u'value': fakes.NO_SHA256,
|
||||
u'path': u'/owner_specified.openstack.sha256'}],
|
||||
key=operator.itemgetter('value')),
|
||||
key=operator.itemgetter('path')),
|
||||
headers={
|
||||
'Content-Type':
|
||||
'application/openstack-images-v2.1-json-patch'})
|
||||
'application/openstack-images-v2.1-json-patch'}),
|
||||
json=self.fake_search_return
|
||||
),
|
||||
dict(method='HEAD',
|
||||
uri='{endpoint}/{container}/{object}'.format(
|
||||
|
@ -471,14 +495,6 @@ class TestImage(BaseTestImage):
|
|||
uri='{endpoint}/{container}/{object}'.format(
|
||||
endpoint=endpoint, container=self.container_name,
|
||||
object=self.image_name)),
|
||||
dict(method='GET',
|
||||
uri=self.get_mock_url(
|
||||
'image', append=['images'], base_url_append='v2'),
|
||||
json=self.fake_search_return),
|
||||
# TODO(mordred) The task workflow results in an extra call
|
||||
# in the upper level wait. We should be able to make this
|
||||
# go away once we refactor a wait_for_image out in the next
|
||||
# patch.
|
||||
dict(method='GET',
|
||||
uri=self.get_mock_url(
|
||||
'image', append=['images'], base_url_append='v2'),
|
||||
|
@ -634,7 +650,8 @@ class TestImage(BaseTestImage):
|
|||
'owner_specified.openstack.sha256': fakes.NO_SHA256,
|
||||
'owner_specified.openstack.object': 'images/{name}'.format(
|
||||
name=self.image_name),
|
||||
'is_public': False}}
|
||||
'is_public': False},
|
||||
'validate_checksum': True}
|
||||
|
||||
ret = args.copy()
|
||||
ret['id'] = self.image_id
|
||||
|
@ -735,6 +752,52 @@ class TestImage(BaseTestImage):
|
|||
|
||||
self.assert_calls()
|
||||
|
||||
def test_create_image_put_v2_wrong_checksum_delete(self):
|
||||
self.cloud.image_api_use_tasks = False
|
||||
|
||||
args = {'name': self.image_name,
|
||||
'container_format': 'bare', 'disk_format': 'qcow2',
|
||||
'owner_specified.openstack.md5': fakes.NO_MD5,
|
||||
'owner_specified.openstack.sha256': fakes.NO_SHA256,
|
||||
'owner_specified.openstack.object': 'images/{name}'.format(
|
||||
name=self.image_name),
|
||||
'visibility': 'private'}
|
||||
|
||||
ret = args.copy()
|
||||
ret['id'] = self.image_id
|
||||
ret['status'] = 'success'
|
||||
ret['checksum'] = 'fake'
|
||||
|
||||
self.register_uris([
|
||||
dict(method='GET',
|
||||
uri='https://image.example.com/v2/images',
|
||||
json={'images': []}),
|
||||
dict(method='POST',
|
||||
uri='https://image.example.com/v2/images',
|
||||
json=ret,
|
||||
validate=dict(json=args)),
|
||||
dict(method='PUT',
|
||||
uri='https://image.example.com/v2/images/{id}/file'.format(
|
||||
id=self.image_id),
|
||||
status_code=400,
|
||||
validate=dict(
|
||||
headers={
|
||||
'Content-Type': 'application/octet-stream',
|
||||
},
|
||||
)),
|
||||
dict(method='DELETE',
|
||||
uri='https://image.example.com/v2/images/{id}'.format(
|
||||
id=self.image_id)),
|
||||
])
|
||||
|
||||
self.assertRaises(
|
||||
exc.OpenStackCloudHTTPError,
|
||||
self._call_create_image,
|
||||
self.image_name,
|
||||
md5='some_fake')
|
||||
|
||||
self.assert_calls()
|
||||
|
||||
def test_create_image_put_bad_int(self):
|
||||
self.cloud.image_api_use_tasks = False
|
||||
|
||||
|
@ -784,6 +847,11 @@ class TestImage(BaseTestImage):
|
|||
'Content-Type': 'application/octet-stream',
|
||||
},
|
||||
)),
|
||||
dict(method='GET',
|
||||
uri='https://image.example.com/v2/images/{id}'.format(
|
||||
id=self.image_id
|
||||
),
|
||||
json=ret),
|
||||
dict(method='GET',
|
||||
uri='https://image.example.com/v2/images',
|
||||
json={'images': [ret]}),
|
||||
|
@ -810,6 +878,7 @@ class TestImage(BaseTestImage):
|
|||
ret = args.copy()
|
||||
ret['id'] = self.image_id
|
||||
ret['status'] = 'success'
|
||||
ret['checksum'] = fakes.NO_MD5
|
||||
|
||||
self.register_uris([
|
||||
dict(method='GET',
|
||||
|
@ -827,6 +896,11 @@ class TestImage(BaseTestImage):
|
|||
'Content-Type': 'application/octet-stream',
|
||||
},
|
||||
)),
|
||||
dict(method='GET',
|
||||
uri='https://image.example.com/v2/images/{id}'.format(
|
||||
id=self.image_id
|
||||
),
|
||||
json=ret),
|
||||
dict(method='GET',
|
||||
uri='https://image.example.com/v2/images',
|
||||
json={'images': [ret]}),
|
||||
|
@ -854,6 +928,7 @@ class TestImage(BaseTestImage):
|
|||
ret = args.copy()
|
||||
ret['id'] = self.image_id
|
||||
ret['status'] = 'success'
|
||||
ret['checksum'] = fakes.NO_MD5
|
||||
|
||||
self.register_uris([
|
||||
dict(method='GET',
|
||||
|
@ -871,6 +946,11 @@ class TestImage(BaseTestImage):
|
|||
'Content-Type': 'application/octet-stream',
|
||||
},
|
||||
)),
|
||||
dict(method='GET',
|
||||
uri='https://image.example.com/v2/images/{id}'.format(
|
||||
id=self.image_id
|
||||
),
|
||||
json=ret),
|
||||
dict(method='GET',
|
||||
uri='https://image.example.com/v2/images',
|
||||
json={'images': [ret]}),
|
||||
|
|
|
@ -11,6 +11,7 @@
|
|||
# under the License.
|
||||
|
||||
from openstack.compute.v2 import server as server_resource
|
||||
from openstack.image.v2 import image as image_resource
|
||||
from openstack.tests.unit import base
|
||||
|
||||
RAW_SERVER_DICT = {
|
||||
|
@ -94,6 +95,9 @@ RAW_GLANCE_IMAGE_DICT = {
|
|||
u'name': u'Test Monty Ubuntu',
|
||||
u'org.openstack__1__architecture': u'x64',
|
||||
u'os_type': u'linux',
|
||||
u'os_hash_algo': u'sha512',
|
||||
u'os_hash_value': u'fake_hash',
|
||||
u'os_hidden': False,
|
||||
u'owner': u'610275',
|
||||
u'protected': False,
|
||||
u'schema': u'/v2/schemas/image',
|
||||
|
@ -373,6 +377,9 @@ class TestNormalize(base.TestCase):
|
|||
u'com.rackspace__1__visible_rackconnect': u'1',
|
||||
u'image_type': u'import',
|
||||
u'org.openstack__1__architecture': u'x64',
|
||||
u'os_hash_algo': u'sha512',
|
||||
u'os_hash_value': u'fake_hash',
|
||||
u'os_hidden': False,
|
||||
u'os_type': u'linux',
|
||||
u'schema': u'/v2/schemas/image',
|
||||
u'user_id': u'156284',
|
||||
|
@ -384,6 +391,9 @@ class TestNormalize(base.TestCase):
|
|||
'min_ram': 0,
|
||||
'name': u'Test Monty Ubuntu',
|
||||
u'org.openstack__1__architecture': u'x64',
|
||||
u'os_hash_algo': u'sha512',
|
||||
u'os_hash_value': u'fake_hash',
|
||||
u'os_hidden': False,
|
||||
u'os_type': u'linux',
|
||||
'owner': u'610275',
|
||||
'properties': {
|
||||
|
@ -398,6 +408,9 @@ class TestNormalize(base.TestCase):
|
|||
u'com.rackspace__1__visible_rackconnect': u'1',
|
||||
u'image_type': u'import',
|
||||
u'org.openstack__1__architecture': u'x64',
|
||||
u'os_hash_algo': u'sha512',
|
||||
u'os_hash_value': u'fake_hash',
|
||||
u'os_hidden': False,
|
||||
u'os_type': u'linux',
|
||||
u'schema': u'/v2/schemas/image',
|
||||
u'user_id': u'156284',
|
||||
|
@ -418,6 +431,12 @@ class TestNormalize(base.TestCase):
|
|||
retval = self.cloud._normalize_image(raw_image)
|
||||
self.assertEqual(expected, retval)
|
||||
|
||||
# Check normalization from Image resource
|
||||
image = image_resource.Image.existing(**RAW_GLANCE_IMAGE_DICT)
|
||||
|
||||
retval = self.cloud._normalize_image(image)
|
||||
self.assertDictEqual(expected, retval)
|
||||
|
||||
def test_normalize_servers_normal(self):
|
||||
res = server_resource.Server(
|
||||
connection=self.cloud,
|
||||
|
@ -888,6 +907,9 @@ class TestStrictNormalize(base.TestCase):
|
|||
u'image_type': u'import',
|
||||
u'org.openstack__1__architecture': u'x64',
|
||||
u'os_type': u'linux',
|
||||
u'os_hash_algo': u'sha512',
|
||||
u'os_hash_value': u'fake_hash',
|
||||
u'os_hidden': False,
|
||||
u'schema': u'/v2/schemas/image',
|
||||
u'user_id': u'156284',
|
||||
u'vm_mode': u'hvm',
|
||||
|
|
|
@ -11,12 +11,15 @@
|
|||
# under the License.
|
||||
|
||||
import operator
|
||||
import six
|
||||
import tempfile
|
||||
|
||||
from keystoneauth1 import adapter
|
||||
import mock
|
||||
import requests
|
||||
from openstack.tests.unit import base
|
||||
|
||||
from openstack import _log
|
||||
from openstack import exceptions
|
||||
from openstack.image.v2 import image
|
||||
|
||||
|
@ -50,7 +53,7 @@ EXAMPLE = {
|
|||
'url': '20',
|
||||
'metadata': {'21': '22'},
|
||||
'architecture': '23',
|
||||
'hypervisor-type': '24',
|
||||
'hypervisor_type': '24',
|
||||
'instance_type_rxtx_factor': 25.1,
|
||||
'instance_uuid': '26',
|
||||
'img_config_drive': '27',
|
||||
|
@ -115,6 +118,7 @@ class TestImage(base.TestCase):
|
|||
self.sess.fetch = mock.Mock(return_value=FakeResponse({}))
|
||||
self.sess.default_microversion = None
|
||||
self.sess.retriable_status_codes = None
|
||||
self.sess.log = _log.setup_logging('openstack')
|
||||
|
||||
def test_basic(self):
|
||||
sot = image.Image()
|
||||
|
@ -175,7 +179,7 @@ class TestImage(base.TestCase):
|
|||
self.assertEqual(EXAMPLE['url'], sot.url)
|
||||
self.assertEqual(EXAMPLE['metadata'], sot.metadata)
|
||||
self.assertEqual(EXAMPLE['architecture'], sot.architecture)
|
||||
self.assertEqual(EXAMPLE['hypervisor-type'], sot.hypervisor_type)
|
||||
self.assertEqual(EXAMPLE['hypervisor_type'], sot.hypervisor_type)
|
||||
self.assertEqual(EXAMPLE['instance_type_rxtx_factor'],
|
||||
sot.instance_type_rxtx_factor)
|
||||
self.assertEqual(EXAMPLE['instance_uuid'], sot.instance_uuid)
|
||||
|
@ -264,7 +268,7 @@ class TestImage(base.TestCase):
|
|||
def test_upload(self):
|
||||
sot = image.Image(**EXAMPLE)
|
||||
|
||||
self.assertIsNone(sot.upload(self.sess))
|
||||
self.assertIsNotNone(sot.upload(self.sess))
|
||||
self.sess.put.assert_called_with('images/IDENTIFIER/file',
|
||||
data=sot.data,
|
||||
headers={"Content-Type":
|
||||
|
@ -284,7 +288,7 @@ class TestImage(base.TestCase):
|
|||
self.sess.get.assert_called_with('images/IDENTIFIER/file',
|
||||
stream=False)
|
||||
|
||||
self.assertEqual(rv, resp.content)
|
||||
self.assertEqual(rv, resp)
|
||||
|
||||
def test_download_checksum_mismatch(self):
|
||||
sot = image.Image(**EXAMPLE)
|
||||
|
@ -314,7 +318,7 @@ class TestImage(base.TestCase):
|
|||
stream=False),
|
||||
mock.call('images/IDENTIFIER', microversion=None)])
|
||||
|
||||
self.assertEqual(rv, resp1.content)
|
||||
self.assertEqual(rv, resp1)
|
||||
|
||||
def test_download_no_checksum_at_all2(self):
|
||||
sot = image.Image(**EXAMPLE)
|
||||
|
@ -340,7 +344,7 @@ class TestImage(base.TestCase):
|
|||
stream=False),
|
||||
mock.call('images/IDENTIFIER', microversion=None)])
|
||||
|
||||
self.assertEqual(rv, resp1.content)
|
||||
self.assertEqual(rv, resp1)
|
||||
|
||||
def test_download_stream(self):
|
||||
sot = image.Image(**EXAMPLE)
|
||||
|
@ -356,6 +360,29 @@ class TestImage(base.TestCase):
|
|||
|
||||
self.assertEqual(rv, resp)
|
||||
|
||||
def test_image_download_output_fd(self):
|
||||
output_file = six.BytesIO()
|
||||
sot = image.Image(**EXAMPLE)
|
||||
response = mock.Mock()
|
||||
response.status_code = 200
|
||||
response.iter_content.return_value = [b'01', b'02']
|
||||
self.sess.get = mock.Mock(return_value=response)
|
||||
sot.download(self.sess, output=output_file)
|
||||
output_file.seek(0)
|
||||
self.assertEqual(b'0102', output_file.read())
|
||||
|
||||
def test_image_download_output_file(self):
|
||||
sot = image.Image(**EXAMPLE)
|
||||
response = mock.Mock()
|
||||
response.status_code = 200
|
||||
response.iter_content.return_value = [b'01', b'02']
|
||||
self.sess.get = mock.Mock(return_value=response)
|
||||
|
||||
output_file = tempfile.NamedTemporaryFile()
|
||||
sot.download(self.sess, output=output_file.name)
|
||||
output_file.seek(0)
|
||||
self.assertEqual(b'0102', output_file.read())
|
||||
|
||||
def test_image_update(self):
|
||||
values = EXAMPLE.copy()
|
||||
del values['instance_uuid']
|
||||
|
|
|
@ -11,6 +11,7 @@
|
|||
# under the License.
|
||||
|
||||
import mock
|
||||
import requests
|
||||
|
||||
from openstack import exceptions
|
||||
from openstack.image.v2 import _proxy
|
||||
|
@ -25,6 +26,17 @@ from openstack.tests.unit import test_proxy_base
|
|||
EXAMPLE = fake_image.EXAMPLE
|
||||
|
||||
|
||||
class FakeResponse(object):
|
||||
def __init__(self, response, status_code=200, headers=None):
|
||||
self.body = response
|
||||
self.status_code = status_code
|
||||
headers = headers if headers else {'content-type': 'application/json'}
|
||||
self.headers = requests.structures.CaseInsensitiveDict(headers)
|
||||
|
||||
def json(self):
|
||||
return self.body
|
||||
|
||||
|
||||
class TestImageProxy(test_proxy_base.TestProxyBase):
|
||||
def setUp(self):
|
||||
super(TestImageProxy, self).setUp()
|
||||
|
@ -68,6 +80,20 @@ class TestImageProxy(test_proxy_base.TestProxyBase):
|
|||
created_image.upload.assert_called_with(self.proxy)
|
||||
self.assertEqual(rv, created_image)
|
||||
|
||||
def test_image_download(self):
|
||||
original_image = image.Image(**EXAMPLE)
|
||||
self._verify('openstack.image.v2.image.Image.download',
|
||||
self.proxy.download_image,
|
||||
method_args=[original_image],
|
||||
method_kwargs={
|
||||
'output': 'some_output',
|
||||
'chunk_size': 1,
|
||||
'stream': True
|
||||
},
|
||||
expected_kwargs={'output': 'some_output',
|
||||
'chunk_size': 1,
|
||||
'stream': True})
|
||||
|
||||
def test_image_delete(self):
|
||||
self.verify_delete(self.proxy.delete_image, image.Image, False)
|
||||
|
||||
|
@ -210,12 +236,77 @@ class TestImageProxy(test_proxy_base.TestProxyBase):
|
|||
def test_task_create(self):
|
||||
self.verify_create(self.proxy.create_task, task.Task)
|
||||
|
||||
def test_task_wait_for(self):
|
||||
value = task.Task(id='1234')
|
||||
self.verify_wait_for_status(
|
||||
self.proxy.wait_for_task,
|
||||
method_args=[value],
|
||||
expected_args=[value, 'success', ['failure'], 2, 120])
|
||||
def test_wait_for_task_immediate_status(self):
|
||||
status = 'success'
|
||||
res = task.Task(id='1234', status=status)
|
||||
|
||||
result = self.proxy.wait_for_task(
|
||||
res, status, "failure", 0.01, 0.1)
|
||||
|
||||
self.assertTrue(result, res)
|
||||
|
||||
def test_wait_for_task_immediate_status_case(self):
|
||||
status = "SUCcess"
|
||||
res = task.Task(id='1234', status=status)
|
||||
|
||||
result = self.proxy.wait_for_task(
|
||||
res, status, "failure", 0.01, 0.1)
|
||||
|
||||
self.assertTrue(result, res)
|
||||
|
||||
def test_wait_for_task_error_396(self):
|
||||
# Ensure we create a new task when we get 396 error
|
||||
res = task.Task(
|
||||
id='id', status='waiting',
|
||||
type='some_type', input='some_input', result='some_result'
|
||||
)
|
||||
|
||||
mock_fetch = mock.Mock()
|
||||
mock_fetch.side_effect = [
|
||||
task.Task(
|
||||
id='id', status='failure',
|
||||
type='some_type', input='some_input', result='some_result',
|
||||
message=_proxy._IMAGE_ERROR_396
|
||||
),
|
||||
task.Task(id='fake', status='waiting'),
|
||||
task.Task(id='fake', status='success'),
|
||||
]
|
||||
|
||||
self.proxy._create = mock.Mock()
|
||||
self.proxy._create.side_effect = [
|
||||
task.Task(id='fake', status='success')
|
||||
]
|
||||
|
||||
with mock.patch.object(task.Task,
|
||||
'fetch', mock_fetch):
|
||||
|
||||
result = self.proxy.wait_for_task(
|
||||
res, interval=0.01, wait=0.1)
|
||||
|
||||
self.assertEqual('success', result.status)
|
||||
|
||||
self.proxy._create.assert_called_with(
|
||||
mock.ANY,
|
||||
input=res.input,
|
||||
type=res.type)
|
||||
|
||||
def test_wait_for_task_wait(self):
|
||||
res = task.Task(id='id', status='waiting')
|
||||
|
||||
mock_fetch = mock.Mock()
|
||||
mock_fetch.side_effect = [
|
||||
task.Task(id='id', status='waiting'),
|
||||
task.Task(id='id', status='waiting'),
|
||||
task.Task(id='id', status='success'),
|
||||
]
|
||||
|
||||
with mock.patch.object(task.Task,
|
||||
'fetch', mock_fetch):
|
||||
|
||||
result = self.proxy.wait_for_task(
|
||||
res, interval=0.01, wait=0.1)
|
||||
|
||||
self.assertEqual('success', result.status)
|
||||
|
||||
def test_tasks_schema_get(self):
|
||||
self._verify2("openstack.proxy.Proxy._get",
|
||||
|
|
|
@ -1045,6 +1045,154 @@ class TestResource(base.TestCase):
|
|||
sot._body.dirty = mock.Mock(return_value={"x": "y"})
|
||||
self.assertRaises(exceptions.MethodNotSupported, sot.commit, "")
|
||||
|
||||
def test_unknown_attrs_under_props_create(self):
|
||||
class Test(resource.Resource):
|
||||
properties = resource.Body("properties")
|
||||
_store_unknown_attrs_as_properties = True
|
||||
|
||||
sot = Test.new(**{
|
||||
'dummy': 'value',
|
||||
})
|
||||
self.assertDictEqual({'dummy': 'value'}, sot.properties)
|
||||
self.assertDictEqual(
|
||||
{'dummy': 'value'}, sot.to_dict()['properties']
|
||||
)
|
||||
self.assertDictEqual(
|
||||
{'dummy': 'value'}, sot['properties']
|
||||
)
|
||||
self.assertEqual('value', sot['properties']['dummy'])
|
||||
|
||||
sot = Test.new(**{
|
||||
'dummy': 'value',
|
||||
'properties': 'a,b,c'
|
||||
})
|
||||
self.assertDictEqual(
|
||||
{'dummy': 'value', 'properties': 'a,b,c'},
|
||||
sot.properties
|
||||
)
|
||||
self.assertDictEqual(
|
||||
{'dummy': 'value', 'properties': 'a,b,c'},
|
||||
sot.to_dict()['properties']
|
||||
)
|
||||
|
||||
sot = Test.new(**{'properties': None})
|
||||
self.assertIsNone(sot.properties)
|
||||
self.assertIsNone(sot.to_dict()['properties'])
|
||||
|
||||
def test_unknown_attrs_not_stored(self):
|
||||
class Test(resource.Resource):
|
||||
properties = resource.Body("properties")
|
||||
|
||||
sot = Test.new(**{
|
||||
'dummy': 'value',
|
||||
})
|
||||
self.assertIsNone(sot.properties)
|
||||
|
||||
def test_unknown_attrs_not_stored1(self):
|
||||
class Test(resource.Resource):
|
||||
_store_unknown_attrs_as_properties = True
|
||||
|
||||
sot = Test.new(**{
|
||||
'dummy': 'value',
|
||||
})
|
||||
self.assertRaises(KeyError, sot.__getitem__, 'properties')
|
||||
|
||||
def test_unknown_attrs_under_props_set(self):
|
||||
class Test(resource.Resource):
|
||||
properties = resource.Body("properties")
|
||||
_store_unknown_attrs_as_properties = True
|
||||
|
||||
sot = Test.new(**{
|
||||
'dummy': 'value',
|
||||
})
|
||||
|
||||
sot['properties'] = {'dummy': 'new_value'}
|
||||
self.assertEqual('new_value', sot['properties']['dummy'])
|
||||
sot.properties = {'dummy': 'new_value1'}
|
||||
self.assertEqual('new_value1', sot['properties']['dummy'])
|
||||
|
||||
def test_unknown_attrs_prepare_request_unpacked(self):
|
||||
class Test(resource.Resource):
|
||||
properties = resource.Body("properties")
|
||||
_store_unknown_attrs_as_properties = True
|
||||
|
||||
# Unknown attribute given as root attribute
|
||||
sot = Test.new(**{
|
||||
'dummy': 'value',
|
||||
'properties': 'a,b,c'
|
||||
})
|
||||
|
||||
request_body = sot._prepare_request(requires_id=False).body
|
||||
self.assertEqual('value', request_body['dummy'])
|
||||
self.assertEqual('a,b,c', request_body['properties'])
|
||||
|
||||
# properties are already a dict
|
||||
sot = Test.new(**{
|
||||
'properties': {
|
||||
'properties': 'a,b,c',
|
||||
'dummy': 'value'
|
||||
}
|
||||
})
|
||||
|
||||
request_body = sot._prepare_request(requires_id=False).body
|
||||
self.assertEqual('value', request_body['dummy'])
|
||||
self.assertEqual('a,b,c', request_body['properties'])
|
||||
|
||||
def test_unknown_attrs_prepare_request_no_unpack_dict(self):
|
||||
# if props type is not None - ensure no unpacking is done
|
||||
class Test(resource.Resource):
|
||||
properties = resource.Body("properties", type=dict)
|
||||
sot = Test.new(**{
|
||||
'properties': {
|
||||
'properties': 'a,b,c',
|
||||
'dummy': 'value'
|
||||
}
|
||||
})
|
||||
|
||||
request_body = sot._prepare_request(requires_id=False).body
|
||||
self.assertDictEqual(
|
||||
{'dummy': 'value', 'properties': 'a,b,c'},
|
||||
request_body['properties'])
|
||||
|
||||
def test_unknown_attrs_prepare_request_patch_unpacked(self):
|
||||
class Test(resource.Resource):
|
||||
properties = resource.Body("properties")
|
||||
_store_unknown_attrs_as_properties = True
|
||||
commit_jsonpatch = True
|
||||
|
||||
sot = Test.existing(**{
|
||||
'dummy': 'value',
|
||||
'properties': 'a,b,c'
|
||||
})
|
||||
|
||||
sot._update(**{'properties': {'dummy': 'new_value'}})
|
||||
|
||||
request_body = sot._prepare_request(requires_id=False, patch=True).body
|
||||
self.assertDictEqual(
|
||||
{
|
||||
u'path': u'/dummy',
|
||||
u'value': u'new_value',
|
||||
u'op': u'replace'
|
||||
},
|
||||
request_body[0])
|
||||
|
||||
def test_unknown_attrs_under_props_translate_response(self):
|
||||
class Test(resource.Resource):
|
||||
properties = resource.Body("properties")
|
||||
_store_unknown_attrs_as_properties = True
|
||||
|
||||
body = {'dummy': 'value', 'properties': 'a,b,c'}
|
||||
response = FakeResponse(body)
|
||||
|
||||
sot = Test()
|
||||
|
||||
sot._translate_response(response, has_body=True)
|
||||
|
||||
self.assertDictEqual(
|
||||
{'dummy': 'value', 'properties': 'a,b,c'},
|
||||
sot.properties
|
||||
)
|
||||
|
||||
|
||||
class TestResourceActions(base.TestCase):
|
||||
|
||||
|
|
Loading…
Reference in New Issue