From 7f46e5dc2378a4c59a62be2c758a780ff610fca9 Mon Sep 17 00:00:00 2001 From: Bartosz Fic Date: Fri, 5 Sep 2014 10:58:41 +0200 Subject: [PATCH] Enable volumes metadata update Cinder currently expose an api to let the users update the volumes metadata so horizon should expose this functionality. The metadata is filtered to remove image metadata attributes. There is work in progress (in Cinder) that will expose the ability to write-through image metadata to underlying images where appropriate. Allowing setting of image properties in this UI would be confusing. Change-Id: If721ac1c908df7651d630f6e7d36f2cc4d69f5da Implements: blueprint ability-to-add-metadata-to-cinder-volumes-and-snapshots Co-Authored-By: Santiago Baldassin Co-Authored-By: Pawel Skowron Co-Authored-By: Bartosz Fic Co-Authored-By: Pawel Koniszewski Co-Authored-By: Michal Dulko Co-Authored-By: David Lyle Co-Authored-By: Paul Karikh --- openstack_dashboard/api/cinder.py | 20 +++- openstack_dashboard/api/rest/cinder.py | 96 +++++++++++++++++++ .../admin/volumes/snapshots/tables.py | 3 +- .../admin/volumes/snapshots/tests.py | 6 +- .../admin/volumes/volume_types/tables.py | 20 +++- .../admin/volumes/volumes/tables.py | 3 +- .../project/volumes/snapshots/tables.py | 21 +++- .../project/volumes/volumes/tables.py | 19 +++- .../app/core/metadata/metadata.service.js | 20 +++- .../core/metadata/metadata.service.spec.js | 26 +++++ .../static/app/core/metadata/modal/modal.html | 3 + .../openstack-service-api/cinder.service.js | 65 ++++++++++++- .../cinder.service.spec.js | 57 +++++++++++ .../test/api_tests/cinder_rest_tests.py | 84 ++++++++++++++++ .../test/api_tests/glance_tests.py | 6 +- .../test/test_data/glance_data.py | 5 +- 16 files changed, 434 insertions(+), 20 deletions(-) diff --git a/openstack_dashboard/api/cinder.py b/openstack_dashboard/api/cinder.py index cfd17386d8..c011b9ed4a 100644 --- a/openstack_dashboard/api/cinder.py +++ b/openstack_dashboard/api/cinder.py @@ -355,6 +355,14 @@ def volume_update(request, volume_id, name, description): **vol_data) +def volume_set_metadata(request, volume_id, metadata): + return cinderclient(request).volumes.set_metadata(volume_id, metadata) + + +def volume_delete_metadata(request, volume_id, keys): + return cinderclient(request).volumes.delete_metadata(volume_id, keys) + + def volume_reset_state(request, volume_id, state): return cinderclient(request).volumes.reset_state(volume_id, state) @@ -445,6 +453,16 @@ def volume_snapshot_update(request, snapshot_id, name, description): **snapshot_data) +def volume_snapshot_set_metadata(request, snapshot_id, metadata): + return cinderclient(request).volume_snapshots.set_metadata( + snapshot_id, metadata) + + +def volume_snapshot_delete_metadata(request, snapshot_id, keys): + return cinderclient(request).volume_snapshots.delete_metadata( + snapshot_id, keys) + + def volume_snapshot_reset_state(request, snapshot_id, state): return cinderclient(request).volume_snapshots.reset_state( snapshot_id, state) @@ -792,7 +810,7 @@ def volume_type_extra_set(request, type_id, metadata): def volume_type_extra_delete(request, type_id, keys): vol_type = volume_type_get(request, type_id) - return vol_type.unset_keys([keys]) + return vol_type.unset_keys(keys) def qos_spec_list(request): diff --git a/openstack_dashboard/api/rest/cinder.py b/openstack_dashboard/api/rest/cinder.py index f626807a43..fd1e6a759f 100644 --- a/openstack_dashboard/api/rest/cinder.py +++ b/openstack_dashboard/api/rest/cinder.py @@ -132,6 +132,34 @@ class VolumeTypes(generic.View): return {'items': [api.cinder.VolumeType(u).to_dict() for u in result]} +@urls.register +class VolumeMetadata(generic.View): + """API for volume metadata""" + url_regex = r'cinder/volumes/(?P[^/]+)/metadata$' + + @rest_utils.ajax() + def get(self, request, volume_id): + """Get a specific volume's metadata + + http://localhost/api/cinder/volumes/1/metadata + """ + return api.cinder.volume_get(request, + volume_id).to_dict().get('metadata') + + @rest_utils.ajax() + def patch(self, request, volume_id): + """Update metadata items for specific volume + + http://localhost/api/cinder/volumes/1/metadata + """ + updated = request.DATA['updated'] + removed = request.DATA['removed'] + if updated: + api.cinder.volume_set_metadata(request, volume_id, updated) + if removed: + api.cinder.volume_delete_metadata(request, volume_id, removed) + + @urls.register class VolumeType(generic.View): """API for getting a volume type. @@ -179,6 +207,74 @@ class VolumeSnapshots(generic.View): return {'items': [u.to_dict() for u in result]} +@urls.register +class VolumeSnapshotMetadata(generic.View): + """API for getting snapshots metadata""" + url_regex = r'cinder/volumesnapshots/' \ + r'(?P[^/]+)/metadata$' + + @rest_utils.ajax() + def get(self, request, volume_snapshot_id): + """Get a specific volumes snapshot metadata + + http://localhost/api/cinder/volumesnapshots/1/metadata + """ + result = api.cinder.volume_snapshot_get(request, + volume_snapshot_id).\ + to_dict().get('metadata') + return result + + @rest_utils.ajax() + def patch(self, request, volume_snapshot_id): + """Update metadata for specific volume snapshot + + http://localhost/api/cinder/volumesnapshots/1/metadata + """ + updated = request.DATA['updated'] + removed = request.DATA['removed'] + if updated: + api.cinder.volume_snapshot_set_metadata(request, + volume_snapshot_id, + updated) + if removed: + api.cinder.volume_snapshot_delete_metadata(request, + volume_snapshot_id, + removed) + + +@urls.register +class VolumeTypeMetadata(generic.View): + """API for getting snapshots metadata""" + url_regex = r'cinder/volumetypes/(?P[^/]+)/metadata$' + + @rest_utils.ajax() + def get(self, request, volume_type_id): + """Get a specific volume's metadata + + http://localhost/api/cinder/volumetypes/1/metadata + """ + metadata = api.cinder.volume_type_extra_get(request, volume_type_id) + result = {x.key: x.value for x in metadata} + return result + + @rest_utils.ajax() + def patch(self, request, volume_type_id): + """Update metadata for specific volume + + http://localhost/api/cinder/volumetypes/1/metadata + """ + updated = request.DATA['updated'] + removed = request.DATA['removed'] + if updated: + api.cinder.volume_type_extra_set(request, + volume_type_id, + updated) + if removed: + api.cinder.volume_type_extra_delete(request, + volume_type_id, + removed) + + @urls.register class Extensions(generic.View): """API for cinder extensions. diff --git a/openstack_dashboard/dashboards/admin/volumes/snapshots/tables.py b/openstack_dashboard/dashboards/admin/volumes/snapshots/tables.py index adebae281b..1a5c5847ba 100644 --- a/openstack_dashboard/dashboards/admin/volumes/snapshots/tables.py +++ b/openstack_dashboard/dashboards/admin/volumes/snapshots/tables.py @@ -72,7 +72,8 @@ class VolumeSnapshotsTable(volumes_tables.VolumesTableBase): table_actions = (snapshots_tables.VolumeSnapshotsFilterAction, snapshots_tables.DeleteVolumeSnapshot,) row_actions = (snapshots_tables.DeleteVolumeSnapshot, - UpdateVolumeSnapshotStatus,) + UpdateVolumeSnapshotStatus, + snapshots_tables.UpdateMetadata) row_class = UpdateRow status_columns = ("status",) columns = ('tenant', 'host', 'name', 'description', 'size', 'status', diff --git a/openstack_dashboard/dashboards/admin/volumes/snapshots/tests.py b/openstack_dashboard/dashboards/admin/volumes/snapshots/tests.py index 89b9c5b9c2..1b7bafdc8c 100644 --- a/openstack_dashboard/dashboards/admin/volumes/snapshots/tests.py +++ b/openstack_dashboard/dashboards/admin/volumes/snapshots/tests.py @@ -19,7 +19,7 @@ from openstack_dashboard.test import helpers as test from openstack_dashboard.dashboards.admin.volumes.snapshots import forms -INDEX_URL = reverse('horizon:admin:volumes:index') +INDEX_URL = 'horizon:admin:volumes:index' class VolumeSnapshotsViewTests(test.BaseAdminViewTests): @@ -79,7 +79,7 @@ class VolumeSnapshotsViewTests(test.BaseAdminViewTests): self.assertNoFormErrors(res) self.assertMessageCount(error=1) - self.assertRedirectsNoFollow(res, INDEX_URL) + self.assertRedirectsNoFollow(res, reverse(INDEX_URL)) @test.create_stubs({cinder: ('volume_snapshot_get', 'volume_get')}) @@ -101,7 +101,7 @@ class VolumeSnapshotsViewTests(test.BaseAdminViewTests): self.assertNoFormErrors(res) self.assertMessageCount(error=1) - self.assertRedirectsNoFollow(res, INDEX_URL) + self.assertRedirectsNoFollow(res, reverse(INDEX_URL)) def test_get_snapshot_status_choices_without_current(self): current_status = {'status': 'available'} diff --git a/openstack_dashboard/dashboards/admin/volumes/volume_types/tables.py b/openstack_dashboard/dashboards/admin/volumes/volume_types/tables.py index 9a67a1ba41..20f3529d16 100644 --- a/openstack_dashboard/dashboards/admin/volumes/volume_types/tables.py +++ b/openstack_dashboard/dashboards/admin/volumes/volume_types/tables.py @@ -195,6 +195,23 @@ class UpdateRow(tables.Row): return volume_type +class UpdateMetadata(tables.LinkAction): + name = "update_metadata" + verbose_name = _("Update Metadata") + ajax = False + attrs = {"ng-controller": "MetadataModalHelperController as modal"} + + def __init__(self, **kwargs): + kwargs['preempt'] = True + super(UpdateMetadata, self).__init__(**kwargs) + + def get_link_url(self, datum): + obj_id = self.table.get_object_id(datum) + self.attrs['ng-click'] = ( + "modal.openMetadataModal('volume_type', '%s', true)" % obj_id) + return "javascript:void(0);" + + class VolumeTypesTable(tables.DataTable): name = tables.WrappingColumn("name", verbose_name=_("Name"), form_field=forms.CharField(max_length=64)) @@ -233,7 +250,8 @@ class VolumeTypesTable(tables.DataTable): EditVolumeType, UpdateVolumeTypeEncryption, DeleteVolumeTypeEncryption, - DeleteVolumeType,) + DeleteVolumeType, + UpdateMetadata) row_class = UpdateRow diff --git a/openstack_dashboard/dashboards/admin/volumes/volumes/tables.py b/openstack_dashboard/dashboards/admin/volumes/volumes/tables.py index b28f7d750b..36db46f58c 100644 --- a/openstack_dashboard/dashboards/admin/volumes/volumes/tables.py +++ b/openstack_dashboard/dashboards/admin/volumes/volumes/tables.py @@ -111,6 +111,7 @@ class VolumesTable(volumes_tables.VolumesTable): row_actions = (volumes_tables.DeleteVolume, UpdateVolumeStatusAction, UnmanageVolumeAction, - MigrateVolume) + MigrateVolume, + volumes_tables.UpdateMetadata) columns = ('tenant', 'host', 'name', 'size', 'status', 'volume_type', 'attachments', 'bootable', 'encryption',) diff --git a/openstack_dashboard/dashboards/project/volumes/snapshots/tables.py b/openstack_dashboard/dashboards/project/volumes/snapshots/tables.py index ebb8dfab5d..ca565c5f2b 100644 --- a/openstack_dashboard/dashboards/project/volumes/snapshots/tables.py +++ b/openstack_dashboard/dashboards/project/volumes/snapshots/tables.py @@ -132,6 +132,24 @@ class CreateVolumeFromSnapshot(tables.LinkAction): return False +class UpdateMetadata(tables.LinkAction): + name = "update_metadata" + verbose_name = _("Update Metadata") + + ajax = False + attrs = {"ng-controller": "MetadataModalHelperController as modal"} + + def __init__(self, **kwargs): + kwargs['preempt'] = True + super(UpdateMetadata, self).__init__(**kwargs) + + def get_link_url(self, datum): + obj_id = self.table.get_object_id(datum) + self.attrs['ng-click'] = ( + "modal.openMetadataModal('volume_snapshot', '%s', true)" % obj_id) + return "javascript:void(0);" + + class UpdateRow(tables.Row): ajax = True @@ -191,7 +209,8 @@ class VolumeSnapshotsTable(volume_tables.VolumesTableBase): launch_actions = (LaunchSnapshotNG,) + launch_actions row_actions = ((CreateVolumeFromSnapshot,) + launch_actions + - (EditVolumeSnapshot, DeleteVolumeSnapshot)) + (EditVolumeSnapshot, DeleteVolumeSnapshot, + UpdateMetadata)) row_class = UpdateRow status_columns = ("status",) permissions = [( diff --git a/openstack_dashboard/dashboards/project/volumes/volumes/tables.py b/openstack_dashboard/dashboards/project/volumes/volumes/tables.py index 09e1d151d9..b02366c3bf 100644 --- a/openstack_dashboard/dashboards/project/volumes/volumes/tables.py +++ b/openstack_dashboard/dashboards/project/volumes/volumes/tables.py @@ -469,6 +469,23 @@ class VolumesFilterAction(tables.FilterAction): if q in volume.name.lower()] +class UpdateMetadata(tables.LinkAction): + name = "update_metadata" + verbose_name = _("Update Metadata") + ajax = False + attrs = {"ng-controller": "MetadataModalHelperController as modal"} + + def __init__(self, **kwargs): + kwargs['preempt'] = True + super(UpdateMetadata, self).__init__(**kwargs) + + def get_link_url(self, datum): + obj_id = self.table.get_object_id(datum) + self.attrs['ng-click'] = ( + "modal.openMetadataModal('volume', '%s', true)" % obj_id) + return "javascript:void(0);" + + class VolumesTable(VolumesTableBase): name = tables.WrappingColumn("name", verbose_name=_("Name"), @@ -504,7 +521,7 @@ class VolumesTable(VolumesTableBase): launch_actions + (EditAttachments, CreateSnapshot, CreateBackup, RetypeVolume, UploadToImage, CreateTransfer, - DeleteTransfer, DeleteVolume)) + DeleteTransfer, DeleteVolume, UpdateMetadata)) class DetachVolume(tables.BatchAction): diff --git a/openstack_dashboard/static/app/core/metadata/metadata.service.js b/openstack_dashboard/static/app/core/metadata/metadata.service.js index 1ba833b704..40d250d071 100644 --- a/openstack_dashboard/static/app/core/metadata/metadata.service.js +++ b/openstack_dashboard/static/app/core/metadata/metadata.service.js @@ -22,7 +22,8 @@ metadataService.$inject = [ 'horizon.app.core.openstack-service-api.nova', - 'horizon.app.core.openstack-service-api.glance' + 'horizon.app.core.openstack-service-api.glance', + 'horizon.app.core.openstack-service-api.cinder' ]; /** @@ -32,7 +33,7 @@ * * Unified acquisition and modification of metadata. */ - function metadataService(nova, glance) { + function metadataService(nova, glance, cinder) { var service = { getMetadata: getMetadata, editMetadata: editMetadata, @@ -52,7 +53,10 @@ aggregate: nova.getAggregateExtraSpecs, flavor: nova.getFlavorExtraSpecs, image: glance.getImageProps, - instance: nova.getInstanceMetadata + instance: nova.getInstanceMetadata, + volume: cinder.getVolumeMetadata, + volume_snapshot: cinder.getVolumeSnapshotMetadata, + volume_type: cinder.getVolumeTypeMetadata }[resource](id); } @@ -69,7 +73,10 @@ aggregate: nova.editAggregateExtraSpecs, flavor: nova.editFlavorExtraSpecs, image: glance.editImageProps, - instance: nova.editInstanceMetadata + instance: nova.editInstanceMetadata, + volume: cinder.editVolumeMetadata, + volume_snapshot: cinder.editVolumeSnapshotMetadata, + volume_type: cinder.editVolumeTypeMetadata }[resource](id, updated, removed); } @@ -86,7 +93,10 @@ aggregate: 'OS::Nova::Aggregate', flavor: 'OS::Nova::Flavor', image: 'OS::Glance::Image', - instance: 'OS::Nova::Server' + instance: 'OS::Nova::Server', + volume: 'OS::Cinder::Volume', + volume_snapshot: 'OS::Cinder::Snapshot', + volume_type: 'OS:Cinder::VolumeType' }[resource] }; if (propertiesTarget) { diff --git a/openstack_dashboard/static/app/core/metadata/metadata.service.spec.js b/openstack_dashboard/static/app/core/metadata/metadata.service.spec.js index ea33882af1..f65bd6ea25 100644 --- a/openstack_dashboard/static/app/core/metadata/metadata.service.spec.js +++ b/openstack_dashboard/static/app/core/metadata/metadata.service.spec.js @@ -31,10 +31,17 @@ editImageProps: function() {}, getNamespaces: function() {}}; + var cinder = {getVolumeMetadata:function() {}, + getVolumeSnapshotMetadata:function() {}, + getVolumeTypeMetadata:function() {}, + editVolumeMetadata: function() {}, + editVolumeSnapshotMetadata: function() {}}; + beforeEach(function() { module(function($provide) { $provide.value('horizon.app.core.openstack-service-api.nova', nova); $provide.value('horizon.app.core.openstack-service-api.glance', glance); + $provide.value('horizon.app.core.openstack-service-api.cinder', cinder); }); }); @@ -97,6 +104,18 @@ expect(glance.editImageProps).toHaveBeenCalledWith('1', 'updated', ['removed']); }); + it('should edit volume metadata', function() { + spyOn(cinder, 'editVolumeMetadata'); + metadataService.editMetadata('volume', '1', 'updated', ['removed']); + expect(cinder.editVolumeMetadata).toHaveBeenCalledWith('1', 'updated', ['removed']); + }); + + it('should edit volume snapshot metadata', function() { + spyOn(cinder, 'editVolumeSnapshotMetadata'); + metadataService.editMetadata('volume_snapshot', '1', 'updated', ['removed']); + expect(cinder.editVolumeSnapshotMetadata).toHaveBeenCalledWith('1', 'updated', ['removed']); + }); + it('should get image namespace', function() { spyOn(glance, 'getNamespaces'); metadataService.getNamespaces('image'); @@ -111,6 +130,13 @@ expect(actual).toBe(expected); }); + it('should get volume metadata', function() { + var expected = 'volume metadata'; + spyOn(cinder, 'getVolumeMetadata').and.returnValue(expected); + var actual = metadataService.getMetadata('volume', '1'); + expect(actual).toBe(expected); + }); + it('should edit instance metadata', function() { spyOn(nova, 'editInstanceMetadata'); metadataService.editMetadata('instance', '1', 'updated', ['removed']); diff --git a/openstack_dashboard/static/app/core/metadata/modal/modal.html b/openstack_dashboard/static/app/core/metadata/modal/modal.html index 8387da7cd9..b6eccf9c98 100644 --- a/openstack_dashboard/static/app/core/metadata/modal/modal.html +++ b/openstack_dashboard/static/app/core/metadata/modal/modal.html @@ -7,6 +7,9 @@ Update Flavor Metadata Update Image Metadata Update Instance Metadata + Update Volume Metadata + Update Volume Snapshot Metadata + Update Volume Type Metadata