Deploy templates: add API tests

Adds tests for the deploy templates API added in 1.55.

Also fixes an issue where the microversion fixture was not used in the resource
cleanup, so new resources would see a 404 and not be deleted. Fixing this
uncovered an issue in the volume tests where volume connectors and targets
could not be deleted due to being associated with a powered on node. The
simplest fix was to move node deletion before the volume connector and target
in resource cleanup.

Change-Id: I09d9a21e1ea5512c0140e818b0ca3de501870c12
Depends-On: https://review.openstack.org/631845
Story: 1722275
Task: 28679
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 43c2af6..d0c0e2b 100644
--- a/ironic_tempest_plugin/services/baremetal/v1/json/baremetal_client.py
+++ b/ironic_tempest_plugin/services/baremetal/v1/json/baremetal_client.py
@@ -114,6 +114,11 @@
         return self._list_request('allocations', **kwargs)
 
     @base.handle_errors
+    def list_deploy_templates(self, **kwargs):
+        """List all deploy templates."""
+        return self._list_request('deploy_templates', **kwargs)
+
+    @base.handle_errors
     def show_node(self, uuid, api_version=None):
         """Gets a specific node.
 
@@ -234,6 +239,14 @@
         uri = '/nodes/%s/allocation' % node_ident
         return self._show_request('nodes', uuid=None, uri=uri)
 
+    def show_deploy_template(self, deploy_template_ident):
+        """Gets a specific deploy template.
+
+        :param deploy_template_ident: Name or UUID of deploy template.
+        :return: Serialized deploy template as a dictionary.
+        """
+        return self._show_request('deploy_templates', deploy_template_ident)
+
     @base.handle_errors
     def create_node(self, chassis_id=None, **kwargs):
         """Create a baremetal node with the specified parameters.
@@ -387,6 +400,26 @@
         return self._create_request('volume/targets', volume_target)
 
     @base.handle_errors
+    def create_deploy_template(self, name, **kwargs):
+        """Create a deploy template with the specified parameters.
+
+        :param name: The name of the deploy template.
+        :param kwargs:
+            steps: deploy steps of the template.
+            uuid: UUID of the deploy template. Optional.
+            extra: meta-data of the deploy template. Optional.
+        :return: A tuple with the server response and the created deploy
+            template.
+        """
+        deploy_template = {'name': name}
+
+        for arg in ('extra', 'steps', 'uuid'):
+            if arg in kwargs:
+                deploy_template[arg] = kwargs[arg]
+
+        return self._create_request('deploy_templates', deploy_template)
+
+    @base.handle_errors
     def delete_node(self, uuid):
         """Deletes a node having the specified UUID.
 
@@ -445,6 +478,15 @@
         return self._delete_request('volume/targets', volume_target_ident)
 
     @base.handle_errors
+    def delete_deploy_template(self, deploy_template_ident):
+        """Deletes a deploy template having the specified name or UUID.
+
+        :param deploy_template_ident: Name or UUID of the deploy template.
+        :return: A tuple with the server response and the response body.
+        """
+        return self._delete_request('deploy_templates', deploy_template_ident)
+
+    @base.handle_errors
     def update_node(self, uuid, patch=None, **kwargs):
         """Update the specified node.
 
@@ -534,6 +576,20 @@
         return self._patch_request('volume/targets', uuid, patch)
 
     @base.handle_errors
