Merge "Adds support for volume snapshots (volume snapshots table and ability to boot from a volume snapshot)."

This commit is contained in:
Jenkins 2012-02-13 02:39:12 +00:00 committed by Gerrit Code Review
commit 2e914f9578
19 changed files with 343 additions and 46 deletions

View File

@ -455,3 +455,16 @@ def volume_attach(request, volume_id, instance_id, device):
def volume_detach(request, instance_id, attachment_id):
novaclient(request).volumes.delete_server_volume(
instance_id, attachment_id)
def volume_snapshot_list(request):
return novaclient(request).volume_snapshots.list()
def volume_snapshot_create(request, volume_id, name, description):
return novaclient(request).volume_snapshots.create(
volume_id, display_name=name, display_description=description)
def volume_snapshot_delete(request, snapshot_id):
novaclient(request).volume_snapshots.delete(snapshot_id)

View File

@ -99,7 +99,7 @@ class LaunchForm(forms.SelfHandlingForm):
widget=forms.CheckboxSelectMultiple(),
help_text=_("Launch instance in these "
"security groups."))
volume = forms.ChoiceField(label=_("Volume"),
volume = forms.ChoiceField(label=_("Volume or Volume Snapshot"),
required=False,
help_text=_("Volume to boot from."))
device_name = forms.CharField(label=_("Device Name"),
@ -131,10 +131,10 @@ class LaunchForm(forms.SelfHandlingForm):
delete_on_terminate = 1
else:
delete_on_terminate = 0
dev_spec = {data['device_name']:
("%s:::%s" % (data['volume'], delete_on_terminate))}
dev_mapping = {data['device_name']:
("%s::%s" % (data['volume'], delete_on_terminate))}
else:
dev_spec = None
dev_mapping = None
api.server_create(request,
data['name'],
@ -143,7 +143,7 @@ class LaunchForm(forms.SelfHandlingForm):
data.get('keypair'),
normalize_newlines(data.get('user_data')),
data.get('security_groups'),
dev_spec,
dev_mapping,
instance_count=int(data.get('count')))
messages.success(request,
_('Instance "%s" launched.') % data["name"])

View File

@ -22,6 +22,7 @@ from django import http
from django.contrib import messages
from django.core.urlresolvers import reverse
from keystoneclient import exceptions as keystone_exceptions
from novaclient.v1_1 import client as nova_client, volume_snapshots
from mox import IgnoreArg, IsA
from horizon import api
@ -74,6 +75,16 @@ class ImageViewTests(test.BaseViewTests):
volume.displayName = ''
self.volumes = (volume,)
self.volume_snapshot = volume_snapshots.Snapshot(
volume_snapshots.SnapshotManager,
{'id': 2,
'displayName': 'test snapshot',
'displayDescription': 'test snapshot description',
'size': 40,
'status': 'available',
'volumeId': 1})
self.volume_snapshots = [self.volume_snapshot]
def test_launch_get(self):
IMAGE_ID = 1
@ -111,14 +122,14 @@ class ImageViewTests(test.BaseViewTests):
self.keypairs[0].name)
def test_launch_post(self):
FLAVOR_ID = self.flavors[0].id
IMAGE_ID = '1'
keypair = self.keypairs[0].name
SERVER_NAME = 'serverName'
USER_DATA = 'userData'
volume = self.volumes[0].id
device_name = 'vda'
BLOCK_DEVICE_MAPPING = {device_name: "1:::0"}
FLAVOR_ID = unicode(self.flavors[0].id)
IMAGE_ID = u'1'
keypair = unicode(self.keypairs[0].name)
SERVER_NAME = u'serverName'
USER_DATA = u'userData'
volume = u'%s:vol' % self.volumes[0].id
device_name = u'vda'
BLOCK_DEVICE_MAPPING = {device_name: u"1:vol::0"}
form_data = {'method': 'LaunchForm',
'flavor': FLAVOR_ID,
@ -130,8 +141,7 @@ class ImageViewTests(test.BaseViewTests):
'tenant_id': self.TEST_TENANT,
'security_groups': 'default',
'volume': volume,
'device_name': device_name
}
'device_name': device_name}
self.mox.StubOutWithMock(api, 'image_get_meta')
self.mox.StubOutWithMock(api, 'flavor_list')
@ -143,9 +153,9 @@ class ImageViewTests(test.BaseViewTests):
api.flavor_list(IsA(http.HttpRequest)).AndReturn(self.flavors)
api.keypair_list(IsA(http.HttpRequest)).AndReturn(self.keypairs)
api.security_group_list(IsA(http.HttpRequest)).AndReturn(
self.security_groups)
api.image_get_meta(IsA(http.HttpRequest),
IMAGE_ID).AndReturn(self.visibleImage)
self.security_groups)
api.image_get_meta(IsA(http.HttpRequest), IMAGE_ID).AndReturn(
self.visibleImage)
api.volume_list(IsA(http.HttpRequest)).AndReturn(self.volumes)
api.server_create(IsA(http.HttpRequest), SERVER_NAME,
str(IMAGE_ID), str(FLAVOR_ID),
@ -254,9 +264,8 @@ class ImageViewTests(test.BaseViewTests):
api.flavor_list(IgnoreArg()).AndReturn(self.flavors)
api.keypair_list(IgnoreArg()).AndReturn(self.keypairs)
api.security_group_list(IsA(http.HttpRequest)).AndReturn(
self.security_groups)
api.image_get_meta(IgnoreArg(),
IMAGE_ID).AndReturn(self.visibleImage)
self.security_groups)
api.image_get_meta(IgnoreArg(), IMAGE_ID).AndReturn(self.visibleImage)
api.volume_list(IgnoreArg()).AndReturn(self.volumes)
exception = keystone_exceptions.ClientException('Failed')
@ -270,9 +279,6 @@ class ImageViewTests(test.BaseViewTests):
None,
instance_count=IsA(int)).AndRaise(exception)
self.mox.StubOutWithMock(messages, 'error')
messages.error(IsA(http.HttpRequest), IsA(basestring))
self.mox.ReplayAll()
url = reverse('horizon:nova:images_and_snapshots:images:launch',
args=[IMAGE_ID])

