Merge "Add support for volume transfers"

This commit is contained in:
Jenkins 2015-01-10 22:42:41 +00:00 committed by Gerrit Code Review
commit 5000934297
15 changed files with 442 additions and 6 deletions

View File

@ -79,7 +79,7 @@ class Volume(BaseCinderAPIResourceWrapper):
'volume_type', 'availability_zone', 'imageRef', 'bootable',
'snapshot_id', 'source_volid', 'attachments', 'tenant_name',
'os-vol-host-attr:host', 'os-vol-tenant-attr:tenant_id',
'metadata', 'volume_image_metadata', 'encrypted']
'metadata', 'volume_image_metadata', 'encrypted', 'transfer']
@property
def is_bootable(self):
@ -129,6 +129,11 @@ class QosSpec(object):
self.value = val
class VolumeTransfer(base.APIResourceWrapper):
_attrs = ['id', 'name', 'created_at', 'volume_id', 'auth_key']
@memoized
def cinderclient(request):
api_version = VERSIONS.get_active_version()
@ -181,7 +186,17 @@ def volume_list(request, search_opts=None):
c_client = cinderclient(request)
if c_client is None:
return []
return [Volume(v) for v in c_client.volumes.list(search_opts=search_opts)]
# build a dictionary of volume_id -> transfer
transfers = {t.volume_id: t
for t in transfer_list(request, search_opts=search_opts)}
volumes = []
for v in c_client.volumes.list(search_opts=search_opts):
v.transfer = transfers.get(v.id)
volumes.append(Volume(v))
return volumes
def volume_get(request, volume_id):
@ -196,6 +211,14 @@ def volume_get(request, volume_id):
# the lack a server_id property; to work around that we'll
# give the attached instance a generic name.
attachment['instance_name'] = _("Unknown instance")
volume_data.transfer = None
if volume_data.status == 'awaiting-transfer':
for transfer in transfer_list(request):
if transfer.volume_id == volume_id:
volume_data.transfer = transfer
break
return Volume(volume_data)
@ -526,3 +549,30 @@ def extension_supported(request, extension_name):
if extension.name == extension_name:
return True
return False
def transfer_list(request, detailed=True, search_opts=None):
"""To see all volumes transfers as an admin pass in a special
search option: {'all_tenants': 1}
"""
c_client = cinderclient(request)
return [VolumeTransfer(v) for v in c_client.transfers.list(
detailed=detailed, search_opts=search_opts)]
def transfer_get(request, transfer_id):
transfer_data = cinderclient(request).transfers.get(transfer_id)
return VolumeTransfer(transfer_data)
def transfer_create(request, transfer_id, name):
volume = cinderclient(request).transfers.create(transfer_id, name)
return VolumeTransfer(volume)
def transfer_accept(request, transfer_id, auth_key):
return cinderclient(request).transfers.accept(transfer_id, auth_key)
def transfer_delete(request, transfer_id):
return cinderclient(request).transfers.delete(transfer_id)

View File

@ -0,0 +1,7 @@
{% extends "horizon/common/_modal_form.html" %}
{% load i18n %}
{% block modal-body-right %}
<h3>{% trans "Description:" %}</h3>
<p>{% trans "Ownership of a volume can be transferred from one project to another. Accepting a transfer requires obtaining the Transfer ID and Authorization Key from the donor. This is equivalent to the <tt>cinder transfer-accept</tt> command." %}</p>
{% endblock %}

View File

@ -0,0 +1,7 @@
{% extends "horizon/common/_modal_form.html" %}
{% load i18n %}
{% block modal-body-right %}
<h3>{% trans "Description:" %}</h3>
<p>{% trans 'Ownership of a volume can be transferred from one project to another. Once a volume transfer is created in a donor project, it then can be "accepted" by a recipient project. This is equivalent to the <tt>cinder transfer-create</tt> command.' %}</p>
{% endblock %}

View File

@ -26,7 +26,7 @@
<dl class="dl-horizontal">
<dt>{% trans "Size" %}</dt>
<dd>{{ volume.size }} {% trans "GB" %}</dd>
<dt>{% trans "Created" %}</dt>
<dt>{% trans "Created" context "Created time" %}</dt>
<dd>{{ volume.created_at|parse_date }}</dd>
</dl>
</div>
@ -78,3 +78,22 @@
{% endif %}
</dl>
</div>
{% if volume.transfer %}
<div class="status row detail">
<h4>{% trans "Volume Transfer" %}</h4>
<hr class="header_rule">
<dl>
<dt>{% trans "ID" %}</dt>
<dd>{{ volume.transfer.id }}</dd>
</dl>
<dl>
<dt>{% trans "Name" %}</dt>
<dd>{{ volume.transfer.name }}</dd>
</dl>
<dl>
<dt>{% trans "Created" context "Created time" %}</dt>
<dd>{{ volume.transfer.created_at|parse_date }}</dd>
</dl>
</div>
{% endif %}

