diff --git a/ironic/common/exception.py b/ironic/common/exception.py index 882a0cb73b..4dc11e69c7 100644 --- a/ironic/common/exception.py +++ b/ironic/common/exception.py @@ -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 diff --git a/ironic/common/glance_service/__init__.py b/ironic/common/glance_service/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ironic/common/glance_service/base_image_service.py b/ironic/common/glance_service/base_image_service.py new file mode 100644 index 0000000000..356fdf4c66 --- /dev/null +++ b/ironic/common/glance_service/base_image_service.py @@ -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) diff --git a/ironic/common/glance_service/service.py b/ironic/common/glance_service/service.py new file mode 100644 index 0000000000..b08f04df12 --- /dev/null +++ b/ironic/common/glance_service/service.py @@ -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. + + """ diff --git a/ironic/common/glance_service/service_utils.py b/ironic/common/glance_service/service_utils.py new file mode 100644 index 0000000000..42926a61f9 --- /dev/null +++ b/ironic/common/glance_service/service_utils.py @@ -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) diff --git a/ironic/common/glance_service/v1/__init__.py b/ironic/common/glance_service/v1/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ironic/common/glance_service/v1/image_service.py b/ironic/common/glance_service/v1/image_service.py new file mode 100644 index 0000000000..1838bfa71e --- /dev/null +++ b/ironic/common/glance_service/v1/image_service.py @@ -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') diff --git a/ironic/common/glance_service/v2/__init__.py b/ironic/common/glance_service/v2/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ironic/common/glance_service/v2/image_service.py b/ironic/common/glance_service/v2/image_service.py new file mode 100644 index 0000000000..10a9e375a8 --- /dev/null +++ b/ironic/common/glance_service/v2/image_service.py @@ -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) diff --git a/ironic/common/image_service.py b/ironic/common/image_service.py new file mode 100644 index 0000000000..9c28f98897 --- /dev/null +++ b/ironic/common/image_service.py @@ -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) diff --git a/ironic/tests/matchers.py b/ironic/tests/matchers.py new file mode 100644 index 0000000000..dca69035fc --- /dev/null +++ b/ironic/tests/matchers.py @@ -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) diff --git a/ironic/tests/stubs.py b/ironic/tests/stubs.py new file mode 100644 index 0000000000..2fcc19d408 --- /dev/null +++ b/ironic/tests/stubs.py @@ -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) diff --git a/ironic/tests/test_glance_service.py b/ironic/tests/test_glance_service.py new file mode 100644 index 0000000000..f0da35912a --- /dev/null +++ b/ironic/tests/test_glance_service.py @@ -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')