diff --git a/doc/source/topics/settings.rst b/doc/source/topics/settings.rst index 119fb0517c..510e5c6be9 100755 --- a/doc/source/topics/settings.rst +++ b/doc/source/topics/settings.rst @@ -963,6 +963,22 @@ web-server (e.g. http:///dashboard) and restart glance-api process. was removed. +``IMAGES_ALLOW_LOCATION`` +-------------------------------- + +.. versionadded:: 10.0.0(Newton) + +Default: ``False`` + +If set to ``True``, this setting allows users to specify an image location +(URL) as the image source when creating or updating images. Depending on +the Glance version, the ability to set an image location is controlled by +policies and/or the Glance configuration. Therefore IMAGES_ALLOW_LOCATION +should only be set to ``True`` if Glance is configured to allow specifying a +location. This setting has no effect when the Keystone catalog doesn't contain +a Glance v2 endpoint. + + ``OPENSTACK_KEYSTONE_BACKEND`` ------------------------------ diff --git a/openstack_dashboard/api/glance.py b/openstack_dashboard/api/glance.py index 494725d0ba..6627124b3a 100644 --- a/openstack_dashboard/api/glance.py +++ b/openstack_dashboard/api/glance.py @@ -56,13 +56,157 @@ except ImportError: pass +class Image(base.APIResourceWrapper): + _attrs = {"architecture", "container_format", "disk_format", "created_at", + "owner", "size", "id", "status", "updated_at", "checksum", + "visibility", "name", "is_public", "protected", "min_disk", + "min_ram"} + _ext_attrs = {"file", "locations", "schema", "tags", "virtual_size", + "kernel_id", "ramdisk_id", "image_url"} + + def __init__(self, apiresource): + super(Image, self).__init__(apiresource) + + def __getattribute__(self, attr): + # Because Glance v2 treats custom properties as normal + # attributes, we need to be more flexible than the resource + # wrappers usually allow. In v1 they were defined under a + # "properties" attribute. + if VERSIONS.active >= 2 and attr == "properties": + return {k: v for (k, v) in self._apiresource.items() + if self.property_visible(k)} + try: + return object.__getattribute__(self, attr) + except AttributeError: + return getattr(self._apiresource, attr) + + @property + def name(self): + return getattr(self._apiresource, 'name', None) + + @property + def size(self): + image_size = getattr(self._apiresource, 'size', 0) + if image_size is None: + return 0 + return image_size + + @size.setter + def size(self, value): + self._apiresource.size = value + + @property + def is_public(self): + # Glance v2 no longer has a 'is_public' attribute, but uses a + # 'visibility' attribute instead. + return (getattr(self._apiresource, 'is_public', None) or + getattr(self._apiresource, 'visibility', None) == "public") + + def property_visible(self, prop_name, show_ext_attrs=False): + if show_ext_attrs: + return prop_name not in self._attrs + else: + return prop_name not in (self._attrs | self._ext_attrs) + + def to_dict(self, show_ext_attrs=False): + # When using v1 Image objects (including when running unit tests + # for v2), self._apiresource is not iterable. In that case, + # the properties are included in the apiresource dict, so + # just return that dict. + if not isinstance(self._apiresource, collections.Iterable): + return self._apiresource.to_dict() + image_dict = super(Image, self).to_dict() + image_dict['is_public'] = self.is_public + image_dict['properties'] = { + k: self._apiresource[k] for k in self._apiresource + if self.property_visible(k, show_ext_attrs=show_ext_attrs)} + return image_dict + + def __eq__(self, other_image): + return self._apiresource == other_image._apiresource + + def __ne__(self, other_image): + return not self.__eq__(other_image) + + @memoized -def glanceclient(request, version='1'): +def glanceclient(request, version=None): + api_version = VERSIONS.get_active_version() + url = base.url_for(request, 'image') insecure = getattr(settings, 'OPENSTACK_SSL_NO_VERIFY', False) cacert = getattr(settings, 'OPENSTACK_SSL_CACERT', None) - return glance_client.Client(version, url, token=request.user.token.id, - insecure=insecure, cacert=cacert) + + # TODO(jpichon): Temporarily keep both till we update the API calls + # to stop hardcoding a version in this file. Once that's done we + # can get rid of the deprecated 'version' parameter. + if version is None: + return api_version['client'].Client(url, token=request.user.token.id, + insecure=insecure, cacert=cacert) + else: + return glance_client.Client(version, url, token=request.user.token.id, + insecure=insecure, cacert=cacert) + + +# Note: Glance is adding more than just public and private in Newton or later +PUBLIC_TO_VISIBILITY_MAP = { + None: None, + True: 'public', + False: 'private' +} + + +def _normalize_is_public_filter(filters): + if not filters: + return + + if VERSIONS.active >= 2: + if 'is_public' in filters: + visibility = PUBLIC_TO_VISIBILITY_MAP[filters['is_public']] + del filters['is_public'] + if visibility is not None: + filters['visibility'] = visibility + elif 'visibility' in filters: + filters['is_public'] = ( + getattr(filters, 'visibility', None) == "public") + del filter['visibility'] + + +def _normalize_list_input(filters, **kwargs): + _normalize_is_public_filter(filters) + + if VERSIONS.active < 2: + # Glance v1 client processes some keywords specifically. + # Others, it just takes as a nested dict called filters. + # This results in the following being passed into the glance client: + # { + # 'is_public': u'true', + # 'sort_key': u'name', + # 'sort_dir': u'asc', + # 'filters': { + # u'min_disk': u'0', + # u'name': u'mysql', + # 'properties': { + # u'os_shutdown_timeout': u'1' + # } + # } + # } + v1_keywords = ['page_size', 'limit', 'sort_dir', 'sort_key', 'marker', + 'is_public', 'return_req_id', 'paginate'] + + filters = {} + properties = {} + for key, value in iter(kwargs.items()): + if key in v1_keywords: + continue + else: + filters[key] = value + del kwargs[key] + + if properties: + filters['properties'] = properties + if filters: + kwargs['filters'] = filters def image_delete(request, image_id): @@ -74,22 +218,12 @@ def image_get(request, image_id): with supplied identifier. """ image = glanceclient(request).images.get(image_id) - if not hasattr(image, 'name'): - image.name = None - return image - - -def is_image_public(im): - is_public_v1 = getattr(im, 'is_public', None) - if is_public_v1 is not None: - return is_public_v1 - else: - return im.visibility == 'public' + return Image(image) def image_list_detailed(request, marker=None, sort_dir='desc', sort_key='created_at', filters=None, paginate=False, - reversed_order=False): + reversed_order=False, **kwargs): """Thin layer above glanceclient, for handling pagination issues. It provides iterating both forward and backward on top of ascetic @@ -144,7 +278,9 @@ def image_list_detailed(request, marker=None, sort_dir='desc', else: request_size = limit + _normalize_list_input(filters, **kwargs) kwargs = {'filters': filters or {}} + if marker: kwargs['marker'] = marker kwargs['sort_key'] = sort_key @@ -183,13 +319,25 @@ def image_list_detailed(request, marker=None, sort_dir='desc', else: images = list(images_iter) - return images, has_more_data, has_prev_data + # TODO(jpichon): Do it better + wrapped_images = [] + for image in images: + wrapped_images.append(Image(image)) + + return wrapped_images, has_more_data, has_prev_data def image_update(request, image_id, **kwargs): image_data = kwargs.get('data', None) try: - return glanceclient(request).images.update(image_id, **kwargs) + # Horizon doesn't support purging image properties. Make sure we don't + # unintentionally remove properties when using v1. We don't need a + # similar setting for v2 because you have to specify which properties + # to remove, and the default is nothing gets removed. + if VERSIONS.active < 2: + kwargs['purge_props'] = False + return Image(glanceclient(request).images.update( + image_id, **kwargs)) finally: if image_data: try: @@ -215,14 +363,15 @@ def get_image_upload_mode(): return mode -class ExternallyUploadedImage(base.APIResourceWrapper): +class ExternallyUploadedImage(Image): def __init__(self, apiresource, request): - self._attrs = apiresource._info.keys() - super(ExternallyUploadedImage, self).__init__(apiresource=apiresource) + super(ExternallyUploadedImage, self).__init__(apiresource) image_endpoint = base.url_for(request, 'image') - # FIXME(tsufiev): Horizon doesn't work with Glance V2 API yet, - # remove hardcoded /v1 as soon as it supports both - self._url = "%s/v1/images/%s" % (image_endpoint, self.id) + if VERSIONS.active >= 2: + upload_template = "%s/v2/images/%s/file" + else: + upload_template = "%s/v1/images/%s" + self._url = upload_template % (image_endpoint, self.id) self._token_id = request.user.token.id def to_dict(self): @@ -233,6 +382,14 @@ class ExternallyUploadedImage(base.APIResourceWrapper): }) return base_dict + @property + def upload_url(self): + return self._url + + @property + def token_id(self): + return self._token_id + def image_create(request, **kwargs): """Create image. @@ -251,8 +408,13 @@ def image_create(request, **kwargs): some time and is handed off to a separate thread. """ data = kwargs.pop('data', None) + location = None + if VERSIONS.active >= 2: + location = kwargs.pop('location', None) image = glanceclient(request).images.create(**kwargs) + if location is not None: + glanceclient(request).images.add_location(image.id, location, {}) if data: if isinstance(data, six.string_types): @@ -268,12 +430,16 @@ def image_create(request, **kwargs): data = SimpleUploadedFile(data.name, data.read(), data.content_type) - thread.start_new_thread(image_update, - (request, image.id), - {'data': data, - 'purge_props': False}) + if VERSIONS.active < 2: + thread.start_new_thread(image_update, + (request, image.id), + {'data': data}) + else: + def upload(): + return glanceclient(request).images.upload(image.id, data) + thread.start_new_thread(upload, ()) - return image + return Image(image) def image_update_properties(request, image_id, remove_props=None, **kwargs): diff --git a/openstack_dashboard/api/rest/config.py b/openstack_dashboard/api/rest/config.py index f655add70d..27bcf8875e 100644 --- a/openstack_dashboard/api/rest/config.py +++ b/openstack_dashboard/api/rest/config.py @@ -40,7 +40,10 @@ class Settings(generic.View): """ url_regex = r'settings/$' SPECIALS = { - 'HORIZON_IMAGES_UPLOAD_MODE': api.glance.get_image_upload_mode() + 'HORIZON_IMAGES_UPLOAD_MODE': api.glance.get_image_upload_mode(), + 'HORIZON_ACTIVE_IMAGE_VERSION': api.glance.VERSIONS.active, + 'IMAGES_ALLOW_LOCATION': getattr(settings, 'IMAGES_ALLOW_LOCATION', + False) } @rest_utils.ajax() diff --git a/openstack_dashboard/api/rest/glance.py b/openstack_dashboard/api/rest/glance.py index 935aab5237..830b12be0f 100644 --- a/openstack_dashboard/api/rest/glance.py +++ b/openstack_dashboard/api/rest/glance.py @@ -52,7 +52,8 @@ class Image(generic.View): http://localhost/api/glance/images/cc758c90-3d98-4ea1-af44-aab405c9c915 """ - return api.glance.image_get(request, image_id).to_dict() + image = api.glance.image_get(request, image_id) + return image.to_dict(show_ext_attrs=True) @rest_utils.ajax(data_required=True) def patch(self, request, image_id): @@ -80,7 +81,6 @@ class Image(generic.View): """ meta = create_image_metadata(request.DATA) - meta['purge_props'] = False api.glance.image_update(request, image_id, **meta) @@ -322,8 +322,8 @@ def create_image_metadata(data): 'min_ram': data.get('min_ram', 0), 'name': data.get('name'), 'disk_format': data.get('disk_format'), - 'container_format': data.get('container_format'), - 'properties': {}} + 'container_format': data.get('container_format')} + properties = {} # 'architecture' will be directly mapped # into the .properties by the handle_unknown_properties function. @@ -331,12 +331,17 @@ def create_image_metadata(data): # compatibility. props = data.get('properties') if props and props.get('description'): - meta['properties']['description'] = props.get('description') + properties['description'] = props.get('description') if data.get('kernel'): - meta['properties']['kernel_id'] = data.get('kernel') + properties['kernel_id'] = data.get('kernel') if data.get('ramdisk'): - meta['properties']['ramdisk_id'] = data.get('ramdisk') - handle_unknown_properties(data, meta) + properties['ramdisk_id'] = data.get('ramdisk') + handle_unknown_properties(data, properties) + if api.glance.VERSIONS.active >= 2: + meta.update(properties) + else: + meta['properties'] = properties + handle_visibility(data.get('visibility'), meta) except KeyError as e: @@ -345,7 +350,7 @@ def create_image_metadata(data): return meta -def handle_unknown_properties(data, meta): +def handle_unknown_properties(data, properties): # The Glance API takes in both known and unknown fields. Unknown fields # are assumed as metadata. To achieve this and continue to use the # existing horizon api wrapper, we need this function. This way, the @@ -359,19 +364,18 @@ def handle_unknown_properties(data, meta): 'deleted_at', 'is_public', 'virtual_size', 'status', 'size', 'owner', 'id', 'updated_at'] other_props = {k: v for (k, v) in data.items() if k not in known_props} - meta['properties'].update(other_props) + properties.update(other_props) def handle_visibility(visibility, meta): - # The following expects a 'visibility' parameter to be passed via - # the AJAX call, then translates this to a Glance API v1 is_public - # parameter. In the future, if the 'visibility' param is exposed on the - # glance API, you can check for version, e.g.: - # if float(api.glance.get_version()) < 2.0: mapping_to_v1 = {'public': True, 'private': False, 'shared': False} # note: presence of 'visibility' previously checked for in general call try: - meta['is_public'] = mapping_to_v1[visibility] + is_public = mapping_to_v1[visibility] + if api.glance.VERSIONS.active >= 2: + meta['visibility'] = visibility + else: + meta['is_public'] = is_public except KeyError as e: raise rest_utils.AjaxError(400, 'invalid visibility option: %s' % e.args[0]) diff --git a/openstack_dashboard/dashboards/project/images/images/forms.py b/openstack_dashboard/dashboards/project/images/images/forms.py index 0105a116fc..1c3a449992 100644 --- a/openstack_dashboard/dashboards/project/images/images/forms.py +++ b/openstack_dashboard/dashboards/project/images/images/forms.py @@ -65,25 +65,34 @@ def create_image_metadata(data): else: container_format = 'bare' - # The Create form uses 'is_public' but the Update form uses 'public'. Just - # being tolerant here so we don't break anything else. - meta = {'is_public': data.get('is_public', data.get('public', False)), - 'protected': data['protected'], + meta = {'protected': data['protected'], 'disk_format': disk_format, 'container_format': container_format, 'min_disk': (data['minimum_disk'] or 0), 'min_ram': (data['minimum_ram'] or 0), - 'name': data['name'], - 'properties': {}} + 'name': data['name']} - if 'description' in data: - meta['properties']['description'] = data['description'] + is_public = data.get('is_public', data.get('public', False)) + properties = {} + # NOTE(tsufiev): in V2 the way how empty non-base attributes (AKA metadata) + # are handled has changed: in V2 empty metadata is kept in image + # properties, while in V1 they were omitted. Skip empty description (which + # is metadata) to keep the same behavior between V1 and V2 + if data.get('description'): + properties['description'] = data['description'] if data.get('kernel'): - meta['properties']['kernel_id'] = data['kernel'] + properties['kernel_id'] = data['kernel'] if data.get('ramdisk'): - meta['properties']['ramdisk_id'] = data['ramdisk'] + properties['ramdisk_id'] = data['ramdisk'] if data.get('architecture'): - meta['properties']['architecture'] = data['architecture'] + properties['architecture'] = data['architecture'] + + if api.glance.VERSIONS.active < 2: + meta.update({'is_public': is_public, 'properties': properties}) + else: + meta['visibility'] = 'public' if is_public else 'private' + meta.update(properties) + return meta @@ -195,6 +204,24 @@ class CreateImageForm(CreateParent): self._hide_file_source_type() if not policy.check((("image", "set_image_location"),), request): self._hide_url_source_type() + + # GlanceV2 feature removals + if api.glance.VERSIONS.active >= 2: + # NOTE: GlanceV2 doesn't support copy-from feature, sorry! + self._hide_is_copying() + if not getattr(settings, 'IMAGES_ALLOW_LOCATION', False): + self._hide_url_source_type() + if (api.glance.get_image_upload_mode() == 'off' or not + policy.check((("image", "upload_image"),), request)): + # Neither setting a location nor uploading image data is + # allowed, so throw an error. + msg = _('The current Horizon settings indicate no valid ' + 'image creation methods are available. Providing ' + 'an image location and/or uploading from the ' + 'local file system must be allowed to support ' + 'image creation.') + messages.error(request, msg) + raise ValidationError(msg) if not policy.check((("image", "publicize_image"),), request): self._hide_is_public() @@ -252,6 +279,10 @@ class CreateImageForm(CreateParent): self.fields['is_public'].widget = HiddenInput() self.fields['is_public'].initial = False + def _hide_is_copying(self): + self.fields['is_copying'].widget = HiddenInput() + self.fields['is_copying'].initial = False + def clean(self): data = super(CreateImageForm, self).clean() @@ -278,7 +309,7 @@ class CreateImageForm(CreateParent): policy.check((("image", "upload_image"),), request) and data.get('image_file', None)): meta['data'] = data['image_file'] - elif data['is_copying']: + elif data.get('is_copying'): meta['copy_from'] = data['image_url'] else: meta['location'] = data['image_url'] @@ -369,9 +400,6 @@ class UpdateImageForm(forms.SelfHandlingForm): image_id = data['image_id'] error_updating = _('Unable to update image "%s".') meta = create_image_metadata(data) - # Ensure we do not delete properties that have already been - # set on an image. - meta['purge_props'] = False try: image = api.glance.image_update(request, image_id, **meta) diff --git a/openstack_dashboard/dashboards/project/images/images/tables.py b/openstack_dashboard/dashboards/project/images/images/tables.py index df18973fd1..e5e10b4fd7 100644 --- a/openstack_dashboard/dashboards/project/images/images/tables.py +++ b/openstack_dashboard/dashboards/project/images/images/tables.py @@ -206,8 +206,14 @@ class OwnerFilter(tables.FixedFilterAction): new_dict = button_dict.copy() new_dict['value'] = new_dict['tenant'] buttons.append(new_dict) - buttons.append(make_dict(_('Shared with Project'), 'shared', - 'fa-share-square-o')) + # FIXME(bpokorny): Remove this check once admins can list images with + # GlanceV2 without getting all images in the whole cloud. + if api.glance.VERSIONS.active >= 2: + buttons.append(make_dict(_('Non-Public from Other Projects'), + 'other', 'fa-group')) + else: + buttons.append(make_dict(_('Shared with Project'), 'shared', + 'fa-share-square-o')) buttons.append(make_dict(_('Public'), 'public', 'fa-group')) return buttons @@ -223,14 +229,15 @@ class OwnerFilter(tables.FixedFilterAction): def get_image_categories(im, user_tenant_id): categories = [] - if api.glance.is_image_public(im): + if im.is_public: categories.append('public') if im.owner == user_tenant_id: categories.append('project') elif im.owner in filter_tenant_ids(): categories.append(im.owner) - elif not api.glance.is_image_public(im): + elif not im.is_public: categories.append('shared') + categories.append('other') return categories diff --git a/openstack_dashboard/dashboards/project/images/images/tests.py b/openstack_dashboard/dashboards/project/images/images/tests.py index ef1f336d58..df0e21699b 100644 --- a/openstack_dashboard/dashboards/project/images/images/tests.py +++ b/openstack_dashboard/dashboards/project/images/images/tests.py @@ -38,7 +38,7 @@ from openstack_dashboard.dashboards.project.images.images import tables IMAGES_INDEX_URL = reverse('horizon:project:images:index') -class CreateImageFormTests(test.TestCase): +class CreateImageFormTests(test.ResetImageAPIVersionMixin, test.TestCase): @test.create_stubs({api.glance: ('image_list_detailed',)}) def test_no_location_or_file(self): filters = {'disk_format': 'aki'} @@ -64,6 +64,7 @@ class CreateImageFormTests(test.TestCase): self.assertFalse(form.is_valid()) @override_settings(HORIZON_IMAGES_ALLOW_UPLOAD=False) + @override_settings(IMAGES_ALLOW_LOCATION=True) @test.create_stubs({api.glance: ('image_list_detailed',)}) def test_image_upload_disabled(self): filters = {'disk_format': 'aki'} @@ -81,7 +82,8 @@ class CreateImageFormTests(test.TestCase): source_type_dict = dict(form.fields['source_type'].choices) self.assertNotIn('file', source_type_dict) - def test_create_image_metadata_docker(self): + @override_settings(OPENSTACK_API_VERSIONS={'image': 1}) + def test_create_image_metadata_docker_v1(self): form_data = { 'name': u'Docker image', 'description': u'Docker image test', @@ -106,8 +108,29 @@ class CreateImageFormTests(test.TestCase): self.assertEqual(meta['properties']['architecture'], form_data['architecture']) + def test_create_image_metadata_docker_v2(self): + form_data = { + 'name': u'Docker image', + 'description': u'Docker image test', + 'source_type': u'url', + 'image_url': u'/', + 'disk_format': u'docker', + 'architecture': u'x86-64', + 'minimum_disk': 15, + 'minimum_ram': 512, + 'is_public': False, + 'protected': False, + 'is_copying': False + } + meta = forms.create_image_metadata(form_data) + self.assertEqual(meta['disk_format'], 'raw') + self.assertEqual(meta['container_format'], 'docker') + self.assertNotIn('properties', meta) + self.assertEqual(meta['description'], form_data['description']) + self.assertEqual(meta['architecture'], form_data['architecture']) -class UpdateImageFormTests(test.TestCase): + +class UpdateImageFormTests(test.ResetImageAPIVersionMixin, test.TestCase): def test_is_format_field_editable(self): form = forms.UpdateImageForm({}) disk_format = form.fields['disk_format'] @@ -127,8 +150,9 @@ class UpdateImageFormTests(test.TestCase): self.assertEqual(res.context['image'].disk_format, image.disk_format) + @override_settings(OPENSTACK_API_VERSIONS={'image': 1}) @test.create_stubs({api.glance: ('image_update', 'image_get')}) - def test_image_update_post(self): + def test_image_update_post_v1(self): image = self.images.first() data = { 'name': u'Ubuntu 11.10', @@ -156,10 +180,49 @@ class UpdateImageFormTests(test.TestCase): name=data['name'], min_ram=data['minimum_ram'], min_disk=data['minimum_disk'], - properties={'description': data['description'], - 'architecture': - data['architecture']}, - purge_props=False).AndReturn(image) + properties={ + 'description': data['description'], + 'architecture': + data['architecture']}).AndReturn(image) + self.mox.ReplayAll() + url = reverse('horizon:project:images:images:update', + args=[image.id]) + res = self.client.post(url, data) + self.assertNoFormErrors(res) + self.assertEqual(res.status_code, 302) + + @test.create_stubs({api.glance: ('image_update', 'image_get')}) + def test_image_update_post_v2(self): + image = self.images.first() + data = { + 'name': u'Ubuntu 11.10', + 'image_id': str(image.id), + 'description': u'Login with admin/admin', + 'source_type': u'url', + 'image_url': u'http://cloud-images.ubuntu.com/releases/' + u'oneiric/release/ubuntu-11.10-server-cloudimg' + u'-amd64-disk1.img', + 'disk_format': u'qcow2', + 'architecture': u'x86-64', + 'minimum_disk': 15, + 'minimum_ram': 512, + 'is_public': False, + 'protected': False, + 'method': 'UpdateImageForm'} + api.glance.image_get(IsA(http.HttpRequest), str(image.id)) \ + .AndReturn(image) + api.glance.image_update(IsA(http.HttpRequest), + image.id, + visibility='private', + protected=data['protected'], + disk_format=data['disk_format'], + container_format="bare", + name=data['name'], + min_ram=data['minimum_ram'], + min_disk=data['minimum_disk'], + description=data['description'], + architecture=data['architecture']).\ + AndReturn(image) self.mox.ReplayAll() url = reverse('horizon:project:images:images:update', args=[image.id]) @@ -168,7 +231,7 @@ class UpdateImageFormTests(test.TestCase): self.assertEqual(res.status_code, 302) -class ImageViewTests(test.TestCase): +class ImageViewTests(test.ResetImageAPIVersionMixin, test.TestCase): @test.create_stubs({api.glance: ('image_list_detailed',)}) def test_image_create_get(self): filters = {'disk_format': 'aki'} @@ -186,8 +249,9 @@ class ImageViewTests(test.TestCase): self.assertTemplateUsed(res, 'project/images/images/create.html') + @override_settings(OPENSTACK_API_VERSIONS={'image': 1}) @test.create_stubs({api.glance: ('image_create',)}) - def test_image_create_post_copy_from(self): + def test_image_create_post_copy_from_v1(self): data = { 'source_type': u'url', 'image_url': u'http://cloud-images.ubuntu.com/releases/' @@ -198,8 +262,9 @@ class ImageViewTests(test.TestCase): api_data = {'copy_from': data['image_url']} self._test_image_create(data, api_data) + @override_settings(OPENSTACK_API_VERSIONS={'image': 1}) @test.create_stubs({api.glance: ('image_create',)}) - def test_image_create_post_location(self): + def test_image_create_post_location_v1(self): data = { 'source_type': u'url', 'image_url': u'http://cloud-images.ubuntu.com/releases/' @@ -210,8 +275,21 @@ class ImageViewTests(test.TestCase): api_data = {'location': data['image_url']} self._test_image_create(data, api_data) + @override_settings(IMAGES_ALLOW_LOCATION=True) @test.create_stubs({api.glance: ('image_create',)}) - def test_image_create_post_upload(self): + def test_image_create_post_location_v2(self): + data = { + 'source_type': u'url', + 'image_url': u'http://cloud-images.ubuntu.com/releases/' + u'oneiric/release/ubuntu-11.10-server-cloudimg' + u'-amd64-disk1.img'} + + api_data = {'location': data['image_url']} + self._test_image_create(data, api_data) + + @override_settings(OPENSTACK_API_VERSIONS={'image': 1}) + @test.create_stubs({api.glance: ('image_create',)}) + def test_image_create_post_upload_v1(self): temp_file = tempfile.NamedTemporaryFile() temp_file.write(b'123') temp_file.flush() @@ -224,7 +302,38 @@ class ImageViewTests(test.TestCase): self._test_image_create(data, api_data) @test.create_stubs({api.glance: ('image_create',)}) - def test_image_create_post_with_kernel_ramdisk(self): + def test_image_create_post_upload_v2(self): + temp_file = tempfile.NamedTemporaryFile() + temp_file.write(b'123') + temp_file.flush() + temp_file.seek(0) + + data = {'source_type': u'file', + 'image_file': temp_file} + + api_data = {'data': IsA(InMemoryUploadedFile)} + self._test_image_create(data, api_data) + + @override_settings(OPENSTACK_API_VERSIONS={'image': 1}) + @test.create_stubs({api.glance: ('image_create',)}) + def test_image_create_post_with_kernel_ramdisk_v1(self): + temp_file = tempfile.NamedTemporaryFile() + temp_file.write(b'123') + temp_file.flush() + temp_file.seek(0) + + data = { + 'source_type': u'file', + 'image_file': temp_file, + 'kernel_id': '007e7d55-fe1e-4c5c-bf08-44b4a496482e', + 'ramdisk_id': '007e7d55-fe1e-4c5c-bf08-44b4a496482a' + } + + api_data = {'data': IsA(InMemoryUploadedFile)} + self._test_image_create(data, api_data) + + @test.create_stubs({api.glance: ('image_create',)}) + def test_image_create_post_with_kernel_ramdisk_v2(self): temp_file = tempfile.NamedTemporaryFile() temp_file.write(b'123') temp_file.flush() @@ -256,14 +365,22 @@ class ImageViewTests(test.TestCase): api_data = {'container_format': 'bare', 'disk_format': data['disk_format'], - 'is_public': True, 'protected': False, 'min_disk': data['minimum_disk'], 'min_ram': data['minimum_ram'], - 'properties': { - 'description': data['description'], - 'architecture': data['architecture']}, 'name': data['name']} + if api.glance.VERSIONS.active < 2: + api_data.update({'is_public': True, + 'properties': { + 'description': data['description'], + 'architecture': data['architecture']} + }) + else: + api_data.update({'visibility': 'public', + 'description': data['description'], + 'architecture': data['architecture'] + }) + api_data.update(extra_api_data) filters = {'disk_format': 'aki'} @@ -286,12 +403,9 @@ class ImageViewTests(test.TestCase): self.assertNoFormErrors(res) self.assertEqual(res.status_code, 302) - @test.create_stubs({api.glance: ('image_get',)}) - def test_image_detail_get(self): - image = self.images.first() - + def _test_image_detail_get(self, image): api.glance.image_get(IsA(http.HttpRequest), str(image.id)) \ - .AndReturn(self.images.first()) + .AndReturn(image) self.mox.ReplayAll() res = self.client.get(reverse('horizon:project:images:images:detail', @@ -302,10 +416,20 @@ class ImageViewTests(test.TestCase): self.assertEqual(res.context['image'].name, image.name) self.assertEqual(res.context['image'].protected, image.protected) + @override_settings(OPENSTACK_API_VERSIONS={'image': 1}) @test.create_stubs({api.glance: ('image_get',)}) - def test_image_detail_custom_props_get(self): - image = self.images.list()[8] + def test_image_detail_get_v1(self): + image = self.images.first() + self._test_image_detail_get(image) + + @test.create_stubs({api.glance: ('image_get',)}) + def test_image_detail_get_v2(self): + image = self.imagesV2.first() + + self._test_image_detail_get(image) + + def _test_image_detail_custom_props_get(self, image): api.glance.image_get(IsA(http.HttpRequest), str(image.id)) \ .AndReturn(image) self.mox.ReplayAll() @@ -320,8 +444,8 @@ class ImageViewTests(test.TestCase): self.assertNotIn(('description'), image_keys) # Test custom properties are sorted - self.assertEqual(image_props[0], ('bar', 'bar', 'bar val')) - self.assertEqual(image_props[1], ('foo', 'foo', 'foo val')) + self.assertLess(image_props.index(('bar', 'bar', 'bar val')), + image_props.index(('foo', 'foo', 'foo val'))) # Test all custom properties appear in template self.assertContains(res, '
bar
') @@ -329,10 +453,20 @@ class ImageViewTests(test.TestCase): self.assertContains(res, '
foo
') self.assertContains(res, '
foo val
') + @override_settings(OPENSTACK_API_VERSIONS={'image': 1}) @test.create_stubs({api.glance: ('image_get',)}) - def test_protected_image_detail_get(self): - image = self.images.list()[2] + def test_image_detail_custom_props_get_v1(self): + image = self.images.list()[8] + self._test_image_detail_custom_props_get(image) + + @test.create_stubs({api.glance: ('image_get',)}) + def test_image_detail_custom_props_get_v2(self): + image = self.imagesV2.list()[2] + + self._test_image_detail_custom_props_get(image) + + def _test_protected_image_detail_get(self, image): api.glance.image_get(IsA(http.HttpRequest), str(image.id)) \ .AndReturn(image) self.mox.ReplayAll() @@ -344,6 +478,19 @@ class ImageViewTests(test.TestCase): 'horizon/common/_detail.html') self.assertEqual(res.context['image'].protected, image.protected) + @override_settings(OPENSTACK_API_VERSIONS={'image': 1}) + @test.create_stubs({api.glance: ('image_get',)}) + def test_protected_image_detail_get_v1(self): + image = self.images.list()[2] + + self._test_protected_image_detail_get(image) + + @test.create_stubs({api.glance: ('image_get',)}) + def test_protected_image_detail_get_v2(self): + image = self.imagesV2.list()[1] + + self._test_protected_image_detail_get(image) + @test.create_stubs({api.glance: ('image_get',)}) def test_image_detail_get_with_exception(self): image = self.images.first() @@ -359,9 +506,8 @@ class ImageViewTests(test.TestCase): @test.create_stubs({api.glance: ('image_get',)}) def test_image_update_get(self): - image = self.images.first() - image.disk_format = "ami" - image.is_public = True + image = self.images.filter(is_public=True)[0] + api.glance.image_get(IsA(http.HttpRequest), str(image.id)) \ .AndReturn(image) self.mox.ReplayAll() diff --git a/openstack_dashboard/dashboards/project/images/images/views.py b/openstack_dashboard/dashboards/project/images/images/views.py index 853a6ab7d5..1fafc38713 100644 --- a/openstack_dashboard/dashboards/project/images/images/views.py +++ b/openstack_dashboard/dashboards/project/images/images/views.py @@ -19,6 +19,7 @@ """ Views for managing images. """ +from django.conf import settings from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse_lazy from django.utils.translation import ugettext_lazy as _ @@ -71,6 +72,9 @@ class CreateView(forms.ModalFormView): context = super(CreateView, self).get_context_data(**kwargs) upload_mode = api.glance.get_image_upload_mode() context['image_upload_enabled'] = upload_mode != 'off' + context['images_allow_location'] = getattr(settings, + 'IMAGES_ALLOW_LOCATION', + False) return context diff --git a/openstack_dashboard/dashboards/project/images/templates/images/images/_create.html b/openstack_dashboard/dashboards/project/images/templates/images/images/_create.html index e3c471a932..d9c4254040 100644 --- a/openstack_dashboard/dashboards/project/images/templates/images/images/_create.html +++ b/openstack_dashboard/dashboards/project/images/templates/images/images/_create.html @@ -8,18 +8,22 @@ {% block modal-body-right %}

{% trans "Description:" %}

- {% if image_upload_enabled %} + {% if image_upload_enabled and images_allow_location %} {% trans "Images can be provided via an HTTP/HTTPS URL or be uploaded from your local file system." %} + {% elif image_upload_enabled %} + {% trans "Currently only images uploaded from your local file system are supported." %} {% else %} {% trans "Currently only images available via an HTTP/HTTPS URL are supported. The image location must be accessible to the Image Service." %} {% endif %}

- {% trans "Please note: " %} - {% if image_upload_enabled %} - {% trans "If you select an image via an HTTP/HTTPS URL, the Image Location field MUST be a valid and direct URL to the image binary; it must also be accessible to the Image Service. URLs that redirect or serve error pages will result in unusable images." %} - {% else %} - {% trans "The Image Location field MUST be a valid and direct URL to the image binary. URLs that redirect or serve error pages will result in unusable images." %} + {% if images_allow_location %} + {% trans "Please note: " %} + {% if image_upload_enabled %} + {% trans "If you select an image via an HTTP/HTTPS URL, the Image Location field MUST be a valid and direct URL to the image binary; it must also be accessible to the Image Service. URLs that redirect or serve error pages will result in unusable images." %} + {% else %} + {% trans "The Image Location field MUST be a valid and direct URL to the image binary. URLs that redirect or serve error pages will result in unusable images." %} + {% endif %} {% endif %}

{% endblock %} diff --git a/openstack_dashboard/dashboards/project/instances/tests.py b/openstack_dashboard/dashboards/project/instances/tests.py index ca089fab95..f7ef39c691 100644 --- a/openstack_dashboard/dashboards/project/instances/tests.py +++ b/openstack_dashboard/dashboards/project/instances/tests.py @@ -28,6 +28,7 @@ from django.core.urlresolvers import reverse from django.forms import widgets from django import http import django.test +from django.test.utils import override_settings from django.utils.http import urlencode from mox3.mox import IgnoreArg # noqa from mox3.mox import IsA # noqa @@ -54,7 +55,14 @@ VOLUME_SEARCH_OPTS = dict(status=AVAILABLE, bootable=True) SNAPSHOT_SEARCH_OPTS = dict(status=AVAILABLE) -class InstanceTests(helpers.TestCase): +class InstanceTests(helpers.ResetImageAPIVersionMixin, helpers.TestCase): + def setUp(self): + super(InstanceTests, self).setUp() + if api.glance.VERSIONS.active < 2: + self.versioned_images = self.images + else: + self.versioned_images = self.imagesV2 + @helpers.create_stubs({ api.nova: ( 'flavor_list', @@ -1511,7 +1519,7 @@ class InstanceTests(helpers.TestCase): config_drive=True, config_drive_default=False, test_with_profile=False): - image = self.images.first() + image = self.versioned_images.first() api.nova.extension_supported('BlockDeviceMappingV2Boot', IsA(http.HttpRequest)) \ @@ -1525,7 +1533,7 @@ class InstanceTests(helpers.TestCase): api.glance.image_list_detailed( IsA(http.HttpRequest), filters={'is_public': True, 'status': 'active'}) \ - .AndReturn([self.images.list(), False, False]) + .AndReturn([self.versioned_images.list(), False, False]) api.glance.image_list_detailed( IsA(http.HttpRequest), filters={'property-owner_id': self.tenant.id, @@ -1674,6 +1682,10 @@ class InstanceTests(helpers.TestCase): self.assertEqual(step.action.initial['config_drive'], config_drive_default) + @override_settings(OPENSTACK_API_VERSIONS={'image': 1}) + def test_launch_instance_get_glance_v1(self): + self.test_launch_instance_get() + @django.test.utils.override_settings( OPENSTACK_HYPERVISOR_FEATURES={'can_set_password': False}) def test_launch_instance_get_without_password(self): @@ -1777,7 +1789,7 @@ class InstanceTests(helpers.TestCase): api.glance.image_list_detailed( IsA(http.HttpRequest), filters={'is_public': True, 'status': 'active'}) \ - .AndReturn([self.images.list(), False, False]) + .AndReturn([self.versioned_images.list(), False, False]) api.glance.image_list_detailed( IsA(http.HttpRequest), filters={'property-owner_id': self.tenant.id, @@ -1853,6 +1865,10 @@ class InstanceTests(helpers.TestCase): for volume in bootable_volumes: self.assertTrue(volume in volume_sources_ids) + @override_settings(OPENSTACK_API_VERSIONS={'image': 1}) + def test_launch_instance_get_bootable_volumes_glance_v1(self): + self.test_launch_instance_get_bootable_volumes() + @helpers.update_settings( OPENSTACK_NEUTRON_NETWORK={'profile_support': 'cisco'}) def test_launch_instance_get_bootable_volumes_with_profile(self): @@ -1879,7 +1895,7 @@ class InstanceTests(helpers.TestCase): test_with_profile=False, test_with_multi_nics=False): flavor = self.flavors.first() - image = self.images.first() + image = self.versioned_images.first() keypair = self.keypairs.first() server = self.servers.first() sec_group = self.security_groups.first() @@ -1903,7 +1919,7 @@ class InstanceTests(helpers.TestCase): api.glance.image_list_detailed( IsA(http.HttpRequest), filters={'is_public': True, 'status': 'active'}) \ - .AndReturn([self.images.list(), False, False]) + .AndReturn([self.versioned_images.list(), False, False]) api.glance.image_list_detailed( IsA(http.HttpRequest), filters={'property-owner_id': self.tenant.id, @@ -2024,6 +2040,10 @@ class InstanceTests(helpers.TestCase): self.assertNoFormErrors(res) self.assertRedirectsNoFollow(res, INDEX_URL) + @override_settings(OPENSTACK_API_VERSIONS={'image': 1}) + def test_launch_instance_post_glance_v1(self): + self.test_launch_instance_post() + def test_launch_instance_post_no_disk_config_supported(self): self.test_launch_instance_post(disk_config=False) @@ -2046,7 +2066,7 @@ class InstanceTests(helpers.TestCase): test_with_multi_nics=False, ): flavor = self.flavors.first() - image = self.images.first() + image = self.versioned_images.first() keypair = self.keypairs.first() server = self.servers.first() sec_group = self.security_groups.first() @@ -2068,7 +2088,7 @@ class InstanceTests(helpers.TestCase): api.glance.image_list_detailed(IsA(http.HttpRequest), filters={'is_public': True, 'status': 'active'}) \ - .AndReturn([self.images.list(), False, False]) + .AndReturn([self.versioned_images.list(), False, False]) api.glance.image_list_detailed( IsA(http.HttpRequest), filters={'property-owner_id': self.tenant.id, @@ -2181,6 +2201,10 @@ class InstanceTests(helpers.TestCase): def test_launch_instance_post_with_profile_and_port_error(self): self._test_launch_instance_post_with_profile_and_port_error() + @override_settings(OPENSTACK_API_VERSIONS={'image': 1}) + def test_launch_instance_post_with_profile_and_port_error_glance_v1(self): + self.test_launch_instance_post_with_profile_and_port_error() + @helpers.update_settings( OPENSTACK_NEUTRON_NETWORK={'profile_support': 'cisco'}) @helpers.create_stubs({api.glance: ('image_list_detailed',), @@ -2263,7 +2287,7 @@ class InstanceTests(helpers.TestCase): api.glance.image_list_detailed( IsA(http.HttpRequest), filters={'is_public': True, 'status': 'active'}) \ - .AndReturn([self.images.list(), False, False]) + .AndReturn([self.versioned_images.list(), False, False]) api.glance.image_list_detailed( IsA(http.HttpRequest), filters={'property-owner_id': self.tenant.id, @@ -2367,6 +2391,10 @@ class InstanceTests(helpers.TestCase): self.assertNoFormErrors(res) self.assertRedirectsNoFollow(res, INDEX_URL) + @override_settings(OPENSTACK_API_VERSIONS={'image': 1}) + def test_launch_instance_post_boot_from_volume_glance_v1(self): + self.test_launch_instance_post_boot_from_volume() + def test_launch_instance_post_boot_from_volume_with_bdmv2(self): self.test_launch_instance_post_boot_from_volume(test_with_bdmv2=True) @@ -2422,7 +2450,7 @@ class InstanceTests(helpers.TestCase): api.glance.image_list_detailed( IsA(http.HttpRequest), filters={'is_public': True, 'status': 'active'}) \ - .AndReturn([self.images.list(), False, False]) + .AndReturn([self.versioned_images.list(), False, False]) api.glance.image_list_detailed( IsA(http.HttpRequest), filters={'property-owner_id': self.tenant.id, @@ -2527,6 +2555,10 @@ class InstanceTests(helpers.TestCase): self.assertNoFormErrors(res) self.assertRedirectsNoFollow(res, INDEX_URL) + @override_settings(OPENSTACK_API_VERSIONS={'image': 1}) + def test_lnch_inst_post_no_images_avail_boot_from_volume_glance_v1(self): + self.test_launch_instance_post_no_images_available_boot_from_volume() + @helpers.update_settings( OPENSTACK_NEUTRON_NETWORK={'profile_support': 'cisco'}) def test_lnch_inst_post_no_images_avail_boot_from_vol_with_profile(self): @@ -2712,7 +2744,7 @@ class InstanceTests(helpers.TestCase): api.glance.image_list_detailed( IsA(http.HttpRequest), filters={'is_public': True, 'status': 'active'}) \ - .AndReturn([self.images.list(), False, False]) + .AndReturn([self.versioned_images.list(), False, False]) api.glance.image_list_detailed( IsA(http.HttpRequest), filters={'property-owner_id': self.tenant.id, @@ -2817,6 +2849,10 @@ class InstanceTests(helpers.TestCase): self.assertNoFormErrors(res) self.assertRedirectsNoFollow(res, INDEX_URL) + @override_settings(OPENSTACK_API_VERSIONS={'image': 1}) + def test_launch_instance_post_boot_from_snapshot_glance_v1(self): + self.test_launch_instance_post_boot_from_snapshot() + def test_launch_instance_post_boot_from_snapshot_with_bdmv2(self): self.test_launch_instance_post_boot_from_snapshot(test_with_bdmv2=True) @@ -2945,7 +2981,7 @@ class InstanceTests(helpers.TestCase): api.glance.image_list_detailed( IsA(http.HttpRequest), filters={'is_public': True, 'status': 'active'}) \ - .AndReturn([self.images.list(), False, False]) + .AndReturn([self.versioned_images.list(), False, False]) api.glance.image_list_detailed( IsA(http.HttpRequest), filters={'property-owner_id': self.tenant.id, @@ -3000,6 +3036,10 @@ class InstanceTests(helpers.TestCase): self.assertTemplateUsed(res, views.WorkflowView.template_name) + @override_settings(OPENSTACK_API_VERSIONS={'image': 1}) + def test_launch_flavorlist_error_glance_v1(self): + self.test_launch_flavorlist_error() + @helpers.update_settings( OPENSTACK_NEUTRON_NETWORK={'profile_support': 'cisco'}) def test_launch_flavorlist_error_with_profile(self): @@ -3024,7 +3064,7 @@ class InstanceTests(helpers.TestCase): def test_launch_form_keystone_exception(self, test_with_profile=False): flavor = self.flavors.first() - image = self.images.first() + image = self.versioned_images.first() keypair = self.keypairs.first() server = self.servers.first() sec_group = self.security_groups.first() @@ -3055,7 +3095,7 @@ class InstanceTests(helpers.TestCase): api.glance.image_list_detailed( IsA(http.HttpRequest), filters={'is_public': True, 'status': 'active'}) \ - .AndReturn([self.images.list(), False, False]) + .AndReturn([self.versioned_images.list(), False, False]) api.glance.image_list_detailed( IsA(http.HttpRequest), filters={'property-owner_id': self.tenant.id, @@ -3151,6 +3191,10 @@ class InstanceTests(helpers.TestCase): self.assertRedirectsNoFollow(res, INDEX_URL) + @override_settings(OPENSTACK_API_VERSIONS={'image': 1}) + def test_launch_form_keystone_exception_with_profile_glance_v1(self): + self.test_launch_form_keystone_exception() + @helpers.update_settings( OPENSTACK_NEUTRON_NETWORK={'profile_support': 'cisco'}) def test_launch_form_keystone_exception_with_profile(self): @@ -3172,7 +3216,7 @@ class InstanceTests(helpers.TestCase): def test_launch_form_instance_count_error(self, test_with_profile=False): flavor = self.flavors.first() - image = self.images.first() + image = self.versioned_images.first() keypair = self.keypairs.first() server = self.servers.first() volume = self.volumes.first() @@ -3197,7 +3241,7 @@ class InstanceTests(helpers.TestCase): api.glance.image_list_detailed( IsA(http.HttpRequest), filters={'is_public': True, 'status': 'active'}) \ - .AndReturn([self.images.list(), False, False]) + .AndReturn([self.versioned_images.list(), False, False]) api.glance.image_list_detailed( IsA(http.HttpRequest), filters={'property-owner_id': self.tenant.id, @@ -3273,6 +3317,10 @@ class InstanceTests(helpers.TestCase): self.assertContains(res, "greater than or equal to 1") + @override_settings(OPENSTACK_API_VERSIONS={'image': 1}) + def test_launch_form_instance_count_error_glance_v1(self): + self.test_launch_form_instance_count_error() + @helpers.create_stubs({api.glance: ('image_list_detailed',), api.neutron: ('network_list', 'profile_list', @@ -3290,7 +3338,7 @@ class InstanceTests(helpers.TestCase): def _test_launch_form_count_error(self, resource, avail, test_with_profile=False): flavor = self.flavors.first() - image = self.images.first() + image = self.versioned_images.first() keypair = self.keypairs.first() server = self.servers.first() volume = self.volumes.first() @@ -3320,7 +3368,7 @@ class InstanceTests(helpers.TestCase): api.glance.image_list_detailed( IsA(http.HttpRequest), filters={'is_public': True, 'status': 'active'}) \ - .AndReturn([self.images.list(), False, False]) + .AndReturn([self.versioned_images.list(), False, False]) api.glance.image_list_detailed( IsA(http.HttpRequest), filters={'property-owner_id': self.tenant.id, @@ -3407,7 +3455,11 @@ class InstanceTests(helpers.TestCase): "512, Requested: 1024)" % {'avail': avail}) self.assertContains(res, msg) - def test_launch_form_cores_count_error(self): + def test_launch_form_cores_count_error_glance_v2(self): + self._test_launch_form_count_error('cores', 1, test_with_profile=False) + + @override_settings(OPENSTACK_API_VERSIONS={'image': 1}) + def test_launch_form_cores_count_error_glance_v1(self): self._test_launch_form_count_error('cores', 1, test_with_profile=False) def test_launch_form_ram_count_error(self): @@ -3461,7 +3513,7 @@ class InstanceTests(helpers.TestCase): api.glance.image_list_detailed( IsA(http.HttpRequest), filters={'is_public': True, 'status': 'active'}) \ - .AndReturn([self.images.list(), False, False]) + .AndReturn([self.versioned_images.list(), False, False]) api.glance.image_list_detailed( IsA(http.HttpRequest), filters={'property-owner_id': self.tenant.id, @@ -3547,18 +3599,22 @@ class InstanceTests(helpers.TestCase): test_with_profile=False, ): flavor = self.flavors.first() - image = self.images.first() + image = self.versioned_images.first() image.min_ram = flavor.ram image.min_disk = flavor.disk + 1 self._test_launch_form_instance_requirement_error(image, flavor, test_with_profile) + @override_settings(OPENSTACK_API_VERSIONS={'image': 1}) + def test_launch_form_instance_requirement_error_disk_glance_v1(self): + self.test_launch_form_instance_requirement_error_disk() + def test_launch_form_instance_requirement_error_ram( self, test_with_profile=False, ): flavor = self.flavors.first() - image = self.images.first() + image = self.versioned_images.first() image.min_ram = flavor.ram + 1 image.min_disk = flavor.disk self._test_launch_form_instance_requirement_error(image, flavor, @@ -3593,7 +3649,7 @@ class InstanceTests(helpers.TestCase): widget_class, widget_attrs): flavor = self.flavors.first() - image = self.images.first() + image = self.versioned_images.first() keypair = self.keypairs.first() server = self.servers.first() volume = self.volumes.first() @@ -3617,7 +3673,7 @@ class InstanceTests(helpers.TestCase): IsA(http.HttpRequest), filters={'is_public': True, 'status': 'active'}).AndReturn( - [self.images.list(), False, False]) + [self.versioned_images.list(), False, False]) api.glance.image_list_detailed( IsA(http.HttpRequest), filters={'property-owner_id': self.tenant.id, @@ -3692,8 +3748,8 @@ class InstanceTests(helpers.TestCase): for widget_part in widget_content.split(): self.assertContains(res, widget_part) - @django.test.utils.override_settings( - OPENSTACK_HYPERVISOR_FEATURES={'can_set_mount_point': True}) + @override_settings( + OPENSTACK_HYPERVISOR_FEATURES={'can_set_mount_point': True},) def test_launch_form_instance_device_name_showed(self): self._test_launch_form_instance_show_device_name( u'vda', widgets.TextInput, { @@ -3701,6 +3757,17 @@ class InstanceTests(helpers.TestCase): 'attrs': {'id': 'id_device_name'}} ) + @override_settings( + OPENSTACK_HYPERVISOR_FEATURES={'can_set_mount_point': True}, + OPENSTACK_API_VERSIONS={'image': 1} + ) + def test_launch_form_instance_device_name_showed_glance_v1(self): + self._test_launch_form_instance_show_device_name( + u'vda', widgets.TextInput, { + 'name': 'device_name', 'value': 'vda', + 'attrs': {'id': 'id_device_name'}} + ) + @django.test.utils.override_settings( OPENSTACK_HYPERVISOR_FEATURES={'can_set_mount_point': False}) def test_launch_form_instance_device_name_hidden(self): @@ -3754,7 +3821,7 @@ class InstanceTests(helpers.TestCase): api.glance.image_list_detailed( IsA(http.HttpRequest), filters={'is_public': True, 'status': 'active'}) \ - .AndReturn([self.images.list(), False, False]) + .AndReturn([self.versioned_images.list(), False, False]) api.glance.image_list_detailed( IsA(http.HttpRequest), filters={'property-owner_id': self.tenant.id, @@ -3834,22 +3901,26 @@ class InstanceTests(helpers.TestCase): def test_launch_form_instance_volume_size_error(self, test_with_profile=False): - image = self.images.get(name='protected_images') + image = self.versioned_images.get(name='protected_images') volume_size = image.min_disk // 2 msg = ("The Volume size is too small for the '%s' image" % image.name) self._test_launch_form_instance_volume_size(image, volume_size, msg, test_with_profile) + @override_settings(OPENSTACK_API_VERSIONS={'image': 1}) + def test_launch_form_instance_volume_size_error_glance_v1(self): + self.test_launch_form_instance_volume_size_error() + def test_launch_form_instance_non_int_volume_size(self, test_with_profile=False): - image = self.images.get(name='protected_images') + image = self.versioned_images.get(name='protected_images') msg = "Enter a whole number." self._test_launch_form_instance_volume_size(image, 1.5, msg, test_with_profile) def test_launch_form_instance_volume_exceed_quota(self): - image = self.images.get(name='protected_images') + image = self.versioned_images.get(name='protected_images') msg = "Requested volume exceeds quota: Available: 0, Requested: 1" self._test_launch_form_instance_volume_size(image, image.min_disk, msg, False, 0) @@ -3975,7 +4046,7 @@ class InstanceTests(helpers.TestCase): quotas: ('tenant_quota_usages',)}) def test_launch_with_empty_device_name_allowed(self): flavor = self.flavors.get(name='m1.massive') - image = self.images.first() + image = self.versioned_images.first() keypair = self.keypairs.first() server = self.servers.first() sec_group = self.security_groups.first() @@ -4007,7 +4078,7 @@ class InstanceTests(helpers.TestCase): api.glance.image_list_detailed( IsA(http.HttpRequest), filters={'is_public': True, 'status': 'active'}) \ - .AndReturn([self.images.list(), False, False]) + .AndReturn([self.versioned_images.list(), False, False]) api.glance.image_list_detailed( IsA(http.HttpRequest), filters={'property-owner_id': self.tenant.id, @@ -4094,6 +4165,10 @@ class InstanceTests(helpers.TestCase): res = self.client.post(url, form_data) self.assertNoFormErrors(res) + @override_settings(OPENSTACK_API_VERSIONS={'image': 1}) + def test_launch_with_empty_device_name_allowed_glance_v1(self): + self.test_launch_with_empty_device_name_allowed() + @helpers.create_stubs({ api.nova: ('flavor_list', 'server_list', 'tenant_absolute_limits', 'extension_supported',), @@ -4157,7 +4232,7 @@ class InstanceTests(helpers.TestCase): api.glance.image_list_detailed( IsA(http.HttpRequest), filters={'is_public': True, 'status': 'active'}) \ - .AndReturn([self.images.list(), False, False]) + .AndReturn([self.versioned_images.list(), False, False]) api.glance.image_list_detailed( IsA(http.HttpRequest), filters={'property-owner_id': self.tenant.id, @@ -4218,6 +4293,10 @@ class InstanceTests(helpers.TestCase): html=True, msg_prefix="The default key pair was not selected.") + @override_settings(OPENSTACK_API_VERSIONS={'image': 1}) + def test_select_default_keypair_if_only_one_glance_v1(self): + self.test_select_default_keypair_if_only_one() + @helpers.update_settings( OPENSTACK_NEUTRON_NETWORK={'profile_support': 'cisco'}) def test_select_default_keypair_if_only_one_with_profile(self): @@ -4945,7 +5024,7 @@ class InstanceAjaxTests(helpers.TestCase): self.assertContains(res, "Not available") -class ConsoleManagerTests(helpers.TestCase): +class ConsoleManagerTests(helpers.ResetImageAPIVersionMixin, helpers.TestCase): def setup_consoles(self): # Need to refresh with mocks or will fail since mox do not detect @@ -5266,9 +5345,8 @@ class ConsoleManagerTests(helpers.TestCase): cinder: ('volume_list', 'volume_snapshot_list',), quotas: ('tenant_quota_usages',)}) - def test_port_cleanup_called_on_failed_vm_launch(self): + def _test_port_cleanup_called_on_failed_vm_launch(self, image, images): flavor = self.flavors.first() - image = self.images.first() keypair = self.keypairs.first() server = self.servers.first() sec_group = self.security_groups.first() @@ -5298,7 +5376,7 @@ class ConsoleManagerTests(helpers.TestCase): api.glance.image_list_detailed( IsA(http.HttpRequest), filters={'is_public': True, 'status': 'active'}) \ - .AndReturn([self.images.list(), False, False]) + .AndReturn([images, False, False]) api.glance.image_list_detailed( IsA(http.HttpRequest), filters={'property-owner_id': self.tenant.id, @@ -5390,3 +5468,14 @@ class ConsoleManagerTests(helpers.TestCase): res = self.client.post(url, form_data) self.assertRedirectsNoFollow(res, INDEX_URL) + + @override_settings(OPENSTACK_API_VERSIONS={'image': 1}) + def test_port_cleanup_called_on_failed_vm_launch_v1(self): + image = self.images.first() + images = self.images.list() + self._test_port_cleanup_called_on_failed_vm_launch(image, images) + + def test_port_cleanup_called_on_failed_vm_launch_v2(self): + image = self.imagesV2.first() + images = self.imagesV2.list() + self._test_port_cleanup_called_on_failed_vm_launch(image, images) diff --git a/openstack_dashboard/dashboards/project/volumes/volumes/tests.py b/openstack_dashboard/dashboards/project/volumes/volumes/tests.py index 778e88ba07..41b2ac0605 100644 --- a/openstack_dashboard/dashboards/project/volumes/volumes/tests.py +++ b/openstack_dashboard/dashboards/project/volumes/volumes/tests.py @@ -40,7 +40,7 @@ VOLUME_VOLUMES_TAB_URL = urlunquote(reverse( SEARCH_OPTS = dict(status=api.cinder.VOLUME_STATE_AVAILABLE) -class VolumeViewTests(test.TestCase): +class VolumeViewTests(test.ResetImageAPIVersionMixin, test.TestCase): @test.create_stubs({cinder: ('volume_create', 'volume_snapshot_list', 'volume_type_list', @@ -752,12 +752,17 @@ class VolumeViewTests(test.TestCase): image.min_disk = 30 self._test_create_volume_from_image_under_image_min_disk_size(image) - def test_create_volume_from_image_under_image_property_min_disk_size(self): + @override_settings(OPENSTACK_API_VERSIONS={'image': 1}) + def test_create_volume_from_image_under_image_prop_min_disk_size_v1(self): image = self.images.get(name="protected_images") image.min_disk = 0 image.properties['min_disk'] = 30 self._test_create_volume_from_image_under_image_min_disk_size(image) + def test_create_volume_from_image_under_image_prop_min_disk_size_v2(self): + image = self.imagesV2.get(name="protected_images") + self._test_create_volume_from_image_under_image_min_disk_size(image) + @test.create_stubs({cinder: ('volume_snapshot_list', 'volume_type_list', 'volume_type_default', diff --git a/openstack_dashboard/local/local_settings.py.example b/openstack_dashboard/local/local_settings.py.example index 9a44002008..b9e1d152ed 100644 --- a/openstack_dashboard/local/local_settings.py.example +++ b/openstack_dashboard/local/local_settings.py.example @@ -57,6 +57,7 @@ WEBROOT = '/' #OPENSTACK_API_VERSIONS = { # "data-processing": 1.1, # "identity": 3, +# "image": 2, # "volume": 2, # "compute": 2, #} @@ -373,6 +374,11 @@ IMAGE_RESERVED_CUSTOM_PROPERTIES = [] # image form. See documentation for deployment considerations. #HORIZON_IMAGES_UPLOAD_MODE = 'legacy' +# Allow a location to be set when creating or updating Glance images. +# If using Glance V2, this value should be False unless the Glance +# configuration and policies allow setting locations. +#IMAGES_ALLOW_LOCATION = False + # OPENSTACK_ENDPOINT_TYPE specifies the endpoint type to use for the endpoints # in the Keystone service catalog. Use this setting when Horizon is running # external to the OpenStack environment. The default is 'publicURL'. diff --git a/openstack_dashboard/static/app/core/images/details/overview.controller.js b/openstack_dashboard/static/app/core/images/details/overview.controller.js index 73691f033a..810a8a1714 100644 --- a/openstack_dashboard/static/app/core/images/details/overview.controller.js +++ b/openstack_dashboard/static/app/core/images/details/overview.controller.js @@ -44,7 +44,11 @@ ctrl.image = image.data; ctrl.image.properties = Object.keys(ctrl.image.properties).map(function mapProps(prop) { - return {name: prop, value: ctrl.image.properties[prop]}; + var propValue = ctrl.image.properties[prop]; + if ($.isArray(propValue) && propValue.length === 0) { + propValue = ''; + } + return {name: prop, value: propValue}; }); userSession.get().then(setProject); diff --git a/openstack_dashboard/static/app/core/images/steps/create-image/create-image.controller.js b/openstack_dashboard/static/app/core/images/steps/create-image/create-image.controller.js index 7a0e0f3287..bcf73e52fb 100644 --- a/openstack_dashboard/static/app/core/images/steps/create-image/create-image.controller.js +++ b/openstack_dashboard/static/app/core/images/steps/create-image/create-image.controller.js @@ -51,12 +51,13 @@ ctrl.imageFormats = imageFormats; ctrl.diskFormats = []; ctrl.prepareUpload = prepareUpload; + ctrl.apiVersion = 0; ctrl.image = { - source_type: 'url', + source_type: '', image_url: '', data: {}, - is_copying: true, + is_copying: false, protected: false, min_disk: 0, min_ram: 0, @@ -77,9 +78,7 @@ { label: gettext('No'), value: false } ]; - ctrl.imageSourceOptions = [ - { label: gettext('URL'), value: 'url' } - ]; + ctrl.imageSourceOptions = []; ctrl.imageVisibilityOptions = [ { label: gettext('Public'), value: 'public'}, @@ -113,6 +112,7 @@ } function getConfiguredFormatsAndModes(response) { + ctrl.apiVersion = response.HORIZON_ACTIVE_IMAGE_VERSION; var settingsFormats = response.OPENSTACK_IMAGE_FORMATS; var uploadMode = response.HORIZON_IMAGES_UPLOAD_MODE; var dupe = angular.copy(imageFormats); @@ -122,9 +122,15 @@ } }); if (uploadMode !== 'off') { - ctrl.imageSourceOptions.splice(0, 0, { - label: gettext('File'), value: 'file-' + uploadMode + var uploadValue = 'file-' + uploadMode; + ctrl.imageSourceOptions.push({ + label: gettext('File'), value: uploadValue }); + ctrl.image.source_type = uploadValue; + } + if (ctrl.apiVersion < 2 || response.IMAGES_ALLOW_LOCATION) { + ctrl.imageSourceOptions.push({ label: gettext('URL'), value: 'url' }); + ctrl.image.source_type = 'url'; } ctrl.imageFormats = dupe; } diff --git a/openstack_dashboard/static/app/core/images/steps/create-image/create-image.controller.spec.js b/openstack_dashboard/static/app/core/images/steps/create-image/create-image.controller.spec.js index 49419ce3ec..aec0706f81 100644 --- a/openstack_dashboard/static/app/core/images/steps/create-image/create-image.controller.spec.js +++ b/openstack_dashboard/static/app/core/images/steps/create-image/create-image.controller.spec.js @@ -186,18 +186,31 @@ var ctrl = createController(); settingsCall.resolve({ OPENSTACK_IMAGE_FORMATS: [], - HORIZON_IMAGES_UPLOAD_MODE: 'off' + HORIZON_IMAGES_UPLOAD_MODE: 'off', + IMAGES_ALLOW_LOCATION: true }); $timeout.flush(); expect(ctrl.imageSourceOptions).toEqual([urlSourceOption]); }); + it('set to "off" and location disallowed disables all source options', function() { + var ctrl = createController(); + settingsCall.resolve({ + OPENSTACK_IMAGE_FORMATS: [], + HORIZON_IMAGES_UPLOAD_MODE: 'off', + IMAGES_ALLOW_LOCATION: false + }); + $timeout.flush(); + expect(ctrl.imageSourceOptions).toEqual([]); + }); + it('set to a non-"off" value enables local file upload', function() { var ctrl = createController(); var fileSourceOption = { label: gettext('File'), value: 'file-sample' }; settingsCall.resolve({ OPENSTACK_IMAGE_FORMATS: [], - HORIZON_IMAGES_UPLOAD_MODE: 'sample' + HORIZON_IMAGES_UPLOAD_MODE: 'sample', + IMAGES_ALLOW_LOCATION: true }); $timeout.flush(); expect(ctrl.imageSourceOptions).toEqual([fileSourceOption, urlSourceOption]); diff --git a/openstack_dashboard/static/app/core/images/steps/create-image/create-image.html b/openstack_dashboard/static/app/core/images/steps/create-image/create-image.html index dc18aabf5f..b150844bfc 100644 --- a/openstack_dashboard/static/app/core/images/steps/create-image/create-image.html +++ b/openstack_dashboard/static/app/core/images/steps/create-image/create-image.html @@ -57,7 +57,7 @@ -
+
-
+
+
+
+

+ The current Horizon settings indicate no valid + image creation methods are available. Providing + an image location and/or uploading from the + local file system must be allowed to support + image creation. +

+
+
diff --git a/openstack_dashboard/test/api_tests/glance_rest_tests.py b/openstack_dashboard/test/api_tests/glance_rest_tests.py index 58ae091c0a..41410b1417 100644 --- a/openstack_dashboard/test/api_tests/glance_rest_tests.py +++ b/openstack_dashboard/test/api_tests/glance_rest_tests.py @@ -14,11 +14,16 @@ # limitations under the License. import mock +from openstack_dashboard import api from openstack_dashboard.api.rest import glance from openstack_dashboard.test import helpers as test -class ImagesRestTestCase(test.TestCase): +class ImagesRestTestCase(test.ResetImageAPIVersionMixin, test.TestCase): + def setUp(self): + super(ImagesRestTestCase, self).setUp() + api.glance.VERSIONS.clear_active_cache() + # # Version # @@ -70,7 +75,7 @@ class ImagesRestTestCase(test.TestCase): gc.image_delete.assert_called_once_with(request, "1") @mock.patch.object(glance.api, 'glance') - def test_image_edit(self, gc): + def test_image_edit_v1(self, gc): request = self.mock_rest_request(body='''{"name": "Test", "disk_format": "aki", "container_format": "aki", "visibility": "public", "protected": false, @@ -79,6 +84,7 @@ class ImagesRestTestCase(test.TestCase): "description": "description", "kernel": "kernel", "min_disk": 10, "min_ram": 5, "ramdisk": 10 } ''') + gc.VERSIONS.active = 1 metadata = {'name': 'Test', 'disk_format': 'aki', @@ -87,13 +93,41 @@ class ImagesRestTestCase(test.TestCase): 'protected': False, 'min_disk': 10, 'min_ram': 5, - 'properties': { - 'description': 'description', - 'architecture': 'testArch', - 'ramdisk_id': 10, - 'kernel_id': 'kernel', - }, - 'purge_props': False} + 'properties': {'description': 'description', + 'architecture': 'testArch', + 'ramdisk_id': 10, + 'kernel_id': 'kernel'} + } + + response = glance.Image().patch(request, "1") + self.assertStatusCode(response, 204) + self.assertEqual(response.content.decode('utf-8'), '') + gc.image_update.assert_called_once_with(request, '1', **metadata) + + @mock.patch.object(glance.api, 'glance') + def test_image_edit_v2(self, gc): + request = self.mock_rest_request(body='''{"name": "Test", + "disk_format": "aki", "container_format": "aki", + "visibility": "public", "protected": false, + "image_url": "test.com", + "source_type": "url", "architecture": "testArch", + "description": "description", "kernel": "kernel", + "min_disk": 10, "min_ram": 5, "ramdisk": 10 } + ''') + gc.VERSIONS.active = 2 + + metadata = {'name': 'Test', + 'disk_format': 'aki', + 'container_format': 'aki', + 'visibility': 'public', + 'protected': False, + 'min_disk': 10, + 'min_ram': 5, + 'description': 'description', + 'architecture': 'testArch', + 'ramdisk_id': 10, + 'kernel_id': 'kernel' + } response = glance.Image().patch(request, "1") self.assertStatusCode(response, 204) @@ -126,7 +160,7 @@ class ImagesRestTestCase(test.TestCase): **kwargs) @mock.patch.object(glance.api, 'glance') - def test_image_create_basic(self, gc): + def test_image_create_v1_basic(self, gc): request = self.mock_rest_request(body='''{"name": "Test", "disk_format": "aki", "import_data": false, "visibility": "public", "container_format": "aki", @@ -136,6 +170,7 @@ class ImagesRestTestCase(test.TestCase): "min_disk": 10, "min_ram": 5, "ramdisk": 10 } ''') new = gc.image_create.return_value + gc.VERSIONS.active = 1 new.to_dict.return_value = {'name': 'testimage'} new.name = 'testimage' @@ -162,7 +197,43 @@ class ImagesRestTestCase(test.TestCase): gc.image_create.assert_called_once_with(request, **metadata) @mock.patch.object(glance.api, 'glance') - def test_image_create_shared(self, gc): + def test_image_create_v2_basic(self, gc): + request = self.mock_rest_request(body='''{"name": "Test", + "disk_format": "aki", "import_data": false, + "visibility": "public", "container_format": "aki", + "protected": false, "image_url": "test.com", + "source_type": "url", "architecture": "testArch", + "description": "description", "kernel": "kernel", + "min_disk": 10, "min_ram": 5, "ramdisk": 10 } + ''') + gc.VERSIONS.active = 2 + new = gc.image_create.return_value + new.to_dict.return_value = {'name': 'testimage'} + new.name = 'testimage' + + metadata = {'name': 'Test', + 'disk_format': 'aki', + 'container_format': 'aki', + 'visibility': 'public', + 'protected': False, + 'min_disk': 10, + 'min_ram': 5, + 'location': 'test.com', + 'description': 'description', + 'architecture': 'testArch', + 'ramdisk_id': 10, + 'kernel_id': 'kernel', + } + + response = glance.Images().put(request) + self.assertStatusCode(response, 201) + self.assertEqual(response.content.decode('utf-8'), + '{"name": "testimage"}') + self.assertEqual(response['location'], '/api/glance/images/testimage') + gc.image_create.assert_called_once_with(request, **metadata) + + @mock.patch.object(glance.api, 'glance') + def test_image_create_v1_shared(self, gc): request = self.mock_rest_request(body='''{"name": "Test", "disk_format": "aki", "import_data": false, "visibility": "shared", "container_format": "aki", @@ -171,6 +242,7 @@ class ImagesRestTestCase(test.TestCase): "description": "description", "kernel": "kernel", "min_disk": 10, "min_ram": 5, "ramdisk": 10 } ''') + gc.VERSIONS.active = 1 new = gc.image_create.return_value new.to_dict.return_value = {'name': 'testimage'} new.name = 'testimage' @@ -198,7 +270,43 @@ class ImagesRestTestCase(test.TestCase): gc.image_create.assert_called_once_with(request, **metadata) @mock.patch.object(glance.api, 'glance') - def test_image_create_private(self, gc): + def test_image_create_v2_shared(self, gc): + request = self.mock_rest_request(body='''{"name": "Test", + "disk_format": "aki", "import_data": false, + "visibility": "shared", "container_format": "aki", + "protected": false, "image_url": "test.com", + "source_type": "url", "architecture": "testArch", + "description": "description", "kernel": "kernel", + "min_disk": 10, "min_ram": 5, "ramdisk": 10 } + ''') + gc.VERSIONS.active = 2 + new = gc.image_create.return_value + new.to_dict.return_value = {'name': 'testimage'} + new.name = 'testimage' + + metadata = {'name': 'Test', + 'disk_format': 'aki', + 'container_format': 'aki', + 'visibility': 'shared', + 'protected': False, + 'min_disk': 10, + 'min_ram': 5, + 'location': 'test.com', + 'description': 'description', + 'architecture': 'testArch', + 'ramdisk_id': 10, + 'kernel_id': 'kernel', + } + + response = glance.Images().put(request) + self.assertStatusCode(response, 201) + self.assertEqual(response.content.decode('utf-8'), + '{"name": "testimage"}') + self.assertEqual(response['location'], '/api/glance/images/testimage') + gc.image_create.assert_called_once_with(request, **metadata) + + @mock.patch.object(glance.api, 'glance') + def test_image_create_v1_private(self, gc): request = self.mock_rest_request(body='''{"name": "Test", "disk_format": "aki", "import_data": false, "visibility": "private", "container_format": "aki", @@ -207,6 +315,7 @@ class ImagesRestTestCase(test.TestCase): "description": "description", "kernel": "kernel", "min_disk": 10, "min_ram": 5, "ramdisk": 10 } ''') + gc.VERSIONS.active = 1 new = gc.image_create.return_value new.to_dict.return_value = {'name': 'testimage'} new.name = 'testimage' @@ -234,7 +343,43 @@ class ImagesRestTestCase(test.TestCase): gc.image_create.assert_called_once_with(request, **metadata) @mock.patch.object(glance.api, 'glance') - def test_image_create_bad_visibility(self, gc): + def test_image_create_v2_private(self, gc): + request = self.mock_rest_request(body='''{"name": "Test", + "disk_format": "aki", "import_data": false, + "visibility": "private", "container_format": "aki", + "protected": false, "image_url": "test.com", + "source_type": "url", "architecture": "testArch", + "description": "description", "kernel": "kernel", + "min_disk": 10, "min_ram": 5, "ramdisk": 10 } + ''') + gc.VERSIONS.active = 2 + new = gc.image_create.return_value + new.to_dict.return_value = {'name': 'testimage'} + new.name = 'testimage' + + metadata = {'name': 'Test', + 'disk_format': 'aki', + 'container_format': 'aki', + 'visibility': 'private', + 'protected': False, + 'min_disk': 10, + 'min_ram': 5, + 'location': 'test.com', + 'description': 'description', + 'architecture': 'testArch', + 'ramdisk_id': 10, + 'kernel_id': 'kernel', + } + + response = glance.Images().put(request) + self.assertStatusCode(response, 201) + self.assertEqual(response.content.decode('utf-8'), + '{"name": "testimage"}') + self.assertEqual(response['location'], '/api/glance/images/testimage') + gc.image_create.assert_called_once_with(request, **metadata) + + @mock.patch.object(glance.api, 'glance') + def test_image_create_v1_bad_visibility(self, gc): request = self.mock_rest_request(body='''{"name": "Test", "disk_format": "aki", "import_data": false, "visibility": "verybad", "container_format": "aki", @@ -243,6 +388,7 @@ class ImagesRestTestCase(test.TestCase): "description": "description", "kernel": "kernel", "min_disk": 10, "min_ram": 5, "ramdisk": 10 } ''') + gc.VERSIONS.active = 1 response = glance.Images().put(request) self.assertStatusCode(response, 400) @@ -250,12 +396,30 @@ class ImagesRestTestCase(test.TestCase): '"invalid visibility option: verybad"') @mock.patch.object(glance.api, 'glance') - def test_image_create_required(self, gc): + def test_image_create_v2_bad_visibility(self, gc): + request = self.mock_rest_request(body='''{"name": "Test", + "disk_format": "aki", "import_data": false, + "visibility": "verybad", "container_format": "aki", + "protected": false, "image_url": "test.com", + "source_type": "url", "architecture": "testArch", + "description": "description", "kernel": "kernel", + "min_disk": 10, "min_ram": 5, "ramdisk": 10 } + ''') + gc.VERSIONS.active = 2 + + response = glance.Images().put(request) + self.assertStatusCode(response, 400) + self.assertEqual(response.content.decode('utf-8'), + '"invalid visibility option: verybad"') + + @mock.patch.object(glance.api, 'glance') + def test_image_create_v1_required(self, gc): request = self.mock_rest_request(body='''{"name": "Test", "disk_format": "raw", "import_data": true, "container_format": "docker", "visibility": "public", "protected": false, "source_type": "url", "image_url": "test.com" }''') + gc.VERSIONS.active = 1 new = gc.image_create.return_value new.to_dict.return_value = {'name': 'testimage'} new.name = 'testimage' @@ -276,13 +440,40 @@ class ImagesRestTestCase(test.TestCase): gc.image_create.assert_called_once_with(request, **metadata) @mock.patch.object(glance.api, 'glance') - def test_image_create_additional_props(self, gc): + def test_image_create_v2_required(self, gc): + request = self.mock_rest_request(body='''{"name": "Test", + "disk_format": "raw", "import_data": true, + "container_format": "docker", + "visibility": "public", "protected": false, + "source_type": "url", "image_url": "test.com" }''') + gc.VERSIONS.active = 2 + new = gc.image_create.return_value + new.to_dict.return_value = {'name': 'testimage'} + new.name = 'testimage' + + metadata = {'name': 'Test', + 'disk_format': 'raw', + 'container_format': 'docker', + 'copy_from': 'test.com', + 'visibility': 'public', + 'protected': False, + 'min_disk': 0, + 'min_ram': 0 + } + response = glance.Images().put(request) + self.assertStatusCode(response, 201) + self.assertEqual(response['location'], '/api/glance/images/testimage') + gc.image_create.assert_called_once_with(request, **metadata) + + @mock.patch.object(glance.api, 'glance') + def test_image_create_v1_additional_props(self, gc): request = self.mock_rest_request(body='''{"name": "Test", "disk_format": "raw", "import_data": true, "container_format": "docker", "visibility": "public", "protected": false, "arbitrary": "property", "another": "prop", "source_type": "url", "image_url": "test.com" }''') + gc.VERSIONS.active = 1 new = gc.image_create.return_value new.to_dict.return_value = {'name': 'testimage'} new.name = 'testimage' @@ -302,6 +493,35 @@ class ImagesRestTestCase(test.TestCase): self.assertEqual(response['location'], '/api/glance/images/testimage') gc.image_create.assert_called_once_with(request, **metadata) + @mock.patch.object(glance.api, 'glance') + def test_image_create_v2_additional_props(self, gc): + request = self.mock_rest_request(body='''{"name": "Test", + "disk_format": "raw", "import_data": true, + "container_format": "docker", + "visibility": "public", "protected": false, + "arbitrary": "property", "another": "prop", + "source_type": "url", "image_url": "test.com" }''') + gc.VERSIONS.active = 2 + new = gc.image_create.return_value + new.to_dict.return_value = {'name': 'testimage'} + new.name = 'testimage' + + metadata = {'name': 'Test', + 'disk_format': 'raw', + 'container_format': 'docker', + 'copy_from': 'test.com', + 'visibility': 'public', + 'protected': False, + 'min_disk': 0, + 'min_ram': 0, + 'arbitrary': 'property', + 'another': 'prop' + } + response = glance.Images().put(request) + self.assertStatusCode(response, 201) + self.assertEqual(response['location'], '/api/glance/images/testimage') + gc.image_create.assert_called_once_with(request, **metadata) + @mock.patch.object(glance.api, 'glance') def test_namespace_get_list(self, gc): request = self.mock_rest_request(**{'GET': {}}) diff --git a/openstack_dashboard/test/api_tests/glance_tests.py b/openstack_dashboard/test/api_tests/glance_tests.py index 6d0feef462..f86b662fa9 100644 --- a/openstack_dashboard/test/api_tests/glance_tests.py +++ b/openstack_dashboard/test/api_tests/glance_tests.py @@ -25,10 +25,15 @@ from openstack_dashboard.test import helpers as test class GlanceApiTests(test.APITestCase): + def setUp(self): + super(GlanceApiTests, self).setUp() + api.glance.VERSIONS.clear_active_cache() + @override_settings(API_RESULT_PAGE_SIZE=2) def test_image_list_detailed_no_pagination(self): # Verify that all images are returned even with a small page size - api_images = self.images.list() + api_images = self.images_api.list() + expected_images = self.images.list() # Wrapped Images filters = {} limit = getattr(settings, 'API_RESULT_LIMIT', 1000) @@ -44,36 +49,38 @@ class GlanceApiTests(test.APITestCase): images, has_more, has_prev = api.glance.image_list_detailed( self.request) - self.assertItemsEqual(images, api_images) + + self.assertListEqual(images, expected_images) self.assertFalse(has_more) self.assertFalse(has_prev) - @override_settings(API_RESULT_PAGE_SIZE=2) - def test_image_list_detailed_sort_options(self): - # Verify that sort_dir and sort_key work - api_images = self.images.list() - filters = {} - limit = getattr(settings, 'API_RESULT_LIMIT', 1000) - sort_dir = 'asc' - sort_key = 'min_disk' + @override_settings(API_RESULT_PAGE_SIZE=2) + def test_image_list_detailed_sort_options(self): + # Verify that sort_dir and sort_key work + api_images = self.images_api.list() + expected_images = self.images.list() # Wrapped Images + filters = {} + limit = getattr(settings, 'API_RESULT_LIMIT', 1000) + sort_dir = 'asc' + sort_key = 'min_disk' - glanceclient = self.stub_glanceclient() - glanceclient.images = self.mox.CreateMockAnything() - glanceclient.images.list(page_size=limit, - limit=limit, - filters=filters, - sort_dir=sort_dir, - sort_key=sort_key) \ - .AndReturn(iter(api_images)) - self.mox.ReplayAll() + glanceclient = self.stub_glanceclient() + glanceclient.images = self.mox.CreateMockAnything() + glanceclient.images.list(page_size=limit, + limit=limit, + filters=filters, + sort_dir=sort_dir, + sort_key=sort_key) \ + .AndReturn(iter(api_images)) + self.mox.ReplayAll() - images, has_more, has_prev = api.glance.image_list_detailed( - self.request, - sort_dir=sort_dir, - sort_key=sort_key) - self.assertItemsEqual(images, api_images) - self.assertFalse(has_more) - self.assertFalse(has_prev) + images, has_more, has_prev = api.glance.image_list_detailed( + self.request, + sort_dir=sort_dir, + sort_key=sort_key) + self.assertListEqual(images, expected_images) + self.assertFalse(has_more) + self.assertFalse(has_prev) @override_settings(API_RESULT_PAGE_SIZE=2) def test_image_list_detailed_pagination_more_page_size(self): @@ -83,7 +90,8 @@ class GlanceApiTests(test.APITestCase): page_size = settings.API_RESULT_PAGE_SIZE limit = getattr(settings, 'API_RESULT_LIMIT', 1000) - api_images = self.images.list() + api_images = self.images_api.list() + expected_images = self.images.list() # Wrapped Images images_iter = iter(api_images) glanceclient = self.stub_glanceclient() @@ -101,8 +109,8 @@ class GlanceApiTests(test.APITestCase): marker=None, filters=filters, paginate=True) - expected_images = api_images[:page_size] - self.assertItemsEqual(images, expected_images) + expected_images = expected_images[:page_size] + self.assertListEqual(images, expected_images) self.assertTrue(has_more) self.assertFalse(has_prev) # Ensure that only the needed number of images are consumed @@ -118,7 +126,8 @@ class GlanceApiTests(test.APITestCase): page_size = settings.API_RESULT_PAGE_SIZE limit = getattr(settings, 'API_RESULT_LIMIT', 1000) - api_images = self.images.list() + api_images = self.images_api.list() + expected_images = self.images.list() # Wrapped Images images_iter = iter(api_images) glanceclient = self.stub_glanceclient() @@ -135,8 +144,8 @@ class GlanceApiTests(test.APITestCase): self.request, filters=filters, paginate=True) - expected_images = api_images[:page_size] - self.assertItemsEqual(images, expected_images) + expected_images = expected_images[:page_size] + self.assertListEqual(images, expected_images) self.assertFalse(has_more) self.assertFalse(has_prev) @@ -148,7 +157,8 @@ class GlanceApiTests(test.APITestCase): page_size = settings.API_RESULT_PAGE_SIZE limit = getattr(settings, 'API_RESULT_LIMIT', 1000) - api_images = self.images.list() + api_images = self.images_api.list() + expected_images = self.images.list() # Wrapped Images images_iter = iter(api_images) glanceclient = self.stub_glanceclient() @@ -164,8 +174,8 @@ class GlanceApiTests(test.APITestCase): self.request, filters=filters, paginate=True) - expected_images = api_images[:page_size] - self.assertItemsEqual(images, expected_images) + expected_images = expected_images[:page_size] + self.assertListEqual(images, expected_images) self.assertFalse(has_more) self.assertFalse(has_prev) self.assertEqual(len(expected_images), len(images)) @@ -178,7 +188,8 @@ class GlanceApiTests(test.APITestCase): limit = getattr(settings, 'API_RESULT_LIMIT', 1000) marker = 'nonsense' - api_images = self.images.list()[page_size:] + api_images = self.images_api.list()[page_size:] + expected_images = self.images.list()[page_size:] # Wrapped Images images_iter = iter(api_images) glanceclient = self.stub_glanceclient() @@ -198,8 +209,8 @@ class GlanceApiTests(test.APITestCase): marker=marker, filters=filters, paginate=True) - expected_images = api_images[:page_size] - self.assertItemsEqual(images, expected_images) + expected_images = expected_images[:page_size] + self.assertListEqual(images, expected_images) self.assertTrue(has_more) self.assertTrue(has_prev) self.assertEqual(len(list(images_iter)), @@ -213,7 +224,8 @@ class GlanceApiTests(test.APITestCase): limit = getattr(settings, 'API_RESULT_LIMIT', 1000) marker = 'nonsense' - api_images = self.images.list()[page_size:] + api_images = self.images_api.list()[page_size:] + expected_images = self.images.list()[page_size:] # Wrapped Images images_iter = iter(api_images) glanceclient = self.stub_glanceclient() @@ -234,8 +246,8 @@ class GlanceApiTests(test.APITestCase): filters=filters, sort_dir='asc', paginate=True) - expected_images = api_images[:page_size] - self.assertItemsEqual(images, expected_images) + expected_images = expected_images[:page_size] + self.assertListEqual(images, expected_images) self.assertTrue(has_more) self.assertTrue(has_prev) self.assertEqual(len(list(images_iter)), @@ -313,11 +325,15 @@ class GlanceApiTests(test.APITestCase): res_types = api.glance.metadefs_resource_types_list(self.request) self.assertItemsEqual(res_types, []) - def test_image_create_external_upload(self): + def _test_image_create_external_upload(self, api_version=2): expected_image = self.images.first() service = base.get_service_from_catalog(self.service_catalog, 'image') base_url = base.get_url_for_service(service, 'RegionOne', 'publicURL') - file_upload_url = '%s/v1/images/%s' % (base_url, expected_image.id) + if api_version == 1: + url_template = '%s/v1/images/%s' + else: + url_template = '%s/v2/images/%s/file' + upload_url = url_template % (base_url, expected_image.id) glanceclient = self.stub_glanceclient() glanceclient.images = self.mox.CreateMockAnything() @@ -325,7 +341,12 @@ class GlanceApiTests(test.APITestCase): self.mox.ReplayAll() actual_image = api.glance.image_create(self.request, data='sample.iso') - actual_image_dict = actual_image.to_dict() - self.assertEqual(file_upload_url, actual_image_dict['upload_url']) - self.assertEqual(self.request.user.token.id, - actual_image_dict['token_id']) + self.assertEqual(upload_url, actual_image.upload_url) + self.assertEqual(self.request.user.token.id, actual_image.token_id) + + @override_settings(OPENSTACK_API_VERSIONS={"image": 1}) + def test_image_create_v1_external_upload(self): + self._test_image_create_external_upload(api_version=1) + + def test_image_create_v2_external_upload(self): + self._test_image_create_external_upload() diff --git a/openstack_dashboard/test/helpers.py b/openstack_dashboard/test/helpers.py index 42d3158edc..58ff917886 100644 --- a/openstack_dashboard/test/helpers.py +++ b/openstack_dashboard/test/helpers.py @@ -514,6 +514,17 @@ class APITestCase(TestCase): return self.ceilometerclient +# Need this to test both Glance API V1 and V2 versions +class ResetImageAPIVersionMixin(object): + def setUp(self): + super(ResetImageAPIVersionMixin, self).setUp() + api.glance.VERSIONS.clear_active_cache() + + def tearDown(self): + api.glance.VERSIONS.clear_active_cache() + super(ResetImageAPIVersionMixin, self).tearDown() + + @unittest.skipUnless(os.environ.get('WITH_SELENIUM', False), "The WITH_SELENIUM env variable is not set.") class SeleniumTestCase(horizon_helpers.SeleniumTestCase): diff --git a/openstack_dashboard/test/integration_tests/pages/project/compute/imagespage.py b/openstack_dashboard/test/integration_tests/pages/project/compute/imagespage.py index 2bb7b83844..88448ba715 100644 --- a/openstack_dashboard/test/integration_tests/pages/project/compute/imagespage.py +++ b/openstack_dashboard/test/integration_tests/pages/project/compute/imagespage.py @@ -21,7 +21,10 @@ from openstack_dashboard.test.integration_tests.pages.project.compute.\ volumes.volumespage import VolumesPage -DEFAULT_IMAGE_SOURCE = 'url' +# TODO(bpokorny): Set the default source back to 'url' once Glance removes +# the show_multiple_locations option, and if the default devstack policies +# allow setting locations. +DEFAULT_IMAGE_SOURCE = 'file' DEFAULT_IMAGE_FORMAT = 'qcow2' DEFAULT_ACCESSIBILITY = False DEFAULT_PROTECTION = False @@ -34,10 +37,9 @@ class ImagesTable(tables.TableRegion): name = "images" CREATE_IMAGE_FORM_FIELDS = ( - "name", "description", "source_type", "image_url", - "image_file", "kernel", "ramdisk", - "disk_format", "architecture", "minimum_disk", - "minimum_ram", "is_public", "protected" + "name", "description", "image_file", "kernel", "ramdisk", + "disk_format", "architecture", "minimum_disk", "minimum_ram", + "is_public", "protected" ) CREATE_VOLUME_FROM_IMAGE_FORM_FIELDS = ( @@ -129,7 +131,9 @@ class ImagesPage(basepage.BaseNavigationPage): create_image_form.name.text = name if description is not None: create_image_form.description.text = description - create_image_form.source_type.value = image_source_type + # TODO(bpokorny): Add this back once the show_multiple_locations + # option is removed from Glance + # create_image_form.source_type.value = image_source_type if image_source_type == 'url': if location is None: create_image_form.image_url.text = \ diff --git a/openstack_dashboard/test/integration_tests/tests/test_images.py b/openstack_dashboard/test/integration_tests/tests/test_images.py index 9e40ca25f3..c165eab726 100644 --- a/openstack_dashboard/test/integration_tests/tests/test_images.py +++ b/openstack_dashboard/test/integration_tests/tests/test_images.py @@ -18,6 +18,10 @@ from openstack_dashboard.test.integration_tests.regions import messages @decorators.config_option_required('image.panel_type', 'legacy', message="Angular Panels not tested") class TestImagesLegacy(helpers.TestCase): + def __init__(self, *args, **kwargs): + super(TestImagesLegacy, self).__init__(*args, **kwargs) + self.IMAGE_NAME = helpers.gen_random_resource_name("image") + @property def images_page(self): return self.home_pg.go_to_compute_imagespage() @@ -46,16 +50,14 @@ class TestImagesAngular(helpers.TestCase): class TestImagesBasic(TestImagesLegacy): """Login as demo user""" - IMAGE_NAME = helpers.gen_random_resource_name("image") - - def image_create(self, local_file=None): + def image_create(self, local_file=None, **kwargs): images_page = self.images_page if local_file: images_page.create_image(self.IMAGE_NAME, - image_source_type='file', - image_file=local_file) + image_file=local_file, + **kwargs) else: - images_page.create_image(self.IMAGE_NAME) + images_page.create_image(self.IMAGE_NAME, **kwargs) self.assertTrue(images_page.find_message_and_dismiss(messages.INFO)) self.assertFalse(images_page.find_message_and_dismiss(messages.ERROR)) self.assertTrue(images_page.is_image_present(self.IMAGE_NAME)) @@ -69,6 +71,7 @@ class TestImagesBasic(TestImagesLegacy): self.assertFalse(images_page.find_message_and_dismiss(messages.ERROR)) self.assertFalse(images_page.is_image_present(self.IMAGE_NAME)) + @decorators.skip_because(bugs=['1595335']) def test_image_create_delete(self): """tests the image creation and deletion functionalities: * creates a new image from horizon.conf http_image @@ -160,7 +163,11 @@ class TestImagesBasic(TestImagesLegacy): 'metadata2': helpers.gen_random_resource_name("value")} with helpers.gen_temporary_file() as file_name: - images_page = self.image_create(local_file=file_name) + # TODO(tsufiev): had to add non-empty description to an image, + # because description is now considered a metadata and we want + # the metadata in a newly created image to be valid + images_page = self.image_create(local_file=file_name, + description='test description') images_page.add_custom_metadata(self.IMAGE_NAME, new_metadata) results = images_page.check_image_details(self.IMAGE_NAME, new_metadata) @@ -254,8 +261,6 @@ class TestImagesBasic(TestImagesLegacy): class TestImagesAdvanced(TestImagesLegacy): """Login as demo user""" - IMAGE_NAME = helpers.gen_random_resource_name("image") - def test_create_volume_from_image(self): """This test case checks create volume from image functionality: Steps: @@ -316,8 +321,6 @@ class TestImagesAdvanced(TestImagesLegacy): class TestImagesAdmin(helpers.AdminTestCase, TestImagesLegacy): """Login as admin user""" - IMAGE_NAME = helpers.gen_random_resource_name("image") - @property def images_page(self): return self.home_pg.go_to_system_imagespage() diff --git a/openstack_dashboard/test/settings.py b/openstack_dashboard/test/settings.py index 6d45615e62..2622781b51 100644 --- a/openstack_dashboard/test/settings.py +++ b/openstack_dashboard/test/settings.py @@ -137,7 +137,8 @@ AVAILABLE_REGIONS = [ ] OPENSTACK_API_VERSIONS = { - "identity": 3 + "identity": 3, + "image": 2 } OPENSTACK_KEYSTONE_URL = "http://localhost:5000/v2.0" diff --git a/openstack_dashboard/test/test_data/glance_data.py b/openstack_dashboard/test/test_data/glance_data.py index 2e3277ccd9..fa9c13fca9 100644 --- a/openstack_dashboard/test/test_data/glance_data.py +++ b/openstack_dashboard/test/test_data/glance_data.py @@ -14,6 +14,7 @@ from glanceclient.v1 import images +from openstack_dashboard import api from openstack_dashboard.test.test_data import utils @@ -31,10 +32,26 @@ class Namespace(dict): return self.__dict__ +class APIResourceV2(dict): + _base_props = [ + 'id', 'name', 'status', 'visibility', 'protected', 'checksum', 'owner', + 'size', 'virtual_size', 'container_format', 'disk_format', + 'created_at', 'updated_at', 'tags', 'direct_url', 'min_ram', + 'min_disk', 'self', 'file', 'schema', 'locations'] + + def __getattr__(self, item): + if item == 'schema': + return {'properties': {k: '' for k in self._base_props}} + else: + return self.get(item) + + def data(TEST): TEST.images = utils.TestDataContainer() + TEST.images_api = utils.TestDataContainer() TEST.snapshots = utils.TestDataContainer() TEST.metadata_defs = utils.TestDataContainer() + TEST.imagesV2 = utils.TestDataContainer() # Snapshots snapshot_dict = {'name': u'snapshot', @@ -62,11 +79,11 @@ def data(TEST): 'is_public': False, 'protected': False} snapshot = images.Image(images.ImageManager(None), snapshot_dict) - TEST.snapshots.add(snapshot) + TEST.snapshots.add(api.glance.Image(snapshot)) snapshot = images.Image(images.ImageManager(None), snapshot_dict_no_owner) - TEST.snapshots.add(snapshot) + TEST.snapshots.add(api.glance.Image(snapshot)) snapshot = images.Image(images.ImageManager(None), snapshot_dict_queued) - TEST.snapshots.add(snapshot) + TEST.snapshots.add(api.glance.Image(snapshot)) # Images image_dict = {'id': '007e7d55-fe1e-4c5c-bf08-44b4a4964822', @@ -210,11 +227,96 @@ def data(TEST): 'protected': False} no_name_image = images.Image(images.ImageManager(None), image_dict) - TEST.images.add(public_image, private_image, protected_image, - public_image2, private_image2, private_image3, - shared_image1, official_image1, multi_prop_image) + TEST.images_api.add(public_image, private_image, protected_image, + public_image2, private_image2, private_image3, + shared_image1, official_image1, multi_prop_image) - TEST.empty_name_image = no_name_image + TEST.images.add(api.glance.Image(public_image), + api.glance.Image(private_image), + api.glance.Image(protected_image), + api.glance.Image(public_image2), + api.glance.Image(private_image2), + api.glance.Image(private_image3), + api.glance.Image(shared_image1), + api.glance.Image(official_image1), + api.glance.Image(multi_prop_image)) + + TEST.empty_name_image = api.glance.Image(no_name_image) + + image_v2_dicts = [{ + 'checksum': 'eb9139e4942121f22bbc2afc0400b2a4', + 'container_format': 'novaImage', + 'created_at': '2014-02-14T20:56:53', + 'direct_url': 'swift+config://ref1/glance/' + 'da8500d5-8b80-4b9c-8410-cc57fb8fb9d5', + 'disk_format': u'qcow2', + 'file': '/v2/images/' + 'da8500d5-8b80-4b9c-8410-cc57fb8fb9d5/file', + 'id': '007e7d55-fe1e-4c5c-bf08-44b4a4964822', + 'kernel_id': 'f6ebd5f0-b110-4406-8c1e-67b28d4e85e7', + 'locations': [ + {'metadata': {}, + 'url': 'swift+config://ref1/glance/' + 'da8500d5-8b80-4b9c-8410-cc57fb8fb9d5'}], + 'min_ram': 0, + 'name': 'public_image', + 'image_type': u'image', + 'min_disk': 0, + 'owner': TEST.tenant.id, + 'protected': False, + 'ramdisk_id': '868efefc-4f2d-4ed8-82b1-7e35576a7a47', + 'size': 20 * 1024 ** 3, + 'status': 'active', + 'tags': ['active_image'], + 'updated_at': '2015-08-31T19:37:45Z', + 'virtual_size': None, + 'visibility': 'public' + }, { + 'checksum': None, + 'container_format': 'novaImage', + 'created_at': '2014-03-16T06:22:14', + 'disk_format': None, + 'image_type': u'image', + 'file': '/v2/images/885d1cb0-9f5c-4677-9d03-175be7f9f984/file', + 'id': 'd6936c86-7fec-474a-85c5-5e467b371c3c', + 'locations': [], + 'min_disk': 30, + 'min_ram': 0, + 'name': 'protected_images', + 'owner': TEST.tenant.id, + 'protected': True, + 'size': 2 * 1024 ** 3, + 'status': "active", + 'tags': ['empty_image'], + 'updated_at': '2015-09-01T22:37:32Z', + 'virtual_size': None, + 'visibility': 'public' + }, { + 'checksum': 'e533283e6aac072533d1d091a7d2e413', + 'container_format': 'novaImage', + 'created_at': '2015-09-02T00:31:16Z', + 'disk_format': 'qcow2', + 'file': '/v2/images/10ca6b6b-48f4-43ac-8159-aa9e9353f5e4/file', + 'id': 'a67e7d45-fe1e-4c5c-bf08-44b4a4964822', + 'image_type': 'an image type', + 'min_disk': 0, + 'min_ram': 0, + 'name': 'multi_prop_image', + 'owner': TEST.tenant.id, + 'protected': False, + 'size': 20 * 1024 ** 3, + 'status': 'active', + 'tags': ['custom_property_image'], + 'updated_at': '2015-09-02T00:31:17Z', + 'virtual_size': None, + 'visibility': 'public', + 'description': u'a multi prop image', + 'foo': u'foo val', + 'bar': u'bar val' + }] + for fixture in image_v2_dicts: + apiresource = APIResourceV2(fixture) + TEST.imagesV2.add(api.glance.Image(apiresource)) metadef_dict = { 'namespace': 'namespace_1', diff --git a/releasenotes/notes/glance-v2-ba86ba34611f95ce.yaml b/releasenotes/notes/glance-v2-ba86ba34611f95ce.yaml new file mode 100644 index 0000000000..4777a12fc5 --- /dev/null +++ b/releasenotes/notes/glance-v2-ba86ba34611f95ce.yaml @@ -0,0 +1,16 @@ +--- +features: + - Adds complete support for Glance v2 so that Horizon no longer depends on + having a Glance v1 endpoint in the Keystone catalog. Also provides + code compatibility between Glance v1 and v2. + - Adds a new config value called IMAGES_ALLOW_LOCATION, which allows users + to set locations when creating or updating images. Depending on the Glance + version, the ability to set locations is controlled by policies and/or + configuration values. +issues: + - If you set 'images_panel' to False for the ANGULAR_FEATURES option (which + is not the default) and configure Horizon to use Glance v2, Ramdisk ID and + Kernel ID don't show properly on the "Edit Image" screen. +other: + - Glance v2 doesn't support the copy-from feature, so this feature is + disabled in Horizon when using Glance v2.