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
This commit is contained in:
Sahid Orentino Ferdjaoui 2013-11-15 16:09:42 +00:00 committed by Gerrit Code Review
parent c1fbaa8ee5
commit a63cfc102b
4 changed files with 191 additions and 23 deletions

View File

@ -1190,6 +1190,8 @@ class InstanceTests(test.TestCase):
admin_pass=u'') admin_pass=u'')
quotas.tenant_quota_usages(IsA(http.HttpRequest)) \ quotas.tenant_quota_usages(IsA(http.HttpRequest)) \
.AndReturn(quota_usages) .AndReturn(quota_usages)
api.nova.flavor_list(IsA(http.HttpRequest)) \
.AndReturn(self.flavors.list())
self.mox.ReplayAll() self.mox.ReplayAll()
@ -1661,6 +1663,8 @@ class InstanceTests(test.TestCase):
.AndRaise(self.exceptions.keystone) .AndRaise(self.exceptions.keystone)
quotas.tenant_quota_usages(IsA(http.HttpRequest)) \ quotas.tenant_quota_usages(IsA(http.HttpRequest)) \
.AndReturn(quota_usages) .AndReturn(quota_usages)
api.nova.flavor_list(IsA(http.HttpRequest)) \
.AndReturn(self.flavors.list())
self.mox.ReplayAll() self.mox.ReplayAll()
@ -1754,6 +1758,8 @@ class InstanceTests(test.TestCase):
.AndReturn(self.limits['absolute']) .AndReturn(self.limits['absolute'])
quotas.tenant_quota_usages(IsA(http.HttpRequest)) \ quotas.tenant_quota_usages(IsA(http.HttpRequest)) \
.AndReturn(quota_usages) .AndReturn(quota_usages)
api.nova.flavor_list(IsA(http.HttpRequest)) \
.AndReturn(self.flavors.list())
self.mox.ReplayAll() self.mox.ReplayAll()
@ -1776,6 +1782,111 @@ class InstanceTests(test.TestCase):
self.assertContains(res, "greater than or equal to 1") 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', @test.create_stubs({api.nova: ('flavor_list', 'server_list',
'tenant_absolute_limits', 'tenant_absolute_limits',
'extension_supported',), 'extension_supported',),

View File

