diff --git a/openstack_dashboard/api/cinder.py b/openstack_dashboard/api/cinder.py index ef574bab90..de69d907a5 100644 --- a/openstack_dashboard/api/cinder.py +++ b/openstack_dashboard/api/cinder.py @@ -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: diff --git a/openstack_dashboard/conf/cinder_policy.json b/openstack_dashboard/conf/cinder_policy.json index b20bb853df..fde92f146d 100644 --- a/openstack_dashboard/conf/cinder_policy.json +++ b/openstack_dashboard/conf/cinder_policy.json @@ -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"]], diff --git a/openstack_dashboard/dashboards/admin/volumes/templates/volumes/volumes/_manage_volume.html b/openstack_dashboard/dashboards/admin/volumes/templates/volumes/volumes/_manage_volume.html new file mode 100644 index 0000000000..4fb0571a48 --- /dev/null +++ b/openstack_dashboard/dashboards/admin/volumes/templates/volumes/volumes/_manage_volume.html @@ -0,0 +1,14 @@ +{% extends "horizon/common/_modal_form.html" %} +{% load i18n %} + +{% block modal-body-right %} +

{% trans "Description:" %}

+

{% blocktrans %} + "Manage" an existing volume from a Cinder host. This will make the volume visible within + OpenStack. +
+
+ This is equivalent to the cinder manage command. + {% endblocktrans %} +

+{% endblock %} diff --git a/openstack_dashboard/dashboards/admin/volumes/templates/volumes/volumes/_unmanage_volume.html b/openstack_dashboard/dashboards/admin/volumes/templates/volumes/volumes/_unmanage_volume.html new file mode 100644 index 0000000000..31b6622cdc --- /dev/null +++ b/openstack_dashboard/dashboards/admin/volumes/templates/volumes/volumes/_unmanage_volume.html @@ -0,0 +1,14 @@ +{% extends "horizon/common/_modal_form.html" %} +{% load i18n %} + +{% block modal-body-right %} +

{% trans "Description:" %}

+

{% 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. +
+
+ This is equivalent to the cinder unmanage command. + {% endblocktrans %} +

+{% endblock %} diff --git a/openstack_dashboard/dashboards/admin/volumes/templates/volumes/volumes/manage_volume.html b/openstack_dashboard/dashboards/admin/volumes/templates/volumes/volumes/manage_volume.html new file mode 100644 index 0000000000..f163cddefa --- /dev/null +++ b/openstack_dashboard/dashboards/admin/volumes/templates/volumes/volumes/manage_volume.html @@ -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 %} diff --git a/openstack_dashboard/dashboards/admin/volumes/templates/volumes/volumes/unmanage_volume.html b/openstack_dashboard/dashboards/admin/volumes/templates/volumes/volumes/unmanage_volume.html new file mode 100644 index 0000000000..6190c0b47b --- /dev/null +++ b/openstack_dashboard/dashboards/admin/volumes/templates/volumes/volumes/unmanage_volume.html @@ -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 %} diff --git a/openstack_dashboard/dashboards/admin/volumes/tests.py b/openstack_dashboard/dashboards/admin/volumes/tests.py index c85525163f..4dab97e25c 100644 --- a/openstack_dashboard/dashboards/admin/volumes/tests.py +++ b/openstack_dashboard/dashboards/admin/volumes/tests.py @@ -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', diff --git a/openstack_dashboard/dashboards/admin/volumes/volumes/forms.py b/openstack_dashboard/dashboards/admin/volumes/volumes/forms.py index b3303be447..49993e6bcc 100644 --- a/openstack_dashboard/dashboards/admin/volumes/volumes/forms.py +++ b/openstack_dashboard/dashboards/admin/volumes/volumes/forms.py @@ -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): diff --git a/openstack_dashboard/dashboards/admin/volumes/volumes/tables.py b/openstack_dashboard/dashboards/admin/volumes/volumes/tables.py index 694f1733ae..6e4d3f4bd7 100644 --- a/openstack_dashboard/dashboards/admin/volumes/volumes/tables.py +++ b/openstack_dashboard/dashboards/admin/volumes/volumes/tables.py @@ -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',) diff --git a/openstack_dashboard/dashboards/admin/volumes/volumes/urls.py b/openstack_dashboard/dashboards/admin/volumes/volumes/urls.py index 15390cd3bf..d5665897e3 100644 --- a/openstack_dashboard/dashboards/admin/volumes/volumes/urls.py +++ b/openstack_dashboard/dashboards/admin/volumes/volumes/urls.py @@ -20,8 +20,16 @@ VIEWS_MOD = ('openstack_dashboard.dashboards.admin.volumes.volumes.views') urlpatterns = patterns( VIEWS_MOD, - url(r'^(?P[^/]+)/$', views.DetailView.as_view(), + url(r'^manage/$', + views.ManageVolumeView.as_view(), + name='manage'), + url(r'^(?P[^/]+)/$', + views.DetailView.as_view(), name='detail'), url(r'^(?P[^/]+)/update_status$', - views.UpdateStatusView.as_view(), name='update_status'), + views.UpdateStatusView.as_view(), + name='update_status'), + url(r'^(?P[^/]+)/unmanage$', + views.UnmanageVolumeView.as_view(), + name='unmanage'), ) diff --git a/openstack_dashboard/dashboards/admin/volumes/volumes/views.py b/openstack_dashboard/dashboards/admin/volumes/volumes/views.py index f28ddc14e3..745d00045d 100644 --- a/openstack_dashboard/dashboards/admin/volumes/volumes/views.py +++ b/openstack_dashboard/dashboards/admin/volumes/volumes/views.py @@ -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' diff --git a/openstack_dashboard/dashboards/project/volumes/volumes/forms.py b/openstack_dashboard/dashboards/project/volumes/volumes/forms.py index 39564975af..70929a3d74 100644 --- a/openstack_dashboard/dashboards/project/volumes/volumes/forms.py +++ b/openstack_dashboard/dashboards/project/volumes/volumes/forms.py @@ -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: diff --git a/openstack_dashboard/test/test_data/cinder_data.py b/openstack_dashboard/test/test_data/cinder_data.py index ae69bd7496..b99696a6c7 100644 --- a/openstack_dashboard/test/test_data/cinder_data.py +++ b/openstack_dashboard/test/test_data/cinder_data.py @@ -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'