View File

@ -0,0 +1,12 @@
{% extends "horizon/common/_modal_form.html" %}
{% load i18n %}
{% block modal-body-right %}
<h3>{% trans "Description:" %}</h3>
<p>{% trans "The Transfer ID and the Authorization Key are needed by the recipient in order to accept the transfer. Please capture both the Transfer ID and the Authorization Key and provide them to your transfer recipient." %}</p>
<p class="alert alert-warning">{% trans "The Authorization Key will not be available after closing this page, so you must capture it now, or else you will be unable to use the transfer." %}</p>
{% endblock %}
{% block modal-footer %}
<a href="{% url 'horizon:project:volumes:volumes_tab' %}" class="btn btn-default secondary cancel close">{% trans "Close" %}</a>
{% endblock %}

View File

@ -0,0 +1,11 @@
{% extends 'base.html' %}
{% load i18n %}
{% block title %}{% trans "Accept Volume Transfer" %}{% endblock %}
{% block page_header %}
{% include "horizon/common/_page_header.html" with title=_("Accept Volume Transfer") %}
{% endblock page_header %}
{% block main %}
{% include 'project/volumes/volumes/_accept_transfer.html' %}
{% endblock %}

View File

@ -0,0 +1,11 @@
{% extends 'base.html' %}
{% load i18n %}
{% block title %}{% trans "Create Volume Transfer" %}{% endblock %}
{% block page_header %}
{% include "horizon/common/_page_header.html" with title=_("Create a Volume Transfer") %}
{% endblock page_header %}
{% block main %}
{% include 'project/volumes/volumes/_create_transfer.html' %}
{% endblock %}

View File

@ -0,0 +1,11 @@
{% extends 'base.html' %}
{% load i18n %}
{% block title %}{% trans "Volume Transfer Details" %}{% endblock %}
{% block page_header %}
{% include "horizon/common/_page_header.html" with title=_("Volume Transfer Details") %}
{% endblock page_header %}
{% block main %}
{% include 'project/volumes/volumes/_show_transfer.html' %}
{% endblock %}

View File

