Added action for creating a volume from snapshot
Added a new action to create new volume from a volume snapshot. The snapshot_id is passed to nova volume API. The 'Create Volume' form is reused, with the 'size' value being checked against the snapshot size, as it cannot be less than the snapshot size. Fixes bug 1023740 PATCH SET 4: Added dropdown to select snapshot source in 'Create Volume' form, when loaded from /nova/volumes/ When a snapshot is selected from the dropdown, the name and size are pre-filled in the form (I've removed the description copy as it is probably specific for the reason a snapshot is being created). PATCH SET 6: rebased with master, fixed a bug with get_context in nova/volume/views Change-Id: I20dd8d698f5d8481938417557e09dcdc9b891e82
This commit is contained in:
parent
da7f44cdc2
commit
2392da498a
@ -518,9 +518,9 @@ def volume_instance_list(request, instance_id):
|
||||
return volumes
|
||||
|
||||
|
||||
def volume_create(request, size, name, description):
|
||||
def volume_create(request, size, name, description, snapshot_id=None):
|
||||
return cinderclient(request).volumes.create(size, display_name=name,
|
||||
display_description=description)
|
||||
display_description=description, snapshot_id=snapshot_id)
|
||||
|
||||
|
||||
def volume_delete(request, volume_id):
|
||||
|
@ -16,6 +16,8 @@
|
||||
|
||||
import logging
|
||||
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.utils.http import urlencode
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from horizon import api
|
||||
@ -34,6 +36,21 @@ class DeleteVolumeSnapshot(tables.DeleteAction):
|
||||
api.volume_snapshot_delete(request, obj_id)
|
||||
|
||||
|
||||
class CreateVolumeFromSnapshot(tables.LinkAction):
|
||||
name = "create_from_snapshot"
|
||||
verbose_name = _("Create Volume")
|
||||
url = "horizon:nova:volumes:create"
|
||||
classes = ("ajax-modal", "btn-camera")
|
||||
|
||||
def get_link_url(self, datum):
|
||||
base_url = reverse(self.url)
|
||||
params = urlencode({"snapshot_id": self.table.get_object_id(datum)})
|
||||
return "?".join([base_url, params])
|
||||
|
||||
def allowed(self, request, volume=None):
|
||||
return volume.status == "available" if volume else False
|
||||
|
||||
|
||||
class UpdateRow(tables.Row):
|
||||
ajax = True
|
||||
|
||||
@ -51,6 +68,6 @@ class VolumeSnapshotsTable(volume_tables.VolumesTableBase):
|
||||
name = "volume_snapshots"
|
||||
verbose_name = _("Volume Snapshots")
|
||||
table_actions = (DeleteVolumeSnapshot,)
|
||||
row_actions = (DeleteVolumeSnapshot,)
|
||||
row_actions = (CreateVolumeFromSnapshot, DeleteVolumeSnapshot)
|
||||
row_class = UpdateRow
|
||||
status_columns = ("status",)
|
||||
|
@ -15,15 +15,54 @@ from horizon import api
|
||||
from horizon import forms
|
||||
from horizon import exceptions
|
||||
from horizon import messages
|
||||
from horizon.utils.fields import SelectWidget
|
||||
from horizon.utils.memoized import memoized
|
||||
|
||||
from ..instances.tables import ACTIVE_STATES
|
||||
|
||||
|
||||
class CreateForm(forms.SelfHandlingForm):
|
||||
name = forms.CharField(max_length="255", label="Volume Name")
|
||||
name = forms.CharField(max_length="255", label=_("Volume Name"))
|
||||
description = forms.CharField(widget=forms.Textarea,
|
||||
label=_("Description"), required=False)
|
||||
size = forms.IntegerField(min_value=1, label="Size (GB)")
|
||||
size = forms.IntegerField(min_value=1, label=_("Size (GB)"))
|
||||
snapshot_source = forms.ChoiceField(label=_("Use snapshot as a source"),
|
||||
widget=SelectWidget(
|
||||
attrs={'class': 'snapshot-selector'},
|
||||
data_attrs=('size', 'display_name'),
|
||||
transform=lambda x:
|
||||
("%s (%sGB)" % (x.display_name,
|
||||
x.size))),
|
||||
required=False)
|
||||
|
||||
def __init__(self, request, *args, **kwargs):
|
||||
super(CreateForm, self).__init__(request, *args, **kwargs)
|
||||
if ("snapshot_id" in request.GET):
|
||||
try:
|
||||
snapshot = self.get_snapshot(request,
|
||||
request.GET["snapshot_id"])
|
||||
self.fields['name'].initial = snapshot.display_name
|
||||
self.fields['size'].initial = snapshot.size
|
||||
self.fields['snapshot_source'].choices = ((snapshot.id,
|
||||
snapshot),)
|
||||
self.fields['size'].help_text = _('Volume size must be equal '
|
||||
'to or greater than the snapshot size (%sGB)'
|
||||
% snapshot.size)
|
||||
except:
|
||||
exceptions.handle(request,
|
||||
_('Unable to load the specified snapshot.'))
|
||||
else:
|
||||
try:
|
||||
snapshots = api.volume_snapshot_list(request)
|
||||
if snapshots:
|
||||
choices = [('', _("Choose a snapshot"))] + \
|
||||
[(s.id, s) for s in snapshots]
|
||||
self.fields['snapshot_source'].choices = choices
|
||||
else:
|
||||
del self.fields['snapshot_source']
|
||||
except:
|
||||
exceptions.handle(request, _("Unable to retrieve "
|
||||
"volume snapshots."))
|
||||
|
||||
def handle(self, request, data):
|
||||
try:
|
||||
@ -33,8 +72,20 @@ class CreateForm(forms.SelfHandlingForm):
|
||||
# send it off to Nova to try and create.
|
||||
usages = api.tenant_quota_usages(request)
|
||||
|
||||
if type(data['size']) is str:
|
||||
data['size'] = int(data['size'])
|
||||
snapshot_id = None
|
||||
if (data.get("snapshot_source", None)):
|
||||
# Create from Snapshot
|
||||
snapshot = self.get_snapshot(request,
|
||||
data["snapshot_source"])
|
||||
snapshot_id = snapshot.id
|
||||
if (data['size'] < snapshot.size):
|
||||
error_message = _('The volume size cannot be less than '
|
||||
'the snapshot size (%sGB)' %
|
||||
snapshot.size)
|
||||
raise ValidationError(error_message)
|
||||
else:
|
||||
if type(data['size']) is str:
|
||||
data['size'] = int(data['size'])
|
||||
|
||||
if usages['gigabytes']['available'] < data['size']:
|
||||
error_message = _('A volume of %(req)iGB cannot be created as '
|
||||
@ -51,7 +102,8 @@ class CreateForm(forms.SelfHandlingForm):
|
||||
volume = api.volume_create(request,
|
||||
data['size'],
|
||||
data['name'],
|
||||
data['description'])
|
||||
data['description'],
|
||||
snapshot_id=snapshot_id)
|
||||
message = 'Creating volume "%s"' % data['name']
|
||||
messages.info(request, message)
|
||||
return volume
|
||||
@ -61,12 +113,16 @@ class CreateForm(forms.SelfHandlingForm):
|
||||
exceptions.handle(request, ignore=True)
|
||||
return self.api_error(_("Unable to create volume."))
|
||||
|
||||
@memoized
|
||||
def get_snapshot(self, request, id):
|
||||
return api.nova.volume_snapshot_get(request, id)
|
||||
|
||||
|
||||
class AttachForm(forms.SelfHandlingForm):
|
||||
instance = forms.ChoiceField(label="Attach to Instance",
|
||||
instance = forms.ChoiceField(label=_("Attach to Instance"),
|
||||
help_text=_("Select an instance to "
|
||||
"attach to."))
|
||||
device = forms.CharField(label="Device Name", initial="/dev/vdc")
|
||||
device = forms.CharField(label=_("Device Name"), initial="/dev/vdc")
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(AttachForm, self).__init__(*args, **kwargs)
|
||||
@ -126,8 +182,8 @@ class CreateSnapshotForm(forms.SelfHandlingForm):
|
||||
description = forms.CharField(widget=forms.Textarea,
|
||||
label=_("Description"), required=False)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(CreateSnapshotForm, self).__init__(*args, **kwargs)
|
||||
def __init__(self, request, *args, **kwargs):
|
||||
super(CreateSnapshotForm, self).__init__(request, *args, **kwargs)
|
||||
|
||||
# populate volume_id
|
||||
volume_id = kwargs.get('initial', {}).get('volume_id', [])
|
||||
|
@ -2,7 +2,7 @@
|
||||
{% load i18n horizon humanize %}
|
||||
|
||||
{% block form_id %}{% endblock %}
|
||||
{% block form_action %}{% url horizon:nova:volumes:create %}{% endblock %}
|
||||
{% block form_action %}{% url horizon:nova:volumes:create %}?{{ request.GET.urlencode }}{% endblock %}
|
||||
|
||||
{% block modal_id %}create_volume_modal{% endblock %}
|
||||
{% block modal-header %}{% trans "Create Volume" %}{% endblock %}
|
||||
|
@ -27,20 +27,24 @@ from horizon import test
|
||||
|
||||
|
||||
class VolumeViewTests(test.TestCase):
|
||||
@test.create_stubs({api: ('tenant_quota_usages', 'volume_create',)})
|
||||
@test.create_stubs({api: ('tenant_quota_usages', 'volume_create',
|
||||
'volume_snapshot_list')})
|
||||
def test_create_volume(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}
|
||||
'size': 50, 'snapshot_source': ''}
|
||||
|
||||
api.tenant_quota_usages(IsA(http.HttpRequest)).AndReturn(usage)
|
||||
api.volume_snapshot_list(IsA(http.HttpRequest)).\
|
||||
AndReturn(self.volume_snapshots.list())
|
||||
api.volume_create(IsA(http.HttpRequest),
|
||||
formData['size'],
|
||||
formData['name'],
|
||||
formData['description']).AndReturn(volume)
|
||||
formData['description'],
|
||||
snapshot_id=None).AndReturn(volume)
|
||||
|
||||
self.mox.ReplayAll()
|
||||
|
||||
@ -50,7 +54,86 @@ class VolumeViewTests(test.TestCase):
|
||||
redirect_url = reverse('horizon:nova:volumes:index')
|
||||
self.assertRedirectsNoFollow(res, redirect_url)
|
||||
|
||||
@test.create_stubs({api: ('tenant_quota_usages',)})
|
||||
@test.create_stubs({api: ('tenant_quota_usages', 'volume_create',
|
||||
'volume_snapshot_list'),
|
||||
api.nova: ('volume_snapshot_get',)})
|
||||
def test_create_volume_from_snapshot(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, 'snapshot_source': snapshot.id}
|
||||
|
||||
# first call- with url param
|
||||
api.tenant_quota_usages(IsA(http.HttpRequest)).AndReturn(usage)
|
||||
api.nova.volume_snapshot_get(IsA(http.HttpRequest),
|
||||
str(snapshot.id)).AndReturn(snapshot)
|
||||
api.volume_create(IsA(http.HttpRequest),
|
||||
formData['size'],
|
||||
formData['name'],
|
||||
formData['description'],
|
||||
snapshot_id=snapshot.id).\
|
||||
AndReturn(volume)
|
||||
# second call- with dropdown
|
||||
api.tenant_quota_usages(IsA(http.HttpRequest)).AndReturn(usage)
|
||||
api.volume_snapshot_list(IsA(http.HttpRequest)).\
|
||||
AndReturn(self.volume_snapshots.list())
|
||||
api.nova.volume_snapshot_get(IsA(http.HttpRequest),
|
||||
str(snapshot.id)).AndReturn(snapshot)
|
||||
api.volume_create(IsA(http.HttpRequest),
|
||||
formData['size'],
|
||||
formData['name'],
|
||||
formData['description'],
|
||||
snapshot_id=snapshot.id).\
|
||||
AndReturn(volume)
|
||||
|
||||
self.mox.ReplayAll()
|
||||
|
||||
# get snapshot from url
|
||||
url = reverse('horizon:nova:volumes:create')
|
||||
res = self.client.post("?".join([url,
|
||||
"snapshot_id=" + str(snapshot.id)]),
|
||||
formData)
|
||||
|
||||
redirect_url = reverse('horizon:nova:volumes:index')
|
||||
self.assertRedirectsNoFollow(res, redirect_url)
|
||||
|
||||
# get snapshot from dropdown list
|
||||
url = reverse('horizon:nova:volumes:create')
|
||||
res = self.client.post(url, formData)
|
||||
|
||||
redirect_url = reverse('horizon:nova:volumes:index')
|
||||
self.assertRedirectsNoFollow(res, redirect_url)
|
||||
|
||||
@test.create_stubs({api: ('tenant_quota_usages',),
|
||||
api.nova: ('volume_snapshot_get',)})
|
||||
def test_create_volume_from_snapshot_invalid_size(self):
|
||||
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': 20, 'snapshot_source': snapshot.id}
|
||||
|
||||
api.tenant_quota_usages(IsA(http.HttpRequest)).AndReturn(usage)
|
||||
api.nova.volume_snapshot_get(IsA(http.HttpRequest),
|
||||
str(snapshot.id)).AndReturn(snapshot)
|
||||
api.tenant_quota_usages(IsA(http.HttpRequest)).AndReturn(usage)
|
||||
|
||||
self.mox.ReplayAll()
|
||||
|
||||
url = reverse('horizon:nova:volumes:create')
|
||||
res = self.client.post("?".join([url,
|
||||
"snapshot_id=" + str(snapshot.id)]),
|
||||
formData, follow=True)
|
||||
self.assertEqual(res.redirect_chain, [])
|
||||
self.assertFormError(res, 'form', None,
|
||||
"The volume size cannot be less than the "
|
||||
"snapshot size (40GB)")
|
||||
|
||||
@test.create_stubs({api: ('tenant_quota_usages', 'volume_snapshot_list')})
|
||||
def test_create_volume_gb_used_over_alloted_quota(self):
|
||||
usage = {'gigabytes': {'available': 100, 'used': 20}}
|
||||
formData = {'name': u'This Volume Is Huge!',
|
||||
@ -59,6 +142,8 @@ class VolumeViewTests(test.TestCase):
|
||||
'size': 5000}
|
||||
|
||||
api.tenant_quota_usages(IsA(http.HttpRequest)).AndReturn(usage)
|
||||
api.volume_snapshot_list(IsA(http.HttpRequest)).\
|
||||
AndReturn(self.volume_snapshots.list())
|
||||
api.tenant_quota_usages(IsA(http.HttpRequest)).AndReturn(usage)
|
||||
|
||||
self.mox.ReplayAll()
|
||||
@ -70,7 +155,7 @@ class VolumeViewTests(test.TestCase):
|
||||
' have 100GB of your quota available.']
|
||||
self.assertEqual(res.context['form'].errors['__all__'], expected_error)
|
||||
|
||||
@test.create_stubs({api: ('tenant_quota_usages',)})
|
||||
@test.create_stubs({api: ('tenant_quota_usages', 'volume_snapshot_list')})
|
||||
def test_create_volume_number_over_alloted_quota(self):
|
||||
usage = {'gigabytes': {'available': 100, 'used': 20},
|
||||
'volumes': {'available': 0}}
|
||||
@ -80,6 +165,8 @@ class VolumeViewTests(test.TestCase):
|
||||
'size': 10}
|
||||
|
||||
api.tenant_quota_usages(IsA(http.HttpRequest)).AndReturn(usage)
|
||||
api.volume_snapshot_list(IsA(http.HttpRequest)).\
|
||||
AndReturn(self.volume_snapshots.list())
|
||||
api.tenant_quota_usages(IsA(http.HttpRequest)).AndReturn(usage)
|
||||
|
||||
self.mox.ReplayAll()
|
||||
|
@ -96,7 +96,9 @@ class CreateSnapshotView(forms.ModalFormView):
|
||||
success_url = reverse_lazy("horizon:nova:images_and_snapshots:index")
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
return {'volume_id': self.kwargs['volume_id']}
|
||||
context = super(CreateSnapshotView, self).get_context_data(**kwargs)
|
||||
context['volume_id'] = self.kwargs['volume_id']
|
||||
return context
|
||||
|
||||
def get_initial(self):
|
||||
return {'volume_id': self.kwargs["volume_id"]}
|
||||
|
@ -1,7 +1,7 @@
|
||||
/* Namespace for core functionality related to Forms. */
|
||||
horizon.forms = {
|
||||
handle_source_group: function() {
|
||||
$(document).on("change", "#id_source_group", function (evt) {
|
||||
$("div.table_wrapper, #modal_wrapper").on("change", "#id_source_group", function (evt) {
|
||||
var $sourceGroup = $('#id_source_group'),
|
||||
$cidrContainer = $('#id_cidr').closest(".control-group");
|
||||
if($sourceGroup.val() === "") {
|
||||
@ -10,6 +10,16 @@ horizon.forms = {
|
||||
$cidrContainer.addClass("hide");
|
||||
}
|
||||
});
|
||||
},
|
||||
handle_snapshot_source: function() {
|
||||
$("div.table_wrapper, #modal_wrapper").on("change", "select#id_snapshot_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("display_name"));
|
||||
var $volSize = $form.find('input#id_size');
|
||||
$volSize.val($option.data("size"));
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@ -48,6 +58,7 @@ horizon.addInitFunction(function () {
|
||||
horizon.modals.addModalInitFunction(horizon.forms.bind_add_item_handlers);
|
||||
|
||||
horizon.forms.handle_source_group();
|
||||
horizon.forms.handle_snapshot_source();
|
||||
|
||||
// Bind event handlers to confirm dangerous actions.
|
||||
$("body").on("click", "form button.btn-danger", function (evt) {
|
||||
@ -62,8 +73,8 @@ horizon.addInitFunction(function () {
|
||||
var type = $(this).val();
|
||||
$(this).closest('fieldset').find('input[type=text]').each(function(index, obj){
|
||||
var label_val = "";
|
||||
if ($(obj).attr("data-" + type)){
|
||||
label_val = $(obj).attr("data-" + type);
|
||||
if ($(obj).data(type)){
|
||||
label_val = $(obj).data(type);
|
||||
} else if ($(obj).attr("data")){
|
||||
label_val = $(obj).attr("data");
|
||||
} else
|
||||
|
@ -1,8 +1,11 @@
|
||||
import re
|
||||
import netaddr
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.forms import forms
|
||||
from django.forms import forms, widgets
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.encoding import force_unicode
|
||||
from django.utils.html import escape, conditional_escape
|
||||
from django.utils.functional import Promise
|
||||
|
||||
ip_allowed_symbols_re = re.compile(r'^[a-fA-F0-9:/\.]+$')
|
||||
IPv4 = 1
|
||||
@ -82,3 +85,44 @@ class IPField(forms.Field):
|
||||
def clean(self, value):
|
||||
super(IPField, self).clean(value)
|
||||
return str(getattr(self, "ip", ""))
|
||||
|
||||
|
||||
class SelectWidget(widgets.Select):
|
||||
"""
|
||||
Customizable select widget, that allows to render
|
||||
data-xxx attributes from choices.
|
||||
|
||||
.. attribute:: data_attrs
|
||||
|
||||
Specifies object properties to serialize as
|
||||
data-xxx attribute. If passed ('id', ),
|
||||
this will be rendered as:
|
||||
<option data-id="123">option_value</option>
|
||||
where 123 is the value of choice_value.id
|
||||
|
||||
.. attribute:: transform
|
||||
|
||||
A callable used to render the display value
|
||||
from the option object.
|
||||
"""
|
||||
def __init__(self, attrs=None, choices=(), data_attrs=(), transform=None):
|
||||
self.data_attrs = data_attrs
|
||||
self.transform = transform
|
||||
super(SelectWidget, self).__init__(attrs, choices)
|
||||
|
||||
def render_option(self, selected_choices, option_value, option_label):
|
||||
option_value = force_unicode(option_value)
|
||||
other_html = (option_value in selected_choices) and \
|
||||
u' selected="selected"' or ''
|
||||
if not isinstance(option_label, (basestring, Promise)):
|
||||
for data_attr in self.data_attrs:
|
||||
data_value = conditional_escape(
|
||||
force_unicode(getattr(option_label,
|
||||
data_attr, "")))
|
||||
other_html += ' data-%s="%s"' % (data_attr, data_value)
|
||||
|
||||
if self.transform:
|
||||
option_label = self.transform(option_label)
|
||||
return u'<option value="%s"%s>%s</option>' % (
|
||||
escape(option_value), other_html,
|
||||
conditional_escape(force_unicode(option_label)))
|
||||
|
Loading…
Reference in New Issue
Block a user