From 00f8b11d956cbdee1bbe06a67b79127b6e3e330f Mon Sep 17 00:00:00 2001 From: Jason Derrett Date: Wed, 25 Sep 2013 11:47:10 -0500 Subject: [PATCH] Add Availability Zone to Volume screens User should be able to choose an AZ (or make unspecified) when creating a volume AZ should be visible in Volumes table AZ list should come from Cinder Change-Id: I23314dedc3eb65454df32102b0a96174f8030d81 Closes-Bug: #1230288 --- openstack_dashboard/api/cinder.py | 30 ++++- .../dashboards/project/volumes/forms.py | 37 +++++- .../dashboards/project/volumes/tables.py | 2 + .../dashboards/project/volumes/tests.py | 124 ++++++++++++++---- .../test/test_data/cinder_data.py | 18 +++ 5 files changed, 186 insertions(+), 25 deletions(-) diff --git a/openstack_dashboard/api/cinder.py b/openstack_dashboard/api/cinder.py index 5c0d377b6a..75801c83a0 100644 --- a/openstack_dashboard/api/cinder.py +++ b/openstack_dashboard/api/cinder.py @@ -28,8 +28,10 @@ from django.conf import settings # noqa from django.utils.translation import ugettext_lazy as _ # noqa from cinderclient.v1 import client as cinder_client +from cinderclient.v1.contrib import list_extensions as cinder_list_extensions from horizon import exceptions +from horizon.utils.memoized import memoized # noqa from openstack_dashboard.api import base from openstack_dashboard.api import nova @@ -92,10 +94,12 @@ def volume_get(request, volume_id): def volume_create(request, size, name, description, volume_type, - snapshot_id=None, metadata=None, image_id=None): + snapshot_id=None, metadata=None, image_id=None, + availability_zone=None): return cinderclient(request).volumes.create(size, display_name=name, display_description=description, volume_type=volume_type, - snapshot_id=snapshot_id, metadata=metadata, imageRef=image_id) + snapshot_id=snapshot_id, metadata=metadata, imageRef=image_id, + availability_zone=availability_zone) def volume_delete(request, volume_id): @@ -163,3 +167,25 @@ def tenant_absolute_limits(request): else: limits_dict[limit.name] = limit.value return limits_dict + + +def availability_zone_list(request, detailed=False): + return cinderclient(request).availability_zones.list(detailed=detailed) + + +@memoized +def list_extensions(request): + return cinder_list_extensions.ListExtManager(cinderclient(request))\ + .show_all() + + +@memoized +def extension_supported(request, extension_name): + """ + This method will determine if Cinder supports a given extension name. + """ + extensions = list_extensions(request) + for extension in extensions: + if extension.name == extension_name: + return True + return False diff --git a/openstack_dashboard/dashboards/project/volumes/forms.py b/openstack_dashboard/dashboards/project/volumes/forms.py index 63d476d62d..ee9184b657 100644 --- a/openstack_dashboard/dashboards/project/volumes/forms.py +++ b/openstack_dashboard/dashboards/project/volumes/forms.py @@ -51,6 +51,8 @@ class CreateForm(forms.SelfHandlingForm): data_attrs=('size', 'name'), transform=lambda x: "%s (%s)" % (x.name, filesizeformat(x.bytes))), required=False) + availability_zone = forms.ChoiceField(label=_("Availability Zone"), + required=False) def __init__(self, request, *args, **kwargs): super(CreateForm, self).__init__(request, *args, **kwargs) @@ -58,6 +60,8 @@ class CreateForm(forms.SelfHandlingForm): self.fields['type'].choices = [("", "")] + \ [(type.name, type.name) for type in volume_types] + self.fields['availability_zone'].choices = \ + self.availability_zones(request) if ("snapshot_id" in request.GET): try: @@ -137,6 +141,34 @@ class CreateForm(forms.SelfHandlingForm): else: del self.fields['volume_source_type'] + # 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 handle(self, request, data): try: usages = quotas.tenant_limit_usages(self.request) @@ -188,6 +220,8 @@ class CreateForm(forms.SelfHandlingForm): metadata = {} + az = data['availability_zone'] or None + volume = cinder.volume_create(request, data['size'], data['name'], @@ -195,7 +229,8 @@ class CreateForm(forms.SelfHandlingForm): data['type'], snapshot_id=snapshot_id, image_id=image_id, - metadata=metadata) + metadata=metadata, + availability_zone=az) message = _('Creating volume "%s"') % data['name'] messages.info(request, message) return volume diff --git a/openstack_dashboard/dashboards/project/volumes/tables.py b/openstack_dashboard/dashboards/project/volumes/tables.py index c84bf0086a..e0b49d0f0b 100644 --- a/openstack_dashboard/dashboards/project/volumes/tables.py +++ b/openstack_dashboard/dashboards/project/volumes/tables.py @@ -199,6 +199,8 @@ class VolumesTable(VolumesTableBase): empty_value="-") attachments = AttachmentColumn("attachments", verbose_name=_("Attached To")) + availability_zone = tables.Column("availability_zone", + verbose_name=_("Availability Zone")) class Meta: name = "volumes" diff --git a/openstack_dashboard/dashboards/project/volumes/tests.py b/openstack_dashboard/dashboards/project/volumes/tests.py index 6a6501cab0..648efcb497 100644 --- a/openstack_dashboard/dashboards/project/volumes/tests.py +++ b/openstack_dashboard/dashboards/project/volumes/tests.py @@ -35,12 +35,15 @@ from openstack_dashboard.usage import quotas class VolumeViewTests(test.TestCase): @test.create_stubs({cinder: ('volume_create', 'volume_snapshot_list', - 'volume_type_list',), + 'volume_type_list', + 'availability_zone_list', + 'extension_supported'), api.glance: ('image_list_detailed',), quotas: ('tenant_limit_usages',)}) def test_create_volume(self): volume = self.volumes.first() volume_type = self.volume_types.first() + az = self.cinder_availability_zones.first().zoneName usage_limit = {'maxTotalVolumeGigabytes': 250, 'gigabytesUsed': 20, 'volumesUsed': len(self.volumes.list()), @@ -50,7 +53,8 @@ class VolumeViewTests(test.TestCase): 'method': u'CreateForm', 'type': volume_type.name, 'size': 50, - 'snapshot_source': ''} + 'snapshot_source': '', + 'availability_zone': az} cinder.volume_type_list(IsA(http.HttpRequest)).\ AndReturn(self.volume_types.list()) @@ -66,6 +70,12 @@ class VolumeViewTests(test.TestCase): filters={'property-owner_id': self.tenant.id, 'status': 'active'}) \ .AndReturn([[], False]) + cinder.availability_zone_list(IsA(http.HttpRequest)).AndReturn( + self.cinder_availability_zones.list()) + + cinder.extension_supported(IsA(http.HttpRequest), 'AvailabilityZones')\ + .AndReturn(True) + cinder.volume_create(IsA(http.HttpRequest), formData['size'], formData['name'], @@ -73,7 +83,9 @@ class VolumeViewTests(test.TestCase): formData['type'], metadata={}, snapshot_id=None, - image_id=None).AndReturn(volume) + image_id=None, + availability_zone=formData['availability_zone'])\ + .AndReturn(volume) self.mox.ReplayAll() @@ -85,7 +97,9 @@ class VolumeViewTests(test.TestCase): @test.create_stubs({cinder: ('volume_create', 'volume_snapshot_list', - 'volume_type_list',), + 'volume_type_list', + 'availability_zone_list', + 'extension_supported'), api.glance: ('image_list_detailed',), quotas: ('tenant_limit_usages',)}) def test_create_volume_dropdown(self): @@ -117,6 +131,12 @@ class VolumeViewTests(test.TestCase): .AndReturn([[], False]) quotas.tenant_limit_usages(IsA(http.HttpRequest)).\ AndReturn(usage_limit) + + cinder.extension_supported(IsA(http.HttpRequest), 'AvailabilityZones')\ + .AndReturn(True) + cinder.availability_zone_list(IsA(http.HttpRequest)).AndReturn( + self.cinder_availability_zones.list()) + cinder.volume_create(IsA(http.HttpRequest), formData['size'], formData['name'], @@ -124,8 +144,8 @@ class VolumeViewTests(test.TestCase): '', metadata={}, snapshot_id=None, - image_id=None).\ - AndReturn(volume) + image_id=None, + availability_zone=None).AndReturn(volume) self.mox.ReplayAll() @@ -138,7 +158,9 @@ class VolumeViewTests(test.TestCase): @test.create_stubs({cinder: ('volume_create', 'volume_snapshot_get', 'volume_get', - 'volume_type_list',), + 'volume_type_list', + 'availability_zone_list', + 'extension_supported'), quotas: ('tenant_limit_usages',)}) def test_create_volume_from_snapshot(self): volume = self.volumes.first() @@ -162,6 +184,12 @@ class VolumeViewTests(test.TestCase): str(snapshot.id)).AndReturn(snapshot) cinder.volume_get(IsA(http.HttpRequest), snapshot.volume_id).\ AndReturn(self.volumes.first()) + + cinder.extension_supported(IsA(http.HttpRequest), 'AvailabilityZones')\ + .AndReturn(True) + cinder.availability_zone_list(IsA(http.HttpRequest)).AndReturn( + self.cinder_availability_zones.list()) + cinder.volume_create(IsA(http.HttpRequest), formData['size'], formData['name'], @@ -169,8 +197,8 @@ class VolumeViewTests(test.TestCase): '', metadata={}, snapshot_id=snapshot.id, - image_id=None).\ - AndReturn(volume) + image_id=None, + availability_zone=None).AndReturn(volume) self.mox.ReplayAll() # get snapshot from url @@ -186,7 +214,9 @@ class VolumeViewTests(test.TestCase): 'volume_snapshot_list', 'volume_snapshot_get', 'volume_get', - 'volume_type_list',), + 'volume_type_list', + 'availability_zone_list', + 'extension_supported'), api.glance: ('image_list_detailed',), quotas: ('tenant_limit_usages',)}) def test_create_volume_from_snapshot_dropdown(self): @@ -220,6 +250,12 @@ class VolumeViewTests(test.TestCase): AndReturn(usage_limit) cinder.volume_snapshot_get(IsA(http.HttpRequest), str(snapshot.id)).AndReturn(snapshot) + + cinder.extension_supported(IsA(http.HttpRequest), 'AvailabilityZones')\ + .AndReturn(True) + cinder.availability_zone_list(IsA(http.HttpRequest)).AndReturn( + self.cinder_availability_zones.list()) + cinder.volume_create(IsA(http.HttpRequest), formData['size'], formData['name'], @@ -227,8 +263,8 @@ class VolumeViewTests(test.TestCase): '', metadata={}, snapshot_id=snapshot.id, - image_id=None).\ - AndReturn(volume) + image_id=None, + availability_zone=None).AndReturn(volume) self.mox.ReplayAll() @@ -241,7 +277,9 @@ class VolumeViewTests(test.TestCase): @test.create_stubs({cinder: ('volume_snapshot_get', 'volume_type_list', - 'volume_get',), + 'volume_get', + 'availability_zone_list', + 'extension_supported'), api.glance: ('image_list_detailed',), quotas: ('tenant_limit_usages',)}) def test_create_volume_from_snapshot_invalid_size(self): @@ -263,6 +301,12 @@ class VolumeViewTests(test.TestCase): str(snapshot.id)).AndReturn(snapshot) cinder.volume_get(IsA(http.HttpRequest), snapshot.volume_id).\ AndReturn(self.volumes.first()) + + cinder.extension_supported(IsA(http.HttpRequest), 'AvailabilityZones')\ + .AndReturn(True) + cinder.availability_zone_list(IsA(http.HttpRequest)).AndReturn( + self.cinder_availability_zones.list()) + quotas.tenant_limit_usages(IsA(http.HttpRequest)).\ AndReturn(usage_limit) @@ -278,7 +322,9 @@ class VolumeViewTests(test.TestCase): "snapshot size (40GB)") @test.create_stubs({cinder: ('volume_create', - 'volume_type_list',), + 'volume_type_list', + 'availability_zone_list', + 'extension_supported'), api.glance: ('image_get',), quotas: ('tenant_limit_usages',)}) def test_create_volume_from_image(self): @@ -301,6 +347,12 @@ class VolumeViewTests(test.TestCase): AndReturn(usage_limit) api.glance.image_get(IsA(http.HttpRequest), str(image.id)).AndReturn(image) + + cinder.extension_supported(IsA(http.HttpRequest), 'AvailabilityZones')\ + .AndReturn(True) + cinder.availability_zone_list(IsA(http.HttpRequest)).AndReturn( + self.cinder_availability_zones.list()) + cinder.volume_create(IsA(http.HttpRequest), formData['size'], formData['name'], @@ -308,8 +360,8 @@ class VolumeViewTests(test.TestCase): '', metadata={}, snapshot_id=None, - image_id=image.id).\ - AndReturn(volume) + image_id=image.id, + availability_zone=None).AndReturn(volume) self.mox.ReplayAll() @@ -324,7 +376,9 @@ class VolumeViewTests(test.TestCase): @test.create_stubs({cinder: ('volume_create', 'volume_type_list', - 'volume_snapshot_list',), + 'volume_snapshot_list', + 'availability_zone_list', + 'extension_supported'), api.glance: ('image_get', 'image_list_detailed'), quotas: ('tenant_limit_usages',)}) @@ -360,6 +414,12 @@ class VolumeViewTests(test.TestCase): .AndReturn(usage_limit) api.glance.image_get(IsA(http.HttpRequest), str(image.id)).AndReturn(image) + + cinder.extension_supported(IsA(http.HttpRequest), 'AvailabilityZones')\ + .AndReturn(True) + cinder.availability_zone_list(IsA(http.HttpRequest)).AndReturn( + self.cinder_availability_zones.list()) + cinder.volume_create(IsA(http.HttpRequest), formData['size'], formData['name'], @@ -367,8 +427,8 @@ class VolumeViewTests(test.TestCase): '', metadata={}, snapshot_id=None, - image_id=image.id).\ - AndReturn(volume) + image_id=image.id, + availability_zone=None).AndReturn(volume) self.mox.ReplayAll() @@ -379,7 +439,9 @@ class VolumeViewTests(test.TestCase): redirect_url = reverse('horizon:project:volumes:index') self.assertRedirectsNoFollow(res, redirect_url) - @test.create_stubs({cinder: ('volume_type_list',), + @test.create_stubs({cinder: ('volume_type_list', + 'availability_zone_list', + 'extension_supported'), api.glance: ('image_get', 'image_list_detailed'), quotas: ('tenant_limit_usages',)}) @@ -400,6 +462,10 @@ class VolumeViewTests(test.TestCase): AndReturn(usage_limit) api.glance.image_get(IsA(http.HttpRequest), str(image.id)).AndReturn(image) + cinder.extension_supported(IsA(http.HttpRequest), 'AvailabilityZones')\ + .AndReturn(True) + cinder.availability_zone_list(IsA(http.HttpRequest)).AndReturn( + self.cinder_availability_zones.list()) quotas.tenant_limit_usages(IsA(http.HttpRequest)).\ AndReturn(usage_limit) @@ -414,7 +480,10 @@ class VolumeViewTests(test.TestCase): "The volume size cannot be less than the " "image size (20.0 GB)") - @test.create_stubs({cinder: ('volume_snapshot_list', 'volume_type_list',), + @test.create_stubs({cinder: ('volume_snapshot_list', + 'volume_type_list', + 'availability_zone_list', + 'extension_supported'), api.glance: ('image_list_detailed',), quotas: ('tenant_limit_usages',)}) def test_create_volume_gb_used_over_alloted_quota(self): @@ -441,6 +510,10 @@ class VolumeViewTests(test.TestCase): filters={'property-owner_id': self.tenant.id, 'status': 'active'}) \ .AndReturn([[], False]) + cinder.extension_supported(IsA(http.HttpRequest), 'AvailabilityZones')\ + .AndReturn(True) + cinder.availability_zone_list(IsA(http.HttpRequest)).AndReturn( + self.cinder_availability_zones.list()) quotas.tenant_limit_usages(IsA(http.HttpRequest)).\ AndReturn(usage_limit) @@ -453,7 +526,10 @@ class VolumeViewTests(test.TestCase): ' have 20GB of your quota available.'] self.assertEqual(res.context['form'].errors['__all__'], expected_error) - @test.create_stubs({cinder: ('volume_snapshot_list', 'volume_type_list',), + @test.create_stubs({cinder: ('volume_snapshot_list', + 'volume_type_list', + 'availability_zone_list', + 'extension_supported'), api.glance: ('image_list_detailed',), quotas: ('tenant_limit_usages',)}) def test_create_volume_number_over_alloted_quota(self): @@ -480,6 +556,10 @@ class VolumeViewTests(test.TestCase): filters={'property-owner_id': self.tenant.id, 'status': 'active'}) \ .AndReturn([[], False]) + cinder.extension_supported(IsA(http.HttpRequest), 'AvailabilityZones')\ + .AndReturn(True) + cinder.availability_zone_list(IsA(http.HttpRequest)).AndReturn( + self.cinder_availability_zones.list()) quotas.tenant_limit_usages(IsA(http.HttpRequest)).\ AndReturn(usage_limit) diff --git a/openstack_dashboard/test/test_data/cinder_data.py b/openstack_dashboard/test/test_data/cinder_data.py index a810b7f87f..ae63082c57 100644 --- a/openstack_dashboard/test/test_data/cinder_data.py +++ b/openstack_dashboard/test/test_data/cinder_data.py @@ -12,7 +12,9 @@ # License for the specific language governing permissions and limitations # under the License. +from cinderclient.v1 import availability_zones from cinderclient.v1 import quotas + from openstack_dashboard.api import base from openstack_dashboard.usage import quotas as usage_quotas @@ -22,6 +24,7 @@ from openstack_dashboard.test.test_data import utils def data(TEST): TEST.cinder_quotas = utils.TestDataContainer() TEST.cinder_quota_usages = utils.TestDataContainer() + TEST.cinder_availability_zones = utils.TestDataContainer() # Quota Sets quota_data = dict(volumes='1', @@ -44,3 +47,18 @@ def data(TEST): quota_usage.tally(k, v['used']) TEST.cinder_quota_usages.add(quota_usage) + + # Availability Zones + # Cinder returns the following structure from os-availability-zone + # {"availabilityZoneInfo": + # [{"zoneState": {"available": true}, "zoneName": "nova"}]} + # Note that the default zone is still "nova" even though this is cinder + TEST.cinder_availability_zones.add( + availability_zones.AvailabilityZone( + availability_zones.AvailabilityZoneManager(None), + { + 'zoneName': 'nova', + 'zoneState': {'available': True} + } + ) + )