View File

@ -110,17 +110,42 @@ class LaunchView(forms.ModalFormView):
return security_group_list
def volume_list(self):
volume_options = [("", _("Select Volume"))]
def _get_volume_select_item(volume):
if hasattr(volume, "volumeId"):
vol_type = "snap"
visible_label = _("Snapshot")
else:
vol_type = "vol"
visible_label = _("Volume")
return (("%s:%s" % (volume.id, vol_type)),
("%s - %s GB (%s)" % (volume.displayName,
volume.size,
visible_label)))
# First add volumes to the list
try:
volumes = [v for v in api.volume_list(self.request) \
if v.status == api.VOLUME_STATE_AVAILABLE]
volume_sel = [(v.id, ("%s (%s GB)" % (v.displayName, v.size))) \
for v in volumes]
volume_sel.insert(0, ("", "Select Volume"))
if v.status == api.VOLUME_STATE_AVAILABLE]
volume_options.extend(
[_get_volume_select_item(vol) for vol in volumes])
except:
exceptions.handle(self.request,
_('Unable to retrieve list of volumes'))
volume_sel = []
return volume_sel
# Next add volume snapshots to the list
try:
snapshots = api.novaclient(self.request).volume_snapshots.list()
snapshots = [s for s in snapshots \
if s.status == api.VOLUME_STATE_AVAILABLE]
volume_options.extend(
[_get_volume_select_item(snap) for snap in snapshots])
except:
exceptions.handle(self.request,
_('Unable to retrieve list of volumes'))
return volume_options
class UpdateView(forms.ModalFormView):

View File

@ -23,7 +23,7 @@ from horizon.dashboards.nova import dashboard
class Snapshots(horizon.Panel):
name = "Snapshots"
name = "Instance Snapshots"
slug = 'snapshots'

View File

@ -32,6 +32,6 @@ class DeleteSnapshot(DeleteImage):
class SnapshotsTable(ImagesTable):
class Meta:
name = "snapshots"
verbose_name = _("Snapshots")
verbose_name = _("Instance Snapshots")
table_actions = (DeleteSnapshot,)
row_actions = (LaunchImage, EditImage, DeleteSnapshot)

View File

@ -32,13 +32,14 @@ from horizon import exceptions
from horizon import tables
from .images.tables import ImagesTable
from .snapshots.tables import SnapshotsTable
from .volume_snapshots.tables import VolumeSnapshotsTable
LOG = logging.getLogger(__name__)
class IndexView(tables.MultiTableView):
table_classes = (ImagesTable, SnapshotsTable)
table_classes = (ImagesTable, SnapshotsTable, VolumeSnapshotsTable)
template_name = 'nova/images_and_snapshots/index.html'
def get_images_data(self):
@ -59,3 +60,12 @@ class IndexView(tables.MultiTableView):
snapshots = []
exceptions.handle(self.request, _("Unable to retrieve snapshots."))
return snapshots
def get_volume_snapshots_data(self):
try:
snapshots = api.volume_snapshot_list(self.request)
except:
snapshots = []
exceptions.handle(self.request, _("Unable to retrieve "
"volume snapshots."))
return snapshots

View File

@ -0,0 +1,26 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# Copyright 2011 Nebula, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import horizon
from horizon.dashboards.nova import dashboard
class VolumeSnapshots(horizon.Panel):
name = "Volume Snapshots"
slug = 'volume_snapshots'
dashboard.Nova.register(VolumeSnapshots)

