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
This commit is contained in:
Dmitriy Rabotyagov 2019-11-25 12:34:30 +02:00
parent bd5642dc73
commit 04a3535e18
12 changed files with 232 additions and 20 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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