Merge "New admin volume panel to manage/unmanage volumes."

This commit is contained in:
Jenkins 2015-02-04 06:29:46 +00:00 committed by Gerrit Code Review
commit ba1fe99f40
13 changed files with 436 additions and 35 deletions

View File

@ -370,6 +370,32 @@ def volume_backup_restore(request, backup_id, volume_id):
volume_id=volume_id)
def volume_manage(request,
host,
identifier,
id_type,
name,
description,
volume_type,
availability_zone,
metadata,
bootable):
source = {id_type: identifier}
return cinderclient(request).volumes.manage(
host=host,
ref=source,
name=name,
description=description,
volume_type=volume_type,
availability_zone=availability_zone,
metadata=metadata,
bootable=bootable)
def volume_unmanage(request, volume_id):
return cinderclient(request).volumes.unmanage(volume=volume_id)
def tenant_quota_get(request, tenant_id):
c_client = cinderclient(request)
if c_client is None:

View File

@ -33,6 +33,9 @@
"volume_extension:quotas:update": [["rule:admin_api"]],
"volume_extension:quota_classes": [],
"volume_extension:volume_manage": [["rule:admin_api"]],
"volume_extension:volume_unmanage": [["rule:admin_api"]],
"volume_extension:volume_admin_actions:reset_status": [["rule:admin_api"]],
"volume_extension:snapshot_admin_actions:reset_status": [["rule:admin_api"]],
"volume_extension:volume_admin_actions:force_delete": [["rule:admin_api"]],

View File

@ -0,0 +1,14 @@
{% extends "horizon/common/_modal_form.html" %}
{% load i18n %}
{% block modal-body-right %}
<h3>{% trans "Description:" %}</h3>
<p>{% blocktrans %}
"Manage" an existing volume from a Cinder host. This will make the volume visible within
OpenStack.
<br>
<br>
This is equivalent to the <tt>cinder manage</tt> command.
{% endblocktrans %}
</p>
{% endblock %}

View File

@ -0,0 +1,14 @@
{% extends "horizon/common/_modal_form.html" %}
{% load i18n %}
{% block modal-body-right %}
<h3>{% trans "Description:" %}</h3>
<p>{% blocktrans %}
When a volume is "unmanaged", the volume will no longer be visible within OpenStack. Note that the
volume will not be deleted from the Cinder host.
<br>
<br>
This is equivalent to the <tt>cinder unmanage</tt> command.
{% endblocktrans %}
</p>
{% endblock %}

View File

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

View File

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

View File

