diff --git a/ironic_tempest_plugin/common/waiters.py b/ironic_tempest_plugin/common/waiters.py index b706ac0..08e0941 100644 --- a/ironic_tempest_plugin/common/waiters.py +++ b/ironic_tempest_plugin/common/waiters.py @@ -110,3 +110,37 @@ def wait_node_instance_association(client, instance_uuid, timeout=None, '%(instance_uuid)s within the required time (%(timeout)s s).' % {'instance_uuid': instance_uuid, 'timeout': timeout}) raise lib_exc.TimeoutException(msg) + + +def wait_for_allocation(client, allocation_ident, timeout=15, interval=1, + expect_error=False): + """Wait for the allocation to become active. + + :param client: an instance of tempest plugin BaremetalClient. + :param allocation_ident: UUID or name of the allocation. + :param timeout: the timeout after which the allocation is considered as + failed. Defaults to 15 seconds. + :param interval: an interval between show_allocation calls. + Defaults to 1 second. + :param expect_error: if True, return successfully even in case of an error. + """ + result = [None] # a mutable object to modify in the closure + + def check(): + result[0] = client.show_allocation(allocation_ident) + allocation = result[0][1] + + if allocation['state'] == 'error' and not expect_error: + raise lib_exc.TempestException( + "Allocation %(ident)s failed: %(error)s" % + {'ident': allocation_ident, + 'error': allocation.get('last_error')}) + else: + return allocation['state'] != 'allocating' + + if not test_utils.call_until_true(check, timeout, interval): + msg = ('Timed out waiting for the allocation %s to become active' % + allocation_ident) + raise lib_exc.TimeoutException(msg) + + return result[0] diff --git a/ironic_tempest_plugin/services/baremetal/v1/json/baremetal_client.py b/ironic_tempest_plugin/services/baremetal/v1/json/baremetal_client.py index f60361a..e6c02b2 100644 --- a/ironic_tempest_plugin/services/baremetal/v1/json/baremetal_client.py +++ b/ironic_tempest_plugin/services/baremetal/v1/json/baremetal_client.py @@ -108,6 +108,11 @@ class BaremetalClient(base.BaremetalClient): """List all registered conductors.""" return self._list_request('conductors', **kwargs) + @base.handle_errors + def list_allocations(self, **kwargs): + """List all registered allocations.""" + return self._list_request('allocations', **kwargs) + @base.handle_errors def show_node(self, uuid, api_version=None): """Gets a specific node. @@ -212,6 +217,23 @@ class BaremetalClient(base.BaremetalClient): """ return self._show_request('conductors', hostname) + def show_allocation(self, allocation_ident): + """Gets a specific allocation. + + :param allocation_ident: UUID or name of allocation. + :return: Serialized allocation as a dictionary. + """ + return self._show_request('allocations', allocation_ident) + + def show_node_allocation(self, node_ident): + """Gets an allocation for the node. + + :param node_ident: Node UUID or name. + :return: Serialized allocation as a dictionary. + """ + uri = '/nodes/%s/allocation' % node_ident + return self._show_request('nodes', uuid=None, uri=uri) + @base.handle_errors def create_node(self, chassis_id=None, **kwargs): """Create a baremetal node with the specified parameters. @@ -226,8 +248,9 @@ class BaremetalClient(base.BaremetalClient): """ node = {} - if kwargs.get('resource_class'): - node['resource_class'] = kwargs['resource_class'] + for field in ('resource_class', 'name'): + if kwargs.get(field): + node[field] = kwargs[field] node.update( {'chassis_uuid': chassis_id, @@ -761,3 +784,25 @@ class BaremetalClient(base.BaremetalClient): (node_uuid, trait), {}) self.expected_success(http_client.NO_CONTENT, resp.status) return resp, body + + @base.handle_errors + def create_allocation(self, resource_class, **kwargs): + """Create a baremetal allocation with the specified parameters. + + :param resource_class: Resource class to request. + :param kwargs: Other fields to pass. + :return: A tuple with the server response and the created allocation. + + """ + kwargs['resource_class'] = resource_class + return self._create_request('allocations', kwargs) + + @base.handle_errors + def delete_allocation(self, allocation_ident): + """Deletes an allocation. + + :param allocation_ident: UUID or name of the allocation. + :return: A tuple with the server response and the response body. + + """ + return self._delete_request('allocations', allocation_ident) diff --git a/ironic_tempest_plugin/tests/api/admin/base.py b/ironic_tempest_plugin/tests/api/admin/base.py index 6f8586f..f34ac68 100644 --- a/ironic_tempest_plugin/tests/api/admin/base.py +++ b/ironic_tempest_plugin/tests/api/admin/base.py @@ -118,6 +118,12 @@ class BaseBaremetalTest(api_version_utils.BaseMicroversionTest, except lib_exc.BadRequest: pass + for node in cls.created_objects['node']: + try: + cls.client.update_node(node, instance_uuid=None) + except lib_exc.TempestException: + pass + for resource in RESOURCE_TYPES: uuids = cls.created_objects[resource] delete_method = getattr(cls.client, 'delete_%s' % resource) @@ -171,7 +177,7 @@ class BaseBaremetalTest(api_version_utils.BaseMicroversionTest, @classmethod @creates('node') def create_node(cls, chassis_id, cpu_arch='x86_64', cpus=8, local_gb=10, - memory_mb=4096, resource_class=None): + memory_mb=4096, **kwargs): """Wrapper utility for creating test baremetal nodes. :param chassis_id: The unique identifier of the chassis. @@ -179,7 +185,7 @@ class BaseBaremetalTest(api_version_utils.BaseMicroversionTest, :param cpus: Number of CPUs. Default: 8. :param local_gb: Disk size. Default: 10. :param memory_mb: Available RAM. Default: 4096. - :param resource_class: Node resource class. + :param kwargs: Other optional node fields. :return: A tuple with the server response and the created node. """ @@ -187,7 +193,7 @@ class BaseBaremetalTest(api_version_utils.BaseMicroversionTest, cpus=cpus, local_gb=local_gb, memory_mb=memory_mb, driver=cls.driver, - resource_class=resource_class) + **kwargs) return resp, body diff --git a/ironic_tempest_plugin/tests/api/admin/test_allocations.py b/ironic_tempest_plugin/tests/api/admin/test_allocations.py new file mode 100644 index 0000000..1891a3b --- /dev/null +++ b/ironic_tempest_plugin/tests/api/admin/test_allocations.py @@ -0,0 +1,208 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import random + +from tempest import config +from tempest.lib import decorators +from tempest.lib import exceptions as lib_exc + +from ironic_tempest_plugin.common import waiters +from ironic_tempest_plugin.tests.api.admin import base + +CONF = config.CONF + + +class TestAllocations(base.BaseBaremetalTest): + """Tests for baremetal allocations.""" + + min_microversion = '1.52' + + def provide_node(self, node_id, cleaning_timeout=None): + super(TestAllocations, self).provide_node(node_id, cleaning_timeout) + # Force non-empty power state, otherwise allocation API won't pick it + self.client.set_node_power_state(node_id, 'power off') + + def setUp(self): + super(TestAllocations, self).setUp() + + # Generate a resource class to prevent parallel tests from clashing + # with each other. + self.resource_class = 'x-small-%d' % random.randrange(1024) + + _, self.chassis = self.create_chassis() + _, self.node = self.create_node(self.chassis['uuid'], + resource_class=self.resource_class) + self.provide_node(self.node['uuid']) + + @decorators.idempotent_id('9203ea28-3c61-4108-8498-22247b654ff6') + def test_create_show_allocation(self): + self.assertIsNone(self.node['allocation_uuid']) + _, body = self.client.create_allocation(self.resource_class) + uuid = body['uuid'] + + self.assertTrue(uuid) + self.assertEqual('allocating', body['state']) + self.assertEqual(self.resource_class, body['resource_class']) + self.assertIsNone(body['last_error']) + self.assertIsNone(body['node_uuid']) + + _, body = waiters.wait_for_allocation(self.client, uuid) + self.assertEqual('active', body['state']) + self.assertEqual(self.resource_class, body['resource_class']) + self.assertIsNone(body['last_error']) + self.assertEqual(self.node['uuid'], body['node_uuid']) + + _, body2 = self.client.show_node_allocation(body['node_uuid']) + self.assertEqual(body, body2) + + _, node = self.client.show_node(self.node['uuid']) + self.assertEqual(uuid, node['allocation_uuid']) + + @decorators.idempotent_id('eb074d06-e5f4-4fb4-b992-c9929db488ae') + def test_create_allocation_with_traits(self): + _, node2 = self.create_node(self.chassis['uuid'], + resource_class=self.resource_class) + self.client.set_node_traits(node2['uuid'], ['CUSTOM_MEOW']) + self.provide_node(node2['uuid']) + + _, body = self.client.create_allocation(self.resource_class, + traits=['CUSTOM_MEOW']) + uuid = body['uuid'] + + self.assertTrue(uuid) + self.assertEqual('allocating', body['state']) + self.assertEqual(['CUSTOM_MEOW'], body['traits']) + self.assertIsNone(body['last_error']) + + _, body = waiters.wait_for_allocation(self.client, uuid) + self.assertEqual('active', body['state']) + self.assertEqual(['CUSTOM_MEOW'], body['traits']) + self.assertIsNone(body['last_error']) + self.assertEqual(node2['uuid'], body['node_uuid']) + + @decorators.idempotent_id('12d19297-f35a-408a-8b1e-3cd244e30abe') + def test_create_allocation_candidate_node(self): + node_name = 'allocation-test-1' + _, node2 = self.create_node(self.chassis['uuid'], + resource_class=self.resource_class, + name=node_name) + self.provide_node(node2['uuid']) + + _, body = self.client.create_allocation(self.resource_class, + candidate_nodes=[node_name]) + uuid = body['uuid'] + + self.assertTrue(uuid) + self.assertEqual('allocating', body['state']) + self.assertEqual([node2['uuid']], body['candidate_nodes']) + self.assertIsNone(body['last_error']) + + _, body = waiters.wait_for_allocation(self.client, uuid) + self.assertEqual('active', body['state']) + self.assertEqual([node2['uuid']], body['candidate_nodes']) + self.assertIsNone(body['last_error']) + self.assertEqual(node2['uuid'], body['node_uuid']) + + @decorators.idempotent_id('84eb3c21-4e16-4f33-9551-dce0f8689462') + def test_delete_allocation(self): + _, body = self.client.create_allocation(self.resource_class) + self.client.delete_allocation(body['uuid']) + self.assertRaises(lib_exc.NotFound, self.client.show_allocation, + body['uuid']) + + @decorators.idempotent_id('5e30452d-ee92-4342-82c1-5eea5e55c937') + def test_delete_allocation_by_name(self): + _, body = self.client.create_allocation(self.resource_class, + name='banana') + self.client.delete_allocation('banana') + self.assertRaises(lib_exc.NotFound, self.client.show_allocation, + 'banana') + + @decorators.idempotent_id('fbbc13bc-86da-438b-af01-d1bc1bab57d6') + def test_show_by_name(self): + _, body = self.client.create_allocation(self.resource_class, + name='banana') + _, loaded_body = self.client.show_allocation('banana') + self._assertExpected(body, loaded_body) + + @decorators.idempotent_id('4ca123c4-160d-4d8d-a3f7-15feda812263') + def test_list_allocations(self): + _, body = self.client.create_allocation(self.resource_class) + + _, listing = self.client.list_allocations() + self.assertIn(body['uuid'], + [i['uuid'] for i in listing['allocations']]) + + _, listing = self.client.list_allocations( + resource_class=self.resource_class) + self.assertEqual([body['uuid']], + [i['uuid'] for i in listing['allocations']]) + + @decorators.idempotent_id('092b7148-9ff0-4107-be57-2cfcd21eb5d7') + def test_list_allocations_by_state(self): + _, body = self.client.create_allocation(self.resource_class) + _, body2 = self.client.create_allocation(self.resource_class + 'foo2') + + waiters.wait_for_allocation(self.client, body['uuid']) + waiters.wait_for_allocation(self.client, body2['uuid'], + expect_error=True) + + _, listing = self.client.list_allocations(state='active') + uuids = [i['uuid'] for i in listing['allocations']] + self.assertIn(body['uuid'], uuids) + self.assertNotIn(body2['uuid'], uuids) + + _, listing = self.client.list_allocations(state='error') + uuids = [i['uuid'] for i in listing['allocations']] + self.assertNotIn(body['uuid'], uuids) + self.assertIn(body2['uuid'], uuids) + + _, listing = self.client.list_allocations(state='allocating') + uuids = [i['uuid'] for i in listing['allocations']] + self.assertNotIn(body['uuid'], uuids) + self.assertNotIn(body2['uuid'], uuids) + + @decorators.attr(type=['negative']) + @decorators.idempotent_id('bf7e1375-019a-466a-a294-9c1052827ada') + def test_create_allocation_resource_class_mismatch(self): + _, body = self.client.create_allocation(self.resource_class + 'foo') + + _, body = waiters.wait_for_allocation(self.client, body['uuid'], + expect_error=True) + self.assertEqual('error', body['state']) + self.assertTrue(body['last_error']) + + @decorators.attr(type=['negative']) + @decorators.idempotent_id('b4eeddee-ca34-44f9-908b-490b78b18486') + def test_create_allocation_traits_mismatch(self): + _, body = self.client.create_allocation( + self.resource_class, traits=['CUSTOM_DOES_NOT_EXIST']) + + _, body = waiters.wait_for_allocation(self.client, body['uuid'], + expect_error=True) + self.assertEqual('error', body['state']) + self.assertTrue(body['last_error']) + + @decorators.attr(type=['negative']) + @decorators.idempotent_id('2378727f-77c3-4289-9562-bd2f3b147a60') + def test_create_allocation_node_mismatch(self): + _, node2 = self.create_node(self.chassis['uuid'], + resource_class=self.resource_class + 'alt') + # Mismatch between the resource class and the candidate node + _, body = self.client.create_allocation( + self.resource_class, candidate_nodes=[node2['uuid']]) + + _, body = waiters.wait_for_allocation(self.client, body['uuid'], + expect_error=True) + self.assertEqual('error', body['state']) + self.assertTrue(body['last_error'])