View File

@ -0,0 +1,45 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# Copyright 2012 Nebula, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import logging
from django.utils.translation import ugettext as _
from horizon import api
from horizon import tables
from ...instances_and_volumes.volumes import tables as volume_tables
LOG = logging.getLogger(__name__)
class DeleteVolumeSnapshot(tables.DeleteAction):
data_type_singular = _("Volume Snapshot")
data_type_plural = _("Volume Snaphots")
classes = ('danger',)
def delete(self, request, obj_id):
api.volume_snapshot_delete(request, obj_id)
class VolumeSnapshotsTable(volume_tables.VolumesTableBase):
volume_id = tables.Column("volumeId", verbose_name=_("Volume ID"))
class Meta:
name = "volume_snapshots"
verbose_name = _("Volume Snapshots")
table_actions = (DeleteVolumeSnapshot,)
row_actions = (DeleteVolumeSnapshot,)

View File

@ -0,0 +1,78 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# Copyright 2011 United States Government as represented by the
# Administrator of the National Aeronautics and Space Administration.
# All Rights Reserved.
#
# Copyright 2011 Nebula, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
from django import http
from django.core.urlresolvers import reverse
from novaclient.v1_1 import volume_snapshots
from mox import IsA
from horizon import api
from horizon import test
INDEX_URL = reverse('horizon:nova:images_and_snapshots:index')
class SnapshotsViewTests(test.BaseViewTests):
def test_create_snapshot_get(self):
VOLUME_ID = u'1'
res = self.client.get(reverse('horizon:nova:instances_and_volumes:'
'volumes:create_snapshot',
args=[VOLUME_ID]))
self.assertTemplateUsed(res, 'nova/instances_and_volumes/'
'volumes/create_snapshot.html')
def test_create_snapshot_post(self):
VOLUME_ID = u'1'
SNAPSHOT_NAME = u'vol snap'
SNAPSHOT_DESCRIPTION = u'vol snap desc'
volume_snapshot = volume_snapshots.Snapshot(
volume_snapshots.SnapshotManager,
{'id': 1,
'displayName': 'test snapshot',
'displayDescription': 'test snapshot description',
'size': 40,
'status': 'available',
'volumeId': 1})
formData = {'method': 'CreateSnapshotForm',
'tenant_id': self.TEST_TENANT,
'volume_id': VOLUME_ID,
'name': SNAPSHOT_NAME,
'description': SNAPSHOT_DESCRIPTION}
self.mox.StubOutWithMock(api, 'volume_snapshot_create')
api.volume_snapshot_create(
IsA(http.HttpRequest), str(VOLUME_ID), SNAPSHOT_NAME,
SNAPSHOT_DESCRIPTION).AndReturn(volume_snapshot)
self.mox.ReplayAll()
res = self.client.post(
reverse('horizon:nova:instances_and_volumes:volumes:'
'create_snapshot',
args=[VOLUME_ID]),
formData)
self.assertRedirectsNoFollow(res, INDEX_URL)

View File

@ -15,6 +15,7 @@ from django.utils.translation import ugettext as _
from horizon import api
from horizon import forms
from horizon import exceptions
from novaclient import exceptions as novaclient_exceptions
@ -82,3 +83,33 @@ class AttachForm(forms.SelfHandlingForm):
_('Error attaching volume: %s') % e.message)
return shortcuts.redirect(
"horizon:nova:instances_and_volumes:index")
class CreateSnapshotForm(forms.SelfHandlingForm):
name = forms.CharField(max_length="255", label=_("Snapshot Name"))
description = forms.CharField(widget=forms.Textarea,
label=_("Description"), required=False)
def __init__(self, *args, **kwargs):
super(CreateSnapshotForm, self).__init__(*args, **kwargs)
# populate volume_id
volume_id = kwargs.get('initial', {}).get('volume_id', [])
self.fields['volume_id'] = forms.CharField(widget=forms.HiddenInput(),
initial=volume_id)
def handle(self, request, data):
try:
api.volume_snapshot_create(request,
data['volume_id'],
data['name'],
data['description'])
message = _('Creating volume snapshot "%s"') % data['name']
LOG.info(message)
messages.info(request, message)
except:
exceptions.handle(request,
_('Error Creating Volume Snapshot: %s'))
return shortcuts.redirect("horizon:nova:images_and_snapshots:index")

View File

