From 04a3535e184dddb8ece828b94d44f589b02a3b94 Mon Sep 17 00:00:00 2001 From: Dmitriy Rabotyagov Date: Mon, 25 Nov 2019 12:34:30 +0200 Subject: [PATCH] Add support to get disk_formats from glance This patch allows administrators to set disk_formats only for glance, while horizon will retrieve list of supported formats from glance API. IMAGE_BACKEND_SETTINGS still may be used to redefine display name of the format or additionally limit list of availble ones. Change-Id: Ia4ea513023895f4ad2a87f91e3d2837c7668d9ae Closes-Bug: 1853822 --- openstack_dashboard/api/glance.py | 28 ++++ openstack_dashboard/api/rest/config.py | 10 ++ .../dashboards/admin/images/tests.py | 5 +- .../dashboards/project/images/images/forms.py | 3 +- .../dashboards/project/images/images/tests.py | 18 ++- .../dashboards/project/volumes/forms.py | 2 +- .../dashboards/project/volumes/tests.py | 4 +- openstack_dashboard/settings.py | 8 - .../test/test_data/glance_data.py | 146 ++++++++++++++++++ .../test/unit/api/rest/test_config.py | 9 +- .../test/unit/api/test_glance.py | 13 ++ .../glance_disk_formats-a13cb994a2d5c1fe.yaml | 6 + 12 files changed, 232 insertions(+), 20 deletions(-) create mode 100644 releasenotes/notes/glance_disk_formats-a13cb994a2d5c1fe.yaml diff --git a/openstack_dashboard/api/glance.py b/openstack_dashboard/api/glance.py index 6b95472948..6233bf1a28 100644 --- a/openstack_dashboard/api/glance.py +++ b/openstack_dashboard/api/glance.py @@ -29,11 +29,13 @@ from django.conf import settings from django.core.files.uploadedfile import InMemoryUploadedFile from django.core.files.uploadedfile import SimpleUploadedFile from django.core.files.uploadedfile import TemporaryUploadedFile +from django.utils.translation import ugettext_lazy as _ from glanceclient.v2 import client import six from six.moves import _thread as thread +from horizon import messages from horizon.utils.memoized import memoized from openstack_dashboard.api import base from openstack_dashboard.contrib.developer.profiler import api as profiler @@ -345,6 +347,32 @@ def image_update(request, image_id, **kwargs): {'file': filename, 'e': e}) +def get_image_formats(request): + image_format_choices = settings.OPENSTACK_IMAGE_BACKEND['image_formats'] + try: + glance_schemas = get_image_schemas(request) + glance_formats = \ + glance_schemas['properties']['disk_format']['enum'] + supported_formats = [] + for value, name in image_format_choices: + if value in glance_formats: + supported_formats.append((value, name)) + else: + LOG.warning('OPENSTACK_IMAGE_BACKEND has a format "%s" ' + 'unsupported by glance', value) + except Exception: + supported_formats = image_format_choices + msg = _('Unable to retrieve image format list.') + messages.error(request, msg) + + return supported_formats + + +@profiler.trace +def get_image_schemas(request): + return glanceclient(request).schemas.get('image').raw() + + def get_image_upload_mode(): mode = settings.HORIZON_IMAGES_UPLOAD_MODE if mode not in ('off', 'legacy', 'direct'): diff --git a/openstack_dashboard/api/rest/config.py b/openstack_dashboard/api/rest/config.py index b4668225f0..eeea25e892 100644 --- a/openstack_dashboard/api/rest/config.py +++ b/openstack_dashboard/api/rest/config.py @@ -58,8 +58,18 @@ class Settings(generic.View): plain_settings = {k: getattr(settings, k, None) for k in settings_allowed if k not in self.SPECIALS} plain_settings.update(self.SPECIALS) + plain_settings.update(self.disk_formats(request)) return plain_settings + def disk_formats(self, request): + # The purpose of OPENSTACK_IMAGE_FORMATS is to provide a simple object + # that does not contain the lazy-loaded translations, so the list can + # be sent as JSON to the client-side (Angular). + return {'OPENSTACK_IMAGE_FORMATS': [ + value + for (value, name) in api.glance.get_image_formats(request) + ]} + @urls.register class Timezones(generic.View): diff --git a/openstack_dashboard/dashboards/admin/images/tests.py b/openstack_dashboard/dashboards/admin/images/tests.py index b7b1307d70..97fb796d5c 100644 --- a/openstack_dashboard/dashboards/admin/images/tests.py +++ b/openstack_dashboard/dashboards/admin/images/tests.py @@ -27,12 +27,15 @@ INDEX_TEMPLATE = 'horizon/common/_data_table_view.html' class ImageCreateViewTest(test.BaseAdminViewTests): + @mock.patch.object(api.glance, 'get_image_schemas') @mock.patch.object(api.glance, 'image_list_detailed') def test_admin_image_create_view_uses_admin_template(self, - mock_image_list): + mock_image_list, + mock_schemas_list): filters1 = {'disk_format': 'aki'} filters2 = {'disk_format': 'ari'} + mock_schemas_list.return_value = self.image_schemas.first() mock_image_list.return_value = [self.images.list(), False, False] res = self.client.get( diff --git a/openstack_dashboard/dashboards/project/images/images/forms.py b/openstack_dashboard/dashboards/project/images/images/forms.py index 063f850843..0665db1add 100644 --- a/openstack_dashboard/dashboards/project/images/images/forms.py +++ b/openstack_dashboard/dashboards/project/images/images/forms.py @@ -174,7 +174,8 @@ class CreateImageForm(CreateParent): if not policy.check((("image", "publicize_image"),), request): self._hide_is_public() - self.fields['disk_format'].choices = IMAGE_FORMAT_CHOICES + self.fields['disk_format'].choices = \ + api.glance.get_image_formats(request) try: kernel_images = api.glance.image_list_detailed( diff --git a/openstack_dashboard/dashboards/project/images/images/tests.py b/openstack_dashboard/dashboards/project/images/images/tests.py index 1379058688..66f21363a4 100644 --- a/openstack_dashboard/dashboards/project/images/images/tests.py +++ b/openstack_dashboard/dashboards/project/images/images/tests.py @@ -38,8 +38,9 @@ IMAGES_INDEX_URL = reverse('horizon:project:images:index') class CreateImageFormTests(test.ResetImageAPIVersionMixin, test.TestCase): + @mock.patch.object(api.glance, 'get_image_formats') @mock.patch.object(api.glance, 'image_list_detailed') - def test_no_location_or_file(self, mock_image_list): + def test_no_location_or_file(self, mock_image_list, mock_schemas_list): mock_image_list.side_effect = [ [self.images.list(), False, False], [self.images.list(), False, False] @@ -133,8 +134,9 @@ class UpdateImageFormTests(test.ResetImageAPIVersionMixin, test.TestCase): class ImageViewTests(test.ResetImageAPIVersionMixin, test.TestCase): + @mock.patch.object(api.glance, 'get_image_schemas') @mock.patch.object(api.glance, 'image_list_detailed') - def test_image_create_get(self, mock_image_list): + def test_image_create_get(self, mock_image_list, mock_schemas_list): mock_image_list.side_effect = [ [self.images.list(), False, False], [self.images.list(), False, False] @@ -151,7 +153,9 @@ class ImageViewTests(test.ResetImageAPIVersionMixin, test.TestCase): mock_image_list.assert_has_calls(image_calls) @override_settings(IMAGES_ALLOW_LOCATION=True) - def test_image_create_post_location_v2(self): + @mock.patch.object(api.glance, 'get_image_schemas') + def test_image_create_post_location_v2(self, mock_schemas_list): + mock_schemas_list.return_value = self.image_schemas.first() data = { 'source_type': u'url', 'image_url': u'http://cloud-images.ubuntu.com/releases/' @@ -161,7 +165,9 @@ class ImageViewTests(test.ResetImageAPIVersionMixin, test.TestCase): api_data = {'location': data['image_url']} self._test_image_create(data, api_data) - def test_image_create_post_upload_v2(self): + @mock.patch.object(api.glance, 'get_image_schemas') + def test_image_create_post_upload_v2(self, mock_schemas_list): + mock_schemas_list.return_value = self.image_schemas.first() temp_file = tempfile.NamedTemporaryFile() temp_file.write(b'123') temp_file.flush() @@ -173,7 +179,9 @@ class ImageViewTests(test.ResetImageAPIVersionMixin, test.TestCase): api_data = {'data': test.IsA(InMemoryUploadedFile)} self._test_image_create(data, api_data) - def test_image_create_post_with_kernel_ramdisk_v2(self): + @mock.patch.object(api.glance, 'get_image_schemas') + def test_image_create_post_with_kernel_ramdisk_v2(self, mock_schemas_list): + mock_schemas_list.return_value = self.image_schemas.first() temp_file = tempfile.NamedTemporaryFile() temp_file.write(b'123') temp_file.flush() diff --git a/openstack_dashboard/dashboards/project/volumes/forms.py b/openstack_dashboard/dashboards/project/volumes/forms.py index 8f14bbd45e..04b6423218 100644 --- a/openstack_dashboard/dashboards/project/volumes/forms.py +++ b/openstack_dashboard/dashboards/project/volumes/forms.py @@ -719,7 +719,7 @@ class UploadToImageForm(forms.SelfHandlingForm): # I can only use 'raw', 'vmdk', 'vdi' or 'qcow2' so qemu-img will not # have issues when processes image request from cinder. disk_format_choices = [(value, name) for value, name - in IMAGE_FORMAT_CHOICES + in glance.get_image_formats(request) if value in VALID_DISK_FORMATS] self.fields['disk_format'].choices = disk_format_choices self.fields['disk_format'].initial = 'raw' diff --git a/openstack_dashboard/dashboards/project/volumes/tests.py b/openstack_dashboard/dashboards/project/volumes/tests.py index 569f100054..1fce6d6b4b 100644 --- a/openstack_dashboard/dashboards/project/volumes/tests.py +++ b/openstack_dashboard/dashboards/project/volumes/tests.py @@ -1668,9 +1668,10 @@ class VolumeViewTests(test.ResetImageAPIVersionMixin, test.TestCase): self.mock_volume_set_bootable.assert_called_once_with( test.IsHttpRequest(), volume.id, True) + @mock.patch.object(api.glance, 'get_image_schemas') @mock.patch.object(cinder, 'volume_upload_to_image') @mock.patch.object(cinder, 'volume_get') - def test_upload_to_image(self, mock_get, mock_upload): + def test_upload_to_image(self, mock_get, mock_upload, mock_schemas_list): volume = self.cinder_volumes.get(name='v2_volume') loaded_resp = {'container_format': 'bare', 'disk_format': 'raw', @@ -1687,6 +1688,7 @@ class VolumeViewTests(test.ResetImageAPIVersionMixin, test.TestCase): 'container_format': 'bare', 'disk_format': 'raw'} + mock_schemas_list.return_value = self.image_schemas.first() mock_get.return_value = volume mock_upload.return_value = loaded_resp diff --git a/openstack_dashboard/settings.py b/openstack_dashboard/settings.py index 02cd17ef3b..fd9f5a82b4 100644 --- a/openstack_dashboard/settings.py +++ b/openstack_dashboard/settings.py @@ -278,14 +278,6 @@ if os.path.exists(LOCAL_SETTINGS_DIR_PATH): _LOG.exception( "Can not exec settings snippet %s", filename) -# The purpose of OPENSTACK_IMAGE_FORMATS is to provide a simple object -# that does not contain the lazy-loaded translations, so the list can -# be sent as JSON to the client-side (Angular). -# TODO(amotoki): Do we really need this here? Can't we calculate this -# in openstack_dashboard.api.rest.config? -OPENSTACK_IMAGE_FORMATS = [fmt for (fmt, name) - in OPENSTACK_IMAGE_BACKEND['image_formats']] - if USER_MENU_LINKS is None: USER_MENU_LINKS = [] if SHOW_OPENRC_FILE: diff --git a/openstack_dashboard/test/test_data/glance_data.py b/openstack_dashboard/test/test_data/glance_data.py index a3a901c7ae..df05f3d2ce 100644 --- a/openstack_dashboard/test/test_data/glance_data.py +++ b/openstack_dashboard/test/test_data/glance_data.py @@ -52,6 +52,7 @@ def data(TEST): TEST.images_api = utils.TestDataContainer() TEST.snapshots = utils.TestDataContainer() TEST.metadata_defs = utils.TestDataContainer() + TEST.image_schemas = utils.TestDataContainer() TEST.imagesV2 = utils.TestDataContainer() TEST.snapshotsV2 = utils.TestDataContainer() @@ -479,3 +480,148 @@ def data(TEST): } metadef = Namespace(metadef_dict) TEST.metadata_defs.add(metadef) + + image_schemas_dict = { + 'additionalProperties': {'type': 'string'}, + 'links': [ + {'href': '{self}', 'rel': 'self'}, + {'href': '{file}', 'rel': 'enclosure'}, + {'href': '{schema}', 'rel': 'describedby'} + ], + 'name': 'image', + 'properties': { + 'architecture': { + 'is_base': False, + 'type': 'string' + }, + 'checksum': { + 'maxLength': 32, + 'readOnly': True, + 'type': ['null', 'string'] + }, + 'container_format': { + 'enum': [ + None, + 'ami', + 'ari', + 'aki', + 'bare', + 'ovf', + 'ova', + 'docker', + 'compressed' + ], + 'type': ['null', 'string'] + }, + 'created_at': {'readOnly': True, 'type': 'string'}, + 'direct_url': {'readOnly': True, 'type': 'string'}, + 'disk_format': { + 'enum': [None, 'raw', 'qcow2'], + 'type': ['null', 'string'] + }, + 'file': {'readOnly': True, 'type': 'string'}, + 'id': {'type': 'string'}, + 'instance_uuid': {'is_base': False, 'type': 'string'}, + 'kernel_id': { + 'is_base': False, + 'type': ['null', 'string'] + }, + 'locations': { + 'items': { + 'properties': { + 'metadata': {'type': 'object'}, + 'url': {'maxLength': 255, 'type': 'string'}, + 'validation_data': { + 'additionalProperties': False, + 'properties': { + 'checksum': { + 'maxLength': 32, + 'minLength': 32, + 'type': 'string' + }, + 'os_hash_algo': { + 'maxLength': 64, + 'type': 'string' + }, + 'os_hash_value': { + 'maxLength': 128, + 'type': 'string' + } + }, + 'required': ['os_hash_algo', 'os_hash_value'], + 'type': 'object', + 'writeOnly': True + } + }, + 'required': ['url', 'metadata'], + 'type': 'object' + }, + 'type': 'array' + }, + 'min_disk': {'type': 'integer'}, + 'min_ram': {'type': 'integer'}, + 'name': {'maxLength': 255, 'type': ['null', 'string']}, + 'os_distro': {'is_base': False, 'type': 'string'}, + 'os_hash_algo': { + 'maxLength': 64, + 'readOnly': True, + 'type': ['null', 'string'] + }, + 'os_hash_value': { + 'maxLength': 128, + 'readOnly': True, + 'type': ['null', 'string'] + }, + 'os_hidden': {'type': 'boolean'}, + 'os_version': {'is_base': False, 'type': 'string'}, + 'owner': { + 'description': 'Owner of the image', + 'maxLength': 255, + 'type': ['null', 'string'] + }, + 'protected': {'type': 'boolean'}, + 'ramdisk_id': { + 'is_base': False, + 'type': ['null', 'string'] + }, + 'schema': {'readOnly': True, 'type': 'string'}, + 'self': {'readOnly': True, 'type': 'string'}, + 'size': {'readOnly': True, 'type': ['null', 'integer']}, + 'status': { + 'enum': [ + 'queued', + 'saving', + 'active', + 'killed', + 'deleted', + 'uploading', + 'importing', + 'pending_delete', + 'deactivated' + ], + 'readOnly': True, + 'type': 'string' + }, + 'stores': {'readOnly': True, 'type': 'string'}, + 'tags': { + 'items': {'maxLength': 255, 'type': 'string'}, + 'type': 'array' + }, + 'updated_at': {'readOnly': True, 'type': 'string'}, + 'virtual_size': { + 'readOnly': True, + 'type': ['null', 'integer'] + }, + 'visibility': { + 'enum': [ + 'community', + 'public', + 'private', + 'shared' + ], + 'type': 'string' + } + } + } + schemas = Namespace(image_schemas_dict) + TEST.image_schemas.add(schemas) diff --git a/openstack_dashboard/test/unit/api/rest/test_config.py b/openstack_dashboard/test/unit/api/rest/test_config.py index dcb1473cdf..7ce7c9a042 100644 --- a/openstack_dashboard/test/unit/api/rest/test_config.py +++ b/openstack_dashboard/test/unit/api/rest/test_config.py @@ -12,15 +12,18 @@ # See the License for the specific language governing permissions and # limitations under the License. -from openstack_dashboard.api.rest import config +import mock + +from openstack_dashboard import api from openstack_dashboard.test import helpers as test class ConfigRestTestCase(test.TestCase): - def test_settings_config_get(self): + @mock.patch.object(api.glance, 'get_image_schemas') + def test_settings_config_get(self, mock_schemas_list): request = self.mock_rest_request() - response = config.Settings().get(request) + response = api.rest.config.Settings().get(request) self.assertStatusCode(response, 200) self.assertIn(b"REST_API_SETTING_1", response.content) self.assertIn(b"REST_API_SETTING_2", response.content) diff --git a/openstack_dashboard/test/unit/api/test_glance.py b/openstack_dashboard/test/unit/api/test_glance.py index a81442f041..0aac18a1d2 100644 --- a/openstack_dashboard/test/unit/api/test_glance.py +++ b/openstack_dashboard/test/unit/api/test_glance.py @@ -314,6 +314,19 @@ class GlanceApiTests(test.APIMockTestCase): mock_images_get.assert_called_once_with('empty') self.assertIsNone(image.name) + @mock.patch.object(api.glance, 'glanceclient') + def test_get_image_formats(self, mock_glanceclient): + glance_schemas = self.image_schemas.first() + glanceclient = mock_glanceclient.return_value + mock_schemas_list = glanceclient.schemas.get('image').raw() + mock_schemas_list.return_value = glance_schemas + disk_formats = [ + item + for item in glance_schemas['properties']['disk_format']['enum'] + if item + ] + self.assertListEqual(sorted(disk_formats), sorted(['raw', 'qcow2'])) + @mock.patch.object(api.glance, 'glanceclient') def test_metadefs_namespace_list(self, mock_glanceclient): metadata_defs = self.metadata_defs.list() diff --git a/releasenotes/notes/glance_disk_formats-a13cb994a2d5c1fe.yaml b/releasenotes/notes/glance_disk_formats-a13cb994a2d5c1fe.yaml new file mode 100644 index 0000000000..44265dd83c --- /dev/null +++ b/releasenotes/notes/glance_disk_formats-a13cb994a2d5c1fe.yaml @@ -0,0 +1,6 @@ +--- +features: + - | + Added support to retrieve supported disk formats from glance, + so you can adjust disk_formats only inside glance-api.conf. + You still can use IMAGE_BACKEND_SETTINGS to adjust format naming.