Glance remote image creation.

Implements Blueprint image-copy-from

Change-Id: I373301b5020d49c78fdba2bcf10c5cc4e05686ef
This commit is contained in:
John Postlethwait 2012-06-04 20:54:19 -07:00
parent 24355b7d3c
commit ca795fe604
8 changed files with 215 additions and 22 deletions

View File

@ -21,6 +21,7 @@
from __future__ import absolute_import from __future__ import absolute_import
import logging import logging
import thread
import urlparse import urlparse
from django.conf import settings from django.conf import settings
@ -69,6 +70,22 @@ def image_update(request, image_id, **kwargs):
return glanceclient(request).images.update(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): def snapshot_list_detailed(request, marker=None, extra_filters=None):
filters = {'property-image_type': 'snapshot'} filters = {'property-image_type': 'snapshot'}
filters.update(extra_filters or {}) filters.update(extra_filters or {})

View File

@ -36,6 +36,69 @@ from horizon import forms
LOG = logging.getLogger(__name__) 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): class UpdateImageForm(forms.SelfHandlingForm):
completion_view = 'horizon:nova:images_and_snapshots:index' completion_view = 'horizon:nova:images_and_snapshots:index'
@ -55,16 +118,11 @@ class UpdateImageForm(forms.SelfHandlingForm):
widget=forms.TextInput( widget=forms.TextInput(
attrs={'readonly': 'readonly'} attrs={'readonly': 'readonly'}
)) ))
container_format = forms.CharField(label=_("Container Format"), disk_format = forms.CharField(label=_("Format"),
widget=forms.TextInput( widget=forms.TextInput(
attrs={'readonly': 'readonly'} attrs={'readonly': 'readonly'}
)) ))
disk_format = forms.CharField(label=_("Disk Format"), public = forms.BooleanField(label=_("Public"), required=False)
widget=forms.TextInput(
attrs={'readonly': 'readonly'}
))
public = forms.BooleanField(label=_("Public"),
required=False)
def handle(self, request, data): def handle(self, request, data):
# TODO add public flag to image meta properties # TODO add public flag to image meta properties
@ -73,7 +131,7 @@ class UpdateImageForm(forms.SelfHandlingForm):
meta = {'is_public': data['public'], meta = {'is_public': data['public'],
'disk_format': data['disk_format'], 'disk_format': data['disk_format'],
'container_format': data['container_format'], 'container_format': 'bare',
'name': data['name'], 'name': data['name'],
'properties': {}} 'properties': {}}
if data['kernel']: if data['kernel']:

View File

@ -55,6 +55,13 @@ class DeleteImage(tables.DeleteAction):
api.image_delete(request, obj_id) 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): class EditImage(tables.LinkAction):
name = "edit" name = "edit"
verbose_name = _("Edit") verbose_name = _("Edit")
@ -73,33 +80,53 @@ def get_image_type(image):
return getattr(image.properties, "image_type", "Image") return getattr(image.properties, "image_type", "Image")
def get_container_format(image): def get_format(image):
container_format = getattr(image, "container_format", "") format = getattr(image, "disk_format", "")
# The "container_format" attribute can actually be set to None, # The "container_format" attribute can actually be set to None,
# which will raise an error if you call upper() on it. # which will raise an error if you call upper() on it.
if container_format is not None: if format is not None:
return container_format.upper() 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): 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:" \ name = tables.Column("name", link="horizon:nova:images_and_snapshots:" \
"images:detail", "images:detail",
verbose_name=_("Image Name")) verbose_name=_("Image Name"))
image_type = tables.Column(get_image_type, image_type = tables.Column(get_image_type,
verbose_name=_("Type"), verbose_name=_("Type"),
filters=(filters.title,)) filters=(filters.title,))
status = tables.Column("status", filters=(filters.title,), status = tables.Column("status",
verbose_name=_("Status")) filters=(filters.title,),
verbose_name=_("Status"),
status=True,
status_choices=STATUS_CHOICES)
public = tables.Column("is_public", public = tables.Column("is_public",
verbose_name=_("Public"), verbose_name=_("Public"),
empty_value=False, empty_value=False,
filters=(filters.yesno, filters.capfirst)) filters=(filters.yesno, filters.capfirst))
container_format = tables.Column(get_container_format, disk_format = tables.Column(get_format, verbose_name=_("Format"))
verbose_name=_("Container Format"))
class Meta: class Meta:
name = "images" name = "images"
row_class = UpdateRow
status_columns = ["status"]
verbose_name = _("Images") verbose_name = _("Images")
table_actions = (DeleteImage,) table_actions = (CreateImage, DeleteImage,)
row_actions = (LaunchImage, EditImage, DeleteImage) row_actions = (LaunchImage, EditImage, DeleteImage,)
pagination_param = "image_marker" pagination_param = "image_marker"

