diff --git a/openstack_dashboard/api/cinder.py b/openstack_dashboard/api/cinder.py index 55bfa745d3..660fde2fcc 100644 --- a/openstack_dashboard/api/cinder.py +++ b/openstack_dashboard/api/cinder.py @@ -93,6 +93,12 @@ class VolumeSnapshot(BaseCinderAPIResourceWrapper): 'os-extended-snapshot-attributes:project_id'] +class VolumeType(BaseCinderAPIResourceWrapper): + + _attrs = ['id', 'name', 'extra_specs', 'created_at', + 'os-extended-snapshot-attributes:project_id'] + + class VolumeBackup(BaseCinderAPIResourceWrapper): _attrs = ['id', 'name', 'description', 'container', 'size', 'status', @@ -162,6 +168,11 @@ def _replace_v2_parameters(data): return data +def version_get(): + api_version = VERSIONS.get_active_version() + return api_version['version'] + + def volume_list(request, search_opts=None): """To see all volumes in the cloud as an admin you can pass in a special search option: {'all_tenants': 1} @@ -212,6 +223,16 @@ def volume_delete(request, volume_id): return cinderclient(request).volumes.delete(volume_id) +def volume_retype(request, volume_id, new_type, migration_policy): + + if not retype_supported(): + raise exceptions.NotAvailable + + return cinderclient(request).volumes.retype(volume_id, + new_type, + migration_policy) + + def volume_update(request, volume_id, name, description): vol_data = {'name': name, 'description': description} @@ -399,3 +420,10 @@ def extension_supported(request, extension_name): if extension.name == extension_name: return True return False + + +@memoized +def retype_supported(): + """retype is only supported after cinder v2. + """ + return version_get() >= 2 diff --git a/openstack_dashboard/conf/cinder_policy.json b/openstack_dashboard/conf/cinder_policy.json index 7d1ef6317c..0f2b0af02b 100644 --- a/openstack_dashboard/conf/cinder_policy.json +++ b/openstack_dashboard/conf/cinder_policy.json @@ -19,6 +19,7 @@ "volume:update_snapshot": [["rule:default"]], "volume:get_all_snapshots": [], "volume:extend": [], + "volume:retype": [], "volume_extension:types_manage": [["rule:admin_api"]], "volume_extension:types_extra_specs": [["rule:admin_api"]], diff --git a/openstack_dashboard/dashboards/project/volumes/templates/volumes/volumes/_retype.html b/openstack_dashboard/dashboards/project/volumes/templates/volumes/volumes/_retype.html new file mode 100644 index 0000000000..80af3cd826 --- /dev/null +++ b/openstack_dashboard/dashboards/project/volumes/templates/volumes/volumes/_retype.html @@ -0,0 +1,41 @@ +{% extends "horizon/common/_modal_form.html" %} +{% load i18n %} +{% load url from future %} + +{% block form_id %}{% endblock %} +{% block form_action %}{% url 'horizon:project:volumes:volumes:retype' volume.id %}{% endblock %} + +{% block modal_id %}retype_volume_modal{% endblock %} +{% block modal-header %}{% trans "Change Volume Type" %}{% endblock %} + +{% block modal-body %} +
+
+ {% include "horizon/common/_form_fields.html" %} +
+
+
+

{% trans "Description:" %}

+

{% blocktrans %} + From here you can change the volume type of a volume after its creation. + This is equivalent to the cinder retype command. + {% endblocktrans %} +

+

{% blocktrans %} + The "Volume Type" selected must be different from the current volume type. + {% endblocktrans %} +

+

{% blocktrans %} + The "Migration Policy" is only used if the volume retype cannot be + completed. If the "Migration Policy" is "On Demand", the back end will + perform volume migration. Note that migration may take a significant + amount of time to complete, in some cases hours. + {% endblocktrans %} +

+
+{% endblock %} + +{% block modal-footer %} + + {% trans "Cancel" %} +{% endblock %} diff --git a/openstack_dashboard/dashboards/project/volumes/templates/volumes/volumes/retype.html b/openstack_dashboard/dashboards/project/volumes/templates/volumes/volumes/retype.html new file mode 100644 index 0000000000..829ab7496d --- /dev/null +++ b/openstack_dashboard/dashboards/project/volumes/templates/volumes/volumes/retype.html @@ -0,0 +1,11 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block title %}{% trans "Change Volume Type" %}{% endblock %} + +{% block page_header %} + {% include "horizon/common/_page_header.html" with title=_("Change Volume Type") %} +{% endblock page_header %} + +{% block main %} + {% include 'project/volumes/volumes/_retype.html' %} +{% endblock %} diff --git a/openstack_dashboard/dashboards/project/volumes/volumes/forms.py b/openstack_dashboard/dashboards/project/volumes/volumes/forms.py index 28f68d990d..1074d57132 100644 --- a/openstack_dashboard/dashboards/project/volumes/volumes/forms.py +++ b/openstack_dashboard/dashboards/project/volumes/volumes/forms.py @@ -543,3 +543,69 @@ class ExtendForm(forms.SelfHandlingForm): exceptions.handle(request, _('Unable to extend volume.'), redirect=redirect) + + +class RetypeForm(forms.SelfHandlingForm): + name = forms.CharField(label=_('Volume Name'), + widget=forms.TextInput( + attrs={'readonly': 'readonly'})) + volume_type = forms.ChoiceField(label=_('Type'), + required=True) + MIGRATION_POLICY_CHOICES = [('never', _('Never')), + ('on-demand', _('On Demand'))] + migration_policy = forms.ChoiceField(label=_('Migration Policy'), + widget=forms.Select(), + choices=(MIGRATION_POLICY_CHOICES), + initial='never', + required=False) + + def __init__(self, request, *args, **kwargs): + super(RetypeForm, self).__init__(request, *args, **kwargs) + + try: + volume_types = cinder.volume_type_list(request) + self.fields['volume_type'].choices = [(t.name, t.name) + for t in volume_types] + self.fields['volume_type'].initial = self.initial['volume_type'] + + except Exception: + redirect_url = reverse("horizon:project:volumes:index") + error_message = _('Unable to retrieve the volume type list.') + exceptions.handle(request, error_message, redirect=redirect_url) + + def clean_volume_type(self): + cleaned_volume_type = self.cleaned_data['volume_type'] + origin_type = self.initial['volume_type'] + + if cleaned_volume_type == origin_type: + error_message = _( + 'New volume type must be different from ' + 'the original volume type "%s".') % cleaned_volume_type + raise ValidationError(error_message) + + return cleaned_volume_type + + def handle(self, request, data): + volume_id = self.initial['id'] + + try: + cinder.volume_retype(request, + volume_id, + data['volume_type'], + data['migration_policy']) + + message = _( + 'Successfully sent the request to change the volume ' + 'type to "%(vtype)s" for volume: "%(name)s"') + params = {'name': data['name'], + 'vtype': data['volume_type']} + messages.info(request, message % params) + + return True + except Exception: + error_message = _( + 'Unable to change the volume type for volume: "%s"') \ + % data['name'] + exceptions.handle(request, error_message) + + return False diff --git a/openstack_dashboard/dashboards/project/volumes/volumes/tables.py b/openstack_dashboard/dashboards/project/volumes/volumes/tables.py index 9a9199baea..dbcb41a09f 100644 --- a/openstack_dashboard/dashboards/project/volumes/volumes/tables.py +++ b/openstack_dashboard/dashboards/project/volumes/volumes/tables.py @@ -216,6 +216,27 @@ class EditVolume(tables.LinkAction): return volume.status in ("available", "in-use") +class RetypeVolume(tables.LinkAction): + name = "retype" + verbose_name = _("Change Volume Type") + url = "horizon:project:volumes:volumes:retype" + classes = ("ajax-modal",) + icon = "pencil" + policy_rules = (("volume", "volume:retype"),) + + def get_policy_target(self, request, datum=None): + project_id = None + if datum: + project_id = getattr(datum, "os-vol-tenant-attr:tenant_id", None) + + return {"project_id": project_id} + + def allowed(self, request, volume=None): + retype_supported = cinder.retype_supported() + + return volume.status in ("available", "in-use") and retype_supported + + class UpdateRow(tables.Row): ajax = True @@ -342,7 +363,8 @@ class VolumesTable(VolumesTableBase): row_class = UpdateRow table_actions = (CreateVolume, DeleteVolume, VolumesFilterAction) row_actions = (EditVolume, ExtendVolume, LaunchVolume, EditAttachments, - CreateSnapshot, CreateBackup, DeleteVolume) + CreateSnapshot, CreateBackup, RetypeVolume, + DeleteVolume) class DetachVolume(tables.BatchAction): diff --git a/openstack_dashboard/dashboards/project/volumes/volumes/tests.py b/openstack_dashboard/dashboards/project/volumes/volumes/tests.py index 2a1f9ecd06..d344d45ebf 100644 --- a/openstack_dashboard/dashboards/project/volumes/volumes/tests.py +++ b/openstack_dashboard/dashboards/project/volumes/volumes/tests.py @@ -1030,6 +1030,113 @@ class VolumeViewTests(test.TestCase): "New size must be greater than " "current size.") + @test.create_stubs({cinder: ('volume_get', + 'retype_supported'), + api.nova: ('server_get',)}) + def test_retype_volume_not_supported_no_action_item(self): + volume = self.cinder_volumes.get(name='my_volume') + server = self.servers.first() + + cinder.volume_get(IsA(http.HttpRequest), volume.id).AndReturn(volume) + cinder.retype_supported().AndReturn(False) + api.nova.server_get(IsA(http.HttpRequest), server.id).AndReturn(server) + + self.mox.ReplayAll() + + url = VOLUME_INDEX_URL + \ + "?action=row_update&table=volumes&obj_id=" + volume.id + + res = self.client.get(url, {}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') + + self.assertEqual(res.status_code, 200) + + self.assertNotContains(res, 'Change Volume Type') + self.assertNotContains(res, 'retype') + + @test.create_stubs({cinder: ('volume_get', + 'retype_supported')}) + def test_retype_volume_supported_action_item(self): + volume = self.cinder_volumes.get(name='v2_volume') + + cinder.volume_get(IsA(http.HttpRequest), volume.id).AndReturn(volume) + cinder.retype_supported().AndReturn(True) + + self.mox.ReplayAll() + + url = VOLUME_INDEX_URL + \ + "?action=row_update&table=volumes&obj_id=" + volume.id + + res = self.client.get(url, {}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') + + self.assertEqual(res.status_code, 200) + + self.assertContains(res, 'Change Volume Type') + self.assertContains(res, 'retype') + + @test.create_stubs({cinder: ('volume_get', + 'volume_retype', + 'volume_type_list')}) + def test_retype_volume(self): + volume = self.cinder_volumes.get(name='my_volume2') + + volume_type = self.cinder_volume_types.get(name='vol_type_1') + + form_data = {'id': volume.id, + 'name': volume.name, + 'volume_type': volume_type.name, + 'migration_policy': 'on-demand'} + + cinder.volume_get(IsA(http.HttpRequest), volume.id).AndReturn(volume) + + cinder.volume_type_list( + IsA(http.HttpRequest)).AndReturn(self.cinder_volume_types.list()) + + cinder.volume_retype( + IsA(http.HttpRequest), + volume.id, + form_data['volume_type'], + form_data['migration_policy']).AndReturn(True) + + self.mox.ReplayAll() + + url = reverse('horizon:project:volumes:volumes:retype', + args=[volume.id]) + res = self.client.post(url, form_data) + + self.assertNoFormErrors(res) + + redirect_url = VOLUME_INDEX_URL + self.assertRedirectsNoFollow(res, redirect_url) + + @test.create_stubs({cinder: ('volume_get', + 'volume_type_list')}) + def test_retype_volume_same_type(self): + volume = self.cinder_volumes.get(name='my_volume2') + + volume_type = self.cinder_volume_types.get(name='vol_type_2') + + form_data = {'id': volume.id, + 'name': volume.name, + 'volume_type': volume_type.name, + 'migration_policy': 'on-demand'} + + cinder.volume_get(IsA(http.HttpRequest), volume.id).AndReturn(volume) + + cinder.volume_type_list( + IsA(http.HttpRequest)).AndReturn(self.cinder_volume_types.list()) + + self.mox.ReplayAll() + + url = reverse('horizon:project:volumes:volumes:retype', + args=[volume.id]) + res = self.client.post(url, form_data) + + self.assertFormError(res, + 'form', + 'volume_type', + 'New volume type must be different from the ' + 'original volume type "%s".' % volume_type.name) + def test_encryption_false(self): self._test_encryption(False) diff --git a/openstack_dashboard/dashboards/project/volumes/volumes/urls.py b/openstack_dashboard/dashboards/project/volumes/volumes/urls.py index a2d8ce314c..8c3aed2b7a 100644 --- a/openstack_dashboard/dashboards/project/volumes/volumes/urls.py +++ b/openstack_dashboard/dashboards/project/volumes/volumes/urls.py @@ -43,4 +43,7 @@ urlpatterns = patterns(VIEWS_MOD, url(r'^(?P[^/]+)/update/$', views.UpdateView.as_view(), name='update'), + url(r'^(?P[^/]+)/retype/$', + views.RetypeView.as_view(), + name='retype'), ) diff --git a/openstack_dashboard/dashboards/project/volumes/volumes/views.py b/openstack_dashboard/dashboards/project/volumes/volumes/views.py index b1ef144e80..a3c02751ee 100644 --- a/openstack_dashboard/dashboards/project/volumes/volumes/views.py +++ b/openstack_dashboard/dashboards/project/volumes/volumes/views.py @@ -242,3 +242,37 @@ class EditAttachmentsView(tables.DataTableView, forms.ModalFormView): return self.form_valid(form) else: return self.get(request, *args, **kwargs) + + +class RetypeView(forms.ModalFormView): + form_class = project_forms.RetypeForm + template_name = 'project/volumes/volumes/retype.html' + success_url = reverse_lazy("horizon:project:volumes:index") + + @memoized.memoized_method + def get_data(self): + try: + volume_id = self.kwargs['volume_id'] + volume = cinder.volume_get(self.request, volume_id) + except Exception: + error_message = _( + 'Unable to retrieve volume information for volume: "%s"') \ + % volume_id + exceptions.handle(self.request, + error_message, + redirect=self.success_url) + + return volume + + def get_context_data(self, **kwargs): + context = super(RetypeView, self).get_context_data(**kwargs) + context['volume'] = self.get_data() + + return context + + def get_initial(self): + volume = self.get_data() + + return {'id': self.kwargs['volume_id'], + 'name': volume.name, + 'volume_type': volume.volume_type} diff --git a/openstack_dashboard/test/api_tests/cinder_tests.py b/openstack_dashboard/test/api_tests/cinder_tests.py index 0392e17533..1fd1892187 100644 --- a/openstack_dashboard/test/api_tests/cinder_tests.py +++ b/openstack_dashboard/test/api_tests/cinder_tests.py @@ -165,3 +165,18 @@ class CinderApiVersionTests(test.TestCase): self.assertIn('description', ret_data.keys()) self.assertNotIn('display_name', ret_data.keys()) self.assertNotIn('display_description', ret_data.keys()) + + @override_settings(OPENSTACK_API_VERSIONS={'volume': 1}) + def test_version_get_1(self): + version = api.cinder.version_get() + self.assertEqual(version, 1) + + @override_settings(OPENSTACK_API_VERSIONS={'volume': 2}) + def test_version_get_2(self): + version = api.cinder.version_get() + self.assertEqual(version, 2) + + @override_settings(OPENSTACK_API_VERSIONS={'volume': 1}) + def test_retype_not_supported(self): + retype_supported = api.cinder.retype_supported() + self.assertFalse(retype_supported) diff --git a/openstack_dashboard/test/test_data/cinder_data.py b/openstack_dashboard/test/test_data/cinder_data.py index 7dcd5a5797..40167c03c1 100644 --- a/openstack_dashboard/test/test_data/cinder_data.py +++ b/openstack_dashboard/test/test_data/cinder_data.py @@ -93,6 +93,17 @@ def data(TEST): 'volume_type': None, 'attachments': [{"id": "1", "server_id": '1', "device": "/dev/hda"}]}) + volume_with_type = volumes.Volume(volumes.VolumeManager(None), + {'id': "7dcb47fd-07d9-42c2-9647-be5eab799ebe", + 'name': 'my_volume2', + 'status': 'in-use', + 'size': 10, + 'display_name': u'my_volume2', + 'display_description': '', + 'created_at': '2013-04-01 10:30:00', + 'volume_type': 'vol_type_2', + 'attachments': [{"id": "2", "server_id": '2', + "device": "/dev/hdb"}]}) volume.bootable = 'true' nameless_volume.bootable = 'true' @@ -101,6 +112,7 @@ def data(TEST): TEST.cinder_volumes.add(api.cinder.Volume(volume)) TEST.cinder_volumes.add(api.cinder.Volume(nameless_volume)) TEST.cinder_volumes.add(api.cinder.Volume(other_volume)) + TEST.cinder_volumes.add(api.cinder.Volume(volume_with_type)) vol_type1 = volume_types.VolumeType(volume_types.VolumeTypeManager(None), {'id': u'1',