+    def update_deploy_template(self, deploy_template_ident, patch):
+        """Update the specified deploy template.
+
+        :param deploy_template_ident: Name or UUID of the deploy template.
+        :param patch: List of dicts representing json patches. Each dict
+            has keys 'path', 'op' and 'value'; to update a field.
+        :return: A tuple with the server response and the updated deploy
+            template.
+        """
+
+        return self._patch_request('deploy_templates', deploy_template_ident,
+                                   patch)
+
+    @base.handle_errors
     def set_node_power_state(self, node_uuid, state):
         """Set power state of the specified node.
 
diff --git a/ironic_tempest_plugin/tests/api/admin/base.py b/ironic_tempest_plugin/tests/api/admin/base.py
index f34ac68..746eea6 100644
--- a/ironic_tempest_plugin/tests/api/admin/base.py
+++ b/ironic_tempest_plugin/tests/api/admin/base.py
@@ -19,6 +19,7 @@
 
 from ironic_tempest_plugin import clients
 from ironic_tempest_plugin.common import waiters
+from ironic_tempest_plugin.services.baremetal import base
 from ironic_tempest_plugin.tests.api.admin import api_microversion_fixture
 
 CONF = config.CONF
@@ -33,8 +34,8 @@
 
 # NOTE(jroll): resources must be deleted in a specific order, this list
 # defines the resource types to clean up, and the correct order.
-RESOURCE_TYPES = ['port', 'portgroup', 'volume_connector', 'volume_target',
-                  'node', 'chassis']
+RESOURCE_TYPES = ['port', 'portgroup', 'node', 'volume_connector',
+                  'volume_target', 'chassis', 'deploy_template']
 
 
 def creates(resource):
@@ -110,6 +111,9 @@
     @classmethod
     def resource_cleanup(cls):
         """Ensure that all created objects get destroyed."""
+        # Use the requested microversion for cleanup to ensure we can delete
+        # resources.
+        base.set_baremetal_api_microversion(cls.request_microversion)
         try:
             for node in cls.deployed_nodes:
                 try:
@@ -130,6 +134,7 @@
                 for u in uuids:
                     delete_method(u, ignore_errors=lib_exc.NotFound)
         finally:
+            base.reset_baremetal_api_microversion()
             super(BaseBaremetalTest, cls).resource_cleanup()
 
     def _assertExpected(self, expected, actual):
@@ -321,6 +326,19 @@
         return resp, body
 
     @classmethod
+    @creates('deploy_template')
+    def create_deploy_template(cls, name, **kwargs):
+        """Wrapper utility for creating test deploy template.
+
+        :param name: The name of the deploy template.
+        :return: A tuple with the server response and the created deploy
+            template.
+        """
+        resp, body = cls.client.create_deploy_template(name=name, **kwargs)
+
+        return resp, body
+
+    @classmethod
     def delete_chassis(cls, chassis_id):
         """Deletes a chassis having the specified UUID.
 
@@ -411,6 +429,21 @@
 
         return resp
 