View File

@ -24,16 +24,54 @@ from django.core.urlresolvers import reverse
from horizon import api from horizon import api
from horizon import test from horizon import test
from mox import IsA from mox import IgnoreArg, IsA
IMAGES_INDEX_URL = reverse('horizon:nova:images_and_snapshots:index') IMAGES_INDEX_URL = reverse('horizon:nova:images_and_snapshots:index')
class ImageViewTests(test.TestCase): 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): def test_image_detail_get(self):
image = self.images.first() image = self.images.first()
self.mox.StubOutWithMock(api.glance, 'image_get')
api.glance.image_get(IsA(http.HttpRequest), str(image.id)) \ api.glance.image_get(IsA(http.HttpRequest), str(image.id)) \
.AndReturn(self.images.first()) .AndReturn(self.images.first())
self.mox.ReplayAll() self.mox.ReplayAll()
@ -45,9 +83,10 @@ class ImageViewTests(test.TestCase):
'nova/images_and_snapshots/images/detail.html') 'nova/images_and_snapshots/images/detail.html')
self.assertEqual(res.context['image'].name, image.name) self.assertEqual(res.context['image'].name, image.name)
@test.create_stubs({api.glance: ('image_get',)})
def test_image_detail_get_with_exception(self): def test_image_detail_get_with_exception(self):
image = self.images.first() image = self.images.first()
self.mox.StubOutWithMock(api.glance, 'image_get')
api.glance.image_get(IsA(http.HttpRequest), str(image.id)) \ api.glance.image_get(IsA(http.HttpRequest), str(image.id)) \
.AndRaise(self.exceptions.glance) .AndRaise(self.exceptions.glance)
self.mox.ReplayAll() self.mox.ReplayAll()

View File

@ -20,12 +20,13 @@
from django.conf.urls.defaults import patterns, url 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' VIEWS_MOD = 'horizon.dashboards.nova.images_and_snapshots.images.views'
urlpatterns = patterns(VIEWS_MOD, urlpatterns = patterns(VIEWS_MOD,
url(r'^create/$', CreateView.as_view(), name='create'),
url(r'^(?P<image_id>[^/]+)/update/$', UpdateView.as_view(), name='update'), url(r'^(?P<image_id>[^/]+)/update/$', UpdateView.as_view(), name='update'),
url(r'^(?P<image_id>[^/]+)/$', DetailView.as_view(), name='detail'), url(r'^(?P<image_id>[^/]+)/$', DetailView.as_view(), name='detail'),
) )

View File

@ -32,12 +32,19 @@ from horizon import exceptions
from horizon import forms from horizon import forms
from horizon import tabs from horizon import tabs
from .forms import UpdateImageForm from .forms import UpdateImageForm
from .forms import CreateImageForm
from .tabs import ImageDetailTabs from .tabs import ImageDetailTabs
LOG = logging.getLogger(__name__) 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): class UpdateView(forms.ModalFormView):
form_class = UpdateImageForm form_class = UpdateImageForm
template_name = 'nova/images_and_snapshots/images/update.html' template_name = 'nova/images_and_snapshots/images/update.html'

View File

@ -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 %}
<div class="left">
<fieldset>
{% include "horizon/common/_form_fields.html" %}
</fieldset>
</div>
<div class="right">
<h3>{% trans "Description:" %}</h3>
<p>
{% trans "Specify an image to upload to the Image Service." %}
</p>
<p>
{% 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.)" %}
</p>
<p>
<strong>{% trans "Please note: " %}</strong>
{% 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." %}
</p>
</div>
{% endblock %}
{% block modal-footer %}
<input class="btn btn-primary pull-right" type="submit" value="{% trans "Create Image" %}" />
<a href="{% url horizon:nova:images_and_snapshots:index %}" class="btn secondary cancel close">{% trans "Cancel" %}</a>
{% endblock %}

View File

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