@ -55,6 +55,15 @@ class EditAttachments(tables.LinkAction):
return volume.status in ("available", "in-use")
class CreateSnapshot(tables.LinkAction):
name = "snapshots"
verbose_name = _("Create Snapshot")
url = "horizon:nova:instances_and_volumes:volumes:create_snapshot"
def allowed(self, request, volume=None):
return volume.status in ("available", "in-use")
def get_size(volume):
return _("%s GB") % volume.size
@ -75,17 +84,11 @@ def get_attachment(volume):
return safestring.mark_safe(", ".join(attachments))
class VolumesTable(tables.DataTable):
name = tables.Column("displayName",
verbose_name=_("Name"),
link="horizon:nova:instances_and_volumes:"
"volumes:detail")
class VolumesTableBase(tables.DataTable):
name = tables.Column("displayName", verbose_name=_("Name"))
description = tables.Column("displayDescription",
verbose_name=("Description"))
verbose_name=_("Description"))
size = tables.Column(get_size, verbose_name=_("Size"))
attachments = tables.Column(get_attachment,
verbose_name=_("Attachments"),
empty_value=_("-"))
status = tables.Column("status", filters=(title,))
def sanitize_id(self, obj_id):
@ -94,11 +97,16 @@ class VolumesTable(tables.DataTable):
def get_object_display(self, obj):
return obj.displayName
class VolumesTable(VolumesTableBase):
attachments = tables.Column(get_attachment,
verbose_name=_("Attachments"))
class Meta:
name = "volumes"
verbose_name = _("Volumes")
table_actions = (CreateVolume, DeleteVolume,)
row_actions = (EditAttachments, DeleteVolume,)
row_actions = (EditAttachments, CreateSnapshot, DeleteVolume)
class DetachVolume(tables.BatchAction):

View File

@ -16,7 +16,7 @@
from django.conf.urls.defaults import patterns, url
from .views import CreateView, EditAttachmentsView
from .views import CreateView, EditAttachmentsView, CreateSnapshotView
urlpatterns = patterns(
@ -25,5 +25,8 @@ urlpatterns = patterns(
url(r'^(?P<volume_id>[^/]+)/attach/$',
EditAttachmentsView.as_view(),
name='attach'),
url(r'^(?P<volume_id>[^/]+)/create_snapshot/$',
CreateSnapshotView.as_view(),
name='create_snapshot'),
url(r'^(?P<volume_id>[^/]+)/detail/$', 'detail', name='detail'),
)

View File

@ -29,7 +29,7 @@ from horizon import api
from horizon import exceptions
from horizon import forms
from horizon import tables
from .forms import CreateForm, AttachForm
from .forms import CreateForm, AttachForm, CreateSnapshotForm
from .tables import AttachmentsTable
@ -63,6 +63,17 @@ class CreateView(forms.ModalFormView):
template_name = 'nova/instances_and_volumes/volumes/create.html'
class CreateSnapshotView(forms.ModalFormView):
form_class = CreateSnapshotForm
template_name = 'nova/instances_and_volumes/volumes/create_snapshot.html'
def get_context_data(self, **kwargs):
return {'volume_id': kwargs['volume_id']}
def get_initial(self):
return {'volume_id': self.kwargs["volume_id"]}
class EditAttachmentsView(tables.DataTableView):
table_class = AttachmentsTable
template_name = 'nova/instances_and_volumes/volumes/attach.html'

View File

@ -13,4 +13,7 @@
<div class="snapshots">
{{ snapshots_table.render }}
</div>
<div class="volume_snapshots">
{{ volume_snapshots_table.render }}
</div>
{% endblock %}

View File

@ -0,0 +1,25 @@
{% extends "horizon/common/_modal_form.html" %}
{% load i18n %}
{% block form_id %}{% endblock %}
{% block form_action %}{% url horizon:nova:instances_and_volumes:volumes:create_snapshot volume_id %}{% endblock %}
{% block modal_id %}create_volume_snapshot_modal{% endblock %}
{% block modal-header %}{% trans "Create Volume Snapshot" %}{% 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 "Volumes are block devices that can be attached to instances." %}</p>
</div>
{% endblock %}
{% block modal-footer %}
<input class="btn primary pull-right" type="submit" value="{% trans "Create Volume Snapshot" %}" />
<a href="{% url horizon:nova:instances_and_volumes: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 Volume Snapshot" %}{% endblock %}
{% block page_header %}
{% include "horizon/common/_page_header.html" with title=_("Create a Volume Snapshot") %}
{% endblock page_header %}
{% block dash_main %}
{% include 'nova/instances_and_volumes/volumes/_create_snapshot.html' %}
{% endblock %}

View File

@ -170,7 +170,9 @@ class BaseViewTests(TestCase):
Base class for view based unit tests.
"""
def assertRedirectsNoFollow(self, response, expected_url):
self.assertEqual(response._headers['location'],
if response.status_code / 100 != 3:
assert("The response did not return a redirect.")
self.assertEqual(response._headers.get('location', None),
('Location', settings.TESTSERVER + expected_url))
self.assertEqual(response.status_code, 302)