Allocation API: functional tests

This change adds tests on allocation API against fake nodes.

Change-Id: I9f750fe9088e4dda3d5d95cd8905101046ce71d1
Depends-On: https://review.openstack.org/636110
Story: #2004341
Task: #29411
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 @@
                '%(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
@@ -109,6 +109,11 @@
         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 @@
         """
         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 @@
 
         """
         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 @@
                                           (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 @@
                 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 @@
     @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 @@
         :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 @@
                                             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'])