From a63cfc102b13c7376ad5445b218333643cbda258 Mon Sep 17 00:00:00 2001 From: Sahid Orentino Ferdjaoui Date: Fri, 15 Nov 2013 16:09:42 +0000 Subject: [PATCH] Checks min requirements before trying to launch an instance Server side version. An image can have properties min_ram/min_disk that prevents to launch instance with a flavor doesn't have enough resources. Notes: Adds in create_instance.py a solution to check requirements Adds in glance_data.py ram/disk properties for and image (default:0) Fix in tests.py, some ut needed to be updated Adds in tests.py two new tests to check min_disk and min_ram Change-Id: Ia4a9e4ed729321f70de93296e3257898a52c8e32 Implements: blueprint image-flavor-minimun-requirements --- .../dashboards/project/instances/tests.py | 111 ++++++++++++++++++ .../instances/workflows/create_instance.py | 78 +++++++++--- .../dashboards/project/volumes/tests.py | 1 + .../test/test_data/glance_data.py | 24 ++-- 4 files changed, 191 insertions(+), 23 deletions(-) diff --git a/openstack_dashboard/dashboards/project/instances/tests.py b/openstack_dashboard/dashboards/project/instances/tests.py index dd2bc86482..cfc8ade892 100644 --- a/openstack_dashboard/dashboards/project/instances/tests.py +++ b/openstack_dashboard/dashboards/project/instances/tests.py @@ -1190,6 +1190,8 @@ class InstanceTests(test.TestCase): admin_pass=u'') quotas.tenant_quota_usages(IsA(http.HttpRequest)) \ .AndReturn(quota_usages) + api.nova.flavor_list(IsA(http.HttpRequest)) \ + .AndReturn(self.flavors.list()) self.mox.ReplayAll() @@ -1661,6 +1663,8 @@ class InstanceTests(test.TestCase): .AndRaise(self.exceptions.keystone) quotas.tenant_quota_usages(IsA(http.HttpRequest)) \ .AndReturn(quota_usages) + api.nova.flavor_list(IsA(http.HttpRequest)) \ + .AndReturn(self.flavors.list()) self.mox.ReplayAll() @@ -1754,6 +1758,8 @@ class InstanceTests(test.TestCase): .AndReturn(self.limits['absolute']) quotas.tenant_quota_usages(IsA(http.HttpRequest)) \ .AndReturn(quota_usages) + api.nova.flavor_list(IsA(http.HttpRequest)) \ + .AndReturn(self.flavors.list()) self.mox.ReplayAll() @@ -1776,6 +1782,111 @@ class InstanceTests(test.TestCase): self.assertContains(res, "greater than or equal to 1") + @test.create_stubs({api.glance: ('image_list_detailed',), + api.neutron: ('network_list', + 'profile_list',), + api.nova: ('extension_supported', + 'flavor_list', + 'keypair_list', + 'tenant_absolute_limits', + 'availability_zone_list',), + api.network: ('security_group_list',), + cinder: ('volume_list', + 'volume_snapshot_list',), + quotas: ('tenant_quota_usages',)}) + def _test_launch_form_instance_requirement_error(self, image, flavor): + keypair = self.keypairs.first() + server = self.servers.first() + volume = self.volumes.first() + sec_group = self.security_groups.first() + avail_zone = self.availability_zones.first() + customization_script = 'user data' + device_name = u'vda' + volume_choice = "%s:vol" % volume.id + quota_usages = self.quota_usages.first() + + api.nova.extension_supported('BlockDeviceMappingV2Boot', + IsA(http.HttpRequest)) \ + .AndReturn(True) + api.nova.flavor_list(IsA(http.HttpRequest)) \ + .AndReturn(self.flavors.list()) + api.nova.keypair_list(IsA(http.HttpRequest)) \ + .AndReturn(self.keypairs.list()) + api.network.security_group_list(IsA(http.HttpRequest)) \ + .AndReturn(self.security_groups.list()) + api.nova.availability_zone_list(IsA(http.HttpRequest)) \ + .AndReturn(self.availability_zones.list()) + api.glance.image_list_detailed(IsA(http.HttpRequest), + filters={'is_public': True, + 'status': 'active'}) \ + .AndReturn([self.images.list(), False]) + api.glance.image_list_detailed(IsA(http.HttpRequest), + filters={'property-owner_id': self.tenant.id, + 'status': 'active'}) \ + .AndReturn([[], False]) + api.neutron.network_list(IsA(http.HttpRequest), + tenant_id=self.tenant.id, + shared=False) \ + .AndReturn(self.networks.list()[:1]) + api.neutron.network_list(IsA(http.HttpRequest), + shared=True) \ + .AndReturn(self.networks.list()[1:]) + # TODO(absubram): Remove if clause and create separate + # test stubs for when profile_support is being used. + # Additionally ensure those are always run even in default setting + if api.neutron.is_port_profiles_supported(): + policy_profiles = self.policy_profiles.list() + api.neutron.profile_list(IsA(http.HttpRequest), + 'policy').AndReturn(policy_profiles) + cinder.volume_list(IsA(http.HttpRequest)) \ + .AndReturn(self.volumes.list()) + cinder.volume_snapshot_list(IsA(http.HttpRequest)).AndReturn([]) + + api.nova.flavor_list(IsA(http.HttpRequest)) \ + .AndReturn(self.flavors.list()) + api.nova.tenant_absolute_limits(IsA(http.HttpRequest)) \ + .AndReturn(self.limits['absolute']) + quotas.tenant_quota_usages(IsA(http.HttpRequest)) \ + .AndReturn(quota_usages) + api.nova.flavor_list(IsA(http.HttpRequest)) \ + .AndReturn(self.flavors.list()) + + self.mox.ReplayAll() + + form_data = {'flavor': flavor.id, + 'source_type': 'image_id', + 'image_id': image.id, + 'availability_zone': avail_zone.zoneName, + 'keypair': keypair.name, + 'name': server.name, + 'customization_script': customization_script, + 'project_id': self.tenants.first().id, + 'user_id': self.user.id, + 'groups': sec_group.name, + 'volume_type': 'volume_id', + 'volume_id': volume_choice, + 'device_name': device_name, + 'count': 1} + + url = reverse('horizon:project:instances:launch') + res = self.client.post(url, form_data) + msg = "The flavor '%s' is too small" % flavor.name + self.assertContains(res, msg) + + def test_launch_form_instance_requirement_error_disk(self): + flavor = self.flavors.first() + image = self.images.first() + image.min_ram = flavor.ram + image.min_disk = flavor.disk + 1 + self._test_launch_form_instance_requirement_error(image, flavor) + + def test_launch_form_instance_requirement_error_ram(self): + flavor = self.flavors.first() + image = self.images.first() + image.min_ram = flavor.ram + 1 + image.min_disk = flavor.disk + self._test_launch_form_instance_requirement_error(image, flavor) + @test.create_stubs({api.nova: ('flavor_list', 'server_list', 'tenant_absolute_limits', 'extension_supported',), diff --git a/openstack_dashboard/dashboards/project/instances/workflows/create_instance.py b/openstack_dashboard/dashboards/project/instances/workflows/create_instance.py index 2d332857c7..3adf3e915f 100644 --- a/openstack_dashboard/dashboards/project/instances/workflows/create_instance.py +++ b/openstack_dashboard/dashboards/project/instances/workflows/create_instance.py @@ -46,6 +46,16 @@ from openstack_dashboard.dashboards.project.images_and_snapshots import utils LOG = logging.getLogger(__name__) +def _flavor_list(request): + """Utility method to retrieve a list of flavor.""" + try: + return api.nova.flavor_list(request) + except Exception: + exceptions.handle(request, + _('Unable to retrieve instance flavors.')) + return [] + + class SelectProjectUserAction(workflows.Action): project_id = forms.ChoiceField(label=_("Project")) user_id = forms.ChoiceField(label=_("User")) @@ -132,6 +142,8 @@ class SetInstanceDetailsAction(workflows.Action): def __init__(self, request, context, *args, **kwargs): self._init_images_cache() + self.request = request + self.context = context super(SetInstanceDetailsAction, self).__init__( request, context, *args, **kwargs) source_type_choices = [ @@ -183,6 +195,48 @@ class SetInstanceDetailsAction(workflows.Action): if not cleaned_data.get('image_id'): msg = _("You must select an image.") self._errors['image_id'] = self.error_class([msg]) + else: + # Prevents trying to launch an image needing more resources. + try: + image_id = cleaned_data.get('image_id') + # We want to retrieve details for a given image, + # however get_available_images uses a cache of image list, + # so it is used instead of image_get to reduce the number + # of API calls. + images = utils.get_available_images( + self.request, + self.context.get('project_id'), + self._images_cache) + image = [x for x in images if x.id == image_id][0] + except IndexError: + image = None + + try: + flavor_id = cleaned_data.get('flavor') + # We want to retrieve details for a given flavor, + # however flavor_list uses a memoized decorator + # so it is used instead of flavor_get to reduce the number + # of API calls. + flavors = _flavor_list(self.request) + flavor = [x for x in flavors if x.id == flavor_id][0] + except IndexError: + flavor = None + + if image and flavor: + props_mapping = (("min_ram", "ram"), ("min_disk", "disk")) + for iprop, fprop in props_mapping: + if getattr(image, iprop) > 0 and \ + getattr(image, iprop) > getattr(flavor, fprop): + msg = _("The flavor '%(flavor)s' is too small for " + "requested image.\n" + "Minimum requirements: " + "%(min_ram)s MB of RAM and " + "%(min_disk)s GB of Root Disk." % + {'flavor': flavor.name, + 'min_ram': image.min_ram, + 'min_disk': image.min_disk}) + self._errors['image_id'] = self.error_class([msg]) + break # Not necessary to continue the tests. elif source_type == 'instance_snapshot_id': if not cleaned_data['instance_snapshot_id']: @@ -236,24 +290,18 @@ class SetInstanceDetailsAction(workflows.Action): '"ram" instead.', sort_key) return getattr(flavor, 'ram') - try: - flavors = api.nova.flavor_list(request) + flavors = _flavor_list(request) + if flavors: flavor_sort = getattr(settings, 'CREATE_INSTANCE_FLAVOR_SORT', {}) rev = flavor_sort.get('reverse', False) sort_key = flavor_sort.get('key', 'ram') - if not callable(sort_key): key = lambda flavor: get_key(flavor, sort_key) else: key = sort_key - - flavor_list = [(flavor.id, "%s" % flavor.name) - for flavor in sorted(flavors, key=key, reverse=rev)] - except Exception: - flavor_list = [] - exceptions.handle(request, - _('Unable to retrieve instance flavors.')) - return flavor_list + return [(flavor.id, "%s" % flavor.name) + for flavor in sorted(flavors, key=key, reverse=rev)] + return [] def populate_availability_zone_choices(self, request, context): try: @@ -277,12 +325,11 @@ class SetInstanceDetailsAction(workflows.Action): try: extra['usages'] = api.nova.tenant_absolute_limits(self.request) extra['usages_json'] = json.dumps(extra['usages']) - flavors = json.dumps([f._info for f in - api.nova.flavor_list(self.request)]) + flavors = json.dumps([f._info for f in _flavor_list(self.request)]) extra['flavors'] = flavors images = utils.get_available_images(self.request, - self.initial['project_id'], - self._images_cache) + self.initial['project_id'], + self._images_cache) if images is not None: attrs = [{'id': i.id, 'min_disk': getattr(i, 'min_disk', 0), @@ -664,6 +711,7 @@ class LaunchInstance(workflows.Workflow): msg = (_('Port not created for profile-id (%s).') % context['profile_id']) exceptions.handle(request, msg) + if port and port.id: nics = [{"port-id": port.id}] diff --git a/openstack_dashboard/dashboards/project/volumes/tests.py b/openstack_dashboard/dashboards/project/volumes/tests.py index efd3559a97..723f8e483d 100644 --- a/openstack_dashboard/dashboards/project/volumes/tests.py +++ b/openstack_dashboard/dashboards/project/volumes/tests.py @@ -481,6 +481,7 @@ class VolumeViewTests(test.TestCase): 'volumesUsed': len(self.volumes.list()), 'maxTotalVolumes': 6} image = self.images.get(name="protected_images") + image.min_disk = 30 formData = {'name': u'A Volume I Am Making', 'description': u'This is a volume I am making for a test.', 'method': u'CreateForm', diff --git a/openstack_dashboard/test/test_data/glance_data.py b/openstack_dashboard/test/test_data/glance_data.py index 8148e17689..83f8f17aad 100644 --- a/openstack_dashboard/test/test_data/glance_data.py +++ b/openstack_dashboard/test/test_data/glance_data.py @@ -63,7 +63,8 @@ def data(TEST): 'container_format': 'novaImage', 'properties': {'image_type': u'image'}, 'is_public': True, - 'protected': False} + 'protected': False, + 'min_ram': 0} public_image = images.Image(images.ImageManager(None), image_dict) image_dict = {'id': 'a001c047-22f8-47d0-80a1-8ec94a9524fe', @@ -74,7 +75,8 @@ def data(TEST): 'owner': TEST.tenant.id, 'container_format': 'aki', 'is_public': False, - 'protected': False} + 'protected': False, + 'min_ram': 0} private_image = images.Image(images.ImageManager(None), image_dict) image_dict = {'id': 'd6936c86-7fec-474a-85c5-5e467b371c3c', @@ -86,7 +88,8 @@ def data(TEST): 'container_format': 'novaImage', 'properties': {'image_type': u'image'}, 'is_public': True, - 'protected': True} + 'protected': True, + 'min_ram': 0} protected_image = images.Image(images.ImageManager(None), image_dict) image_dict = {'id': '278905a6-4b52-4d1e-98f9-8c57bb25ba32', @@ -98,7 +101,8 @@ def data(TEST): 'container_format': 'novaImage', 'properties': {'image_type': u'image'}, 'is_public': True, - 'protected': False} + 'protected': False, + 'min_ram': 0} public_image2 = images.Image(images.ImageManager(None), image_dict) image_dict = {'id': '710a1acf-a3e3-41dd-a32d-5d6b6c86ea10', @@ -109,7 +113,8 @@ def data(TEST): 'owner': TEST.tenant.id, 'container_format': 'aki', 'is_public': False, - 'protected': False} + 'protected': False, + 'min_ram': 0} private_image2 = images.Image(images.ImageManager(None), image_dict) image_dict = {'id': '7cd892fd-5652-40f3-a450-547615680132', @@ -120,7 +125,8 @@ def data(TEST): 'owner': TEST.tenant.id, 'container_format': 'aki', 'is_public': False, - 'protected': False} + 'protected': False, + 'min_ram': 0} private_image3 = images.Image(images.ImageManager(None), image_dict) # A shared image. Not public and not local tenant. @@ -132,7 +138,8 @@ def data(TEST): 'owner': 'someothertenant', 'container_format': 'aki', 'is_public': False, - 'protected': False} + 'protected': False, + 'min_ram': 0} shared_image1 = images.Image(images.ImageManager(None), image_dict) # "Official" image. Public and tenant matches an entry @@ -145,7 +152,8 @@ def data(TEST): 'owner': 'officialtenant', 'container_format': 'aki', 'is_public': True, - 'protected': False} + 'protected': False, + 'min_ram': 0} official_image1 = images.Image(images.ImageManager(None), image_dict) image_dict = {'id': 'a67e7d45-fe1e-4c5c-bf08-44b4a4964822',