Enable changing volume type of a volume

Expose the functionality of the 'cinder retype' command in the UI.
It allows user to change the volume type of a volume whose status is
in-use or available when horizon's cinder API version is >= 2.

cinder retype is only supported starting cinder v2.

If enabled_backends is specified in /etc/cinder/cinder.conf,
retype is actually performed by a specific driver.
It depends on the drivers (backends) that are associated
with volume types.
Volume types are set through type-key extra specs.

If enabled_backends in cinder.conf is not specified, volumes are
created by LVM so retype is actually performaned in LVM.

During retype, if cinder finds it can not retype, it will check
if the migration policy is on_demand or never. If the policy is
is never, then cinder does not do anything, otherwise, it will
perform migration. By default, in the horizon retype dialog UI,
migration policy is never which is also the default
of the cinder cli command.

Currently in horizon cinder api default version is 1. In order to
test this functionallity, you need to update
openstack_dashboard/local/local_settings.py to have the "volume"
API to use version 2 so the "Change Volume Type" action menu
shows up for the volume. If local_settings.py is not available, you
need to copy the local_settings.py.example file, change it to
local_settings.py, update other necessary settings and also update
have the API version setting like the followings:

OPENSTACK_API_VERSIONS = {
    #"data_processing": 1.1,
    #"identity": 3,
    "volume": 2
}

Implements: blueprint volume-retype

Change-Id: Id8bc539e1849f5910df34d7b76cc250ec82f9671
This commit is contained in:
Gloria Gu 2014-07-11 14:40:02 -07:00
parent 59e410eb24
commit fa7105d0da
11 changed files with 341 additions and 1 deletions

View File

@ -93,6 +93,12 @@ class VolumeSnapshot(BaseCinderAPIResourceWrapper):
'os-extended-snapshot-attributes:project_id'] '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): class VolumeBackup(BaseCinderAPIResourceWrapper):
_attrs = ['id', 'name', 'description', 'container', 'size', 'status', _attrs = ['id', 'name', 'description', 'container', 'size', 'status',
@ -162,6 +168,11 @@ def _replace_v2_parameters(data):
return data return data
def version_get():
api_version = VERSIONS.get_active_version()
return api_version['version']
def volume_list(request, search_opts=None): def volume_list(request, search_opts=None):
"""To see all volumes in the cloud as an admin you can pass in a special """To see all volumes in the cloud as an admin you can pass in a special
search option: {'all_tenants': 1} search option: {'all_tenants': 1}
@ -212,6 +223,16 @@ def volume_delete(request, volume_id):
return cinderclient(request).volumes.delete(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): def volume_update(request, volume_id, name, description):
vol_data = {'name': name, vol_data = {'name': name,
'description': description} 'description': description}
@ -399,3 +420,10 @@ def extension_supported(request, extension_name):
if extension.name == extension_name: if extension.name == extension_name:
return True return True
return False return False
@memoized
def retype_supported():
"""retype is only supported after cinder v2.
"""
return version_get() >= 2

View File

@ -19,6 +19,7 @@
"volume:update_snapshot": [["rule:default"]], "volume:update_snapshot": [["rule:default"]],
"volume:get_all_snapshots": [], "volume:get_all_snapshots": [],
"volume:extend": [], "volume:extend": [],
"volume:retype": [],
"volume_extension:types_manage": [["rule:admin_api"]], "volume_extension:types_manage": [["rule:admin_api"]],
"volume_extension:types_extra_specs": [["rule:admin_api"]], "volume_extension:types_extra_specs": [["rule:admin_api"]],

View File

@ -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 %}
<div class="left">
<fieldset>
{% include "horizon/common/_form_fields.html" %}
</fieldset>
</div>
<div class="right">
<h3>{% trans "Description:" %}</h3>
<p>{% blocktrans %}
From here you can change the volume type of a volume after its creation.
This is equivalent to the <tt>cinder retype</tt> command.
{% endblocktrans %}
</p>
<p>{% blocktrans %}
The "Volume Type" selected must be different from the current volume type.
{% endblocktrans %}
</p>
<p>{% 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 %}
</p>
</div>
{% endblock %}
{% block modal-footer %}
<input class="btn btn-primary pull-right" type="submit" value="{% trans "Change Volume Type" %}" />
<a href="{% url 'horizon:project:volumes:index' %}" class="btn btn-default secondary cancel close">{% trans "Cancel" %}</a>
{% endblock %}

View File

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

View File

@ -543,3 +543,69 @@ class ExtendForm(forms.SelfHandlingForm):
exceptions.handle(request, exceptions.handle(request,
_('Unable to extend volume.'), _('Unable to extend volume.'),
redirect=redirect) 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

View File

@ -216,6 +216,27 @@ class EditVolume(tables.LinkAction):
return volume.status in ("available", "in-use") 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): class UpdateRow(tables.Row):
ajax = True ajax = True
@ -342,7 +363,8 @@ class VolumesTable(VolumesTableBase):
row_class = UpdateRow row_class = UpdateRow
table_actions = (CreateVolume, DeleteVolume, VolumesFilterAction) table_actions = (CreateVolume, DeleteVolume, VolumesFilterAction)
row_actions = (EditVolume, ExtendVolume, LaunchVolume, EditAttachments, row_actions = (EditVolume, ExtendVolume, LaunchVolume, EditAttachments,
CreateSnapshot, CreateBackup, DeleteVolume) CreateSnapshot, CreateBackup, RetypeVolume,
DeleteVolume)
class DetachVolume(tables.BatchAction): class DetachVolume(tables.BatchAction):

View File

@ -1030,6 +1030,113 @@ class VolumeViewTests(test.TestCase):
"New size must be greater than " "New size must be greater than "
"current size.") "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): def test_encryption_false(self):
self._test_encryption(False) self._test_encryption(False)

View File

@ -43,4 +43,7 @@ urlpatterns = patterns(VIEWS_MOD,
url(r'^(?P<volume_id>[^/]+)/update/$', url(r'^(?P<volume_id>[^/]+)/update/$',
views.UpdateView.as_view(), views.UpdateView.as_view(),
name='update'), name='update'),
url(r'^(?P<volume_id>[^/]+)/retype/$',
views.RetypeView.as_view(),
name='retype'),
) )

View File

@ -242,3 +242,37 @@ class EditAttachmentsView(tables.DataTableView, forms.ModalFormView):
return self.form_valid(form) return self.form_valid(form)
else: else:
return self.get(request, *args, **kwargs) 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}

View File

@ -165,3 +165,18 @@ class CinderApiVersionTests(test.TestCase):
self.assertIn('description', ret_data.keys()) self.assertIn('description', ret_data.keys())
self.assertNotIn('display_name', ret_data.keys()) self.assertNotIn('display_name', ret_data.keys())
self.assertNotIn('display_description', 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)

View File

@ -93,6 +93,17 @@ def data(TEST):
'volume_type': None, 'volume_type': None,
'attachments': [{"id": "1", "server_id": '1', 'attachments': [{"id": "1", "server_id": '1',
"device": "/dev/hda"}]}) "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' volume.bootable = 'true'
nameless_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(volume))
TEST.cinder_volumes.add(api.cinder.Volume(nameless_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(other_volume))
TEST.cinder_volumes.add(api.cinder.Volume(volume_with_type))
vol_type1 = volume_types.VolumeType(volume_types.VolumeTypeManager(None), vol_type1 = volume_types.VolumeType(volume_types.VolumeTypeManager(None),
{'id': u'1', {'id': u'1',