@ -20,6 +20,7 @@ Views for managing volumes.
from django.conf import settings
from django.core.urlresolvers import reverse
from django.forms import ValidationError # noqa
from django import http
from django.template.defaultfilters import filesizeformat # noqa
from django.utils.translation import pgettext_lazy
from django.utils.translation import ugettext_lazy as _
@ -520,6 +521,65 @@ class CreateSnapshotForm(forms.SelfHandlingForm):
redirect=redirect)
class CreateTransferForm(forms.SelfHandlingForm):
name = forms.CharField(max_length=255, label=_("Transfer Name"),
required=False)
def handle(self, request, data):
try:
volume_id = self.initial['volume_id']
transfer = cinder.transfer_create(request, volume_id, data['name'])
if data['name']:
msg = _('Created volume transfer: "%s".') % data['name']
else:
msg = _('Created volume transfer.')
messages.success(request, msg)
response = http.HttpResponseRedirect(
reverse("horizon:project:volumes:volumes:show_transfer",
args=(transfer.id, transfer.auth_key)))
return response
except Exception:
exceptions.handle(request, _('Unable to create volume transfer.'))
class AcceptTransferForm(forms.SelfHandlingForm):
# These max lengths correspond to the sizes in cinder
transfer_id = forms.CharField(max_length=36, label=_("Transfer ID"))
auth_key = forms.CharField(max_length=16, label=_("Authorization Key"))
def handle(self, request, data):
try:
transfer = cinder.transfer_accept(request,
data['transfer_id'],
data['auth_key'])
msg = (_('Successfully accepted volume transfer: "%s"')
% data['transfer_id'])
messages.success(request, msg)
return transfer
except Exception:
exceptions.handle(request, _('Unable to accept volume transfer.'))
class ShowTransferForm(forms.SelfHandlingForm):
name = forms.CharField(
label=_("Transfer Name"),
widget=forms.TextInput(attrs={'readonly': 'readonly'}),
required=False)
id = forms.CharField(
label=_("Transfer ID"),
widget=forms.TextInput(attrs={'readonly': 'readonly'}),
required=False)
auth_key = forms.CharField(
label=_("Authorization Key"),
widget=forms.TextInput(attrs={'readonly': 'readonly'}),
required=False)
def handle(self, request, data):
pass
class UpdateForm(forms.SelfHandlingForm):
name = forms.CharField(max_length=255,
label=_("Volume Name"),

View File

@ -24,6 +24,7 @@ from django.utils.translation import ugettext_lazy as _
from django.utils.translation import ungettext_lazy
from horizon import exceptions
from horizon import messages
from horizon import tables
from openstack_dashboard import api
@ -186,6 +187,17 @@ class CreateSnapshot(VolumePolicyTargetMixin, tables.LinkAction):
return volume.status in ("available", "in-use")
class CreateTransfer(VolumePolicyTargetMixin, tables.LinkAction):
name = "create_transfer"
verbose_name = _("Create Transfer")
url = "horizon:project:volumes:volumes:create_transfer"
classes = ("ajax-modal",)
policy_rules = (("volume", "volume:create_transfer"),)
def allowed(self, request, volume=None):
return volume.status == "available"
class CreateBackup(VolumePolicyTargetMixin, tables.LinkAction):
name = "backups"
verbose_name = _("Create Backup")
@ -238,6 +250,48 @@ class RetypeVolume(VolumePolicyTargetMixin, tables.LinkAction):
return volume.status in ("available", "in-use")
class AcceptTransfer(tables.LinkAction):
name = "accept_transfer"
verbose_name = _("Accept Transfer")
url = "horizon:project:volumes:volumes:accept_transfer"
classes = ("ajax-modal",)
icon = "exchange"
policy_rules = (("volume", "volume:accept_transfer"),)
ajax = True
def single(self, table, request, object_id=None):
return HttpResponse(self.render())
class DeleteTransfer(VolumePolicyTargetMixin, tables.Action):
# This class inherits from tables.Action instead of the more obvious
# tables.DeleteAction due to the confirmation message. When the delete
# is successful, DeleteAction automatically appends the name of the
# volume to the message, e.g. "Deleted volume transfer 'volume'". But
# we are deleting the volume *transfer*, whose name is different.
name = "delete_transfer"
verbose_name = _("Cancel Transfer")
policy_rules = (("volume", "volume:delete_transfer"),)
classes = ('btn-danger',)
def allowed(self, request, volume):
return (volume.status == "awaiting-transfer" and
getattr(volume, 'transfer', None))
def single(self, table, request, volume_id):
volume = table.get_object_by_id(volume_id)
try:
cinder.transfer_delete(request, volume.transfer.id)
if volume.transfer.name:
msg = _('Successfully deleted volume transfer "%s"'
) % volume.transfer.name
else:
msg = _("Successfully deleted volume transfer")
messages.success(request, msg)
except Exception:
exceptions.handle(request, _("Unable to delete volume transfer."))
class UpdateRow(tables.Row):
ajax = True
@ -361,10 +415,12 @@ class VolumesTable(VolumesTableBase):
verbose_name = _("Volumes")
status_columns = ["status"]
row_class = UpdateRow
table_actions = (CreateVolume, DeleteVolume, VolumesFilterAction)
table_actions = (CreateVolume, AcceptTransfer, DeleteVolume,
VolumesFilterAction)
row_actions = (EditVolume, ExtendVolume, LaunchVolume, EditAttachments,
CreateSnapshot, CreateBackup, RetypeVolume,
UploadToImage, DeleteVolume)
UploadToImage, CreateTransfer, DeleteTransfer,
DeleteVolume)
class DetachVolume(tables.BatchAction):

View File

