diff --git a/openstack_dashboard/api/cinder.py b/openstack_dashboard/api/cinder.py index 660fde2fcc..15668ea5b6 100644 --- a/openstack_dashboard/api/cinder.py +++ b/openstack_dashboard/api/cinder.py @@ -245,6 +245,15 @@ def volume_reset_state(request, volume_id, state): return cinderclient(request).volumes.reset_state(volume_id, state) +def volume_upload_to_image(request, volume_id, force, image_name, + container_format, disk_format): + return cinderclient(request).volumes.upload_to_image(volume_id, + force, + image_name, + container_format, + disk_format) + + def volume_snapshot_get(request, snapshot_id): snapshot = cinderclient(request).volume_snapshots.get(snapshot_id) return VolumeSnapshot(snapshot) diff --git a/openstack_dashboard/conf/cinder_policy.json b/openstack_dashboard/conf/cinder_policy.json index 0f2b0af02b..8fe7bc7718 100644 --- a/openstack_dashboard/conf/cinder_policy.json +++ b/openstack_dashboard/conf/cinder_policy.json @@ -20,6 +20,7 @@ "volume:get_all_snapshots": [], "volume:extend": [], "volume:retype": [], + "volume:upload_to_image": [], "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/_upload_to_image.html b/openstack_dashboard/dashboards/project/volumes/templates/volumes/volumes/_upload_to_image.html new file mode 100644 index 0000000000..8351a57c0a --- /dev/null +++ b/openstack_dashboard/dashboards/project/volumes/templates/volumes/volumes/_upload_to_image.html @@ -0,0 +1,42 @@ +{% extends "horizon/common/_modal_form.html" %} +{% load i18n %} +{% load url from future %} + +{% block form_id %}{% endblock %} +{% block form_action %}{% url 'horizon:project:volumes:volumes:upload_to_image' volume.id %}{% endblock %} + +{% block modal_id %}update_volume_modal{% endblock %} +{% block modal-header %}{% trans "Upload Volume to Image" %}{% endblock %} + +{% block modal-body %} +
+
+ {% include "horizon/common/_form_fields.html" %} +
+
+
+

{% trans "Description:" %}

+

{% blocktrans %} + From here you can upload the volume to the Image Service as an image. + This is equivalent to the cinder upload-to-image command. + {% endblocktrans %} +

+

{% blocktrans %} + Choose "Disk Format" for the image. The volume images are created with + the QEMU disk image utility. + {% endblocktrans %} +

+ {% if volume.status == 'in-use' %} +

{% blocktrans %} + When the volume status is "in-use", you can use "Force" to upload the + volume to an image. + {% endblocktrans %} +

