diff --git a/horizon/api/glance.py b/horizon/api/glance.py index 8151f3d8b2..675de17667 100644 --- a/horizon/api/glance.py +++ b/horizon/api/glance.py @@ -21,6 +21,7 @@ from __future__ import absolute_import import logging +import thread import urlparse from django.conf import settings @@ -69,6 +70,22 @@ def image_update(request, image_id, **kwargs): return glanceclient(request).images.update(image_id, **kwargs) +def image_create(request, **kwargs): + copy_from = None + + if kwargs.get('copy_from'): + copy_from = kwargs.pop('copy_from') + + image = glanceclient(request).images.create(**kwargs) + + if copy_from: + thread.start_new_thread(image_update, + (request, image.id), + {'copy_from': copy_from}) + + return image + + def snapshot_list_detailed(request, marker=None, extra_filters=None): filters = {'property-image_type': 'snapshot'} filters.update(extra_filters or {}) diff --git a/horizon/dashboards/nova/images_and_snapshots/images/forms.py b/horizon/dashboards/nova/images_and_snapshots/images/forms.py index d47e7f4d17..afe361ac8b 100644 --- a/horizon/dashboards/nova/images_and_snapshots/images/forms.py +++ b/horizon/dashboards/nova/images_and_snapshots/images/forms.py @@ -36,6 +36,69 @@ from horizon import forms LOG = logging.getLogger(__name__) +class CreateImageForm(forms.SelfHandlingForm): + completion_view = 'horizon:nova:images_and_snapshots:index' + + name = forms.CharField(max_length="255", label=_("Name"), required=True) + copy_from = forms.CharField(max_length="255", + label=_("Image Location"), + help_text=_("An external (HTTP) URL where" + " the image should be loaded from."), + required=True) + disk_format = forms.ChoiceField(label=_('Format'), + required=True, + choices=[('', ''), + ('aki', + 'Amazon Kernel Image (AKI)'), + ('ami', + 'Amazon Machine Image (AMI)'), + ('ari', + 'Amazon Ramdisk Image (ARI)'), + ('iso', + 'Optical Disk Image (ISO)'), + ('qcow2', + 'QEMU Emulator (QCOW2)'), + ('raw', 'Raw'), + ('vdi', 'VDI'), + ('vhd', 'VHD'), + ('vmdk', 'VMDK')], + widget=forms.Select(attrs={'class': + 'switchable'})) + minimum_disk = forms.IntegerField(label=_("Minimum Disk (GB)"), + help_text=_('The minimum disk size' + ' required to boot the' + ' image. If unspecified, this' + ' value defaults to 0' + ' (no minimum).'), + required=False) + minimum_ram = forms.IntegerField(label=_("Minimum Ram (MB)"), + help_text=_('The minimum disk size' + ' required to boot the' + ' image. If unspecified, this' + ' value defaults to 0 (no' + ' minimum).'), + required=False) + is_public = forms.BooleanField(label=_("Public"), required=False) + + def handle(self, request, data): + meta = {'is_public': data['is_public'], + 'disk_format': data['disk_format'], + 'container_format': 'bare', # Not used in Glance ATM. + 'copy_from': data['copy_from'], + 'min_disk': (data['minimum_disk'] or 0), + 'min_ram': (data['minimum_ram'] or 0), + 'name': data['name']} + + try: + api.glance.image_create(request, **meta) + messages.success(request, + _('Your image %s has been queued for creation.' % + data['name'])) + except: + exceptions.handle(request, _('Unable to create new image.')) + return shortcuts.redirect(self.get_success_url()) + + class UpdateImageForm(forms.SelfHandlingForm): completion_view = 'horizon:nova:images_and_snapshots:index' @@ -55,16 +118,11 @@ class UpdateImageForm(forms.SelfHandlingForm): widget=forms.TextInput( attrs={'readonly': 'readonly'} )) - container_format = forms.CharField(label=_("Container Format"), - widget=forms.TextInput( - attrs={'readonly': 'readonly'} - )) - disk_format = forms.CharField(label=_("Disk Format"), + disk_format = forms.CharField(label=_("Format"), widget=forms.TextInput( attrs={'readonly': 'readonly'} )) - public = forms.BooleanField(label=_("Public"), - required=False) + public = forms.BooleanField(label=_("Public"), required=False) def handle(self, request, data): # TODO add public flag to image meta properties @@ -73,7 +131,7 @@ class UpdateImageForm(forms.SelfHandlingForm): meta = {'is_public': data['public'], 'disk_format': data['disk_format'], - 'container_format': data['container_format'], + 'container_format': 'bare', 'name': data['name'], 'properties': {}} if data['kernel']: diff --git a/horizon/dashboards/nova/images_and_snapshots/images/tables.py b/horizon/dashboards/nova/images_and_snapshots/images/tables.py index 546cd48884..573edfadf2 100644 --- a/horizon/dashboards/nova/images_and_snapshots/images/tables.py +++ b/horizon/dashboards/nova/images_and_snapshots/images/tables.py @@ -55,6 +55,13 @@ class DeleteImage(tables.DeleteAction): api.image_delete(request, obj_id) +class CreateImage(tables.LinkAction): + name = "create" + verbose_name = _("Create Image") + url = "horizon:nova:images_and_snapshots:images:create" + classes = ("ajax-modal", "btn-create") + + class EditImage(tables.LinkAction): name = "edit" verbose_name = _("Edit") @@ -73,33 +80,53 @@ def get_image_type(image): return getattr(image.properties, "image_type", "Image") -def get_container_format(image): - container_format = getattr(image, "container_format", "") +def get_format(image): + format = getattr(image, "disk_format", "") # The "container_format" attribute can actually be set to None, # which will raise an error if you call upper() on it. - if container_format is not None: - return container_format.upper() + if format is not None: + return format.upper() + + +class UpdateRow(tables.Row): + ajax = True + + def get_data(self, request, image_id): + image = api.image_get(request, image_id) + return image class ImagesTable(tables.DataTable): + STATUS_CHOICES = ( + ("active", True), + ("saving", None), + ("queued", None), + ("pending_delete", None), + ("killed", False), + ("deleted", False), + ) name = tables.Column("name", link="horizon:nova:images_and_snapshots:" \ "images:detail", verbose_name=_("Image Name")) image_type = tables.Column(get_image_type, verbose_name=_("Type"), filters=(filters.title,)) - status = tables.Column("status", filters=(filters.title,), - verbose_name=_("Status")) + status = tables.Column("status", + filters=(filters.title,), + verbose_name=_("Status"), + status=True, + status_choices=STATUS_CHOICES) public = tables.Column("is_public", verbose_name=_("Public"), empty_value=False, filters=(filters.yesno, filters.capfirst)) - container_format = tables.Column(get_container_format, - verbose_name=_("Container Format")) + disk_format = tables.Column(get_format, verbose_name=_("Format")) class Meta: name = "images" + row_class = UpdateRow + status_columns = ["status"] verbose_name = _("Images") - table_actions = (DeleteImage,) - row_actions = (LaunchImage, EditImage, DeleteImage) + table_actions = (CreateImage, DeleteImage,) + row_actions = (LaunchImage, EditImage, DeleteImage,) pagination_param = "image_marker" diff --git a/horizon/dashboards/nova/images_and_snapshots/images/tests.py b/horizon/dashboards/nova/images_and_snapshots/images/tests.py index c9aa1056de..b93a7da122 100644 --- a/horizon/dashboards/nova/images_and_snapshots/images/tests.py +++ b/horizon/dashboards/nova/images_and_snapshots/images/tests.py @@ -24,16 +24,54 @@ from django.core.urlresolvers import reverse from horizon import api from horizon import test -from mox import IsA +from mox import IgnoreArg, IsA IMAGES_INDEX_URL = reverse('horizon:nova:images_and_snapshots:index') class ImageViewTests(test.TestCase): + def test_image_create_get(self): + url = reverse('horizon:nova:images_and_snapshots:images:create') + res = self.client.get(url) + self.assertTemplateUsed(res, + 'nova/images_and_snapshots/images/create.html') + + @test.create_stubs({api.glance: ('image_create',)}) + def test_image_create_post(self): + data = { + 'name': u'Ubuntu 11.10', + 'copy_from': u'http://cloud-images.ubuntu.com/releases/' + u'oneiric/release/ubuntu-11.10-server-cloudimg' + u'-amd64-disk1.img', + '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", + copy_from=data['copy_from'], + disk_format=data['disk_format'], + is_public=True, + min_disk=data['minimum_disk'], + min_ram=data['minimum_ram'], + name=data['name']). \ + AndReturn(self.images.first()) + self.mox.ReplayAll() + + url = reverse('horizon:nova: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() - self.mox.StubOutWithMock(api.glance, 'image_get') + api.glance.image_get(IsA(http.HttpRequest), str(image.id)) \ .AndReturn(self.images.first()) self.mox.ReplayAll() @@ -45,9 +83,10 @@ class ImageViewTests(test.TestCase): 'nova/images_and_snapshots/images/detail.html') self.assertEqual(res.context['image'].name, image.name) + @test.create_stubs({api.glance: ('image_get',)}) def test_image_detail_get_with_exception(self): image = self.images.first() - self.mox.StubOutWithMock(api.glance, 'image_get') + api.glance.image_get(IsA(http.HttpRequest), str(image.id)) \ .AndRaise(self.exceptions.glance) self.mox.ReplayAll() diff --git a/horizon/dashboards/nova/images_and_snapshots/images/urls.py b/horizon/dashboards/nova/images_and_snapshots/images/urls.py index dc26e1e0f8..a4e7ad18ec 100644 --- a/horizon/dashboards/nova/images_and_snapshots/images/urls.py +++ b/horizon/dashboards/nova/images_and_snapshots/images/urls.py @@ -20,12 +20,13 @@ from django.conf.urls.defaults import patterns, url -from .views import UpdateView, DetailView +from .views import UpdateView, DetailView, CreateView VIEWS_MOD = 'horizon.dashboards.nova.images_and_snapshots.images.views' urlpatterns = patterns(VIEWS_MOD, + url(r'^create/$', CreateView.as_view(), name='create'), url(r'^(?P[^/]+)/update/$', UpdateView.as_view(), name='update'), url(r'^(?P[^/]+)/$', DetailView.as_view(), name='detail'), ) diff --git a/horizon/dashboards/nova/images_and_snapshots/images/views.py b/horizon/dashboards/nova/images_and_snapshots/images/views.py index 1e4deee5a1..947bd8a7ce 100644 --- a/horizon/dashboards/nova/images_and_snapshots/images/views.py +++ b/horizon/dashboards/nova/images_and_snapshots/images/views.py @@ -32,12 +32,19 @@ from horizon import exceptions from horizon import forms from horizon import tabs from .forms import UpdateImageForm +from .forms import CreateImageForm from .tabs import ImageDetailTabs LOG = logging.getLogger(__name__) +class CreateView(forms.ModalFormView): + form_class = CreateImageForm + template_name = 'nova/images_and_snapshots/images/create.html' + context_object_name = 'image' + + class UpdateView(forms.ModalFormView): form_class = UpdateImageForm template_name = 'nova/images_and_snapshots/images/update.html' diff --git a/horizon/dashboards/nova/templates/nova/images_and_snapshots/images/_create.html b/horizon/dashboards/nova/templates/nova/images_and_snapshots/images/_create.html new file mode 100644 index 0000000000..0df41f8eec --- /dev/null +++ b/horizon/dashboards/nova/templates/nova/images_and_snapshots/images/_create.html @@ -0,0 +1,33 @@ +{% extends "horizon/common/_modal_form.html" %} +{% load i18n %} + +{% block form_id %}create_image_form{% endblock %} +{% block form_action %}{% url horizon:nova:images_and_snapshots:images:create %}{% endblock %} + +{% block modal-header %}{% trans "Create An Image" %}{% endblock %} + +{% block modal-body %} +
+
+ {% include "horizon/common/_form_fields.html" %} +
+
+
+

{% trans "Description:" %}

+

+ {% trans "Specify an image to upload to the Image Service." %} +

+

+ {% trans "Currently only images available via an HTTP URL are supported. The image location must be accessible to the Image Service. Compressed image binaries are supported (.zip and .tar.gz.)" %} +

+

+ {% trans "Please note: " %} + {% trans "The Image Location field MUST be a valid and direct URL to the image binary. URLs that redirect or serve error pages will results in unusable images." %} +

+
+{% endblock %} + +{% block modal-footer %} + + {% trans "Cancel" %} +{% endblock %} diff --git a/horizon/dashboards/nova/templates/nova/images_and_snapshots/images/create.html b/horizon/dashboards/nova/templates/nova/images_and_snapshots/images/create.html new file mode 100644 index 0000000000..b9fa856ff7 --- /dev/null +++ b/horizon/dashboards/nova/templates/nova/images_and_snapshots/images/create.html @@ -0,0 +1,11 @@ +{% extends 'nova/base.html' %} +{% load i18n %} +{% block title %}{% trans "Create An Image" %}{% endblock %} + +{% block page_header %} + {% include "horizon/common/_page_header.html" with title=_("Create An Image") %} +{% endblock page_header %} + +{% block dash_main %} + {% include 'nova/images_and_snapshots/images/_create.html' %} +{% endblock %}