@ -19,6 +19,7 @@ from mox import IsA # noqa
from openstack_dashboard import api
from openstack_dashboard.api import cinder
from openstack_dashboard.api import keystone
from openstack_dashboard.dashboards.admin.volumes.volumes import forms
from openstack_dashboard.test import helpers as test
@ -60,6 +61,89 @@ class VolumeTests(test.BaseAdminViewTests):
formData)
self.assertNoFormErrors(res)
@test.create_stubs({cinder: ('volume_manage',
'volume_type_list',
'availability_zone_list',
'extension_supported')})
def test_manage_volume(self):
metadata = {'key': u'k1',
'value': u'v1'}
formData = {'host': 'host-1',
'identifier': 'vol-1',
'id_type': u'source-name',
'name': 'name-1',
'description': 'manage a volume',
'volume_type': 'vol_type_1',
'availability_zone': 'nova',
'metadata': metadata['key'] + '=' + metadata['value'],
'bootable': False}
cinder.volume_type_list(
IsA(http.HttpRequest)).\
AndReturn(self.volume_types.list())
cinder.availability_zone_list(
IsA(http.HttpRequest)).\
AndReturn(self.availability_zones.list())
cinder.extension_supported(
IsA(http.HttpRequest),
'AvailabilityZones').\
AndReturn(True)
cinder.volume_manage(
IsA(http.HttpRequest),
host=formData['host'],
identifier=formData['identifier'],
id_type=formData['id_type'],
name=formData['name'],
description=formData['description'],
volume_type=formData['volume_type'],
availability_zone=formData['availability_zone'],
metadata={metadata['key']: metadata['value']},
bootable=formData['bootable'])
self.mox.ReplayAll()
res = self.client.post(
reverse('horizon:admin:volumes:volumes:manage'),
formData)
self.assertNoFormErrors(res)
def test_manage_volume_extra_specs(self):
# these should pass
forms.validate_metadata("key1=val1")
forms.validate_metadata("key1=val1,key2=val2")
forms.validate_metadata("key1=val1,key2=val2,key3=val3")
forms.validate_metadata("key1=")
# these should throw a validation error
self.assertRaises(forms.ValidationError,
forms.validate_metadata, "key1==val1")
self.assertRaises(forms.ValidationError,
forms.validate_metadata, "key1=val1,")
self.assertRaises(forms.ValidationError,
forms.validate_metadata, "=val1")
self.assertRaises(forms.ValidationError,
forms.validate_metadata, ",")
self.assertRaises(forms.ValidationError,
forms.validate_metadata, " ")
@test.create_stubs({cinder: ('volume_unmanage',
'volume_get')})
def test_unmanage_volume(self):
# important - need to get the v2 cinder volume which has host data
volume_list = \
filter(lambda x: x.name == 'v2_volume', self.cinder_volumes.list())
volume = volume_list[0]
formData = {'volume_name': volume.name,
'host_name': 'host@backend-name#pool',
'volume_id': volume.id}
cinder.volume_get(IsA(http.HttpRequest), volume.id).AndReturn(volume)
cinder.volume_unmanage(IsA(http.HttpRequest), volume.id).\
AndReturn(volume)
self.mox.ReplayAll()
res = self.client.post(
reverse('horizon:admin:volumes:volumes:unmanage',
args=(volume.id,)),
formData)
self.assertNoFormErrors(res)
@test.create_stubs({cinder: ('volume_type_list_with_qos_associations',
'qos_spec_list',
'extension_supported',

View File

@ -16,6 +16,7 @@
# License for the specific language governing permissions and limitations
# under the License.
from django.forms import ValidationError # noqa
from django.utils.translation import ugettext_lazy as _
from horizon import exceptions
@ -23,6 +24,142 @@ from horizon import forms
from horizon import messages
from openstack_dashboard.api import cinder
from openstack_dashboard.dashboards.project.volumes.volumes \
import forms as project_forms
def validate_metadata(value):
error_msg = _('Invalid metadata entry. Use comma-separated'
' key=value pairs')
if value:
specs = value.split(",")
for spec in specs:
keyval = spec.split("=")
# ensure both sides of "=" exist, but allow blank value
if not len(keyval) == 2 or not keyval[0]:
raise ValidationError(error_msg)
class ManageVolume(forms.SelfHandlingForm):
identifier = forms.CharField(
max_length=255,
label=_("Identifier"),
help_text=_("Name or other identifier for existing volume"))
id_type = forms.ChoiceField(
label=_("Identifier Type"),
help_text=_("Type of backend device identifier provided"))
host = forms.CharField(
max_length=255,
label=_("Host"),
help_text=_("Cinder host on which the existing volume resides; "
"takes the form: host@backend-name#pool"))
name = forms.CharField(
max_length=255,
label=_("Volume Name"),
required=False,
help_text=_("Volume name to be assigned"))
description = forms.CharField(max_length=255, widget=forms.Textarea(
attrs={'class': 'modal-body-fixed-width', 'rows': 4}),
label=_("Description"), required=False)
metadata = forms.CharField(max_length=255, widget=forms.Textarea(
attrs={'class': 'modal-body-fixed-width', 'rows': 2}),
label=_("Metadata"), required=False,
help_text=_("Comma-separated key=value pairs"),
validators=[validate_metadata])
volume_type = forms.ChoiceField(
label=_("Volume Type"),
required=False)
availability_zone = forms.ChoiceField(
label=_("Availability Zone"),
required=False)
bootable = forms.BooleanField(
label=_("Bootable"),
required=False,
help_text=_("Specifies that the newly created volume "
"should be marked as bootable"))
def __init__(self, request, *args, **kwargs):
super(ManageVolume, self).__init__(request, *args, **kwargs)
self.fields['id_type'].choices = [("source-name", _("Name"))] + \
[("source-id", _("ID"))]
volume_types = cinder.volume_type_list(request)
self.fields['volume_type'].choices = [("", _("No volume type"))] + \
[(type.name, type.name)
for type in volume_types]
self.fields['availability_zone'].choices = \
project_forms.availability_zones(request)
def handle(self, request, data):
try:
az = data.get('availability_zone')
# assume user enters metadata with "key1=val1,key2=val2"
# convert to dictionary
metadataDict = {}
metadata = data.get('metadata')
if metadata:
metadata.replace(" ", "")
for item in metadata.split(','):
key, value = item.split('=')
metadataDict[key] = value
cinder.volume_manage(request,
host=data['host'],
identifier=data['identifier'],
id_type=data['id_type'],
name=data['name'],
description=data['description'],
volume_type=data['volume_type'],
availability_zone=az,
metadata=metadataDict,
bootable=data['bootable'])
# for success message, use identifier if user does not
# provide a volume name
volume_name = data['name']
if not volume_name:
volume_name = data['identifier']
messages.success(
request,
_('Successfully sent the request to manage volume: %s')
% volume_name)
return True
except Exception:
exceptions.handle(request, _("Unable to manage volume."))
return False
class UnmanageVolume(forms.SelfHandlingForm):
name = forms.CharField(label=_("Volume Name"),
required=False,
widget=forms.TextInput(
attrs={'readonly': 'readonly'}))
host = forms.CharField(label=_("Host"),
required=False,
widget=forms.TextInput(
attrs={'readonly': 'readonly'}))
volume_id = forms.CharField(label=_("ID"),
required=False,
widget=forms.TextInput(
attrs={'readonly': 'readonly'}))
def __init__(self, request, *args, **kwargs):
super(UnmanageVolume, self).__init__(request, *args, **kwargs)
def handle(self, request, data):
try:
cinder.volume_unmanage(request, self.initial['volume_id'])
messages.success(
request,
_('Successfully sent the request to unmanage volume: %s')
% data['name'])
return True
except Exception:
exceptions.handle(request, _("Unable to unmanage volume."))
return False
class CreateVolumeType(forms.SelfHandlingForm):

View File

@ -12,7 +12,9 @@
from django.utils.translation import ugettext_lazy as _
from horizon import exceptions
from horizon import tables
from openstack_dashboard.dashboards.project.volumes \
.volumes import tables as volumes_tables
@ -26,6 +28,42 @@ class VolumesFilterAction(tables.FilterAction):
if q in volume.name.lower()]
class ManageVolumeAction(tables.LinkAction):
name = "manage"
verbose_name = _("Manage Volume")
url = "horizon:admin:volumes:volumes:manage"
classes = ("ajax-modal",)
icon = "plus"
policy_rules = (("volume", "volume_extension:volume_manage"),)
ajax = True
class UnmanageVolumeAction(tables.LinkAction):
name = "unmanage"
verbose_name = _("Unmanage Volume")
url = "horizon:admin:volumes:volumes:unmanage"
classes = ("ajax-modal",)
icon = "pencil"
policy_rules = (("volume", "volume_extension:volume_unmanage"),)
def allowed(self, request, volume=None):
# don't allow unmanage if volume is attached to instance or
# volume has snapshots
if volume:
if volume.attachments:
return False
try:
return (volume.status in volumes_tables.DELETABLE_STATES and
not getattr(volume, 'has_snapshot', False))
except Exception:
exceptions.handle(request,
_("Unable to retrieve snapshot data."))
return False
return False
class UpdateVolumeStatusAction(tables.LinkAction):
name = "update_status"
verbose_name = _("Update Volume Status")
@ -48,7 +86,11 @@ class VolumesTable(volumes_tables.VolumesTable):
verbose_name = _("Volumes")
status_columns = ["status"]
row_class = volumes_tables.UpdateRow
table_actions = (volumes_tables.DeleteVolume, VolumesFilterAction)
row_actions = (volumes_tables.DeleteVolume, UpdateVolumeStatusAction)
table_actions = (ManageVolumeAction,
volumes_tables.DeleteVolume,
VolumesFilterAction)
row_actions = (volumes_tables.DeleteVolume,
UpdateVolumeStatusAction,
UnmanageVolumeAction)
columns = ('tenant', 'host', 'name', 'size', 'status', 'volume_type',
'attachments', 'bootable', 'encryption',)

View File

@ -20,8 +20,16 @@ VIEWS_MOD = ('openstack_dashboard.dashboards.admin.volumes.volumes.views')
urlpatterns = patterns(
VIEWS_MOD,
url(r'^(?P<volume_id>[^/]+)/$', views.DetailView.as_view(),
url(r'^manage/$',
views.ManageVolumeView.as_view(),
name='manage'),
url(r'^(?P<volume_id>[^/]+)/$',
views.DetailView.as_view(),
name='detail'),
url(r'^(?P<volume_id>[^/]+)/update_status$',
views.UpdateStatusView.as_view(), name='update_status'),
views.UpdateStatusView.as_view(),
name='update_status'),
url(r'^(?P<volume_id>[^/]+)/unmanage$',
views.UnmanageVolumeView.as_view(),
name='unmanage'),
)

View File

@ -40,6 +40,55 @@ class DetailView(volumes_views.DetailView):
return reverse('horizon:admin:volumes:index')
class ManageVolumeView(forms.ModalFormView):
form_class = volumes_forms.ManageVolume
template_name = 'admin/volumes/volumes/manage_volume.html'
modal_header = _("Manage Volume")
form_id = "manage_volume_modal"
submit_label = _("Manage")
success_url = reverse_lazy('horizon:admin:volumes:volumes_tab')
submit_url = reverse_lazy('horizon:admin:volumes:volumes:manage')
cancel_url = reverse_lazy("horizon:admin:volumes:index")
def get_context_data(self, **kwargs):
context = super(ManageVolumeView, self).get_context_data(**kwargs)
return context
class UnmanageVolumeView(forms.ModalFormView):
form_class = volumes_forms.UnmanageVolume
template_name = 'admin/volumes/volumes/unmanage_volume.html'
modal_header = _("Confirm Unmanage Volume")
form_id = "unmanage_volume_modal"
submit_label = _("Unmanage")
success_url = reverse_lazy('horizon:admin:volumes:volumes_tab')
submit_url = 'horizon:admin:volumes:volumes:unmanage'
cancel_url = reverse_lazy("horizon:admin:volumes:index")
def get_context_data(self, **kwargs):
context = super(UnmanageVolumeView, self).get_context_data(**kwargs)
args = (self.kwargs['volume_id'],)
context['submit_url'] = reverse(self.submit_url, args=args)
return context
@memoized.memoized_method
def get_data(self):
try:
volume_id = self.kwargs['volume_id']
volume = cinder.volume_get(self.request, volume_id)
except Exception:
exceptions.handle(self.request,
_('Unable to retrieve volume details.'),
redirect=self.success_url)
return volume
def get_initial(self):
volume = self.get_data()
return {'volume_id': self.kwargs["volume_id"],
'name': volume.name,
'host': getattr(volume, "os-vol-host-attr:host")}
class CreateVolumeTypeView(forms.ModalFormView):
form_class = volumes_forms.CreateVolumeType
template_name = 'admin/volumes/volumes/create_volume_type.html'

View File

@ -45,6 +45,35 @@ VALID_DISK_FORMATS = ('raw', 'vmdk', 'vdi', 'qcow2')
DEFAULT_CONTAINER_FORMAT = 'bare'
# Determine whether the extension for Cinder AZs is enabled
def cinder_az_supported(request):
try:
return cinder.extension_supported(request, 'AvailabilityZones')
except Exception:
exceptions.handle(request, _('Unable to determine if availability '
'zones extension is supported.'))
return False
def availability_zones(request):
zone_list = []
if cinder_az_supported(request):
try:
zones = api.cinder.availability_zone_list(request)
zone_list = [(zone.zoneName, zone.zoneName)
for zone in zones if zone.zoneState['available']]
zone_list.sort()
except Exception:
exceptions.handle(request, _('Unable to retrieve availability '
'zones.'))
if not zone_list:
zone_list.insert(0, ("", _("No availability zones found")))
elif len(zone_list) > 0:
zone_list.insert(0, ("", _("Any Availability Zone")))
return zone_list
class CreateForm(forms.SelfHandlingForm):
name = forms.CharField(max_length=255, label=_("Volume Name"),
required=False)
@ -124,7 +153,7 @@ class CreateForm(forms.SelfHandlingForm):
def prepare_source_fields_if_image_specified(self, request):
self.fields['availability_zone'].choices = \
self.availability_zones(request)
availability_zones(request)
try:
image = self.get_image(request,
request.GET["image_id"])
@ -156,7 +185,7 @@ class CreateForm(forms.SelfHandlingForm):
def prepare_source_fields_if_volume_specified(self, request):
self.fields['availability_zone'].choices = \
self.availability_zones(request)
availability_zones(request)
volume = None
try:
volume = self.get_volume(request, request.GET["volume_id"])
@ -182,7 +211,7 @@ class CreateForm(forms.SelfHandlingForm):
def prepare_source_fields_default(self, request):
source_type_choices = []
self.fields['availability_zone'].choices = \
self.availability_zones(request)
availability_zones(request)
try:
available = api.cinder.VOLUME_STATE_AVAILABLE
@ -264,34 +293,6 @@ class CreateForm(forms.SelfHandlingForm):
self._errors['volume_source'] = self.error_class([msg])
return cleaned_data
# Determine whether the extension for Cinder AZs is enabled
def cinder_az_supported(self, request):
try:
return cinder.extension_supported(request, 'AvailabilityZones')
except Exception:
exceptions.handle(request, _('Unable to determine if '
'availability zones extension '
'is supported.'))
return False
def availability_zones(self, request):
zone_list = []
if self.cinder_az_supported(request):
try:
zones = api.cinder.availability_zone_list(request)
zone_list = [(zone.zoneName, zone.zoneName)
for zone in zones if zone.zoneState['available']]
zone_list.sort()
except Exception:
exceptions.handle(request, _('Unable to retrieve availability '
'zones.'))
if not zone_list:
zone_list.insert(0, ("", _("No availability zones found")))
elif len(zone_list) > 0:
zone_list.insert(0, ("", _("Any Availability Zone")))
return zone_list
def get_volumes(self, request):
volumes = []
try:

View File

@ -158,6 +158,7 @@ def data(TEST):
'size': 20,
'created_at': '2014-01-27 10:30:00',
'volume_type': None,
'os-vol-host-attr:host': 'host@backend-name#pool',
'bootable': 'true',
'attachments': []})
volume_v2.bootable = 'true'