+ {% endif %} +
+{% endblock %} + +{% block modal-footer %} + + {% trans "Cancel" %} +{% endblock %} diff --git a/openstack_dashboard/dashboards/project/volumes/templates/volumes/volumes/upload_to_image.html b/openstack_dashboard/dashboards/project/volumes/templates/volumes/volumes/upload_to_image.html new file mode 100644 index 0000000000..484da4cd1c --- /dev/null +++ b/openstack_dashboard/dashboards/project/volumes/templates/volumes/volumes/upload_to_image.html @@ -0,0 +1,11 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block title %}{% trans "Upload Volume to Image" %}{% endblock %} + +{% block page_header %} + {% include "horizon/common/_page_header.html" with title=_("Upload Volume to Image") %} +{% endblock page_header %} + +{% block main %} + {% include 'project/volumes/volumes/_upload_to_image.html' %} +{% endblock %} diff --git a/openstack_dashboard/dashboards/project/volumes/volumes/forms.py b/openstack_dashboard/dashboards/project/volumes/volumes/forms.py index 4222c02ab2..073386dbba 100644 --- a/openstack_dashboard/dashboards/project/volumes/volumes/forms.py +++ b/openstack_dashboard/dashboards/project/volumes/volumes/forms.py @@ -36,6 +36,11 @@ from openstack_dashboard.dashboards.project.images import utils from openstack_dashboard.dashboards.project.instances import tables from openstack_dashboard.usage import quotas +IMAGE_BACKEND_SETTINGS = getattr(settings, 'OPENSTACK_IMAGE_BACKEND', {}) +IMAGE_FORMAT_CHOICES = IMAGE_BACKEND_SETTINGS.get('image_formats', []) +VALID_DISK_FORMATS = ('raw', 'vmdk', 'vdi', 'qcow2') +DEFAULT_CONTAINER_FORMAT = 'bare' + class CreateForm(forms.SelfHandlingForm): name = forms.CharField(max_length="255", label=_("Volume Name")) @@ -511,6 +516,64 @@ class UpdateForm(forms.SelfHandlingForm): redirect=redirect) +class UploadToImageForm(forms.SelfHandlingForm): + name = forms.CharField(label=_('Volume Name'), + widget=forms.TextInput( + attrs={'readonly': 'readonly'})) + image_name = forms.CharField(max_length="255", label=_('Image Name'), + required=True) + disk_format = forms.ChoiceField(label=_('Disk Format'), + widget=forms.Select(), + required=False) + force = forms.BooleanField(label=_("Force"), + widget=forms.CheckboxInput(), + required=False) + + def __init__(self, request, *args, **kwargs): + super(UploadToImageForm, self).__init__(request, *args, **kwargs) + + # 'vhd','iso','aki','ari' and 'ami' disk formats are supported by + # glance, but not by qemu-img. qemu-img supports 'vpc', 'cloop', 'cow' + # and 'qcow' which are not supported by glance. + # I can only use 'raw', 'vmdk', 'vdi' or 'qcow2' so qemu-img will not + # have issues when processes image request from cinder. + disk_format_choices = [(value, name) for value, name + in IMAGE_FORMAT_CHOICES + if value in VALID_DISK_FORMATS] + self.fields['disk_format'].choices = disk_format_choices + self.fields['disk_format'].initial = 'raw' + if self.initial['status'] != 'in-use': + self.fields['force'].widget = forms.widgets.HiddenInput() + + def handle(self, request, data): + volume_id = self.initial['id'] + + try: + # 'aki','ari','ami' container formats are supported by glance, + # but they need matching disk format to use. + # Glance usually uses 'bare' for other disk formats except + # amazon's. Please check the comment in CreateImageForm class + cinder.volume_upload_to_image(request, + volume_id, + data['force'], + data['image_name'], + DEFAULT_CONTAINER_FORMAT, + data['disk_format']) + message = _( + 'Successfully sent the request to upload volume to image ' + 'for volume: "%s"') % data['name'] + messages.info(request, message) + + return True + except Exception: + error_message = _( + 'Unable to upload volume to image for volume: "%s"') \ + % data['name'] + exceptions.handle(request, error_message) + + return False + + class ExtendForm(forms.SelfHandlingForm): name = forms.CharField( label=_("Volume Name"), diff --git a/openstack_dashboard/dashboards/project/volumes/volumes/tables.py b/openstack_dashboard/dashboards/project/volumes/volumes/tables.py index dbcb41a09f..27b4a016fe 100644 --- a/openstack_dashboard/dashboards/project/volumes/volumes/tables.py +++ b/openstack_dashboard/dashboards/project/volumes/volumes/tables.py @@ -198,6 +198,29 @@ class CreateBackup(tables.LinkAction): volume.status == "available") +class UploadToImage(tables.LinkAction): + name = "upload_to_image" + verbose_name = _("Upload to Image") + url = "horizon:project:volumes:volumes:upload_to_image" + classes = ("ajax-modal",) + icon = "cloud-upload" + policy_rules = (("volume", "volume:upload_to_image"),) + + 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): + has_image_service_perm = \ + request.user.has_perm('openstack.services.image') + + return volume.status in ("available", "in-use") and \ + has_image_service_perm + + class EditVolume(tables.LinkAction): name = "edit" verbose_name = _("Edit Volume") @@ -364,7 +387,7 @@ class VolumesTable(VolumesTableBase): table_actions = (CreateVolume, DeleteVolume, VolumesFilterAction) row_actions = (EditVolume, ExtendVolume, LaunchVolume, EditAttachments, CreateSnapshot, CreateBackup, RetypeVolume, - DeleteVolume) + UploadToImage, 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 32cb148a77..d4d8a46c3e 100644 --- a/openstack_dashboard/dashboards/project/volumes/volumes/tests.py +++ b/openstack_dashboard/dashboards/project/volumes/volumes/tests.py @@ -1005,6 +1005,47 @@ class VolumeViewTests(test.TestCase): res = self.client.post(url, formData) self.assertRedirectsNoFollow(res, VOLUME_INDEX_URL) + @test.create_stubs({cinder: ('volume_upload_to_image', + 'volume_get')}) + def test_upload_to_image(self): + volume = self.cinder_volumes.get(name='v2_volume') + loaded_resp = {'container_format': 'bare', + 'disk_format': 'raw', + 'id': '741fe2ac-aa2f-4cec-82a9-4994896b43fb', + 'image_id': '2faa080b-dd56-4bf0-8f0a-0d4627d8f306', + 'image_name': 'test', + 'size': '2', + 'status': 'uploading'} + + form_data = {'id': volume.id, + 'name': volume.name, + 'image_name': 'testimage', + 'force': True, + 'container_format': 'bare', + 'disk_format': 'raw'} + + cinder.volume_get(IsA(http.HttpRequest), volume.id).AndReturn(volume) + + cinder.volume_upload_to_image( + IsA(http.HttpRequest), + form_data['id'], + form_data['force'], + form_data['image_name'], + form_data['container_format'], + form_data['disk_format']).AndReturn(loaded_resp) + + self.mox.ReplayAll() + + url = reverse('horizon:project:volumes:volumes:upload_to_image', + args=[volume.id]) + res = self.client.post(url, form_data) + + self.assertNoFormErrors(res) + self.assertMessageCount(info=1) + + redirect_url = VOLUME_INDEX_URL + self.assertRedirectsNoFollow(res, redirect_url) + @test.create_stubs({cinder: ('volume_get', 'volume_extend')}) def test_extend_volume(self): diff --git a/openstack_dashboard/dashboards/project/volumes/volumes/urls.py b/openstack_dashboard/dashboards/project/volumes/volumes/urls.py index 8c3aed2b7a..01c31ae89e 100644 --- a/openstack_dashboard/dashboards/project/volumes/volumes/urls.py +++ b/openstack_dashboard/dashboards/project/volumes/volumes/urls.py @@ -40,6 +40,9 @@ urlpatterns = patterns(VIEWS_MOD, url(r'^(?P[^/]+)/$', views.DetailView.as_view(), name='detail'), + url(r'^(?P[^/]+)/upload_to_image/$', + views.UploadToImageView.as_view(), + name='upload_to_image'), url(r'^(?P[^/]+)/update/$', views.UpdateView.as_view(), name='update'), diff --git a/openstack_dashboard/dashboards/project/volumes/volumes/views.py b/openstack_dashboard/dashboards/project/volumes/volumes/views.py index a3c02751ee..f068e9e0f2 100644 --- a/openstack_dashboard/dashboards/project/volumes/volumes/views.py +++ b/openstack_dashboard/dashboards/project/volumes/volumes/views.py @@ -148,6 +148,40 @@ class CreateSnapshotView(forms.ModalFormView): return {'volume_id': self.kwargs["volume_id"]} +class UploadToImageView(forms.ModalFormView): + form_class = project_forms.UploadToImageForm + template_name = 'project/volumes/volumes/upload_to_image.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(UploadToImageView, 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, + 'status': volume.status} + + class UpdateView(forms.ModalFormView): form_class = project_forms.UpdateForm template_name = 'project/volumes/volumes/update.html'