@ -46,6 +46,16 @@ from openstack_dashboard.dashboards.project.images_and_snapshots import utils
LOG = logging.getLogger(__name__) 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): class SelectProjectUserAction(workflows.Action):
project_id = forms.ChoiceField(label=_("Project")) project_id = forms.ChoiceField(label=_("Project"))
user_id = forms.ChoiceField(label=_("User")) user_id = forms.ChoiceField(label=_("User"))
@ -132,6 +142,8 @@ class SetInstanceDetailsAction(workflows.Action):
def __init__(self, request, context, *args, **kwargs): def __init__(self, request, context, *args, **kwargs):
self._init_images_cache() self._init_images_cache()
self.request = request
self.context = context
super(SetInstanceDetailsAction, self).__init__( super(SetInstanceDetailsAction, self).__init__(
request, context, *args, **kwargs) request, context, *args, **kwargs)
source_type_choices = [ source_type_choices = [
@ -183,6 +195,48 @@ class SetInstanceDetailsAction(workflows.Action):
if not cleaned_data.get('image_id'): if not cleaned_data.get('image_id'):
msg = _("You must select an image.") msg = _("You must select an image.")
self._errors['image_id'] = self.error_class([msg]) 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': elif source_type == 'instance_snapshot_id':
if not cleaned_data['instance_snapshot_id']: if not cleaned_data['instance_snapshot_id']:
@ -236,24 +290,18 @@ class SetInstanceDetailsAction(workflows.Action):
'"ram" instead.', sort_key) '"ram" instead.', sort_key)
return getattr(flavor, 'ram') return getattr(flavor, 'ram')
try: flavors = _flavor_list(request)
flavors = api.nova.flavor_list(request) if flavors:
flavor_sort = getattr(settings, 'CREATE_INSTANCE_FLAVOR_SORT', {}) flavor_sort = getattr(settings, 'CREATE_INSTANCE_FLAVOR_SORT', {})
rev = flavor_sort.get('reverse', False) rev = flavor_sort.get('reverse', False)
sort_key = flavor_sort.get('key', 'ram') sort_key = flavor_sort.get('key', 'ram')
if not callable(sort_key): if not callable(sort_key):
key = lambda flavor: get_key(flavor, sort_key) key = lambda flavor: get_key(flavor, sort_key)
else: else:
key = sort_key key = sort_key
return [(flavor.id, "%s" % flavor.name)
flavor_list = [(flavor.id, "%s" % flavor.name) for flavor in sorted(flavors, key=key, reverse=rev)]
for flavor in sorted(flavors, key=key, reverse=rev)] return []
except Exception:
flavor_list = []
exceptions.handle(request,
_('Unable to retrieve instance flavors.'))
return flavor_list
def populate_availability_zone_choices(self, request, context): def populate_availability_zone_choices(self, request, context):
try: try:
@ -277,12 +325,11 @@ class SetInstanceDetailsAction(workflows.Action):
try: try:
extra['usages'] = api.nova.tenant_absolute_limits(self.request) extra['usages'] = api.nova.tenant_absolute_limits(self.request)
extra['usages_json'] = json.dumps(extra['usages']) extra['usages_json'] = json.dumps(extra['usages'])
flavors = json.dumps([f._info for f in flavors = json.dumps([f._info for f in _flavor_list(self.request)])
api.nova.flavor_list(self.request)])
extra['flavors'] = flavors extra['flavors'] = flavors
images = utils.get_available_images(self.request, images = utils.get_available_images(self.request,
self.initial['project_id'], self.initial['project_id'],
self._images_cache) self._images_cache)
if images is not None: if images is not None:
attrs = [{'id': i.id, attrs = [{'id': i.id,
'min_disk': getattr(i, 'min_disk', 0), 'min_disk': getattr(i, 'min_disk', 0),
@ -664,6 +711,7 @@ class LaunchInstance(workflows.Workflow):
msg = (_('Port not created for profile-id (%s).') % msg = (_('Port not created for profile-id (%s).') %
context['profile_id']) context['profile_id'])
exceptions.handle(request, msg) exceptions.handle(request, msg)
if port and port.id: if port and port.id:
nics = [{"port-id": port.id}] nics = [{"port-id": port.id}]

View File

@ -481,6 +481,7 @@ class VolumeViewTests(test.TestCase):
'volumesUsed': len(self.volumes.list()), 'volumesUsed': len(self.volumes.list()),
'maxTotalVolumes': 6} 'maxTotalVolumes': 6}
image = self.images.get(name="protected_images") image = self.images.get(name="protected_images")
image.min_disk = 30
formData = {'name': u'A Volume I Am Making', formData = {'name': u'A Volume I Am Making',
'description': u'This is a volume I am making for a test.', 'description': u'This is a volume I am making for a test.',
'method': u'CreateForm', 'method': u'CreateForm',

View File

@ -63,7 +63,8 @@ def data(TEST):
'container_format': 'novaImage', 'container_format': 'novaImage',
'properties': {'image_type': u'image'}, 'properties': {'image_type': u'image'},
'is_public': True, 'is_public': True,
'protected': False} 'protected': False,
'min_ram': 0}
public_image = images.Image(images.ImageManager(None), image_dict) public_image = images.Image(images.ImageManager(None), image_dict)
image_dict = {'id': 'a001c047-22f8-47d0-80a1-8ec94a9524fe', image_dict = {'id': 'a001c047-22f8-47d0-80a1-8ec94a9524fe',
@ -74,7 +75,8 @@ def data(TEST):
'owner': TEST.tenant.id, 'owner': TEST.tenant.id,
'container_format': 'aki', 'container_format': 'aki',
'is_public': False, 'is_public': False,
'protected': False} 'protected': False,
'min_ram': 0}
private_image = images.Image(images.ImageManager(None), image_dict) private_image = images.Image(images.ImageManager(None), image_dict)
image_dict = {'id': 'd6936c86-7fec-474a-85c5-5e467b371c3c', image_dict = {'id': 'd6936c86-7fec-474a-85c5-5e467b371c3c',
@ -86,7 +88,8 @@ def data(TEST):
'container_format': 'novaImage', 'container_format': 'novaImage',
'properties': {'image_type': u'image'}, 'properties': {'image_type': u'image'},
'is_public': True, 'is_public': True,
'protected': True} 'protected': True,
'min_ram': 0}
protected_image = images.Image(images.ImageManager(None), image_dict) protected_image = images.Image(images.ImageManager(None), image_dict)
image_dict = {'id': '278905a6-4b52-4d1e-98f9-8c57bb25ba32', image_dict = {'id': '278905a6-4b52-4d1e-98f9-8c57bb25ba32',
@ -98,7 +101,8 @@ def data(TEST):
'container_format': 'novaImage', 'container_format': 'novaImage',
'properties': {'image_type': u'image'}, 'properties': {'image_type': u'image'},
'is_public': True, 'is_public': True,
'protected': False} 'protected': False,
'min_ram': 0}
public_image2 = images.Image(images.ImageManager(None), image_dict) public_image2 = images.Image(images.ImageManager(None), image_dict)
image_dict = {'id': '710a1acf-a3e3-41dd-a32d-5d6b6c86ea10', image_dict = {'id': '710a1acf-a3e3-41dd-a32d-5d6b6c86ea10',
@ -109,7 +113,8 @@ def data(TEST):
'owner': TEST.tenant.id, 'owner': TEST.tenant.id,
'container_format': 'aki', 'container_format': 'aki',
'is_public': False, 'is_public': False,
'protected': False} 'protected': False,
'min_ram': 0}
private_image2 = images.Image(images.ImageManager(None), image_dict) private_image2 = images.Image(images.ImageManager(None), image_dict)
image_dict = {'id': '7cd892fd-5652-40f3-a450-547615680132', image_dict = {'id': '7cd892fd-5652-40f3-a450-547615680132',
@ -120,7 +125,8 @@ def data(TEST):
'owner': TEST.tenant.id, 'owner': TEST.tenant.id,
'container_format': 'aki', 'container_format': 'aki',
'is_public': False, 'is_public': False,
'protected': False} 'protected': False,
'min_ram': 0}
private_image3 = images.Image(images.ImageManager(None), image_dict) private_image3 = images.Image(images.ImageManager(None), image_dict)
# A shared image. Not public and not local tenant. # A shared image. Not public and not local tenant.
@ -132,7 +138,8 @@ def data(TEST):
'owner': 'someothertenant', 'owner': 'someothertenant',
'container_format': 'aki', 'container_format': 'aki',
'is_public': False, 'is_public': False,
'protected': False} 'protected': False,
'min_ram': 0}
shared_image1 = images.Image(images.ImageManager(None), image_dict) shared_image1 = images.Image(images.ImageManager(None), image_dict)
# "Official" image. Public and tenant matches an entry # "Official" image. Public and tenant matches an entry
@ -145,7 +152,8 @@ def data(TEST):
'owner': 'officialtenant', 'owner': 'officialtenant',
'container_format': 'aki', 'container_format': 'aki',
'is_public': True, 'is_public': True,
'protected': False} 'protected': False,
'min_ram': 0}
official_image1 = images.Image(images.ImageManager(None), image_dict) official_image1 = images.Image(images.ImageManager(None), image_dict)
image_dict = {'id': 'a67e7d45-fe1e-4c5c-bf08-44b4a4964822', image_dict = {'id': 'a67e7d45-fe1e-4c5c-bf08-44b4a4964822',