@ -1033,7 +1033,8 @@ class VolumeViewTests(test.TestCase):
self.assertContains(res, expected_string, html=True,
msg_prefix="The create button is not disabled")
@test.create_stubs({cinder: ('volume_get', 'tenant_absolute_limits'),
@test.create_stubs({cinder: ('tenant_absolute_limits',
'volume_get',),
api.nova: ('server_get',)})
def test_detail_view(self):
volume = self.cinder_volumes.first()
@ -1435,3 +1436,100 @@ class VolumeViewTests(test.TestCase):
self.assertFormError(res, "form", "new_size",
"Volume cannot be extended to 1000GB as you only "
"have 80GB of your quota available.")
@test.create_stubs({cinder: ('volume_backup_supported',
'volume_list',
'tenant_absolute_limits'),
api.nova: ('server_list',)})
def test_create_transfer_availability(self):
limits = self.cinder_limits['absolute']
cinder.volume_backup_supported(IsA(http.HttpRequest))\
.MultipleTimes().AndReturn(False)
cinder.volume_list(IsA(http.HttpRequest), search_opts=None)\
.AndReturn(self.volumes.list())
api.nova.server_list(IsA(http.HttpRequest), search_opts=None)\
.AndReturn([self.servers.list(), False])
cinder.tenant_absolute_limits(IsA(http.HttpRequest))\
.MultipleTimes().AndReturn(limits)
self.mox.ReplayAll()
res = self.client.get(VOLUME_INDEX_URL)
table = res.context['volumes_table']
# Verify that the create transfer action is present if and only if
# the volume is available
for vol in table.data:
actions = [a.name for a in table.get_row_actions(vol)]
self.assertEqual('create_transfer' in actions,
vol.status == 'available')
@test.create_stubs({cinder: ('transfer_create',)})
def test_create_transfer(self):
volumes = self.volumes.list()
volToTransfer = [v for v in volumes if v.status == 'available'][0]
formData = {'volume_id': volToTransfer.id,
'name': u'any transfer name'}
cinder.transfer_create(IsA(http.HttpRequest),
formData['volume_id'],
formData['name']).AndReturn(
self.cinder_volume_transfers.first())
self.mox.ReplayAll()
# Create a transfer for the first available volume
url = reverse('horizon:project:volumes:volumes:create_transfer',
args=[volToTransfer.id])
res = self.client.post(url, formData)
self.assertNoFormErrors(res)
@test.create_stubs({cinder: ('volume_backup_supported',
'volume_list',
'transfer_delete',
'tenant_absolute_limits'),
api.nova: ('server_list',)})
def test_delete_transfer(self):
transfer = self.cinder_volume_transfers.first()
volumes = []
# Attach the volume transfer to the relevant volume
for v in self.cinder_volumes.list():
if v.id == transfer.volume_id:
v.status = 'awaiting-transfer'
v.transfer = transfer
volumes.append(v)
formData = {'action':
'volumes__delete_transfer__%s' % transfer.volume_id}
cinder.volume_backup_supported(IsA(http.HttpRequest))\
.MultipleTimes().AndReturn(False)
cinder.volume_list(IsA(http.HttpRequest), search_opts=None)\
.AndReturn(volumes)
cinder.transfer_delete(IsA(http.HttpRequest), transfer.id)
api.nova.server_list(IsA(http.HttpRequest), search_opts=None).\
AndReturn([self.servers.list(), False])
cinder.tenant_absolute_limits(IsA(http.HttpRequest)).MultipleTimes().\
AndReturn(self.cinder_limits['absolute'])
self.mox.ReplayAll()
url = VOLUME_INDEX_URL
res = self.client.post(url, formData, follow=True)
self.assertNoFormErrors(res)
self.assertIn('Successfully deleted volume transfer "test transfer"',
[m.message for m in res.context['messages']])
@test.create_stubs({cinder: ('transfer_accept',)})
def test_accept_transfer(self):
transfer = self.cinder_volume_transfers.first()
cinder.transfer_accept(IsA(http.HttpRequest), transfer.id,
transfer.auth_key)
self.mox.ReplayAll()
formData = {'transfer_id': transfer.id, 'auth_key': transfer.auth_key}
url = reverse('horizon:project:volumes:volumes:accept_transfer')
res = self.client.post(url, formData, follow=True)
self.assertNoFormErrors(res)

View File

@ -35,6 +35,15 @@ urlpatterns = patterns(
url(r'^(?P<volume_id>[^/]+)/create_snapshot/$',
views.CreateSnapshotView.as_view(),
name='create_snapshot'),
url(r'^(?P<volume_id>[^/]+)/create_transfer/$',
views.CreateTransferView.as_view(),
name='create_transfer'),
url(r'^accept_transfer/$',
views.AcceptTransferView.as_view(),
name='accept_transfer'),
url(r'^(?P<transfer_id>[^/]+)/auth/(?P<auth_key>[^/]+)/$',
views.ShowTransferView.as_view(),
name='show_transfer'),
url(r'^(?P<volume_id>[^/]+)/create_backup/$',
backup_views.CreateBackupView.as_view(),
name='create_backup'),

View File

@ -190,6 +190,72 @@ class UploadToImageView(forms.ModalFormView):
'status': volume.status}
class CreateTransferView(forms.ModalFormView):
form_class = project_forms.CreateTransferForm
template_name = 'project/volumes/volumes/create_transfer.html'
success_url = reverse_lazy('horizon:project:volumes:volumes_tab')
modal_id = "create_volume_transfer_modal"
modal_header = _("Create Volume Transfer")
submit_label = _("Create Volume Transfer")
submit_url = "horizon:project:volumes:volumes:create_transfer"
def get_context_data(self, *args, **kwargs):
context = super(CreateTransferView, self).get_context_data(**kwargs)
volume_id = self.kwargs['volume_id']
context['volume_id'] = volume_id
context['submit_url'] = reverse(self.submit_url, args=[volume_id])
return context
def get_initial(self):
return {'volume_id': self.kwargs["volume_id"]}
class AcceptTransferView(forms.ModalFormView):
form_class = project_forms.AcceptTransferForm
template_name = 'project/volumes/volumes/accept_transfer.html'
success_url = reverse_lazy('horizon:project:volumes:volumes_tab')
modal_id = "accept_volume_transfer_modal"
modal_header = _("Accept Volume Transfer")
submit_label = _("Accept Volume Transfer")
submit_url = reverse_lazy(
"horizon:project:volumes:volumes:accept_transfer")
class ShowTransferView(forms.ModalFormView):
form_class = project_forms.ShowTransferForm
template_name = 'project/volumes/volumes/show_transfer.html'
success_url = reverse_lazy('horizon:project:volumes:volumes_tab')
modal_id = "show_volume_transfer_modal"
modal_header = _("Volume Transfer")
submit_url = "horizon:project:volumes:volumes:show_transfer"
def get_object(self):
try:
return self._object
except AttributeError:
transfer_id = self.kwargs['transfer_id']
try:
self._object = cinder.transfer_get(self.request, transfer_id)
return self._object
except Exception:
exceptions.handle(self.request,
_('Unable to retrieve volume transfer.'))
def get_context_data(self, **kwargs):
context = super(ShowTransferView, self).get_context_data(**kwargs)
context['transfer_id'] = self.kwargs['transfer_id']
context['auth_key'] = self.kwargs['auth_key']
context['submit_url'] = reverse(self.submit_url, args=[
context['transfer_id'], context['auth_key']])
return context
def get_initial(self):
transfer = self.get_object()
return {'id': transfer.id,
'name': transfer.name,
'auth_key': self.kwargs['auth_key']}
class UpdateView(forms.ModalFormView):
form_class = project_forms.UpdateForm
template_name = 'project/volumes/volumes/update.html'

View File

@ -22,12 +22,19 @@ from openstack_dashboard.test import helpers as test
class CinderApiTests(test.APITestCase):
def test_volume_list(self):
search_opts = {'all_tenants': 1}
detailed = True
volumes = self.cinder_volumes.list()
volume_transfers = self.cinder_volume_transfers.list()
cinderclient = self.stub_cinderclient()
cinderclient.volumes = self.mox.CreateMockAnything()
cinderclient.volumes.list(search_opts=search_opts,).AndReturn(volumes)
cinderclient.transfers = self.mox.CreateMockAnything()
cinderclient.transfers.list(
detailed=detailed,
search_opts=search_opts,).AndReturn(volume_transfers)
self.mox.ReplayAll()
# No assertions are necessary. Verification is handled by mox.

View File

@ -19,6 +19,7 @@ from cinderclient.v2 import services
from cinderclient.v2 import volume_backups as vol_backups
from cinderclient.v2 import volume_encryption_types as vol_enc_types
from cinderclient.v2 import volume_snapshots as vol_snaps
from cinderclient.v2 import volume_transfers
from cinderclient.v2 import volume_types
from cinderclient.v2 import volumes
@ -41,6 +42,7 @@ def data(TEST):
TEST.cinder_quotas = utils.TestDataContainer()
TEST.cinder_quota_usages = utils.TestDataContainer()
TEST.cinder_availability_zones = utils.TestDataContainer()
TEST.cinder_volume_transfers = utils.TestDataContainer()
# Services
service_1 = services.Service(services.ServiceManager(None), {
@ -287,3 +289,13 @@ def data(TEST):
TEST.cinder_qos_specs.add(qos_spec1, qos_spec2)
vol_type1.associated_qos_spec = qos_spec1.name
TEST.cinder_qos_spec_associations.add(vol_type1)
# volume_transfers
transfer_1 = volume_transfers.VolumeTransfer(
volume_transfers.VolumeTransferManager(None), {
'id': '99999999-8888-7777-6666-555555555555',
'name': 'test transfer',
'volume_id': volume.id,
'auth_key': 'blah',
'created_at': ''})
TEST.cinder_volume_transfers.add(transfer_1)