+    @classmethod
+    def delete_deploy_template(cls, deploy_template_ident):
+        """Deletes a deploy template having the specified name or UUID.
+
+        :param deploy_template_ident: Name or UUID of the deploy template.
+        :return: Server response.
+        """
+        resp, body = cls.client.delete_deploy_template(deploy_template_ident)
+
+        if deploy_template_ident in cls.created_objects['deploy_template']:
+            cls.created_objects['deploy_template'].remove(
+                deploy_template_ident)
+
+        return resp
+
     def validate_self_link(self, resource, uuid, link):
         """Check whether the given self link formatted correctly."""
         expected_link = "{base}/{pref}/{res}/{uuid}".format(
diff --git a/ironic_tempest_plugin/tests/api/admin/test_deploy_templates.py b/ironic_tempest_plugin/tests/api/admin/test_deploy_templates.py
new file mode 100644
index 0000000..a281015
--- /dev/null
+++ b/ironic_tempest_plugin/tests/api/admin/test_deploy_templates.py
@@ -0,0 +1,340 @@
+#    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 copy
+
+from tempest.lib.common.utils import data_utils
+from tempest.lib import decorators
+from tempest.lib import exceptions as lib_exc
+
+from ironic_tempest_plugin.tests.api.admin import api_microversion_fixture
+from ironic_tempest_plugin.tests.api.admin import base
+
+
+EXAMPLE_STEPS = [{
+    'interface': 'bios',
+    'step': 'apply_configuration',
+    'args': {},
+    'priority': 10
+}]
+
+
+def _get_random_trait():
+    return data_utils.rand_name('CUSTOM', '').replace('-', '_')
+
+
+class TestDeployTemplates(base.BaseBaremetalTest):
+    """Tests for deploy templates."""
+
+    min_microversion = '1.55'
+
+    def setUp(self):
+        super(TestDeployTemplates, self).setUp()
+        self.name = _get_random_trait()
+        self.steps = copy.deepcopy(EXAMPLE_STEPS)
+        _, self.template = self.create_deploy_template(self.name,
+                                                       steps=self.steps)
+
+    @decorators.idempotent_id('8f39c794-4e6f-42fc-bfcb-e1f6eafff2e9')
+    def test_create_deploy_template_specifying_uuid(self):
+        name = _get_random_trait()
+        uuid = data_utils.rand_uuid()
+
+        _, template = self.create_deploy_template(name=name, steps=self.steps,
+                                                  uuid=uuid)
+
+        _, body = self.client.show_deploy_template(uuid)
+        self._assertExpected(template, body)
+
+    @decorators.idempotent_id('7ac8fdd5-928b-4fbc-8341-3ebaad9def5e')
+    def test_delete_deploy_template(self):
+        self.delete_deploy_template(self.template['uuid'])
+
+        self.assertRaises(lib_exc.NotFound, self.client.show_deploy_template,
+                          self.template['uuid'])
+
+    @decorators.idempotent_id('f424dd67-46c3-4169-b8f0-7e0c18b70437')
+    def test_show_deploy_template(self):
+        _, template = self.client.show_deploy_template(self.template['uuid'])
+        self._assertExpected(self.template, template)
+        self.assertIn('name', template)
+        self.assertIn('steps', template)
+        self.assertIn('uuid', template)
+        self.assertIn('extra', template)
+
+    @decorators.idempotent_id('2fd98e9a-10ce-405a-a32c-0d6079766183')
+    def test_show_deploy_template_with_links(self):
+        _, template = self.client.show_deploy_template(self.template['uuid'])
+        self.assertIn('links', template.keys())
+        self.assertEqual(2, len(template['links']))
+        self.assertIn(template['uuid'], template['links'][0]['href'])
+
+    @decorators.idempotent_id('cec2a01d-07af-4062-a8b0-9a1703f65bcf')
+    def test_list_deploy_templates(self):
+        _, body = self.client.list_deploy_templates()
+        self.assertIn(self.template['uuid'],
+                      [i['uuid'] for i in body['deploy_templates']])
+        # Verify self links.
+        for template in body['deploy_templates']:
+            self.validate_self_link('deploy_templates', template['uuid'],
+                                    template['links'][0]['href'])
+
+    @decorators.idempotent_id('89aea2bf-c094-445f-b869-9fd56d1dfe5a')
+    def test_list_with_limit(self):
+        for i in range(2):
+            name = _get_random_trait()
+            self.create_deploy_template(name, steps=self.steps)
+
+        _, body = self.client.list_deploy_templates(limit=3)
+
+        next_marker = body['deploy_templates'][-1]['uuid']
+        self.assertIn(next_marker, body['next'])
+
+    @decorators.idempotent_id('c09c917e-c4d2-4148-9b6a-459bd126ed7c')
+    def test_list_deploy_templates_detail(self):
+        uuids = [
+            self.create_deploy_template(_get_random_trait(), steps=self.steps)
+            [1]['uuid'] for i in range(0, 5)]
+
+        _, body = self.client.list_deploy_templates(detail=True)
+
+        templates_dict = dict((template['uuid'], template)
+                              for template in body['deploy_templates']
+                              if template['uuid'] in uuids)
+
+        for uuid in uuids:
+            self.assertIn(uuid, templates_dict)
+            template = templates_dict[uuid]
+            self.assertIn('name', template)
+            self.assertIn('steps', template)
+            self.assertIn('uuid', template)
+            self.assertIn('extra', template)
+            # Verify self link.
+            self.validate_self_link('deploy_templates', template['uuid'],
+                                    template['links'][0]['href'])
+
+    @decorators.idempotent_id('a6cf1ade-e19a-41e2-b151-13ecf0d8f08c')
+    def test_update_deploy_template_replace(self):
+        new_name = _get_random_trait()
+        new_steps = [{
+            'interface': 'raid',
+            'step': 'create_configuration',
+            'args': {},
+            'priority': 10,
+        }]
+
+        patch = [{'path': '/name', 'op': 'replace', 'value': new_name},
+                 {'path': '/steps', 'op': 'replace', 'value': new_steps}]
+
+        self.client.update_deploy_template(self.template['uuid'], patch)
+
+        _, body = self.client.show_deploy_template(self.template['uuid'])
+        self.assertEqual(new_name, body['name'])
+        self.assertEqual(new_steps, body['steps'])
+
+    @decorators.idempotent_id('bb168c63-452b-4065-9a81-77853ca9540a')
+    def test_update_deploy_template_add(self):
+        new_steps = [
+            {
+                'interface': 'bios',
+                'step': 'cache_bios_settings',
+                'args': {},
+                'priority': 20
+            },
+            {
+                'interface': 'bios',
+                'step': 'factory_reset',
+                'args': {},
+                'priority': 30
+            },
+        ]
+
+        patch = [{'path': '/steps/1', 'op': 'add', 'value': new_steps[0]},
+                 {'path': '/steps/2', 'op': 'add', 'value': new_steps[1]}]
+
+        self.client.update_deploy_template(self.template['uuid'], patch)
+
+        _, body = self.client.show_deploy_template(self.template['uuid'])
+        self.assertEqual(self.steps + new_steps, body['steps'])
+
+    @decorators.idempotent_id('2aa204a2-1d50-48fd-8b76-d2ed15586d50')
+    def test_update_deploy_template_mixed_ops(self):
+        new_name = _get_random_trait()
+        new_steps = [
+            {
+                'interface': 'bios',
+                'step': 'apply_configuration',
+                'args': {},
+                'priority': 20
+            },
+            {
+                'interface': 'bios',
+                'step': 'apply_configuration',
+                'args': {},
+                'priority': 30
+            },
+        ]
+
+        patch = [{'path': '/name', 'op': 'replace', 'value': new_name},
+                 {'path': '/steps/0', 'op': 'replace', 'value': new_steps[0]},
+                 {'path': '/steps/0', 'op': 'remove'},
+                 {'path': '/steps/0', 'op': 'add', 'value': new_steps[1]}]
+
+        self.client.update_deploy_template(self.template['uuid'], patch)
+
+        _, body = self.client.show_deploy_template(self.template['uuid'])
+        self.assertEqual(new_name, body['name'])
+        self.assertEqual([new_steps[1]], body['steps'])
+
+
+class TestDeployTemplatesOldAPI(base.BaseBaremetalTest):
+    """Negative tests for deploy templates using an old API version."""
+
+    max_microversion = '1.54'
+
+    def setUp(self):
+        super(TestDeployTemplatesOldAPI, self).setUp()
+        self.useFixture(
+            api_microversion_fixture.APIMicroversionFixture(
+                self.max_microversion)
+        )
+
+    @decorators.attr(type=['negative'])
+    @decorators.idempotent_id('e9481a0d-23e0-4757-bc11-c3c9ab9d3839')
+    def test_create_deploy_template_old_api(self):
+        self.assertRaises(lib_exc.NotFound,
+                          self.create_deploy_template,
+                          name=_get_random_trait(), steps=EXAMPLE_STEPS)
+
+    @decorators.attr(type=['negative'])
+    @decorators.idempotent_id('0d3af2aa-ba53-4c8a-92d4-91f9b4179fe7')
+    def test_update_deploy_template_old_api(self):
+        patch = [{'path': '/name', 'op': 'replace',
+                  'value': _get_random_trait()}]
+
+        self.assertRaises(lib_exc.NotFound,
+                          self.client.update_deploy_template,
+                          _get_random_trait(), patch)
+
+    @decorators.attr(type=['negative'])
+    @decorators.idempotent_id('1646b1e5-ab81-45a8-9ea0-30444a4dcaa2')
+    def test_delete_deploy_template_old_api(self):
+        self.assertRaises(lib_exc.NotFound,
+                          self.client.delete_deploy_template,
+                          _get_random_trait())
+
+    @decorators.attr(type=['negative'])
+    @decorators.idempotent_id('819480ac-f36a-4402-b1d5-504d7cf55b1f')
+    def test_list_deploy_templates_old_api(self):
+        self.assertRaises(lib_exc.NotFound,
+                          self.client.list_deploy_templates)
+
+
+class TestDeployTemplatesNegative(base.BaseBaremetalTest):
+    """Negative tests for deploy templates."""
+
+    min_microversion = '1.55'
+
+    def setUp(self):
+        super(TestDeployTemplatesNegative, self).setUp()
+        self.useFixture(
+            api_microversion_fixture.APIMicroversionFixture(
+                self.min_microversion)
+        )
+        self.steps = EXAMPLE_STEPS
+
+    @decorators.attr(type=['negative'])
+    @decorators.idempotent_id('a4085c08-e718-4c2f-a796-0e115b659243')
+    def test_create_deploy_template_invalid_name(self):
+        name = 'invalid-name'
+        self.assertRaises(lib_exc.BadRequest,
+                          self.create_deploy_template, name=name,
+                          steps=self.steps)
+
+    @decorators.attr(type=['negative'])
+    @decorators.idempotent_id('6390acc4-9490-4b23-8b4c-41888a78c9b7')
+    def test_create_deploy_template_duplicated_deploy_template_name(self):
+        name = _get_random_trait()
+        self.create_deploy_template(name=name, steps=self.steps)
+        self.assertRaises(lib_exc.Conflict, self.create_deploy_template,
+                          name=name, steps=self.steps)
+
+    @decorators.attr(type=['negative'])
+    @decorators.idempotent_id('ed3f0cec-13e8-4175-9fdb-d129e7b7fe10')
+    def test_create_deploy_template_no_mandatory_field_name(self):
+        self.assertRaises(lib_exc.BadRequest, self.create_deploy_template,
+                          name=None, steps=self.steps)
+
+    @decorators.attr(type=['negative'])
+    @decorators.idempotent_id('af5dd0df-d903-463f-9535-9e4e9d6fd576')
+    def test_create_deploy_template_no_mandatory_field_steps(self):
+        self.assertRaises(lib_exc.BadRequest, self.create_deploy_template,
+                          name=_get_random_trait())
+
+    @decorators.attr(type=['negative'])
+    @decorators.idempotent_id('cbd33bc5-7602-40b7-943e-3e92217567a3')
+    def test_create_deploy_template_malformed_steps(self):
+        steps = {'key': 'value'}
+        self.assertRaises(lib_exc.BadRequest, self.create_deploy_template,
+                          name=_get_random_trait(), steps=steps)
+
+    @decorators.attr(type=['negative'])
+    @decorators.idempotent_id('2a562fca-f377-4a6e-b332-37ee82d3a983')
+    def test_create_deploy_template_malformed_deploy_template_uuid(self):
+        uuid = 'malformed:uuid'
+        self.assertRaises(lib_exc.BadRequest, self.create_deploy_template,
+                          name=_get_random_trait(), steps=self.steps,
+                          uuid=uuid)
+
+    @decorators.attr(type=['negative'])
+    @decorators.idempotent_id('2c006994-88ca-43b7-b605-897d479229d9')
+    def test_show_deploy_template_nonexistent(self):
+        self.assertRaises(lib_exc.NotFound, self.client.show_deploy_template,
+                          data_utils.rand_uuid())
+
+    @decorators.attr(type=['negative'])
+    @decorators.idempotent_id('5a815f37-f015-4d68-9b22-099504f74805')
+    def test_update_deploy_template_remove_mandatory_field_steps(self):
+        name = _get_random_trait()
+        _, template = self.create_deploy_template(name=name, steps=self.steps)
+
+        # Removing one item from the collection
+        self.assertRaises(lib_exc.BadRequest,
+                          self.client.update_deploy_template,
+                          template['uuid'],
+                          [{'path': '/steps/0', 'op': 'remove'}])
+
+        # Removing the collection
+        self.assertRaises(lib_exc.BadRequest,
+                          self.client.update_deploy_template,
+                          template['uuid'],
+                          [{'path': '/steps', 'op': 'remove'}])
+
+    @decorators.attr(type=['negative'])
+    @decorators.idempotent_id('ee852ebb-a601-4593-9d59-063fcbc8f964')
+    def test_update_deploy_template_remove_mandatory_field_name(self):
+        name = _get_random_trait()
+        _, template = self.create_deploy_template(name=name, steps=self.steps)
+        self.assertRaises(lib_exc.BadRequest,
+                          self.client.update_deploy_template,
+                          template['uuid'],
+                          [{'path': '/name', 'op': 'remove'}])
+
+    @decorators.attr(type=['negative'])
+    @decorators.idempotent_id('e59bf38d-272f-4490-b21e-9db217f11378')
+    def test_update_deploy_template_replace_empty_name(self):
+        name = _get_random_trait()
+        _, template = self.create_deploy_template(name=name, steps=self.steps)
+        self.assertRaises(lib_exc.BadRequest,
+                          self.client.update_deploy_template,
+                          template['uuid'],
+                          [{'path': '/name', 'op': 'replace', 'value': ''}])