diff --git a/doc/source/topics/deployment.rst b/doc/source/topics/deployment.rst index 8cd11874c..872b96d38 100644 --- a/doc/source/topics/deployment.rst +++ b/doc/source/topics/deployment.rst @@ -33,6 +33,27 @@ how to customize it, and where other components may take over: .. _a known bug in python-keystoneclient: https://bugs.launchpad.net/keystone/+bug/1004114 +File Uploads +============ + +Horizon allows users to upload files via their web browser to other OpenStack +services such as Glance and Swift. Files uploaded through this mechanism are +first stored on the Horizon server before being forwarded on - files are not +uploaded directly or streamed as Horizon receives them. As Horizon itself does +not impose any restrictions on the size of file uploads, production deployments +will want to consider configuring their server hosting the Horizon application +to enforce such a limit to prevent large uploads exhausting system resources +and disrupting services. Deployments using Apache2 can use the +`LimitRequestBody directive`_ to achieve this. + +Uploads to the Glance image store service tend to be particularly large - in +the order of hundreds of megabytes to multiple gigabytes. Deployments are able +to disable the ability to upload images through Horizon by setting +``HORIZON_IMAGES_ALLOW_UPLOAD`` to ``False`` in your ``local_settings.py`` +file. + + .. _LimitRequestBody directive: http://httpd.apache.org/docs/2.2/mod/core.html#limitrequestbody + Session Storage =============== diff --git a/openstack_dashboard/dashboards/admin/images/templates/images/_create.html b/openstack_dashboard/dashboards/admin/images/templates/images/_create.html index 500e359d2..836bcbffa 100644 --- a/openstack_dashboard/dashboards/admin/images/templates/images/_create.html +++ b/openstack_dashboard/dashboards/admin/images/templates/images/_create.html @@ -3,6 +3,7 @@ {% block form_id %}create_image_form{% endblock %} {% block form_action %}{% url horizon:admin:images:create %}{% endblock %} +{% block form_attrs %}enctype="multipart/form-data"{% endblock %} {% block modal-header %}{% trans "Create An Image" %}{% endblock %} diff --git a/openstack_dashboard/dashboards/project/images_and_snapshots/images/forms.py b/openstack_dashboard/dashboards/project/images_and_snapshots/images/forms.py index 480019f77..436294166 100644 --- a/openstack_dashboard/dashboards/project/images_and_snapshots/images/forms.py +++ b/openstack_dashboard/dashboards/project/images_and_snapshots/images/forms.py @@ -24,6 +24,9 @@ Views for managing images. import logging +from django.conf import settings +from django.forms import ValidationError +from django.forms.widgets import HiddenInput from django.utils.translation import ugettext_lazy as _ from horizon import exceptions @@ -42,7 +45,10 @@ class CreateImageForm(forms.SelfHandlingForm): label=_("Image Location"), help_text=_("An external (HTTP) URL to load " "the image from."), - required=True) + required=False) + image_file = forms.FileField(label=_("Image File"), + help_text=("A local image to upload."), + required=False) disk_format = forms.ChoiceField(label=_('Format'), required=True, choices=[('', ''), @@ -81,6 +87,22 @@ class CreateImageForm(forms.SelfHandlingForm): required=False) is_public = forms.BooleanField(label=_("Public"), required=False) + def __init__(self, *args, **kwargs): + super(CreateImageForm, self).__init__(*args, **kwargs) + if not settings.HORIZON_IMAGES_ALLOW_UPLOAD: + self.fields['image_file'].widget = HiddenInput() + + def clean(self): + data = super(CreateImageForm, self).clean() + if not data['copy_from'] and not data['image_file']: + raise ValidationError( + _("A image or external image location must be specified.")) + elif data['copy_from'] and data['image_file']: + raise ValidationError( + _("Can not specify both image and external image location.")) + else: + return data + def handle(self, request, data): # Glance does not really do anything with container_format at the # moment. It requires it is set to the same disk_format for the three @@ -95,11 +117,15 @@ class CreateImageForm(forms.SelfHandlingForm): meta = {'is_public': data['is_public'], 'disk_format': data['disk_format'], 'container_format': container_format, - 'copy_from': data['copy_from'], 'min_disk': (data['minimum_disk'] or 0), 'min_ram': (data['minimum_ram'] or 0), 'name': data['name']} + if settings.HORIZON_IMAGES_ALLOW_UPLOAD and data['image_file']: + meta['data'] = self.files['image_file'] + else: + meta['copy_from'] = data['copy_from'] + try: image = api.glance.image_create(request, **meta) messages.success(request, diff --git a/openstack_dashboard/dashboards/project/images_and_snapshots/images/tests.py b/openstack_dashboard/dashboards/project/images_and_snapshots/images/tests.py index 8ec19f60d..5f6251f29 100644 --- a/openstack_dashboard/dashboards/project/images_and_snapshots/images/tests.py +++ b/openstack_dashboard/dashboards/project/images_and_snapshots/images/tests.py @@ -18,9 +18,13 @@ # License for the specific language governing permissions and limitations # under the License. +import tempfile + from django import http from django.conf import settings +from django.core.files.uploadedfile import InMemoryUploadedFile from django.core.urlresolvers import reverse +from django.forms.widgets import HiddenInput from django.test.utils import override_settings from mox import IsA @@ -30,11 +34,39 @@ from openstack_dashboard import api from openstack_dashboard.test import helpers as test from . import tables +from .forms import CreateImageForm IMAGES_INDEX_URL = reverse('horizon:project:images_and_snapshots:index') +class CreateImageFormTests(test.TestCase): + def test_no_location_or_file(self): + """ + The form will not be valid if both copy_from and image_file are not + provided. + """ + post = { + 'name': u'Ubuntu 11.10', + 'disk_format': u'qcow2', + 'minimum_disk': 15, + 'minimum_ram': 512, + 'is_public': 1} + files = {} + form = CreateImageForm(post, files) + self.assertEqual(form.is_valid(), False) + + @override_settings(HORIZON_IMAGES_ALLOW_UPLOAD=False) + def test_image_upload_disabled(self): + """ + If HORIZON_IMAGES_ALLOW_UPLOAD is false, the image_file field widget + will be a HiddenInput widget instead of a FileInput widget. + """ + form = CreateImageForm({}) + self.assertEqual( + isinstance(form.fields['image_file'].widget, HiddenInput), True) + + class ImageViewTests(test.TestCase): def test_image_create_get(self): url = reverse('horizon:project:images_and_snapshots:images:create') @@ -43,7 +75,7 @@ class ImageViewTests(test.TestCase): 'project/images_and_snapshots/images/create.html') @test.create_stubs({api.glance: ('image_create',)}) - def test_image_create_post(self): + def test_image_create_post_copy_from(self): data = { 'name': u'Ubuntu 11.10', 'copy_from': u'http://cloud-images.ubuntu.com/releases/' @@ -72,6 +104,38 @@ class ImageViewTests(test.TestCase): self.assertNoFormErrors(res) self.assertEqual(res.status_code, 302) + @test.create_stubs({api.glance: ('image_create',)}) + def test_image_create_post_upload(self): + temp_file = tempfile.TemporaryFile() + temp_file.write('123') + temp_file.flush() + temp_file.seek(0) + data = { + 'name': u'Test Image', + 'image_file': temp_file, + 'disk_format': u'qcow2', + 'minimum_disk': 15, + 'minimum_ram': 512, + 'is_public': 1, + 'method': 'CreateImageForm'} + + api.glance.image_create(IsA(http.HttpRequest), + container_format="bare", + disk_format=data['disk_format'], + is_public=True, + min_disk=data['minimum_disk'], + min_ram=data['minimum_ram'], + name=data['name'], + data=IsA(InMemoryUploadedFile)). \ + AndReturn(self.images.first()) + self.mox.ReplayAll() + + url = reverse('horizon:project:images_and_snapshots:images:create') + res = self.client.post(url, data) + + 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() diff --git a/openstack_dashboard/dashboards/project/images_and_snapshots/templates/images_and_snapshots/images/_create.html b/openstack_dashboard/dashboards/project/images_and_snapshots/templates/images_and_snapshots/images/_create.html index f06cfd9f0..a26c927c5 100644 --- a/openstack_dashboard/dashboards/project/images_and_snapshots/templates/images_and_snapshots/images/_create.html +++ b/openstack_dashboard/dashboards/project/images_and_snapshots/templates/images_and_snapshots/images/_create.html @@ -3,6 +3,7 @@ {% block form_id %}create_image_form{% endblock %} {% block form_action %}{% url horizon:project:images_and_snapshots:images:create %}{% endblock %} +{% block form_attrs %}enctype="multipart/form-data"{% endblock %} {% block modal-header %}{% trans "Create An Image" %}{% endblock %} diff --git a/openstack_dashboard/settings.py b/openstack_dashboard/settings.py index 223a5cce9..da899449f 100644 --- a/openstack_dashboard/settings.py +++ b/openstack_dashboard/settings.py @@ -65,6 +65,11 @@ HORIZON_CONFIG = { 'unauthorized': exceptions.UNAUTHORIZED}, } +# Set to True to allow users to upload images to glance via Horizon server. +# When enabled, a file form field will appear on the create image form. +# See documentation for deployment considerations. +HORIZON_IMAGES_ALLOW_UPLOAD = True + MIDDLEWARE_CLASSES = ( 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', diff --git a/openstack_dashboard/test/settings.py b/openstack_dashboard/test/settings.py index 8c09dd0fc..aead3263f 100644 --- a/openstack_dashboard/test/settings.py +++ b/openstack_dashboard/test/settings.py @@ -55,6 +55,11 @@ HORIZON_CONFIG = { 'unauthorized': UNAUTHORIZED}, } +# Set to True to allow users to upload images to glance via Horizon server. +# When enabled, a file form field will appear on the create image form. +# See documentation for deployment considerations. +HORIZON_IMAGES_ALLOW_UPLOAD = True + AVAILABLE_REGIONS = [ ('http://localhost:5000/v2.0', 'local'), ('http://remote:5000/v2.0', 'remote'),