From 3247e19ba86d3ee3f48a2c2769c94125871c2354 Mon Sep 17 00:00:00 2001 From: Josh Durgin Date: Mon, 18 Feb 2013 14:26:40 -0800 Subject: [PATCH] Add ability to create a volume from an image Currently there is no way to get data onto a volume through the dashboard without taking manual steps in a guest. It's possible to create a volume from a snapshot, but there's no way to get data onto a volume to create the snapshot in the first place. Creating a volume from an image is particularly useful when booting from a volume. This was implemented in cinder and nova-volume in Folsom. Expose this to dashboard users as well. Create a new action for this on the images panel, reusing the 'Create Volume' form. Also make this possible from the volumes panel, by adding a 'source type' field to the 'Create Volume' form. This lets users choose among no source (the default), snapshot, and image, hiding the lists of snapshots and images when the corresponding source type is not selected, similar to the 'image type' field in the 'Launch Instance' workflow. Use the same infrastructure as creating from a snapshot to update the size and name based on the image size and name. Extract the image listing code out of the instance panel, into a generic utility module. Also add a size conversion function, since image sizes are reported by glance in bytes, while volumes are specified in gigabytes. Change-Id: I8314ab011734d80d5f611b338ed6e2538e6bab7e Signed-off-by: Josh Durgin --- horizon/static/horizon/js/horizon.forms.js | 33 ++ horizon/utils/functions.py | 8 + openstack_dashboard/api/cinder.py | 4 +- .../images_and_snapshots/images/tables.py | 20 +- .../project/images_and_snapshots/utils.py | 63 ++++ .../instances/workflows/create_instance.py | 56 +--- .../dashboards/project/volumes/forms.py | 80 ++++- .../dashboards/project/volumes/tests.py | 290 ++++++++++++++++-- .../test/test_data/glance_data.py | 8 + 9 files changed, 491 insertions(+), 71 deletions(-) create mode 100644 openstack_dashboard/dashboards/project/images_and_snapshots/utils.py diff --git a/horizon/static/horizon/js/horizon.forms.js b/horizon/static/horizon/js/horizon.forms.js index ac8f3555b..2147e6f0f 100644 --- a/horizon/static/horizon/js/horizon.forms.js +++ b/horizon/static/horizon/js/horizon.forms.js @@ -9,6 +9,16 @@ horizon.forms = { var $volSize = $form.find('input#id_size'); $volSize.val($option.data("size")); }); + }, + handle_image_source: function() { + $("div.table_wrapper, #modal_wrapper").on("change", "select#id_image_source", function(evt) { + var $option = $(this).find("option:selected"); + var $form = $(this).closest('form'); + var $volName = $form.find('input#id_name'); + $volName.val($option.data("name")); + var $volSize = $form.find('input#id_size'); + $volSize.val($option.data("size")); + }); } }; @@ -67,6 +77,7 @@ horizon.addInitFunction(function () { horizon.modals.addModalInitFunction(horizon.forms.init_examples); horizon.forms.handle_snapshot_source(); + horizon.forms.handle_image_source(); // Bind event handlers to confirm dangerous actions. $("body").on("click", "form button.btn-danger", function (evt) { @@ -108,6 +119,28 @@ horizon.addInitFunction(function () { $(modal).find('select.switchable').trigger('change'); }); + // Handle field toggles for the Create Volume source type field + function update_volume_source_displayed_fields (field) { + var $this = $(field), + base_type = $this.val(); + + $this.find("option").each(function () { + if (this.value != base_type) { + $("#id_" + this.value).closest(".control-group").hide(); + } else { + $("#id_" + this.value).closest(".control-group").show(); + } + }); + } + + $(document).on('change', '#id_volume_source_type', function (evt) { + update_volume_source_displayed_fields(this); + }); + + $('#id_volume_source_type').change(); + horizon.modals.addModalInitFunction(function (modal) { + $(modal).find("#id_volume_source_type").change(); + }); /* Help tooltips */ diff --git a/horizon/utils/functions.py b/horizon/utils/functions.py index 798a6e388..e7ae8d468 100644 --- a/horizon/utils/functions.py +++ b/horizon/utils/functions.py @@ -1,3 +1,5 @@ +import math + from django.utils.encoding import force_unicode from django.utils.functional import lazy @@ -7,3 +9,9 @@ def _lazy_join(separator, strings): for s in strings]) lazy_join = lazy(_lazy_join, unicode) + + +def bytes_to_gigabytes(bytes): + # Converts the number of bytes to the next highest number of Gigabytes + # For example 5000000 (5 Meg) would return '1' + return int(math.ceil(float(bytes) / 1024 ** 3)) diff --git a/openstack_dashboard/api/cinder.py b/openstack_dashboard/api/cinder.py index 7f3ebdb79..2917c0774 100644 --- a/openstack_dashboard/api/cinder.py +++ b/openstack_dashboard/api/cinder.py @@ -89,10 +89,10 @@ def volume_get(request, volume_id): def volume_create(request, size, name, description, volume_type, - snapshot_id=None, metadata=None): + snapshot_id=None, metadata=None, image_id=None): return cinderclient(request).volumes.create(size, display_name=name, display_description=description, volume_type=volume_type, - snapshot_id=snapshot_id, metadata=metadata) + snapshot_id=snapshot_id, metadata=metadata, imageRef=image_id) def volume_delete(request, volume_id): diff --git a/openstack_dashboard/dashboards/project/images_and_snapshots/images/tables.py b/openstack_dashboard/dashboards/project/images_and_snapshots/images/tables.py index 0fd771a37..7ec89a429 100644 --- a/openstack_dashboard/dashboards/project/images_and_snapshots/images/tables.py +++ b/openstack_dashboard/dashboards/project/images_and_snapshots/images/tables.py @@ -84,6 +84,23 @@ class EditImage(tables.LinkAction): return False +class CreateVolumeFromImage(tables.LinkAction): + name = "create_volume_from_image" + verbose_name = _("Create Volume") + url = "horizon:project:volumes:create" + classes = ("ajax-modal", "btn-camera") + + def get_link_url(self, datum): + base_url = reverse(self.url) + params = urlencode({"image_id": self.table.get_object_id(datum)}) + return "?".join([base_url, params]) + + def allowed(self, request, image=None): + if image: + return image.status == "active" + return False + + def filter_tenants(): return getattr(settings, 'IMAGES_LIST_FILTER_TENANTS', []) @@ -199,5 +216,6 @@ class ImagesTable(tables.DataTable): # all the columns by default. columns = ["name", "status", "public", "protected", "disk_format"] table_actions = (OwnerFilter, CreateImage, DeleteImage,) - row_actions = (LaunchImage, EditImage, DeleteImage,) + row_actions = (LaunchImage, CreateVolumeFromImage, + EditImage, DeleteImage,) pagination_param = "image_marker" diff --git a/openstack_dashboard/dashboards/project/images_and_snapshots/utils.py b/openstack_dashboard/dashboards/project/images_and_snapshots/utils.py new file mode 100644 index 000000000..534005a46 --- /dev/null +++ b/openstack_dashboard/dashboards/project/images_and_snapshots/utils.py @@ -0,0 +1,63 @@ +from django.utils.translation import ugettext_lazy as _ + +from horizon import exceptions + +from openstack_dashboard.api import glance + + +def get_available_images(request, project_id=None, images_cache=None): + """ + Returns a list of images that are public or owned by the given + project_id. If project_id is not specified, only public images + are returned. + + :param images_cache: + An optional dict-like object in which to + cache public and per-project id image metadata. + """ + if images_cache is None: + images_cache = {} + public_images = images_cache.get('public_images', []) + images_by_project = images_cache.get('images_by_project', {}) + if 'public_images' not in images_cache: + public = {"is_public": True, + "status": "active"} + try: + images, _more = glance.image_list_detailed( + request, filters=public) + [public_images.append(image) for image in images] + images_cache['public_images'] = public_images + except: + exceptions.handle(request, + _("Unable to retrieve public images.")) + + # Preempt if we don't have a project_id yet. + if project_id is None: + images_by_project[project_id] = [] + + if project_id not in images_by_project: + owner = {"property-owner_id": project_id, + "status": "active"} + try: + owned_images, _more = glance.image_list_detailed( + request, filters=owner) + except: + exceptions.handle(request, + _("Unable to retrieve images for " + "the current project.")) + images_by_project[project_id] = owned_images + if 'images_by_project' not in images_cache: + images_cache['images_by_project'] = images_by_project + + owned_images = images_by_project[project_id] + images = owned_images + public_images + + # Remove duplicate images + image_ids = [] + final_images = [] + for image in images: + if image.id not in image_ids: + image_ids.append(image.id) + final_images.append(image) + return [image for image in final_images + if image.container_format not in ('aki', 'ari')] diff --git a/openstack_dashboard/dashboards/project/instances/workflows/create_instance.py b/openstack_dashboard/dashboards/project/instances/workflows/create_instance.py index 357f14c51..54e9215ba 100644 --- a/openstack_dashboard/dashboards/project/instances/workflows/create_instance.py +++ b/openstack_dashboard/dashboards/project/instances/workflows/create_instance.py @@ -30,8 +30,8 @@ from horizon import workflows from openstack_dashboard import api from openstack_dashboard.api import cinder -from openstack_dashboard.api import glance from openstack_dashboard.usage import quotas +from ...images_and_snapshots.utils import get_available_images LOG = logging.getLogger(__name__) @@ -219,52 +219,14 @@ class SetInstanceDetailsAction(workflows.Action): return cleaned_data - def _get_available_images(self, request, context): - project_id = context.get('project_id', None) - if not hasattr(self, "_public_images"): - public = {"is_public": True, - "status": "active"} - try: - public_images, _more = glance.image_list_detailed( - request, filters=public) - except: - public_images = [] - exceptions.handle(request, - _("Unable to retrieve public images.")) - self._public_images = public_images - - # Preempt if we don't have a project_id yet. - if project_id is None: - setattr(self, "_images_for_%s" % project_id, []) - - if not hasattr(self, "_images_for_%s" % project_id): - owner = {"property-owner_id": project_id, - "status": "active"} - try: - owned_images, _more = glance.image_list_detailed( - request, filters=owner) - except: - owned_images = [] - exceptions.handle(request, - _("Unable to retrieve images for " - "the current project.")) - setattr(self, "_images_for_%s" % project_id, owned_images) - - owned_images = getattr(self, "_images_for_%s" % project_id) - images = owned_images + self._public_images - - # Remove duplicate images - image_ids = [] - final_images = [] - for image in images: - if image.id not in image_ids: - image_ids.append(image.id) - final_images.append(image) - return [image for image in final_images - if image.container_format not in ('aki', 'ari')] + def _init_images_cache(self): + if not hasattr(self, '_images_cache'): + self._images_cache = {} def populate_image_id_choices(self, request, context): - images = self._get_available_images(request, context) + self._init_images_cache() + images = get_available_images(request, context.get('project_id'), + self._images_cache) choices = [(image.id, image.name) for image in images if image.properties.get("image_type", '') != "snapshot"] @@ -275,7 +237,9 @@ class SetInstanceDetailsAction(workflows.Action): return choices def populate_instance_snapshot_id_choices(self, request, context): - images = self._get_available_images(request, context) + self._init_images_cache() + images = get_available_images(request, context.get('project_id'), + self._images_cache) choices = [(image.id, image.name) for image in images if image.properties.get("image_type", '') == "snapshot"] diff --git a/openstack_dashboard/dashboards/project/volumes/forms.py b/openstack_dashboard/dashboards/project/volumes/forms.py index 98a5d4336..1acd0e676 100644 --- a/openstack_dashboard/dashboards/project/volumes/forms.py +++ b/openstack_dashboard/dashboards/project/volumes/forms.py @@ -10,17 +10,21 @@ Views for managing volumes. from django.conf import settings from django.core.urlresolvers import reverse from django.forms import ValidationError +from django.template.defaultfilters import filesizeformat from django.utils.translation import ugettext_lazy as _ from horizon import forms from horizon import exceptions from horizon import messages from horizon.utils.fields import SelectWidget +from horizon.utils.functions import bytes_to_gigabytes from horizon.utils.memoized import memoized from openstack_dashboard import api from openstack_dashboard.api import cinder +from openstack_dashboard.api import glance from openstack_dashboard.usage import quotas +from ..images_and_snapshots.utils import get_available_images from ..instances.tables import ACTIVE_STATES @@ -32,6 +36,8 @@ class CreateForm(forms.SelfHandlingForm): required=False) size = forms.IntegerField(min_value=1, label=_("Size (GB)")) encryption = forms.ChoiceField(label=_("Encryption"), required=False) + volume_source_type = forms.ChoiceField(label=_("Volume Source"), + required=False) snapshot_source = forms.ChoiceField(label=_("Use snapshot as a source"), widget=SelectWidget( attrs={'class': 'snapshot-selector'}, @@ -40,6 +46,15 @@ class CreateForm(forms.SelfHandlingForm): ("%s (%sGB)" % (x.display_name, x.size))), required=False) + image_source = forms.ChoiceField(label=_("Use image as a source"), + widget=SelectWidget( + attrs={'class': 'image-selector'}, + data_attrs=('size', 'name'), + transform=lambda x: + ("%s (%s)" % + (x.name, + filesizeformat(x.bytes)))), + required=False) def __init__(self, request, *args, **kwargs): super(CreateForm, self).__init__(request, *args, **kwargs) @@ -84,13 +99,35 @@ class CreateForm(forms.SelfHandlingForm): self.fields['size'].help_text = _('Volume size must be equal ' 'to or greater than the snapshot size (%sGB)' % snapshot.size) + del self.fields['image_source'] + del self.fields['volume_source_type'] except: exceptions.handle(request, _('Unable to load the specified snapshot.')) + elif ('image_id' in request.GET): + try: + image = self.get_image(request, + request.GET["image_id"]) + image.bytes = image.size + self.fields['name'].initial = image.name + self.fields['size'].initial = bytes_to_gigabytes(image.size) + self.fields['image_source'].choices = ((image.id, image),) + self.fields['size'].help_text = _('Volume size must be equal ' + 'to or greater than the image size (%s)' + % filesizeformat(image.size)) + del self.fields['snapshot_source'] + del self.fields['volume_source_type'] + except: + msg = _('Unable to load the specified image. %s') + exceptions.handle(request, msg % request.GET['image_id']) else: + source_type_choices = [] + try: snapshots = cinder.volume_snapshot_list(request) if snapshots: + source_type_choices.append(("snapshot_source", + _("Snapshot"))) choices = [('', _("Choose a snapshot"))] + \ [(s.id, s) for s in snapshots] self.fields['snapshot_source'].choices = choices @@ -100,6 +137,27 @@ class CreateForm(forms.SelfHandlingForm): exceptions.handle(request, _("Unable to retrieve " "volume snapshots.")) + images = get_available_images(request, + request.user.tenant_id) + if images: + source_type_choices.append(("image_source", _("Image"))) + choices = [('', _("Choose an image"))] + for image in images: + image.bytes = image.size + image.size = bytes_to_gigabytes(image.bytes) + choices.append((image.id, image)) + self.fields['image_source'].choices = choices + else: + del self.fields['image_source'] + + if source_type_choices: + choices = ([('no_source_type', + _("No source, empty volume."))] + + source_type_choices) + self.fields['volume_source_type'].choices = choices + else: + del self.fields['volume_source_type'] + def handle(self, request, data): try: # FIXME(johnp): cinderclient currently returns a useless @@ -109,7 +167,10 @@ class CreateForm(forms.SelfHandlingForm): usages = quotas.tenant_quota_usages(request) snapshot_id = None - if (data.get("snapshot_source", None)): + image_id = None + source_type = data.get('volume_source_type', None) + if (data.get("snapshot_source", None) and + source_type in [None, 'snapshot_source']): # Create from Snapshot snapshot = self.get_snapshot(request, data["snapshot_source"]) @@ -119,6 +180,18 @@ class CreateForm(forms.SelfHandlingForm): 'the snapshot size (%sGB)' % snapshot.size) raise ValidationError(error_message) + elif (data.get("image_source", None) and + source_type in [None, 'image_source']): + # Create from Snapshot + image = self.get_image(request, + data["image_source"]) + image_id = image.id + image_size = bytes_to_gigabytes(image.size) + if (data['size'] < image_size): + error_message = _('The volume size cannot be less than ' + 'the image size (%s)' % + filesizeformat(image.size)) + raise ValidationError(error_message) else: if type(data['size']) is str: data['size'] = int(data['size']) @@ -146,6 +219,7 @@ class CreateForm(forms.SelfHandlingForm): data['description'], data['type'], snapshot_id=snapshot_id, + image_id=image_id, metadata=metadata) message = 'Creating volume "%s"' % data['name'] messages.info(request, message) @@ -162,6 +236,10 @@ class CreateForm(forms.SelfHandlingForm): def get_snapshot(self, request, id): return cinder.volume_snapshot_get(request, id) + @memoized + def get_image(self, request, id): + return glance.image_get(request, id) + class AttachForm(forms.SelfHandlingForm): instance = forms.ChoiceField(label=_("Attach to Instance"), diff --git a/openstack_dashboard/dashboards/project/volumes/tests.py b/openstack_dashboard/dashboards/project/volumes/tests.py index 6dbde8dc2..bf8f5f973 100644 --- a/openstack_dashboard/dashboards/project/volumes/tests.py +++ b/openstack_dashboard/dashboards/project/volumes/tests.py @@ -35,6 +35,7 @@ class VolumeViewTests(test.TestCase): @test.create_stubs({cinder: ('volume_create', 'volume_snapshot_list', 'volume_type_list',), + api.glance: ('image_list_detailed',), quotas: ('tenant_quota_usages',)}) def test_create_volume(self): volume = self.volumes.first() @@ -52,13 +53,22 @@ class VolumeViewTests(test.TestCase): quotas.tenant_quota_usages(IsA(http.HttpRequest)).AndReturn(usage) cinder.volume_snapshot_list(IsA(http.HttpRequest)).\ AndReturn(self.volume_snapshots.list()) + api.glance.image_list_detailed(IsA(http.HttpRequest), + filters={'is_public': True, + 'status': 'active'}) \ + .AndReturn([self.images.list(), False]) + api.glance.image_list_detailed(IsA(http.HttpRequest), + filters={'property-owner_id': self.tenant.id, + 'status': 'active'}) \ + .AndReturn([[], False]) cinder.volume_create(IsA(http.HttpRequest), formData['size'], formData['name'], formData['description'], formData['type'], metadata={}, - snapshot_id=None).AndReturn(volume) + snapshot_id=None, + image_id=None).AndReturn(volume) self.mox.ReplayAll() @@ -70,6 +80,53 @@ class VolumeViewTests(test.TestCase): @test.create_stubs({cinder: ('volume_create', 'volume_snapshot_list', + 'volume_type_list',), + api.glance: ('image_list_detailed',), + quotas: ('tenant_quota_usages',)}) + def test_create_volume_dropdown(self): + volume = self.volumes.first() + usage = {'gigabytes': {'available': 250}, 'volumes': {'available': 6}} + formData = {'name': u'A Volume I Am Making', + 'description': u'This is a volume I am making for a test.', + 'method': u'CreateForm', + 'size': 50, + 'type': '', + 'volume_source_type': 'no_source_type', + 'snapshot_source': self.volume_snapshots.first().id, + 'image_source': self.images.first().id} + + cinder.volume_type_list(IsA(http.HttpRequest)).\ + AndReturn(self.volume_types.list()) + cinder.volume_snapshot_list(IsA(http.HttpRequest)).\ + AndReturn(self.volume_snapshots.list()) + api.glance.image_list_detailed(IsA(http.HttpRequest), + filters={'is_public': True, + 'status': 'active'}) \ + .AndReturn([self.images.list(), False]) + api.glance.image_list_detailed(IsA(http.HttpRequest), + filters={'property-owner_id': self.tenant.id, + 'status': 'active'}) \ + .AndReturn([[], False]) + quotas.tenant_quota_usages(IsA(http.HttpRequest)).AndReturn(usage) + cinder.volume_create(IsA(http.HttpRequest), + formData['size'], + formData['name'], + formData['description'], + '', + metadata={}, + snapshot_id=None, + image_id=None).\ + AndReturn(volume) + + self.mox.ReplayAll() + + url = reverse('horizon:project:volumes:create') + res = self.client.post(url, formData) + + redirect_url = reverse('horizon:project:volumes:index') + self.assertRedirectsNoFollow(res, redirect_url) + + @test.create_stubs({cinder: ('volume_create', 'volume_snapshot_get', 'volume_get', 'volume_type_list',), @@ -85,7 +142,6 @@ class VolumeViewTests(test.TestCase): 'type': '', 'snapshot_source': snapshot.id} - # first call- with url param cinder.volume_type_list(IsA(http.HttpRequest)).\ AndReturn(self.volume_types.list()) quotas.tenant_quota_usages(IsA(http.HttpRequest)).AndReturn(usage) @@ -99,25 +155,9 @@ class VolumeViewTests(test.TestCase): formData['description'], '', metadata={}, - snapshot_id=snapshot.id).\ + snapshot_id=snapshot.id, + image_id=None).\ AndReturn(volume) - # second call- with dropdown - cinder.volume_type_list(IsA(http.HttpRequest)).\ - AndReturn(self.volume_types.list()) - quotas.tenant_quota_usages(IsA(http.HttpRequest)).AndReturn(usage) - cinder.volume_snapshot_list(IsA(http.HttpRequest)).\ - AndReturn(self.volume_snapshots.list()) - cinder.volume_snapshot_get(IsA(http.HttpRequest), - str(snapshot.id)).AndReturn(snapshot) - cinder.volume_create(IsA(http.HttpRequest), - formData['size'], - formData['name'], - formData['description'], - '', - metadata={}, - snapshot_id=snapshot.id).\ - AndReturn(volume) - self.mox.ReplayAll() # get snapshot from url @@ -129,6 +169,52 @@ class VolumeViewTests(test.TestCase): redirect_url = reverse('horizon:project:volumes:index') self.assertRedirectsNoFollow(res, redirect_url) + @test.create_stubs({cinder: ('volume_create', + 'volume_snapshot_list', + 'volume_snapshot_get', + 'volume_get', + 'volume_type_list',), + api.glance: ('image_list_detailed',), + quotas: ('tenant_quota_usages',)}) + def test_create_volume_from_snapshot_dropdown(self): + volume = self.volumes.first() + usage = {'gigabytes': {'available': 250}, 'volumes': {'available': 6}} + snapshot = self.volume_snapshots.first() + formData = {'name': u'A Volume I Am Making', + 'description': u'This is a volume I am making for a test.', + 'method': u'CreateForm', + 'size': 50, + 'type': '', + 'volume_source_type': 'snapshot_source', + 'snapshot_source': snapshot.id} + + cinder.volume_type_list(IsA(http.HttpRequest)).\ + AndReturn(self.volume_types.list()) + cinder.volume_snapshot_list(IsA(http.HttpRequest)).\ + AndReturn(self.volume_snapshots.list()) + api.glance.image_list_detailed(IsA(http.HttpRequest), + filters={'is_public': True, + 'status': 'active'}) \ + .AndReturn([self.images.list(), False]) + api.glance.image_list_detailed(IsA(http.HttpRequest), + filters={'property-owner_id': self.tenant.id, + 'status': 'active'}) \ + .AndReturn([[], False]) + quotas.tenant_quota_usages(IsA(http.HttpRequest)).AndReturn(usage) + cinder.volume_snapshot_get(IsA(http.HttpRequest), + str(snapshot.id)).AndReturn(snapshot) + cinder.volume_create(IsA(http.HttpRequest), + formData['size'], + formData['name'], + formData['description'], + '', + metadata={}, + snapshot_id=snapshot.id, + image_id=None).\ + AndReturn(volume) + + self.mox.ReplayAll() + # get snapshot from dropdown list url = reverse('horizon:project:volumes:create') res = self.client.post(url, formData) @@ -139,6 +225,7 @@ class VolumeViewTests(test.TestCase): @test.create_stubs({cinder: ('volume_snapshot_get', 'volume_type_list', 'volume_get',), + api.glance: ('image_list_detailed',), quotas: ('tenant_quota_usages',)}) def test_create_volume_from_snapshot_invalid_size(self): usage = {'gigabytes': {'available': 250}, 'volumes': {'available': 6}} @@ -168,7 +255,132 @@ class VolumeViewTests(test.TestCase): "The volume size cannot be less than the " "snapshot size (40GB)") + @test.create_stubs({cinder: ('volume_create', + 'volume_type_list',), + api.glance: ('image_get',), + quotas: ('tenant_quota_usages',)}) + def test_create_volume_from_image(self): + volume = self.volumes.first() + usage = {'gigabytes': {'available': 250}, 'volumes': {'available': 6}} + image = self.images.first() + formData = {'name': u'A Volume I Am Making', + 'description': u'This is a volume I am making for a test.', + 'method': u'CreateForm', + 'size': 40, + 'type': '', + 'image_source': image.id} + + cinder.volume_type_list(IsA(http.HttpRequest)).\ + AndReturn(self.volume_types.list()) + quotas.tenant_quota_usages(IsA(http.HttpRequest)).AndReturn(usage) + api.glance.image_get(IsA(http.HttpRequest), + str(image.id)).AndReturn(image) + cinder.volume_create(IsA(http.HttpRequest), + formData['size'], + formData['name'], + formData['description'], + '', + metadata={}, + snapshot_id=None, + image_id=image.id).\ + AndReturn(volume) + + self.mox.ReplayAll() + + # get image from url + url = reverse('horizon:project:volumes:create') + res = self.client.post("?".join([url, + "image_id=" + str(image.id)]), + formData) + + redirect_url = reverse('horizon:project:volumes:index') + self.assertRedirectsNoFollow(res, redirect_url) + + @test.create_stubs({cinder: ('volume_create', + 'volume_type_list', + 'volume_snapshot_list',), + api.glance: ('image_get', + 'image_list_detailed'), + quotas: ('tenant_quota_usages',)}) + def test_create_volume_from_image_dropdown(self): + volume = self.volumes.first() + usage = {'gigabytes': {'available': 250}, 'volumes': {'available': 6}} + image = self.images.first() + formData = {'name': u'A Volume I Am Making', + 'description': u'This is a volume I am making for a test.', + 'method': u'CreateForm', + 'size': 30, + 'type': '', + 'volume_source_type': 'image_source', + 'snapshot_source': self.volume_snapshots.first().id, + 'image_source': image.id} + + cinder.volume_type_list(IsA(http.HttpRequest)).\ + AndReturn(self.volume_types.list()) + cinder.volume_snapshot_list(IsA(http.HttpRequest)).\ + AndReturn(self.volume_snapshots.list()) + api.glance.image_list_detailed(IsA(http.HttpRequest), + filters={'is_public': True, + 'status': 'active'}) \ + .AndReturn([self.images.list(), False]) + api.glance.image_list_detailed(IsA(http.HttpRequest), + filters={'property-owner_id': self.tenant.id, + 'status': 'active'}) \ + .AndReturn([[], False]) + quotas.tenant_quota_usages(IsA(http.HttpRequest)).AndReturn(usage) + api.glance.image_get(IsA(http.HttpRequest), + str(image.id)).AndReturn(image) + cinder.volume_create(IsA(http.HttpRequest), + formData['size'], + formData['name'], + formData['description'], + '', + metadata={}, + snapshot_id=None, + image_id=image.id).\ + AndReturn(volume) + + self.mox.ReplayAll() + + # get image from dropdown list + url = reverse('horizon:project:volumes:create') + res = self.client.post(url, formData) + + redirect_url = reverse('horizon:project:volumes:index') + self.assertRedirectsNoFollow(res, redirect_url) + + @test.create_stubs({cinder: ('volume_type_list',), + api.glance: ('image_get', + 'image_list_detailed'), + quotas: ('tenant_quota_usages',)}) + def test_create_volume_from_image_invalid_size(self): + usage = {'gigabytes': {'available': 250}, 'volumes': {'available': 6}} + image = self.images.first() + formData = {'name': u'A Volume I Am Making', + 'description': u'This is a volume I am making for a test.', + 'method': u'CreateForm', + 'size': 1, 'image_source': image.id} + + cinder.volume_type_list(IsA(http.HttpRequest)).\ + AndReturn(self.volume_types.list()) + quotas.tenant_quota_usages(IsA(http.HttpRequest)).AndReturn(usage) + api.glance.image_get(IsA(http.HttpRequest), + str(image.id)).AndReturn(image) + quotas.tenant_quota_usages(IsA(http.HttpRequest)).AndReturn(usage) + + self.mox.ReplayAll() + + url = reverse('horizon:project:volumes:create') + res = self.client.post("?".join([url, + "image_id=" + str(image.id)]), + formData, follow=True) + self.assertEqual(res.redirect_chain, []) + self.assertFormError(res, 'form', None, + "The volume size cannot be less than the " + "image size (20.0 GB)") + @test.create_stubs({cinder: ('volume_snapshot_list', 'volume_type_list',), + api.glance: ('image_list_detailed',), quotas: ('tenant_quota_usages',)}) def test_create_volume_gb_used_over_alloted_quota(self): usage = {'gigabytes': {'available': 100, 'used': 20}} @@ -182,6 +394,14 @@ class VolumeViewTests(test.TestCase): quotas.tenant_quota_usages(IsA(http.HttpRequest)).AndReturn(usage) cinder.volume_snapshot_list(IsA(http.HttpRequest)).\ AndReturn(self.volume_snapshots.list()) + api.glance.image_list_detailed(IsA(http.HttpRequest), + filters={'is_public': True, + 'status': 'active'}) \ + .AndReturn([self.images.list(), False]) + api.glance.image_list_detailed(IsA(http.HttpRequest), + filters={'property-owner_id': self.tenant.id, + 'status': 'active'}) \ + .AndReturn([[], False]) quotas.tenant_quota_usages(IsA(http.HttpRequest)).AndReturn(usage) self.mox.ReplayAll() @@ -194,6 +414,7 @@ class VolumeViewTests(test.TestCase): self.assertEqual(res.context['form'].errors['__all__'], expected_error) @test.create_stubs({cinder: ('volume_snapshot_list', 'volume_type_list',), + api.glance: ('image_list_detailed',), quotas: ('tenant_quota_usages',)}) def test_create_volume_number_over_alloted_quota(self): usage = {'gigabytes': {'available': 100, 'used': 20}, @@ -208,6 +429,14 @@ class VolumeViewTests(test.TestCase): quotas.tenant_quota_usages(IsA(http.HttpRequest)).AndReturn(usage) cinder.volume_snapshot_list(IsA(http.HttpRequest)).\ AndReturn(self.volume_snapshots.list()) + api.glance.image_list_detailed(IsA(http.HttpRequest), + filters={'is_public': True, + 'status': 'active'}) \ + .AndReturn([self.images.list(), False]) + api.glance.image_list_detailed(IsA(http.HttpRequest), + filters={'property-owner_id': self.tenant.id, + 'status': 'active'}) \ + .AndReturn([[], False]) quotas.tenant_quota_usages(IsA(http.HttpRequest)).AndReturn(usage) self.mox.ReplayAll() @@ -222,6 +451,7 @@ class VolumeViewTests(test.TestCase): @test.create_stubs({cinder: ('volume_create', 'volume_snapshot_list', 'volume_type_list',), + api.glance: ('image_list_detailed',), quotas: ('tenant_quota_usages',)}) def test_create_volume_encrypted(self): volume = self.volumes.first() @@ -244,13 +474,22 @@ class VolumeViewTests(test.TestCase): quotas.tenant_quota_usages(IsA(http.HttpRequest)).AndReturn(usage) cinder.volume_snapshot_list(IsA(http.HttpRequest)).\ AndReturn(self.volume_snapshots.list()) + api.glance.image_list_detailed(IsA(http.HttpRequest), + filters={'is_public': True, + 'status': 'active'}) \ + .AndReturn([self.images.list(), False]) + api.glance.image_list_detailed(IsA(http.HttpRequest), + filters={'property-owner_id': self.tenant.id, + 'status': 'active'}) \ + .AndReturn([[], False]) cinder.volume_create(IsA(http.HttpRequest), formData['size'], formData['name'], formData['description'], formData['type'], metadata={'encryption': formData['encryption']}, - snapshot_id=None).AndReturn(volume) + snapshot_id=None, + image_id=None).AndReturn(volume) self.mox.ReplayAll() @@ -263,6 +502,7 @@ class VolumeViewTests(test.TestCase): settings.OPENSTACK_HYPERVISOR_FEATURES['can_encrypt_volumes'] = PREV @test.create_stubs({cinder: ('volume_snapshot_list', 'volume_type_list',), + api.glance: ('image_list_detailed',), quotas: ('tenant_quota_usages',)}) def test_create_volume_cannot_encrypt(self): volume = self.volumes.first() @@ -289,6 +529,14 @@ class VolumeViewTests(test.TestCase): quotas.tenant_quota_usages(IsA(http.HttpRequest)).AndReturn(usage) cinder.volume_snapshot_list(IsA(http.HttpRequest)).\ AndReturn(self.volume_snapshots.list()) + api.glance.image_list_detailed(IsA(http.HttpRequest), + filters={'is_public': True, + 'status': 'active'}) \ + .AndReturn([self.images.list(), False]) + api.glance.image_list_detailed(IsA(http.HttpRequest), + filters={'property-owner_id': self.tenant.id, + 'status': 'active'}) \ + .AndReturn([[], False]) self.mox.ReplayAll() diff --git a/openstack_dashboard/test/test_data/glance_data.py b/openstack_dashboard/test/test_data/glance_data.py index 0b5dc4c15..9ad972b0e 100644 --- a/openstack_dashboard/test/test_data/glance_data.py +++ b/openstack_dashboard/test/test_data/glance_data.py @@ -57,6 +57,7 @@ def data(TEST): image_dict = {'id': '007e7d55-fe1e-4c5c-bf08-44b4a4964822', 'name': 'public_image', 'status': "active", + 'size': 20 * 1024 ** 3, 'owner': TEST.tenant.id, 'container_format': 'novaImage', 'properties': {'image_type': u'image'}, @@ -67,6 +68,7 @@ def data(TEST): image_dict = {'id': 'a001c047-22f8-47d0-80a1-8ec94a9524fe', 'name': 'private_image', 'status': "active", + 'size': 10 * 1024 ** 2, 'owner': TEST.tenant.id, 'container_format': 'aki', 'is_public': False, @@ -77,6 +79,7 @@ def data(TEST): 'name': 'protected_images', 'status': "active", 'owner': TEST.tenant.id, + 'size': 2 * 1024 ** 3, 'container_format': 'novaImage', 'properties': {'image_type': u'image'}, 'is_public': True, @@ -86,6 +89,7 @@ def data(TEST): image_dict = {'id': '278905a6-4b52-4d1e-98f9-8c57bb25ba32', 'name': 'public_image 2', 'status': "active", + 'size': 5 * 1024 ** 3, 'owner': TEST.tenant.id, 'container_format': 'novaImage', 'properties': {'image_type': u'image'}, @@ -96,6 +100,7 @@ def data(TEST): image_dict = {'id': '710a1acf-a3e3-41dd-a32d-5d6b6c86ea10', 'name': 'private_image 2', 'status': "active", + 'size': 30 * 1024 ** 3, 'owner': TEST.tenant.id, 'container_format': 'aki', 'is_public': False, @@ -105,6 +110,7 @@ def data(TEST): image_dict = {'id': '7cd892fd-5652-40f3-a450-547615680132', 'name': 'private_image 3', 'status': "active", + 'size': 2 * 1024 ** 3, 'owner': TEST.tenant.id, 'container_format': 'aki', 'is_public': False, @@ -115,6 +121,7 @@ def data(TEST): image_dict = {'id': 'c8756975-7a3b-4e43-b7f7-433576112849', 'name': 'shared_image 1', 'status': "active", + 'size': 8 * 1024 ** 3, 'owner': 'someothertenant', 'container_format': 'aki', 'is_public': False, @@ -126,6 +133,7 @@ def data(TEST): image_dict = {'id': 'f448704f-0ce5-4d34-8441-11b6581c6619', 'name': 'official_image 1', 'status': "active", + 'size': 2 * 1024 ** 3, 'owner': 'officialtenant', 'container_format': 'aki', 'is_public': True,