Move glance image service client from nova and cinder into ironic
Most of the code was present on nova.image.glance and cinder.image.glance. Should be removed once common code lands on python-glanceclient. Changes to code in glanceclient: - import names - added import_versioned_module func. to image_service - register options when module ironic.common.image_service loaded Change-Id: Ia7deb1a79c388333410b6abc24736481d435de77 Implements: blueprint image-tools
This commit is contained in:
parent
c97cf82a3f
commit
5e76790196
|
@ -32,6 +32,7 @@ from ironic.common import safe_utils
|
|||
from ironic.openstack.common import excutils
|
||||
from ironic.openstack.common import log as logging
|
||||
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
exc_log_opts = [
|
||||
|
@ -303,3 +304,52 @@ class OrphanedObjectError(IronicException):
|
|||
|
||||
class IncompatibleObjectVersion(IronicException):
|
||||
message = _('Version %(objver)s of %(objname)s is not supported')
|
||||
|
||||
|
||||
class GlanceConnectionFailed(IronicException):
|
||||
message = "Connection to glance host %(host)s:%(port)s failed: %(reason)s"
|
||||
|
||||
|
||||
class ImageNotAuthorized(IronicException):
|
||||
message = "Not authorized for image %(image_id)s."
|
||||
|
||||
|
||||
class InvalidImageRef(IronicException):
|
||||
message = "Invalid image href %(image_href)s."
|
||||
code = 400
|
||||
|
||||
|
||||
class ServiceUnavailable(IronicException):
|
||||
message = "Connection failed"
|
||||
|
||||
|
||||
class Forbidden(IronicException):
|
||||
message = "Requested OpenStack Images API is forbidden"
|
||||
|
||||
|
||||
class BadRequest(IronicException):
|
||||
pass
|
||||
|
||||
|
||||
class HTTPException(IronicException):
|
||||
message = "Requested version of OpenStack Images API is not available."
|
||||
|
||||
|
||||
class InvalidEndpoint(IronicException):
|
||||
message = "The provided endpoint is invalid"
|
||||
|
||||
|
||||
class CommunicationError(IronicException):
|
||||
message = "Unable to communicate with the server."
|
||||
|
||||
|
||||
class HTTPForbidden(Forbidden):
|
||||
pass
|
||||
|
||||
|
||||
class Unauthorized(IronicException):
|
||||
pass
|
||||
|
||||
|
||||
class HTTPNotFound(NotFound):
|
||||
pass
|
||||
|
|
|
@ -0,0 +1,298 @@
|
|||
# vim: tabstop=4 shiftwidth=4 softtabstop=4
|
||||
|
||||
# Copyright 2010 OpenStack Foundation
|
||||
# Copyright 2013 Hewlett-Packard Development Company, L.P.
|
||||
# All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||
# not use this file except in compliance with the License. You may obtain
|
||||
# a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
|
||||
import functools
|
||||
import logging
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
import urlparse
|
||||
|
||||
from glanceclient import client
|
||||
from ironic.common import exception
|
||||
from ironic.common.glance_service import service_utils
|
||||
|
||||
from oslo.config import cfg
|
||||
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
CONF = cfg.CONF
|
||||
|
||||
|
||||
def _translate_image_exception(image_id, exc_value):
|
||||
if isinstance(exc_value, (exception.Forbidden,
|
||||
exception.Unauthorized)):
|
||||
return exception.ImageNotAuthorized(image_id=image_id)
|
||||
if isinstance(exc_value, exception.NotFound):
|
||||
return exception.ImageNotFound(image_id=image_id)
|
||||
if isinstance(exc_value, exception.BadRequest):
|
||||
return exception.Invalid(exc_value)
|
||||
return exc_value
|
||||
|
||||
|
||||
def _translate_plain_exception(exc_value):
|
||||
if isinstance(exc_value, (exception.Forbidden,
|
||||
exception.Unauthorized)):
|
||||
return exception.NotAuthorized(exc_value)
|
||||
if isinstance(exc_value, exception.NotFound):
|
||||
return exception.NotFound(exc_value)
|
||||
if isinstance(exc_value, exception.BadRequest):
|
||||
return exception.Invalid(exc_value)
|
||||
return exc_value
|
||||
|
||||
|
||||
def check_image_service(func):
|
||||
"""Creates a glance client if doesn't exists and calls the function."""
|
||||
@functools.wraps(func)
|
||||
def wrapper(self, *args, **kwargs):
|
||||
"""wrapper around methods calls
|
||||
:param image_href: href that describes the location of an image
|
||||
"""
|
||||
|
||||
if self.client:
|
||||
return func(self, *args, **kwargs)
|
||||
|
||||
image_href = kwargs.get('image_href', None)
|
||||
(image_id, self.glance_host,
|
||||
self.glance_port, use_ssl) = service_utils.parse_image_ref(image_href)
|
||||
|
||||
if use_ssl:
|
||||
scheme = 'https'
|
||||
else:
|
||||
scheme = 'http'
|
||||
params = {}
|
||||
params['insecure'] = CONF.glance.glance_api_insecure
|
||||
if CONF.glance.auth_strategy == 'keystone':
|
||||
params['token'] = self.context.auth_token
|
||||
endpoint = '%s://%s:%s' % (scheme, self.glance_host, self.glance_port)
|
||||
self.client = client.Client(self.version,
|
||||
endpoint, **params)
|
||||
return func(self, *args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
|
||||
class BaseImageService(object):
|
||||
|
||||
def __init__(self, client=None, version=1, context=None):
|
||||
self.client = client
|
||||
self.version = version
|
||||
self.context = context
|
||||
|
||||
def call(self, method, *args, **kwargs):
|
||||
"""Call a glance client method.
|
||||
If we get a connection error,
|
||||
retry the request according to CONF.glance_num_retries.
|
||||
|
||||
:param context: The request context, for access checks.
|
||||
:param version: The requested API version.v
|
||||
:param method: The method requested to be called.
|
||||
:param args: A list of positional arguments for the method called
|
||||
:param kwargs: A dict of keyword arguments for the method called
|
||||
|
||||
:raises: GlanceConnectionFailed
|
||||
"""
|
||||
retry_excs = (exception.ServiceUnavailable,
|
||||
exception.InvalidEndpoint,
|
||||
exception.CommunicationError)
|
||||
image_excs = (exception.Forbidden,
|
||||
exception.Unauthorized,
|
||||
exception.NotFound,
|
||||
exception.BadRequest)
|
||||
num_attempts = 1 + CONF.glance.glance_num_retries
|
||||
|
||||
for attempt in xrange(1, num_attempts + 1):
|
||||
try:
|
||||
return getattr(self.client.images, method)(*args, **kwargs)
|
||||
except retry_excs as e:
|
||||
host = self.glance_host
|
||||
port = self.glance_port
|
||||
extra = "retrying"
|
||||
error_msg = _("Error contacting glance server "
|
||||
"'%(host)s:%(port)s' for '%(method)s', "
|
||||
"%(extra)s.")
|
||||
if attempt == num_attempts:
|
||||
extra = 'done trying'
|
||||
LOG.exception(error_msg, {'host': host,
|
||||
'port': port,
|
||||
'num_attempts': num_attempts,
|
||||
'method': method,
|
||||
'extra': extra})
|
||||
raise exception.GlanceConnectionFailed(host=host,
|
||||
port=port,
|
||||
reason=str(e))
|
||||
LOG.exception(error_msg, {'host': host,
|
||||
'port': port,
|
||||
'num_attempts': num_attempts,
|
||||
'attempt': attempt,
|
||||
'method': method,
|
||||
'extra': extra})
|
||||
time.sleep(1)
|
||||
except image_excs as e:
|
||||
exc_type, exc_value, exc_trace = sys.exc_info()
|
||||
if method == 'list':
|
||||
new_exc = _translate_plain_exception(
|
||||
exc_value)
|
||||
else:
|
||||
new_exc = _translate_image_exception(
|
||||
args[0], exc_value)
|
||||
raise new_exc, None, exc_trace
|
||||
|
||||
@check_image_service
|
||||
def _detail(self, method='list', **kwargs):
|
||||
"""Calls out to Glance for a list of detailed image information.
|
||||
|
||||
:returns: A list of dicts containing image metadata.
|
||||
"""
|
||||
LOG.debug(_("Getting a full list of images metadata from glance."))
|
||||
params = service_utils.extract_query_params(kwargs, self.version)
|
||||
|
||||
images = self.call(method, **params)
|
||||
|
||||
_images = []
|
||||
for image in images:
|
||||
if service_utils.is_image_available(self.context, image):
|
||||
_images.append(service_utils.translate_from_glance(image))
|
||||
|
||||
return _images
|
||||
|
||||
@check_image_service
|
||||
def _show(self, image_href, method='get'):
|
||||
"""Returns a dict with image data for the given opaque image id.
|
||||
|
||||
:param image_id: The opaque image identifier.
|
||||
:returns: A dict containing image metadata.
|
||||
|
||||
:raises: ImageNotFound
|
||||
"""
|
||||
LOG.debug(_("Getting image metadata from glance. Image: %s")
|
||||
% image_href)
|
||||
(image_id, self.glance_host,
|
||||
self.glance_port, use_ssl) = service_utils.parse_image_ref(image_href)
|
||||
|
||||
image = self.call(method, image_id)
|
||||
|
||||
if not service_utils.is_image_available(self.context, image):
|
||||
raise exception.ImageNotFound(image_id=image_id)
|
||||
|
||||
base_image_meta = service_utils.translate_from_glance(image)
|
||||
return base_image_meta
|
||||
|
||||
@check_image_service
|
||||
def _download(self, image_id, data=None, method='data'):
|
||||
"""Calls out to Glance for data and writes data.
|
||||
|
||||
:param image_id: The opaque image identifier.
|
||||
:param data: (Optional) File object to write data to.
|
||||
"""
|
||||
(image_id, self.glance_host,
|
||||
self.glance_port, use_ssl) = service_utils.parse_image_ref(image_id)
|
||||
|
||||
if self.version == 2 \
|
||||
and 'file' in CONF.glance.allowed_direct_url_schemes:
|
||||
|
||||
location = self._get_location(image_id)
|
||||
url = urlparse.urlparse(location)
|
||||
if url.scheme == "file":
|
||||
with open(url.path, "r") as f:
|
||||
#TODO(ghe): Use system call for downloading files.
|
||||
# Bug #1199522
|
||||
|
||||
# FIXME(jbresnah) a system call to cp could have
|
||||
# significant performance advantages, however we
|
||||
# do not have the path to files at this point in
|
||||
# the abstraction.
|
||||
shutil.copyfileobj(f, data)
|
||||
return
|
||||
|
||||
image_chunks = self.call(method, image_id)
|
||||
|
||||
if data is None:
|
||||
return image_chunks
|
||||
else:
|
||||
for chunk in image_chunks:
|
||||
data.write(chunk)
|
||||
|
||||
@check_image_service
|
||||
def _create(self, image_meta, data=None, method='create'):
|
||||
"""Store the image data and return the new image object.
|
||||
|
||||
:param image_meta: A dict containing image metadata
|
||||
:param data: (Optional) File object to create image from.
|
||||
:returns: dict -- New created image metadata
|
||||
"""
|
||||
sent_service_image_meta = service_utils.translate_to_glance(image_meta)
|
||||
|
||||
#TODO(ghe): Allow copy-from or location headers Bug #1199532
|
||||
|
||||
if data:
|
||||
sent_service_image_meta['data'] = data
|
||||
|
||||
recv_service_image_meta = self.call(method, **sent_service_image_meta)
|
||||
|
||||
return service_utils.translate_from_glance(recv_service_image_meta)
|
||||
|
||||
@check_image_service
|
||||
def _update(self, image_id, image_meta, data=None, method='update',
|
||||
purge_props=False):
|
||||
|
||||
"""Modify the given image with the new data.
|
||||
|
||||
:param image_id: The opaque image identifier.
|
||||
:param data: (Optional) File object to update data from.
|
||||
:param purge_props: (Optional=False) Purge existing properties.
|
||||
:returns: dict -- New created image metadata
|
||||
"""
|
||||
(image_id, self.glance_host,
|
||||
self.glance_port, use_ssl) = service_utils.parse_image_ref(image_id)
|
||||
if image_meta:
|
||||
image_meta = service_utils.translate_to_glance(image_meta)
|
||||
else:
|
||||
image_meta = {}
|
||||
if self.version == 1:
|
||||
image_meta['purge_props'] = purge_props
|
||||
if data:
|
||||
image_meta['data'] = data
|
||||
|
||||
#NOTE(bcwaldon): id is not an editable field, but it is likely to be
|
||||
# passed in by calling code. Let's be nice and ignore it.
|
||||
image_meta.pop('id', None)
|
||||
|
||||
image_meta = self.call(method, image_id, **image_meta)
|
||||
|
||||
if self.version == 2 and data:
|
||||
self.call('upload', image_id, data)
|
||||
image_meta = self._show(image_id)
|
||||
|
||||
return image_meta
|
||||
|
||||
@check_image_service
|
||||
def _delete(self, image_id, method='delete'):
|
||||
"""Delete the given image.
|
||||
|
||||
:param image_id: The opaque image identifier.
|
||||
|
||||
:raises: ImageNotFound if the image does not exist.
|
||||
:raises: NotAuthorized if the user is not an owner.
|
||||
:raises: ImageNotAuthorized if the user is not authorized.
|
||||
|
||||
"""
|
||||
(image_id, glance_host,
|
||||
glance_port, use_ssl) = service_utils.parse_image_ref(image_id)
|
||||
|
||||
self.call(method, image_id)
|
|
@ -0,0 +1,82 @@
|
|||
# vim: tabstop=4 shiftwidth=4 softtabstop=4
|
||||
|
||||
# Copyright 2013 Hewlett-Packard Development Company, L.P.
|
||||
# All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||
# not use this file except in compliance with the License. You may obtain
|
||||
# a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
import abc
|
||||
|
||||
|
||||
class ImageService(object):
|
||||
"""Provides storage and retrieval of disk image objects within Glance."""
|
||||
|
||||
__metaclass__ = abc.ABCMeta
|
||||
|
||||
@abc.abstractmethod
|
||||
def __init__(self):
|
||||
"""Constructor."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def detail(self):
|
||||
"""Calls out to Glance for a list of detailed image information."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def show(self, image_id):
|
||||
"""Returns a dict with image data for the given opaque image id.
|
||||
|
||||
:param image_id: The opaque image identifier.
|
||||
:returns: A dict containing image metadata.
|
||||
|
||||
:raises: ImageNotFound
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def download(self, image_id, data=None):
|
||||
"""Calls out to Glance for data and writes data.
|
||||
|
||||
:param image_id: The opaque image identifier.
|
||||
:param data: (Optional) File object to write data to.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def create(self, image_meta, data=None):
|
||||
"""Store the image data and return the new image object.
|
||||
|
||||
:param image_meta: A dict containing image metadata
|
||||
:param data: (Optional) File object to create image from.
|
||||
:returns: dict -- New created image metadata
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def update(self, image_id,
|
||||
image_meta, data=None, purge_props=False):
|
||||
"""Modify the given image with the new data.
|
||||
|
||||
:param image_id: The opaque image identifier.
|
||||
:param data: (Optional) File object to update data from.
|
||||
:param purge_props: (Optional=True) Purge existing properties.
|
||||
:returns: dict -- New created image metadata
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def delete(self, image_id):
|
||||
"""Delete the given image.
|
||||
|
||||
:param image_id: The opaque image identifier.
|
||||
|
||||
:raises: ImageNotFound if the image does not exist.
|
||||
:raises: NotAuthorized if the user is not an owner.
|
||||
:raises: ImageNotAuthorized if the user is not authorized.
|
||||
|
||||
"""
|
|
@ -0,0 +1,208 @@
|
|||
# vim: tabstop=4 shiftwidth=4 softtabstop=4
|
||||
|
||||
# Copyright 2012 OpenStack Foundation
|
||||
# Copyright 2013 Hewlett-Packard Development Company, L.P.
|
||||
# All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||
# not use this file except in compliance with the License. You may obtain
|
||||
# a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
import copy
|
||||
import logging
|
||||
import urlparse
|
||||
|
||||
from oslo.config import cfg
|
||||
|
||||
from ironic.common import exception
|
||||
from ironic.openstack.common import jsonutils
|
||||
from ironic.openstack.common import timeutils
|
||||
|
||||
|
||||
CONF = cfg.CONF
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def generate_glance_url():
|
||||
"""Generate the URL to glance."""
|
||||
return "%s://%s:%d" % (CONF.glance.glance_protocol,
|
||||
CONF.glance.glance_host,
|
||||
CONF.glance.glance_port)
|
||||
|
||||
|
||||
def generate_image_url(image_ref):
|
||||
"""Generate an image URL from an image_ref."""
|
||||
return "%s/images/%s" % (generate_glance_url(), image_ref)
|
||||
|
||||
|
||||
def _extract_attributes(image):
|
||||
IMAGE_ATTRIBUTES = ['size', 'disk_format', 'owner',
|
||||
'container_format', 'checksum', 'id',
|
||||
'name', 'created_at', 'updated_at',
|
||||
'deleted_at', 'deleted', 'status',
|
||||
'min_disk', 'min_ram', 'is_public']
|
||||
|
||||
IMAGE_ATTRIBUTES_V2 = ['tags', 'visibility', 'protected',
|
||||
'file', 'schema']
|
||||
|
||||
output = {}
|
||||
for attr in IMAGE_ATTRIBUTES:
|
||||
output[attr] = getattr(image, attr, None)
|
||||
|
||||
output['properties'] = getattr(image, 'properties', {})
|
||||
|
||||
if hasattr(image, 'schema') and 'v2' in image['schema']:
|
||||
IMAGE_ATTRIBUTES = IMAGE_ATTRIBUTES + IMAGE_ATTRIBUTES_V2
|
||||
for attr in IMAGE_ATTRIBUTES_V2:
|
||||
output[attr] = getattr(image, attr, None)
|
||||
output['schema'] = image['schema']
|
||||
|
||||
for image_property in set(image.keys()) - set(IMAGE_ATTRIBUTES):
|
||||
output['properties'][image_property] = image[image_property]
|
||||
|
||||
return output
|
||||
|
||||
|
||||
def _convert_timestamps_to_datetimes(image_meta):
|
||||
"""Returns image with timestamp fields converted to datetime objects."""
|
||||
for attr in ['created_at', 'updated_at', 'deleted_at']:
|
||||
if image_meta.get(attr):
|
||||
image_meta[attr] = timeutils.parse_isotime(image_meta[attr])
|
||||
return image_meta
|
||||
|
||||
_CONVERT_PROPS = ('block_device_mapping', 'mappings')
|
||||
|
||||
|
||||
def _convert(metadata, method):
|
||||
metadata = copy.deepcopy(metadata)
|
||||
properties = metadata.get('properties')
|
||||
if properties:
|
||||
for attr in _CONVERT_PROPS:
|
||||
if attr in properties:
|
||||
prop = properties[attr]
|
||||
if method == 'from':
|
||||
if isinstance(prop, basestring):
|
||||
properties[attr] = jsonutils.loads(prop)
|
||||
if method == 'to':
|
||||
if not isinstance(prop, basestring):
|
||||
properties[attr] = jsonutils.dumps(prop)
|
||||
return metadata
|
||||
|
||||
|
||||
def _remove_read_only(image_meta):
|
||||
IMAGE_ATTRIBUTES = ['status', 'updated_at', 'created_at', 'deleted_at']
|
||||
output = copy.deepcopy(image_meta)
|
||||
for attr in IMAGE_ATTRIBUTES:
|
||||
if attr in output:
|
||||
del output[attr]
|
||||
return output
|
||||
|
||||
|
||||
def _get_api_server():
|
||||
"""Shuffle a list of CONF.glance_api_servers and return an iterator
|
||||
that will cycle through the list, looping around to the beginning
|
||||
if necessary.
|
||||
"""
|
||||
api_server = CONF.glance.glance_api_servers or \
|
||||
CONF.glance.glance_host + ':' + str(CONF.glance.glance_port)
|
||||
if '//' not in api_server:
|
||||
api_server = 'http://' + api_server
|
||||
url = urlparse.urlparse(api_server)
|
||||
port = url.port or 80
|
||||
host = url.netloc.split(':', 1)[0]
|
||||
use_ssl = (url.scheme == 'https')
|
||||
return host, port, use_ssl
|
||||
|
||||
|
||||
def parse_image_ref(image_href):
|
||||
"""Parse an image href into composite parts.
|
||||
|
||||
:param image_href: href of an image
|
||||
:returns: a tuple of the form (image_id, host, port)
|
||||
|
||||
:raises ValueError
|
||||
"""
|
||||
if '/' not in str(image_href):
|
||||
image_id = image_href
|
||||
(glance_host, glance_port, use_ssl) = _get_api_server()
|
||||
return (image_id, glance_host, glance_port, use_ssl)
|
||||
else:
|
||||
try:
|
||||
url = urlparse.urlparse(image_href)
|
||||
if url.scheme == 'glance':
|
||||
(glance_host, glance_port, use_ssl) = _get_api_server()
|
||||
image_id = image_href.split('/')[-1]
|
||||
else:
|
||||
glance_port = url.port or 80
|
||||
glance_host = url.netloc.split(':', 1)[0]
|
||||
image_id = url.path.split('/')[-1]
|
||||
use_ssl = (url.scheme == 'https')
|
||||
return (image_id, glance_host, glance_port, use_ssl)
|
||||
except ValueError:
|
||||
raise exception.InvalidImageRef(image_href=image_href)
|
||||
|
||||
|
||||
def extract_query_params(params, version):
|
||||
_params = {}
|
||||
accepted_params = ('filters', 'marker', 'limit',
|
||||
'sort_key', 'sort_dir')
|
||||
for param in accepted_params:
|
||||
if params.get(param):
|
||||
_params[param] = params.get(param)
|
||||
# ensure filters is a dict
|
||||
_params.setdefault('filters', {})
|
||||
|
||||
# NOTE(vish): don't filter out private images
|
||||
# NOTE(ghe): in v2, not passing any visibility doesn't filter prvate images
|
||||
if version == 1:
|
||||
_params['filters'].setdefault('is_public', 'none')
|
||||
|
||||
return _params
|
||||
|
||||
|
||||
def translate_to_glance(image_meta):
|
||||
image_meta = _convert(image_meta, 'to')
|
||||
image_meta = _remove_read_only(image_meta)
|
||||
return image_meta
|
||||
|
||||
|
||||
def translate_from_glance(image):
|
||||
image_meta = _extract_attributes(image)
|
||||
image_meta = _convert_timestamps_to_datetimes(image_meta)
|
||||
image_meta = _convert(image_meta, 'from')
|
||||
return image_meta
|
||||
|
||||
|
||||
def is_image_available(context, image):
|
||||
"""Check image availability.
|
||||
|
||||
This check is needed in case Nova and Glance are deployed
|
||||
without authentication turned on.
|
||||
"""
|
||||
# The presence of an auth token implies this is an authenticated
|
||||
# request and we need not handle the noauth use-case.
|
||||
if hasattr(context, 'auth_token') and context.auth_token:
|
||||
return True
|
||||
if image.is_public or context.is_admin:
|
||||
return True
|
||||
properties = image.properties
|
||||
if context.project_id and ('owner_id' in properties):
|
||||
return str(properties['owner_id']) == str(context.project_id)
|
||||
|
||||
if context.project_id and ('project_id' in properties):
|
||||
return str(properties['project_id']) == str(context.project_id)
|
||||
|
||||
try:
|
||||
user_id = properties['user_id']
|
||||
except KeyError:
|
||||
return False
|
||||
|
||||
return str(user_id) == str(context.user_id)
|
|
@ -0,0 +1,43 @@
|
|||
# vim: tabstop=4 shiftwidth=4 softtabstop=4
|
||||
|
||||
# Copyright 2013 Hewlett-Packard Development Company, L.P.
|
||||
# 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.
|
||||
|
||||
|
||||
from ironic.common.glance_service import base_image_service
|
||||
from ironic.common.glance_service import service
|
||||
|
||||
|
||||
class GlanceImageService(base_image_service.BaseImageService,
|
||||
service.ImageService):
|
||||
|
||||
def detail(self, **kwargs):
|
||||
return self._detail(method='list', **kwargs)
|
||||
|
||||
def show(self, image_id):
|
||||
return self._show(image_id, method='get')
|
||||
|
||||
def download(self, image_id, data=None):
|
||||
return self._download(image_id, method='data', data=data)
|
||||
|
||||
def create(self, image_meta, data=None):
|
||||
return self._create(image_meta, method='create', data=data)
|
||||
|
||||
def update(self, image_id, image_meta, data=None, purge_props=False):
|
||||
return self._update(image_id, image_meta, data=data, method='update',
|
||||
purge_props=purge_props)
|
||||
|
||||
def delete(self, image_id):
|
||||
return self._delete(image_id, method='delete')
|
|
@ -0,0 +1,71 @@
|
|||
# vim: tabstop=4 shiftwidth=4 softtabstop=4
|
||||
|
||||
# Copyright 2013 Hewlett-Packard Development Company, L.P.
|
||||
# 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.
|
||||
|
||||
from oslo.config import cfg
|
||||
|
||||
from ironic.common import exception as exc
|
||||
from ironic.common.glance_service import base_image_service
|
||||
from ironic.common.glance_service import service
|
||||
from ironic.common.glance_service import service_utils
|
||||
|
||||
|
||||
glance_opts = [
|
||||
cfg.ListOpt('allowed_direct_url_schemes',
|
||||
default=[],
|
||||
help='A list of url scheme that can be downloaded directly '
|
||||
'via the direct_url. Currently supported schemes: '
|
||||
'[file].')
|
||||
]
|
||||
|
||||
CONF = cfg.CONF
|
||||
CONF.register_opts(glance_opts, group='glance')
|
||||
|
||||
|
||||
class GlanceImageService(base_image_service.BaseImageService,
|
||||
service.ImageService):
|
||||
|
||||
def detail(self, **kwargs):
|
||||
return self._detail(method='list', **kwargs)
|
||||
|
||||
def show(self, image_id):
|
||||
return self._show(image_id, method='get')
|
||||
|
||||
def download(self, image_id, data=None):
|
||||
return self._download(image_id, method='data', data=data)
|
||||
|
||||
def create(self, image_meta, data=None):
|
||||
image_id = self._create(image_meta, method='create', data=None)['id']
|
||||
return self.update(image_id, None, data)
|
||||
|
||||
def update(self, image_id, image_meta, data=None, purge_props=False):
|
||||
# NOTE(ghe): purge_props not working until bug 1206472 solved
|
||||
return self._update(image_id, image_meta, data, method='update',
|
||||
purge_props=False)
|
||||
|
||||
def delete(self, image_id):
|
||||
return self._delete(image_id, method='delete')
|
||||
|
||||
def _get_location(self, image_id):
|
||||
"""Returns the direct url representing the backend storage location,
|
||||
or None if this attribute is not shown by Glance.
|
||||
"""
|
||||
image_meta = self.call('get', image_id)
|
||||
|
||||
if not service_utils.is_image_available(self.context, image_meta):
|
||||
raise exc.ImageNotFound(image_id=image_id)
|
||||
|
||||
return getattr(image_meta, 'direct_url', None)
|
|
@ -0,0 +1,67 @@
|
|||
# vim: tabstop=4 shiftwidth=4 softtabstop=4
|
||||
# Copyright 2010 OpenStack LLC.
|
||||
# Copyright 2013 Hewlett-Packard Development Company, L.P.
|
||||
# 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.
|
||||
|
||||
|
||||
from ironic.openstack.common import importutils
|
||||
|
||||
from oslo.config import cfg
|
||||
|
||||
|
||||
glance_opts = [
|
||||
cfg.StrOpt('glance_host',
|
||||
default='$my_ip',
|
||||
help='default glance hostname or ip'),
|
||||
cfg.IntOpt('glance_port',
|
||||
default=9292,
|
||||
help='default glance port'),
|
||||
cfg.StrOpt('glance_protocol',
|
||||
default='http',
|
||||
help='Default protocol to use when connecting to glance. '
|
||||
'Set to https for SSL.'),
|
||||
cfg.StrOpt('glance_api_servers',
|
||||
help='A list of the glance api servers available to nova. '
|
||||
'Prefix with https:// for ssl-based glance api servers. '
|
||||
'([hostname|ip]:port)'),
|
||||
cfg.BoolOpt('glance_api_insecure',
|
||||
default=False,
|
||||
help='Allow to perform insecure SSL (https) requests to '
|
||||
'glance'),
|
||||
cfg.IntOpt('glance_num_retries',
|
||||
default=0,
|
||||
help='Number retries when downloading an image from glance'),
|
||||
cfg.StrOpt('auth_strategy',
|
||||
default='keystone',
|
||||
help='Default protocol to use when connecting to glance. '
|
||||
'Set to https for SSL.'),
|
||||
]
|
||||
|
||||
|
||||
CONF = cfg.CONF
|
||||
CONF.register_opts(glance_opts, group='glance')
|
||||
|
||||
|
||||
def import_versioned_module(version, submodule=None):
|
||||
module = 'ironic.common.glance_service.v%s' % version
|
||||
if submodule:
|
||||
module = '.'.join((module, submodule))
|
||||
return importutils.import_module(module)
|
||||
|
||||
|
||||
def Service(client=None, version=1, context=None):
|
||||
module = import_versioned_module(version, 'image_service')
|
||||
service_class = getattr(module, 'GlanceImageService')
|
||||
return service_class(client, version, context)
|
|
@ -0,0 +1,105 @@
|
|||
# vim: tabstop=4 shiftwidth=4 softtabstop=4
|
||||
|
||||
# Copyright 2010 United States Government as represented by the
|
||||
# Administrator of the National Aeronautics and Space Administration.
|
||||
# Copyright 2012 Hewlett-Packard Development Company, L.P.
|
||||
#
|
||||
# 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.
|
||||
|
||||
"""Matcher classes to be used inside of the testtools assertThat framework."""
|
||||
|
||||
import pprint
|
||||
|
||||
|
||||
class DictKeysMismatch(object):
|
||||
def __init__(self, d1only, d2only):
|
||||
self.d1only = d1only
|
||||
self.d2only = d2only
|
||||
|
||||
def describe(self):
|
||||
return ('Keys in d1 and not d2: %(d1only)s.'
|
||||
' Keys in d2 and not d1: %(d2only)s' % self.__dict__)
|
||||
|
||||
def get_details(self):
|
||||
return {}
|
||||
|
||||
|
||||
class DictMismatch(object):
|
||||
def __init__(self, key, d1_value, d2_value):
|
||||
self.key = key
|
||||
self.d1_value = d1_value
|
||||
self.d2_value = d2_value
|
||||
|
||||
def describe(self):
|
||||
return ("Dictionaries do not match at %(key)s."
|
||||
" d1: %(d1_value)s d2: %(d2_value)s" % self.__dict__)
|
||||
|
||||
def get_details(self):
|
||||
return {}
|
||||
|
||||
|
||||
class DictMatches(object):
|
||||
|
||||
def __init__(self, d1, approx_equal=False, tolerance=0.001):
|
||||
self.d1 = d1
|
||||
self.approx_equal = approx_equal
|
||||
self.tolerance = tolerance
|
||||
|
||||
def __str__(self):
|
||||
return 'DictMatches(%s)' % (pprint.pformat(self.d1))
|
||||
|
||||
# Useful assertions
|
||||
def match(self, d2):
|
||||
"""Assert two dicts are equivalent.
|
||||
|
||||
This is a 'deep' match in the sense that it handles nested
|
||||
dictionaries appropriately.
|
||||
|
||||
NOTE:
|
||||
|
||||
If you don't care (or don't know) a given value, you can specify
|
||||
the string DONTCARE as the value. This will cause that dict-item
|
||||
to be skipped.
|
||||
|
||||
"""
|
||||
|
||||
d1keys = set(self.d1.keys())
|
||||
d2keys = set(d2.keys())
|
||||
if d1keys != d2keys:
|
||||
d1only = d1keys - d2keys
|
||||
d2only = d2keys - d1keys
|
||||
return DictKeysMismatch(d1only, d2only)
|
||||
|
||||
for key in d1keys:
|
||||
d1value = self.d1[key]
|
||||
d2value = d2[key]
|
||||
try:
|
||||
error = abs(float(d1value) - float(d2value))
|
||||
within_tolerance = error <= self.tolerance
|
||||
except (ValueError, TypeError):
|
||||
# If both values aren't convertible to float, just ignore
|
||||
# ValueError if arg is a str, TypeError if it's something else
|
||||
# (like None)
|
||||
within_tolerance = False
|
||||
|
||||
if hasattr(d1value, 'keys') and hasattr(d2value, 'keys'):
|
||||
matcher = DictMatches(d1value)
|
||||
did_match = matcher.match(d2value)
|
||||
if did_match is not None:
|
||||
return did_match
|
||||
elif 'DONTCARE' in (d1value, d2value):
|
||||
continue
|
||||
elif self.approx_equal and within_tolerance:
|
||||
continue
|
||||
elif d1value != d2value:
|
||||
return DictMismatch(key, d1value, d2value)
|
|
@ -0,0 +1,118 @@
|
|||
# vim: tabstop=4 shiftwidth=4 softtabstop=4
|
||||
|
||||
# Copyright (c) 2011 Citrix Systems, Inc.
|
||||
#
|
||||
# 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.
|
||||
|
||||
from ironic.common import exception
|
||||
|
||||
|
||||
NOW_GLANCE_FORMAT = "2010-10-11T10:30:22"
|
||||
|
||||
|
||||
class StubGlanceClient(object):
|
||||
|
||||
def __init__(self, images=None):
|
||||
self._images = []
|
||||
_images = images or []
|
||||
map(lambda image: self.create(**image), _images)
|
||||
|
||||
#NOTE(bcwaldon): HACK to get client.images.* to work
|
||||
self.images = lambda: None
|
||||
for fn in ('list', 'get', 'data', 'create', 'update', 'delete'):
|
||||
setattr(self.images, fn, getattr(self, fn))
|
||||
|
||||
#TODO(bcwaldon): implement filters
|
||||
def list(self, filters=None, marker=None, limit=30):
|
||||
if marker is None:
|
||||
index = 0
|
||||
else:
|
||||
for index, image in enumerate(self._images):
|
||||
if image.id == str(marker):
|
||||
index += 1
|
||||
break
|
||||
else:
|
||||
raise exception.BadRequest('Marker not found')
|
||||
|
||||
return self._images[index:index + limit]
|
||||
|
||||
def get(self, image_id):
|
||||
for image in self._images:
|
||||
if image.id == str(image_id):
|
||||
return image
|
||||
raise exception.ImageNotFound(image_id)
|
||||
|
||||
def data(self, image_id):
|
||||
self.get(image_id)
|
||||
return []
|
||||
|
||||
def create(self, **metadata):
|
||||
metadata['created_at'] = NOW_GLANCE_FORMAT
|
||||
metadata['updated_at'] = NOW_GLANCE_FORMAT
|
||||
|
||||
self._images.append(FakeImage(metadata))
|
||||
|
||||
try:
|
||||
image_id = str(metadata['id'])
|
||||
except KeyError:
|
||||
# auto-generate an id if one wasn't provided
|
||||
image_id = str(len(self._images))
|
||||
|
||||
self._images[-1].id = image_id
|
||||
|
||||
return self._images[-1]
|
||||
|
||||
def update(self, image_id, **metadata):
|
||||
for i, image in enumerate(self._images):
|
||||
if image.id == str(image_id):
|
||||
for k, v in metadata.items():
|
||||
setattr(self._images[i], k, v)
|
||||
return self._images[i]
|
||||
raise exception.NotFound(image_id)
|
||||
|
||||
def delete(self, image_id):
|
||||
for i, image in enumerate(self._images):
|
||||
if image.id == image_id:
|
||||
# When you delete an image from glance, it sets the status to
|
||||
# DELETED. If you try to delete a DELETED image, it raises
|
||||
# HTTPForbidden.
|
||||
image_data = self._images[i]
|
||||
if image_data.deleted:
|
||||
raise exception.Forbidden()
|
||||
image_data.deleted = True
|
||||
return
|
||||
raise exception.NotFound(image_id)
|
||||
|
||||
|
||||
class FakeImage(object):
|
||||
def __init__(self, metadata):
|
||||
IMAGE_ATTRIBUTES = ['size', 'disk_format', 'owner',
|
||||
'container_format', 'checksum', 'id',
|
||||
'name', 'created_at', 'updated_at',
|
||||
'deleted', 'status',
|
||||
'min_disk', 'min_ram', 'is_public']
|
||||
raw = dict.fromkeys(IMAGE_ATTRIBUTES)
|
||||
raw.update(metadata)
|
||||
self.__dict__['raw'] = raw
|
||||
|
||||
def __getattr__(self, key):
|
||||
try:
|
||||
return self.__dict__['raw'][key]
|
||||
except KeyError:
|
||||
raise AttributeError(key)
|
||||
|
||||
def __setattr__(self, key, value):
|
||||
try:
|
||||
self.__dict__['raw'][key] = value
|
||||
except KeyError:
|
||||
raise AttributeError(key)
|
|
@ -0,0 +1,655 @@
|
|||
# vim: tabstop=4 shiftwidth=4 softtabstop=4
|
||||
|
||||
# Copyright 2013 Hewlett-Packard Development Company, L.P.
|
||||
# All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||
# not use this file except in compliance with the License. You may obtain
|
||||
# a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
|
||||
import datetime
|
||||
import filecmp
|
||||
import os
|
||||
import tempfile
|
||||
import testtools
|
||||
|
||||
from ironic.common import exception
|
||||
from ironic.common.glance_service import base_image_service
|
||||
from ironic.common.glance_service import service_utils
|
||||
from ironic.common import image_service as service
|
||||
from ironic.openstack.common import context
|
||||
from ironic.tests import matchers
|
||||
from ironic.tests import stubs
|
||||
from ironic.tests import utils
|
||||
|
||||
from oslo.config import cfg
|
||||
|
||||
CONF = cfg.CONF
|
||||
|
||||
|
||||
class NullWriter(object):
|
||||
"""Used to test ImageService.get which takes a writer object."""
|
||||
|
||||
def write(self, *arg, **kwargs):
|
||||
pass
|
||||
|
||||
|
||||
class TestGlanceSerializer(testtools.TestCase):
|
||||
def test_serialize(self):
|
||||
metadata = {'name': 'image1',
|
||||
'is_public': True,
|
||||
'foo': 'bar',
|
||||
'properties': {
|
||||
'prop1': 'propvalue1',
|
||||
'mappings': [
|
||||
{'virtual': 'aaa',
|
||||
'device': 'bbb'},
|
||||
{'virtual': 'xxx',
|
||||
'device': 'yyy'}],
|
||||
'block_device_mapping': [
|
||||
{'virtual_device': 'fake',
|
||||
'device_name': '/dev/fake'},
|
||||
{'virtual_device': 'ephemeral0',
|
||||
'device_name': '/dev/fake0'}]}}
|
||||
|
||||
converted_expected = {
|
||||
'name': 'image1',
|
||||
'is_public': True,
|
||||
'foo': 'bar',
|
||||
'properties': {
|
||||
'prop1': 'propvalue1',
|
||||
'mappings':
|
||||
'[{"device": "bbb", "virtual": "aaa"}, '
|
||||
'{"device": "yyy", "virtual": "xxx"}]',
|
||||
'block_device_mapping':
|
||||
'[{"virtual_device": "fake", "device_name": "/dev/fake"}, '
|
||||
'{"virtual_device": "ephemeral0", '
|
||||
'"device_name": "/dev/fake0"}]'}}
|
||||
converted = service_utils._convert(metadata, 'to')
|
||||
self.assertEqual(converted, converted_expected)
|
||||
self.assertEqual(service_utils._convert(converted, 'from'),
|
||||
metadata)
|
||||
|
||||
|
||||
class TestGlanceImageService(utils.BaseTestCase):
|
||||
NOW_GLANCE_OLD_FORMAT = "2010-10-11T10:30:22"
|
||||
NOW_GLANCE_FORMAT = "2010-10-11T10:30:22.000000"
|
||||
|
||||
class tzinfo(datetime.tzinfo):
|
||||
@staticmethod
|
||||
def utcoffset(*args, **kwargs):
|
||||
return datetime.timedelta()
|
||||
|
||||
NOW_DATETIME = datetime.datetime(2010, 10, 11, 10, 30, 22, tzinfo=tzinfo())
|
||||
|
||||
def setUp(self):
|
||||
super(TestGlanceImageService, self).setUp()
|
||||
client = stubs.StubGlanceClient()
|
||||
self.context = context.RequestContext(auth_token=True)
|
||||
self.context.user_id = 'fake'
|
||||
self.context.project_id = 'fake'
|
||||
self.service = service.Service(client, 1, self.context)
|
||||
|
||||
CONF.set_default('glance_host', 'localhost', group='glance')
|
||||
try:
|
||||
CONF.set_default('auth_strategy', 'keystone', group='glance')
|
||||
except Exception:
|
||||
opts = [
|
||||
cfg.StrOpt('auth_strategy', default='keystone'),
|
||||
]
|
||||
CONF.register_opts(opts)
|
||||
|
||||
return
|
||||
|
||||
@staticmethod
|
||||
def _make_fixture(**kwargs):
|
||||
fixture = {'name': None,
|
||||
'properties': {},
|
||||
'status': None,
|
||||
'is_public': None}
|
||||
fixture.update(kwargs)
|
||||
return fixture
|
||||
|
||||
def _make_datetime_fixture(self):
|
||||
return self._make_fixture(created_at=self.NOW_GLANCE_FORMAT,
|
||||
updated_at=self.NOW_GLANCE_FORMAT,
|
||||
deleted_at=self.NOW_GLANCE_FORMAT)
|
||||
|
||||
def test_create_with_instance_id(self):
|
||||
# Ensure instance_id is persisted as an image-property.
|
||||
fixture = {'name': 'test image',
|
||||
'is_public': False,
|
||||
'properties': {'instance_id': '42', 'user_id': 'fake'}}
|
||||
image_id = self.service.create(fixture)['id']
|
||||
image_meta = self.service.show(image_id)
|
||||
expected = {
|
||||
'id': image_id,
|
||||
'name': 'test image',
|
||||
'is_public': False,
|
||||
'size': None,
|
||||
'min_disk': None,
|
||||
'min_ram': None,
|
||||
'disk_format': None,
|
||||
'container_format': None,
|
||||
'checksum': None,
|
||||
'created_at': self.NOW_DATETIME,
|
||||
'updated_at': self.NOW_DATETIME,
|
||||
'deleted_at': None,
|
||||
'deleted': None,
|
||||
'status': None,
|
||||
'properties': {'instance_id': '42', 'user_id': 'fake'},
|
||||
'owner': None,
|
||||
}
|
||||
self.assertThat(image_meta, matchers.DictMatches(expected))
|
||||
|
||||
image_metas = self.service.detail()
|
||||
self.assertThat(image_metas[0], matchers.DictMatches(expected))
|
||||
|
||||
def test_create_without_instance_id(self):
|
||||
"""Ensure we can create an image without having to specify an
|
||||
instance_id. Public images are an example of an image not tied to an
|
||||
instance.
|
||||
"""
|
||||
fixture = {'name': 'test image', 'is_public': False}
|
||||
image_id = self.service.create(fixture)['id']
|
||||
|
||||
expected = {
|
||||
'id': image_id,
|
||||
'name': 'test image',
|
||||
'is_public': False,
|
||||
'size': None,
|
||||
'min_disk': None,
|
||||
'min_ram': None,
|
||||
'disk_format': None,
|
||||
'container_format': None,
|
||||
'checksum': None,
|
||||
'created_at': self.NOW_DATETIME,
|
||||
'updated_at': self.NOW_DATETIME,
|
||||
'deleted_at': None,
|
||||
'deleted': None,
|
||||
'status': None,
|
||||
'properties': {},
|
||||
'owner': None,
|
||||
}
|
||||
actual = self.service.show(image_id)
|
||||
self.assertThat(actual, matchers.DictMatches(expected))
|
||||
|
||||
def test_create(self):
|
||||
fixture = self._make_fixture(name='test image')
|
||||
num_images = len(self.service.detail())
|
||||
image_id = self.service.create(fixture)['id']
|
||||
|
||||
self.assertNotEquals(None, image_id)
|
||||
self.assertEquals(num_images + 1,
|
||||
len(self.service.detail()))
|
||||
|
||||
def test_create_and_show_non_existing_image(self):
|
||||
fixture = self._make_fixture(name='test image')
|
||||
image_id = self.service.create(fixture)['id']
|
||||
|
||||
self.assertNotEquals(None, image_id)
|
||||
self.assertRaises(exception.ImageNotFound,
|
||||
self.service.show,
|
||||
'bad image id')
|
||||
|
||||
def test_detail_private_image(self):
|
||||
fixture = self._make_fixture(name='test image')
|
||||
fixture['is_public'] = False
|
||||
properties = {'owner_id': 'proj1'}
|
||||
fixture['properties'] = properties
|
||||
|
||||
self.service.create(fixture)['id']
|
||||
|
||||
proj = self.context.project_id
|
||||
self.context.project_id = 'proj1'
|
||||
|
||||
image_metas = self.service.detail()
|
||||
|
||||
self.context.project_id = proj
|
||||
|
||||
self.assertEqual(1, len(image_metas))
|
||||
self.assertEqual(image_metas[0]['name'], 'test image')
|
||||
self.assertEqual(image_metas[0]['is_public'], False)
|
||||
|
||||
def test_detail_marker(self):
|
||||
fixtures = []
|
||||
ids = []
|
||||
for i in range(10):
|
||||
fixture = self._make_fixture(name='TestImage %d' % (i))
|
||||
fixtures.append(fixture)
|
||||
ids.append(self.service.create(fixture)['id'])
|
||||
|
||||
image_metas = self.service.detail(marker=ids[1])
|
||||
self.assertEquals(len(image_metas), 8)
|
||||
i = 2
|
||||
for meta in image_metas:
|
||||
expected = {
|
||||
'id': ids[i],
|
||||
'status': None,
|
||||
'is_public': None,
|
||||
'name': 'TestImage %d' % (i),
|
||||
'properties': {},
|
||||
'size': None,
|
||||
'min_disk': None,
|
||||
'min_ram': None,
|
||||
'disk_format': None,
|
||||
'container_format': None,
|
||||
'checksum': None,
|
||||
'created_at': self.NOW_DATETIME,
|
||||
'updated_at': self.NOW_DATETIME,
|
||||
'deleted_at': None,
|
||||
'deleted': None,
|
||||
'owner': None,
|
||||
}
|
||||
|
||||
self.assertThat(meta, matchers.DictMatches(expected))
|
||||
i = i + 1
|
||||
|
||||
def test_detail_limit(self):
|
||||
fixtures = []
|
||||
ids = []
|
||||
for i in range(10):
|
||||
fixture = self._make_fixture(name='TestImage %d' % (i))
|
||||
fixtures.append(fixture)
|
||||
ids.append(self.service.create(fixture)['id'])
|
||||
|
||||
image_metas = self.service.detail(limit=5)
|
||||
self.assertEquals(len(image_metas), 5)
|
||||
|
||||
def test_detail_default_limit(self):
|
||||
fixtures = []
|
||||
ids = []
|
||||
for i in range(10):
|
||||
fixture = self._make_fixture(name='TestImage %d' % (i))
|
||||
fixtures.append(fixture)
|
||||
ids.append(self.service.create(fixture)['id'])
|
||||
|
||||
image_metas = self.service.detail()
|
||||
for i, meta in enumerate(image_metas):
|
||||
self.assertEqual(meta['name'], 'TestImage %d' % (i))
|
||||
|
||||
def test_detail_marker_and_limit(self):
|
||||
fixtures = []
|
||||
ids = []
|
||||
for i in range(10):
|
||||
fixture = self._make_fixture(name='TestImage %d' % (i))
|
||||
fixtures.append(fixture)
|
||||
ids.append(self.service.create(fixture)['id'])
|
||||
|
||||
image_metas = self.service.detail(marker=ids[3], limit=5)
|
||||
self.assertEquals(len(image_metas), 5)
|
||||
i = 4
|
||||
for meta in image_metas:
|
||||
expected = {
|
||||
'id': ids[i],
|
||||
'status': None,
|
||||
'is_public': None,
|
||||
'name': 'TestImage %d' % (i),
|
||||
'properties': {},
|
||||
'size': None,
|
||||
'min_disk': None,
|
||||
'min_ram': None,
|
||||
'disk_format': None,
|
||||
'container_format': None,
|
||||
'checksum': None,
|
||||
'created_at': self.NOW_DATETIME,
|
||||
'updated_at': self.NOW_DATETIME,
|
||||
'deleted_at': None,
|
||||
'deleted': None,
|
||||
'owner': None,
|
||||
}
|
||||
self.assertThat(meta, matchers.DictMatches(expected))
|
||||
i = i + 1
|
||||
|
||||
def test_detail_invalid_marker(self):
|
||||
fixtures = []
|
||||
ids = []
|
||||
for i in range(10):
|
||||
fixture = self._make_fixture(name='TestImage %d' % (i))
|
||||
fixtures.append(fixture)
|
||||
ids.append(self.service.create(fixture)['id'])
|
||||
|
||||
self.assertRaises(exception.Invalid, self.service.detail,
|
||||
marker='invalidmarker')
|
||||
|
||||
def test_update(self):
|
||||
fixture = self._make_fixture(name='test image')
|
||||
image = self.service.create(fixture)
|
||||
image_id = image['id']
|
||||
fixture['name'] = 'new image name'
|
||||
self.service.update(image_id, fixture)
|
||||
|
||||
new_image_data = self.service.show(image_id)
|
||||
self.assertEquals('new image name', new_image_data['name'])
|
||||
|
||||
def test_delete(self):
|
||||
fixture1 = self._make_fixture(name='test image 1')
|
||||
fixture2 = self._make_fixture(name='test image 2')
|
||||
fixtures = [fixture1, fixture2]
|
||||
|
||||
num_images = len(self.service.detail())
|
||||
self.assertEquals(0, num_images)
|
||||
|
||||
ids = []
|
||||
for fixture in fixtures:
|
||||
new_id = self.service.create(fixture)['id']
|
||||
ids.append(new_id)
|
||||
|
||||
num_images = len(self.service.detail())
|
||||
self.assertEquals(2, num_images)
|
||||
|
||||
self.service.delete(ids[0])
|
||||
# When you delete an image from glance, it sets the status to DELETED
|
||||
# and doesn't actually remove the image.
|
||||
|
||||
# Check the image is still there.
|
||||
num_images = len(self.service.detail())
|
||||
self.assertEquals(2, num_images)
|
||||
|
||||
# Check the image is marked as deleted.
|
||||
num_images = reduce(lambda x, y: x + (0 if y['deleted'] else 1),
|
||||
self.service.detail(), 0)
|
||||
self.assertEquals(1, num_images)
|
||||
|
||||
def test_show_passes_through_to_client(self):
|
||||
fixture = self._make_fixture(name='image1', is_public=True)
|
||||
image_id = self.service.create(fixture)['id']
|
||||
|
||||
image_meta = self.service.show(image_id)
|
||||
expected = {
|
||||
'id': image_id,
|
||||
'name': 'image1',
|
||||
'is_public': True,
|
||||
'size': None,
|
||||
'min_disk': None,
|
||||
'min_ram': None,
|
||||
'disk_format': None,
|
||||
'container_format': None,
|
||||
'checksum': None,
|
||||
'created_at': self.NOW_DATETIME,
|
||||
'updated_at': self.NOW_DATETIME,
|
||||
'deleted_at': None,
|
||||
'deleted': None,
|
||||
'status': None,
|
||||
'properties': {},
|
||||
'owner': None,
|
||||
}
|
||||
self.assertEqual(image_meta, expected)
|
||||
|
||||
def test_show_raises_when_no_authtoken_in_the_context(self):
|
||||
fixture = self._make_fixture(name='image1',
|
||||
is_public=False,
|
||||
properties={'one': 'two'})
|
||||
image_id = self.service.create(fixture)['id']
|
||||
self.context.auth_token = False
|
||||
self.assertRaises(exception.ImageNotFound,
|
||||
self.service.show,
|
||||
image_id)
|
||||
|
||||
def test_detail_passes_through_to_client(self):
|
||||
fixture = self._make_fixture(name='image10', is_public=True)
|
||||
image_id = self.service.create(fixture)['id']
|
||||
image_metas = self.service.detail()
|
||||
expected = [
|
||||
{
|
||||
'id': image_id,
|
||||
'name': 'image10',
|
||||
'is_public': True,
|
||||
'size': None,
|
||||
'min_disk': None,
|
||||
'min_ram': None,
|
||||
'disk_format': None,
|
||||
'container_format': None,
|
||||
'checksum': None,
|
||||
'created_at': self.NOW_DATETIME,
|
||||
'updated_at': self.NOW_DATETIME,
|
||||
'deleted_at': None,
|
||||
'deleted': None,
|
||||
'status': None,
|
||||
'properties': {},
|
||||
'owner': None,
|
||||
},
|
||||
]
|
||||
self.assertEqual(image_metas, expected)
|
||||
|
||||
def test_show_makes_datetimes(self):
|
||||
fixture = self._make_datetime_fixture()
|
||||
image_id = self.service.create(fixture)['id']
|
||||
image_meta = self.service.show(image_id)
|
||||
self.assertEqual(image_meta['created_at'], self.NOW_DATETIME)
|
||||
self.assertEqual(image_meta['updated_at'], self.NOW_DATETIME)
|
||||
|
||||
def test_detail_makes_datetimes(self):
|
||||
fixture = self._make_datetime_fixture()
|
||||
self.service.create(fixture)
|
||||
image_meta = self.service.detail()[0]
|
||||
self.assertEqual(image_meta['created_at'], self.NOW_DATETIME)
|
||||
self.assertEqual(image_meta['updated_at'], self.NOW_DATETIME)
|
||||
|
||||
def test_download_with_retries(self):
|
||||
tries = [0]
|
||||
|
||||
class MyGlanceStubClient(stubs.StubGlanceClient):
|
||||
"""A client that fails the first time, then succeeds."""
|
||||
def get(self, image_id):
|
||||
if tries[0] == 0:
|
||||
tries[0] = 1
|
||||
raise exception.ServiceUnavailable('')
|
||||
else:
|
||||
return {}
|
||||
|
||||
stub_client = MyGlanceStubClient()
|
||||
stub_context = context.RequestContext(auth_token=True)
|
||||
stub_context.user_id = 'fake'
|
||||
stub_context.project_id = 'fake'
|
||||
stub_service = service.Service(stub_client, 1, stub_context)
|
||||
image_id = 1 # doesn't matter
|
||||
writer = NullWriter()
|
||||
|
||||
# When retries are disabled, we should get an exception
|
||||
self.config(glance_num_retries=0, group='glance')
|
||||
self.assertRaises(exception.GlanceConnectionFailed,
|
||||
stub_service.download, image_id, writer)
|
||||
|
||||
# Now lets enable retries. No exception should happen now.
|
||||
tries = [0]
|
||||
self.config(glance_num_retries=1, group='glance')
|
||||
stub_service.download(image_id, writer)
|
||||
|
||||
def test_download_file_url(self):
|
||||
#NOTE: only in v2 API
|
||||
class MyGlanceStubClient(stubs.StubGlanceClient):
|
||||
|
||||
"""A client that returns a file url."""
|
||||
|
||||
(outfd, s_tmpfname) = tempfile.mkstemp(prefix='directURLsrc')
|
||||
outf = os.fdopen(outfd, 'w')
|
||||
inf = open('/dev/urandom', 'r')
|
||||
for i in range(10):
|
||||
_data = inf.read(1024)
|
||||
outf.write(_data)
|
||||
outf.close()
|
||||
|
||||
def get(self, image_id):
|
||||
return type('GlanceTestDirectUrlMeta', (object,),
|
||||
{'direct_url': 'file://%s' + self.s_tmpfname})
|
||||
|
||||
stub_context = context.RequestContext(auth_token=True)
|
||||
stub_context.user_id = 'fake'
|
||||
stub_context.project_id = 'fake'
|
||||
stub_client = MyGlanceStubClient()
|
||||
(outfd, tmpfname) = tempfile.mkstemp(prefix='directURLdst')
|
||||
writer = os.fdopen(outfd, 'w')
|
||||
|
||||
stub_service = service.Service(stub_client,
|
||||
context=stub_context,
|
||||
version=2)
|
||||
image_id = 1 # doesn't matter
|
||||
|
||||
self.config(allowed_direct_url_schemes=['file'], group='glance')
|
||||
stub_service.download(image_id, writer)
|
||||
writer.close()
|
||||
|
||||
# compare the two files
|
||||
rc = filecmp.cmp(tmpfname, stub_client.s_tmpfname)
|
||||
self.assertTrue(rc, "The file %s and %s should be the same" %
|
||||
(tmpfname, stub_client.s_tmpfname))
|
||||
os.remove(stub_client.s_tmpfname)
|
||||
os.remove(tmpfname)
|
||||
|
||||
def test_client_forbidden_converts_to_imagenotauthed(self):
|
||||
class MyGlanceStubClient(stubs.StubGlanceClient):
|
||||
"""A client that raises a Forbidden exception."""
|
||||
def get(self, image_id):
|
||||
raise exception.Forbidden(image_id)
|
||||
|
||||
stub_client = MyGlanceStubClient()
|
||||
stub_context = context.RequestContext(auth_token=True)
|
||||
stub_context.user_id = 'fake'
|
||||
stub_context.project_id = 'fake'
|
||||
stub_service = service.Service(stub_client, 1, stub_context)
|
||||
image_id = 1 # doesn't matter
|
||||
writer = NullWriter()
|
||||
self.assertRaises(exception.ImageNotAuthorized, stub_service.download,
|
||||
image_id, writer)
|
||||
|
||||
def test_client_httpforbidden_converts_to_imagenotauthed(self):
|
||||
class MyGlanceStubClient(stubs.StubGlanceClient):
|
||||
"""A client that raises a HTTPForbidden exception."""
|
||||
def get(self, image_id):
|
||||
raise exception.HTTPForbidden(image_id)
|
||||
|
||||
stub_client = MyGlanceStubClient()
|
||||
stub_context = context.RequestContext(auth_token=True)
|
||||
stub_context.user_id = 'fake'
|
||||
stub_context.project_id = 'fake'
|
||||
stub_service = service.Service(stub_client, 1, stub_context)
|
||||
image_id = 1 # doesn't matter
|
||||
writer = NullWriter()
|
||||
self.assertRaises(exception.ImageNotAuthorized, stub_service.download,
|
||||
image_id, writer)
|
||||
|
||||
def test_client_notfound_converts_to_imagenotfound(self):
|
||||
class MyGlanceStubClient(stubs.StubGlanceClient):
|
||||
"""A client that raises a NotFound exception."""
|
||||
def get(self, image_id):
|
||||
raise exception.NotFound(image_id)
|
||||
|
||||
stub_client = MyGlanceStubClient()
|
||||
stub_context = context.RequestContext(auth_token=True)
|
||||
stub_context.user_id = 'fake'
|
||||
stub_context.project_id = 'fake'
|
||||
stub_service = service.Service(stub_client, 1, stub_context)
|
||||
image_id = 1 # doesn't matter
|
||||
writer = NullWriter()
|
||||
self.assertRaises(exception.ImageNotFound, stub_service.download,
|
||||
image_id, writer)
|
||||
|
||||
def test_client_httpnotfound_converts_to_imagenotfound(self):
|
||||
class MyGlanceStubClient(stubs.StubGlanceClient):
|
||||
"""A client that raises a HTTPNotFound exception."""
|
||||
def get(self, image_id):
|
||||
raise exception.HTTPNotFound(image_id)
|
||||
|
||||
stub_client = MyGlanceStubClient()
|
||||
stub_context = context.RequestContext(auth_token=True)
|
||||
stub_context.user_id = 'fake'
|
||||
stub_context.project_id = 'fake'
|
||||
stub_service = service.Service(stub_client, 1, stub_context)
|
||||
image_id = 1 # doesn't matter
|
||||
writer = NullWriter()
|
||||
self.assertRaises(exception.ImageNotFound, stub_service.download,
|
||||
image_id, writer)
|
||||
|
||||
def test_check_image_service_client_set(self):
|
||||
def func(self):
|
||||
return True
|
||||
|
||||
self.service.client = True
|
||||
|
||||
wrapped_func = base_image_service.check_image_service(func)
|
||||
self.assertTrue(wrapped_func(self.service))
|
||||
|
||||
def test_check_image_service__no_client_set_http(self):
|
||||
def func(service, *args, **kwargs):
|
||||
return (service.client.endpoint, args, kwargs)
|
||||
|
||||
self.service.client = None
|
||||
params = {'image_href': 'http://123.123.123.123:9292/image_uuid'}
|
||||
self.config(auth_strategy='keystone', group='glance')
|
||||
wrapped_func = base_image_service.check_image_service(func)
|
||||
self.assertEqual(('http://123.123.123.123:9292', (), params),
|
||||
wrapped_func(self.service, **params))
|
||||
|
||||
def test_get_image_service__no_client_set_https(self):
|
||||
def func(service, *args, **kwargs):
|
||||
return (service.client.endpoint, args, kwargs)
|
||||
|
||||
self.service.client = None
|
||||
params = {'image_href': 'https://123.123.123.123:9292/image_uuid'}
|
||||
self.config(auth_strategy='keystone', group='glance')
|
||||
wrapped_func = base_image_service.check_image_service(func)
|
||||
|
||||
self.assertEqual(('https://123.123.123.123:9292', (), params),
|
||||
wrapped_func(self.service, **params))
|
||||
|
||||
|
||||
def _create_failing_glance_client(info):
|
||||
class MyGlanceStubClient(stubs.StubGlanceClient):
|
||||
"""A client that fails the first time, then succeeds."""
|
||||
def get(self, image_id):
|
||||
info['num_calls'] += 1
|
||||
if info['num_calls'] == 1:
|
||||
raise exception.ServiceUnavailable('')
|
||||
return {}
|
||||
|
||||
return MyGlanceStubClient()
|
||||
|
||||
|
||||
class TestGlanceUrl(utils.BaseTestCase):
|
||||
|
||||
def test_generate_glance_http_url(self):
|
||||
self.config(glance_host="127.0.0.1", group='glance')
|
||||
generated_url = service_utils.generate_glance_url()
|
||||
http_url = "http://%s:%d" % (CONF.glance.glance_host,
|
||||
CONF.glance.glance_port)
|
||||
self.assertEqual(generated_url, http_url)
|
||||
|
||||
def test_generate_glance_https_url(self):
|
||||
self.config(glance_protocol="https", group='glance')
|
||||
self.config(glance_host="127.0.0.1", group='glance')
|
||||
generated_url = service_utils.generate_glance_url()
|
||||
https_url = "https://%s:%d" % (CONF.glance.glance_host,
|
||||
CONF.glance.glance_port)
|
||||
self.assertEqual(generated_url, https_url)
|
||||
|
||||
|
||||
class TestServiceUtils(utils.BaseTestCase):
|
||||
|
||||
def test_parse_image_ref_no_ssl(self):
|
||||
image_href = 'http://127.0.0.1:9292/image_path/image_uuid'
|
||||
parsed_href = service_utils.parse_image_ref(image_href)
|
||||
self.assertEqual(parsed_href, ('image_uuid', '127.0.0.1', 9292, False))
|
||||
|
||||
def test_parse_image_ref_ssl(self):
|
||||
image_href = 'https://127.0.0.1:9292/image_path/image_uuid'
|
||||
parsed_href = service_utils.parse_image_ref(image_href)
|
||||
self.assertEqual(parsed_href, ('image_uuid', '127.0.0.1', 9292, True))
|
||||
|
||||
def test_generate_image_url(self):
|
||||
image_href = 'image_uuid'
|
||||
CONF.set_default('glance_host', '123.123.123.123', group='glance')
|
||||
CONF.set_default('glance_port', 1234, group='glance')
|
||||
CONF.set_default('glance_protocol', 'https', group='glance')
|
||||
generated_url = service_utils.generate_image_url(image_href)
|
||||
self.assertEqual(generated_url,
|
||||
'https://123.123.123.123:1234/images/image_uuid')
|
Loading…
Reference in New Issue