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:
Ghe Rivero 2013-06-28 10:16:03 +02:00
parent c97cf82a3f
commit 5e76790196
13 changed files with 1697 additions and 0 deletions

View File

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

View File

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

105
ironic/tests/matchers.py Normal file
View File

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

118
ironic/tests/stubs.py Normal file
View File

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

View File

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