Merge "Updated from global requirements"
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 c03b49b..550128a 100644
--- a/ironic_tempest_plugin/services/baremetal/v1/json/baremetal_client.py
+++ b/ironic_tempest_plugin/services/baremetal/v1/json/baremetal_client.py
@@ -640,3 +640,58 @@
         resp, body = self.get(uri)
         self.expected_success(200, resp.status)
         return resp, self.deserialize(body)
+
+    @base.handle_errors
+    def list_node_traits(self, node_uuid):
+        """List all traits associated with the node.
+
+        :param node_uuid: The unique identifier of the node.
+        """
+        return self._list_request('/nodes/%s/traits' % node_uuid)
+
+    @base.handle_errors
+    def set_node_traits(self, node_uuid, traits):
+        """Set all traits of the specified node.
+
+        :param node_uuid: The unique identifier of the node.
+        :param traits: A list of traits to set.
+        """
+        request = {'traits': traits}
+        resp, body = self._put_request('nodes/%s/traits' %
+                                       node_uuid, request)
+        self.expected_success(http_client.NO_CONTENT, resp.status)
+        return resp, body
+
+    @base.handle_errors
+    def add_node_trait(self, node_uuid, trait):
+        """Add a trait to the specified node.
+
+        :param node_uuid: The unique identifier of the node.
+        :param trait: A trait to add.
+        """
+        resp, body = self._put_request('nodes/%s/traits/%s' %
+                                       (node_uuid, trait), {})
+        self.expected_success(http_client.NO_CONTENT, resp.status)
+        return resp, body
+
+    @base.handle_errors
+    def remove_node_traits(self, node_uuid):
+        """Remove all traits from the specified node.
+
+        :param node_uuid: Unique identifier of the node in UUID format.
+        """
+        resp, body = self._delete_request('nodes/%s/traits' % node_uuid, {})
+        self.expected_success(http_client.NO_CONTENT, resp.status)
+        return resp, body
+
+    @base.handle_errors
+    def remove_node_trait(self, node_uuid, trait):
+        """Remove a trait from the specified node.
+
+        :param node_uuid: Unique identifier of the node in UUID format.
+        :param trait: A trait to remove.
+        """
+        resp, body = self._delete_request('nodes/%s/traits/%s' %
+                                          (node_uuid, trait), {})
+        self.expected_success(http_client.NO_CONTENT, resp.status)
+        return resp, body
diff --git a/ironic_tempest_plugin/tests/api/admin/test_nodes.py b/ironic_tempest_plugin/tests/api/admin/test_nodes.py
index d992a65..75a17ec 100644
--- a/ironic_tempest_plugin/tests/api/admin/test_nodes.py
+++ b/ironic_tempest_plugin/tests/api/admin/test_nodes.py
@@ -10,6 +10,7 @@
 #    License for the specific language governing permissions and limitations
 #    under the License.
 
+from oslo_utils import uuidutils
 import six
 from tempest import config
 from tempest.lib.common.utils import data_utils
@@ -401,3 +402,324 @@
                           self.node['uuid'], self.nport_id)
 
         self.client.vif_detach(self.node['uuid'], self.nport_id)
+
+
+class TestNodesTraits(base.BaseBaremetalTest):
+
+    min_microversion = '1.37'
+
+    def setUp(self):
+        super(TestNodesTraits, self).setUp()
+        self.useFixture(
+            api_microversion_fixture.APIMicroversionFixture(
+                TestNodesTraits.min_microversion)
+        )
+        _, self.chassis = self.create_chassis()
+        # One standard trait & one custom trait.
+        self.traits = ['CUSTOM_TRAIT1', 'HW_CPU_X86_VMX']
+        _, self.node = self.create_node(self.chassis['uuid'])
+
+    @decorators.idempotent_id('5c3a2dd0-af10-474d-a209-d30426e1eb5d')
+    def test_list_node_traits(self):
+        """List traits for a node."""
+        _, body = self.client.list_node_traits(self.node['uuid'])
+        self.assertEqual([], body['traits'])
+
+        self.client.set_node_traits(self.node['uuid'], self.traits)
+        _, body = self.client.list_node_traits(self.node['uuid'])
+        self.assertEqual(self.traits, sorted(body['traits']))
+
+    @decorators.attr(type='negative')
+    @decorators.idempotent_id('3b83dbd3-4a89-4173-920a-ca33ed3aad69')
+    def test_list_node_traits_non_existent_node(self):
+        """Try to list traits for a non-existent node."""
+        node_uuid = uuidutils.generate_uuid()
+        self.assertRaises(
+            lib_exc.NotFound,
+            self.client.list_node_traits, node_uuid)
+
+    @decorators.idempotent_id('aa961bf6-ea2f-484b-961b-eae2da0e6b7e')
+    def test_set_node_traits(self):
+        """Set the traits for a node."""
+        self.client.set_node_traits(self.node['uuid'], self.traits)
+
+        _, body = self.client.list_node_traits(self.node['uuid'])
+        self.assertEqual(self.traits, sorted(body['traits']))
+
+        self.client.set_node_traits(self.node['uuid'], [])
+
+        _, body = self.client.list_node_traits(self.node['uuid'])
+        self.assertEqual([], body['traits'])
+
+    @decorators.idempotent_id('727a5e11-5654-459f-8af6-e14eb987a283')
+    def test_set_node_traits_max_traits(self):
+        """Set the maximum number of traits for a node."""
+        traits = ['CUSTOM_TRAIT%d' % i for i in range(50)]
+        self.client.set_node_traits(self.node['uuid'], traits)
+
+        _, body = self.client.list_node_traits(self.node['uuid'])
+        self.assertEqual(sorted(traits), sorted(body['traits']))
+
+    @decorators.attr(type='negative')
+    @decorators.idempotent_id('75831f5d-ca44-403b-8fd6-f7cad95b1c54')
+    def test_set_node_traits_too_many(self):
+        """Set more than the maximum number of traits for a node."""
+        traits = ['CUSTOM_TRAIT%d' % i for i in range(51)]
+        self.assertRaises(
+            lib_exc.BadRequest,
+            self.client.set_node_traits, self.node['uuid'], traits)
+
+        _, body = self.client.list_node_traits(self.node['uuid'])
+        self.assertEqual([], body['traits'])
+
+    @decorators.idempotent_id('d81ceeab-a50f-427a-bc5a-aa916478d0d3')
+    def test_set_node_traits_duplicate_trait(self):
+        """Set the traits for a node, ensuring duplicates are ignored."""
+        self.client.set_node_traits(self.node['uuid'],
+                                    ['CUSTOM_TRAIT1', 'CUSTOM_TRAIT1'])
+
+        _, body = self.client.list_node_traits(self.node['uuid'])
+        self.assertEqual(['CUSTOM_TRAIT1'], body['traits'])
+
+    @decorators.attr(type='negative')
+    @decorators.idempotent_id('2fb4c9d9-8e5b-4189-b547-26596014491c')
+    def test_set_node_traits_non_existent_node(self):
+        """Try to set traits for a non-existent node."""
+        node_uuid = uuidutils.generate_uuid()
+        self.assertRaises(
+            lib_exc.NotFound,
+            self.client.set_node_traits, node_uuid, ['CUSTOM_TRAIT1'])
+
+    @decorators.idempotent_id('47db09d9-af2b-424d-9d51-7efca2920f20')
+    def test_add_node_trait_long(self):
+        """Add a node trait of the largest possible length."""
+        trait_long_name = 'CUSTOM_' + data_utils.arbitrary_string(248).upper()
+        self.client.add_node_trait(self.node['uuid'], trait_long_name)
+
+        _, body = self.client.list_node_traits(self.node['uuid'])
+        self.assertEqual([trait_long_name], body['traits'])
+
+    @decorators.attr(type='negative')
+    @decorators.idempotent_id('2a4daa8d-2b85-40ac-a8a0-0462cc9a57ef')
+    def test_add_node_trait_too_long(self):
+        """Try to add a node trait longer than the largest possible length."""
+        trait_long_name = 'CUSTOM_' + data_utils.arbitrary_string(249).upper()
+        self.assertRaises(
+            lib_exc.BadRequest,
+            self.client.add_node_trait, self.node['uuid'], trait_long_name)
+
+        _, body = self.client.list_node_traits(self.node['uuid'])
+        self.assertEqual([], body['traits'])
+
+    @decorators.idempotent_id('4b737e7f-101e-493e-b5ce-494fbffe18fd')
+    def test_add_node_trait_duplicate_trait(self):
+        """Add a node trait that already exists."""
+        self.client.add_node_trait(self.node['uuid'], 'CUSTOM_TRAIT1')
+        self.client.add_node_trait(self.node['uuid'], 'CUSTOM_TRAIT1')
+
+        _, body = self.client.list_node_traits(self.node['uuid'])
+        self.assertEqual(['CUSTOM_TRAIT1'], body['traits'])
+
+    @decorators.attr(type='negative')
+    @decorators.idempotent_id('65bce181-89ce-435e-a7d8-3ba60aafd08d')
+    def test_add_node_trait_too_many(self):
+        """Add a trait to a node that would exceed the maximum."""
+        traits = ['CUSTOM_TRAIT%d' % i for i in range(50)]
+        self.client.set_node_traits(self.node['uuid'], traits)
+
+        self.assertRaises(
+            lib_exc.BadRequest,
+            self.client.add_node_trait, self.node['uuid'], 'CUSTOM_TRAIT50')
+
+        _, body = self.client.list_node_traits(self.node['uuid'])
+        self.assertEqual(sorted(traits), sorted(body['traits']))
+
+    @decorators.attr(type='negative')
+    @decorators.idempotent_id('cca0e831-32af-4ce9-bfce-d3834fea57aa')
+    def test_add_node_trait_non_existent_node(self):
+        """Try to add a trait to a non-existent node."""
+        node_uuid = uuidutils.generate_uuid()
+        self.assertRaises(
+            lib_exc.NotFound,
+            self.client.add_node_trait, node_uuid, 'CUSTOM_TRAIT1')
+
+    @decorators.idempotent_id('e4bf8bf0-3004-44bc-8bfe-f9f1a167d999')
+    def test_remove_node_traits(self):
+        """Remove all traits from a node."""
+        self.client.set_node_traits(self.node['uuid'], self.traits)
+
+        self.client.remove_node_traits(self.node['uuid'])
+
+        _, body = self.client.list_node_traits(self.node['uuid'])
+        self.assertEqual([], body['traits'])
+
+    @decorators.idempotent_id('4d8c9a35-0036-4139-85c1-5f242395680f')
+    def test_remove_node_traits_no_traits(self):
+        """Remove all traits from a node that has no traits."""
+        self.client.remove_node_traits(self.node['uuid'])
+
+        _, body = self.client.list_node_traits(self.node['uuid'])
+        self.assertEqual([], body['traits'])
+
+    @decorators.attr(type='negative')
+    @decorators.idempotent_id('625c911a-48e8-4bef-810b-7cf33c0846a2')
+    def test_remove_node_traits_non_existent_node(self):
+        """Try to remove all traits from a non-existent node."""
+        node_uuid = uuidutils.generate_uuid()
+        self.assertRaises(
+            lib_exc.NotFound,
+            self.client.remove_node_traits, node_uuid)
+
+    @decorators.idempotent_id('3591d514-39b9-425e-9afe-ea74ae347486')
+    def test_remove_node_trait(self):
+        """Remove a trait from a node."""
+        self.client.set_node_traits(self.node['uuid'], self.traits)
+
+        self.client.remove_node_trait(self.node['uuid'], 'CUSTOM_TRAIT1')
+
+        _, body = self.client.list_node_traits(self.node['uuid'])
+        self.assertEqual(['HW_CPU_X86_VMX'], body['traits'])
+
+    @decorators.attr(type='negative')
+    @decorators.idempotent_id('b50ae543-5e5e-4b1a-b2f2-9e00fe55974b')
+    def test_remove_node_trait_non_existent_trait(self):
+        """Try to remove a non-existent trait from a node."""
+        self.assertRaises(
+            lib_exc.NotFound,
+            self.client.remove_node_trait, self.node['uuid'], 'CUSTOM_TRAIT1')
+
+    @decorators.attr(type='negative')
+    @decorators.idempotent_id('f1469745-7cdf-4cae-9699-73d029c47bc3')
+    def test_remove_node_trait_non_existent_node(self):
+        """Try to remove a trait from a non-existent node."""
+        node_uuid = uuidutils.generate_uuid()
+        self.assertRaises(
+            lib_exc.NotFound,
+            self.client.remove_node_trait, node_uuid, 'CUSTOM_TRAIT1')
+
+    @decorators.idempotent_id('03f9e57f-e584-448a-926f-53035e583e7e')
+    def test_list_nodes_detail(self):
+        """Get detailed nodes list."""
+        self.client.set_node_traits(self.node['uuid'], self.traits)
+
+        _, body = self.client.list_nodes_detail()
+        self.assertGreaterEqual(len(body['nodes']), 1)
+
+        for node in body['nodes']:
+            self.assertIn('traits', node)
+            if node['uuid'] == self.node['uuid']:
+                self.assertEqual(self.traits, sorted(node['traits']))
+
+    @decorators.idempotent_id('2b82f704-1580-403a-af92-92c29a7eebb7')
+    def test_list_nodes_traits_field(self):
+        """Get nodes list with the traits field."""
+        self.client.set_node_traits(self.node['uuid'], self.traits)
+
+        _, body = self.client.list_nodes(fields='uuid,traits')
+        self.assertGreaterEqual(len(body['nodes']), 1)
+
+        for node in body['nodes']:
+            self.assertIn('traits', node)
+            if node['uuid'] == self.node['uuid']:
+                self.assertEqual(self.traits, sorted(node['traits']))
+
+    @decorators.idempotent_id('c83c537a-76aa-4d8a-8673-128d01ee403d')
+    def test_show_node(self):
+        """Show a node with traits."""
+        self.client.set_node_traits(self.node['uuid'], self.traits)
+
+        _, body = self.client.show_node(self.node['uuid'])
+
+        self.assertIn('traits', body)
+        self.assertEqual(self.traits, sorted(body['traits']))
+
+    @decorators.attr(type='negative')
+    @decorators.idempotent_id('9ab6a19c-83b9-4600-b55b-325a51e2f8f6')
+    def test_update_node_traits(self):
+        """Try updating an existing node with traits."""
+        patch = [{'path': '/traits',
+                  'op': 'add',
+                  'value': ['CUSTOM_TRAIT1']}]
+        self.assertRaises(
+            lib_exc.BadRequest,
+            self.client.update_node, self.node['uuid'], patch)
+
+        _, body = self.client.list_node_traits(self.node['uuid'])
+        self.assertEqual([], body['traits'])
+
+
+class TestNodesTraitsOldApi(base.BaseBaremetalTest):
+
+    def setUp(self):
+        super(TestNodesTraitsOldApi, self).setUp()
+        _, self.chassis = self.create_chassis()
+        _, self.node = self.create_node(self.chassis['uuid'])
+
+    @decorators.attr(type='negative')
+    @decorators.idempotent_id('5419af7b-4e27-4be4-88f6-e01c598a8102')
+    def test_list_node_traits_old_api(self):
+        """Try to list traits for a node using an older api version."""
+        exc = self.assertRaises(
+            lib_exc.UnexpectedResponseCode,
+            self.client.list_node_traits, self.node['uuid'])
+        self.assertEqual(406, exc.resp.status)
+
+    @decorators.attr(type='negative')
+    @decorators.idempotent_id('a4353f3a-bedc-4579-9c7e-4bebcd95903d')
+    def test_add_node_trait_old_api(self):
+        """Try to add a trait to a node using an older api version."""
+        exc = self.assertRaises(
+            lib_exc.UnexpectedResponseCode,
+            self.client.add_node_trait, self.node['uuid'], 'CUSTOM_TRAIT1')
+        self.assertEqual(405, exc.resp.status)
+
+    @decorators.attr(type='negative')
+    @decorators.idempotent_id('91cc43d8-2f6f-4b1b-95e9-68dedca54e6b')
+    def test_set_node_traits_old_api(self):
+        """Try to set traits for a node using an older api version."""
+        exc = self.assertRaises(
+            lib_exc.UnexpectedResponseCode,
+            self.client.set_node_traits, self.node['uuid'], ['CUSTOM_TRAIT1'])
+        self.assertEqual(405, exc.resp.status)
+
+    @decorators.attr(type='negative')
+    @decorators.idempotent_id('0f9af890-a57a-4c25-86c8-6418d1b8f4d4')
+    def test_remove_node_trait_old_api(self):
+        """Try to remove a trait from a node using an older api version."""
+        self.assertRaises(
+            lib_exc.NotFound,
+            self.client.remove_node_trait, self.node['uuid'], 'CUSTOM_TRAIT1')
+
+    @decorators.attr(type='negative')
+    @decorators.idempotent_id('f8375b3c-1939-4d1c-97c4-d23e10680090')
+    def test_remove_node_traits_old_api(self):
+        """Try to remove all traits from a node using an older api version."""
+        self.assertRaises(
+            lib_exc.NotFound,
+            self.client.remove_node_traits, self.node['uuid'])
+
+    @decorators.attr(type='negative')
+    @decorators.idempotent_id('525eeb59-b7ce-413d-a37b-401e67402a4c')
+    def test_list_nodes_detail_old_api(self):
+        """Get detailed nodes list, ensure they have no traits."""
+        _, body = self.client.list_nodes_detail()
+        self.assertGreaterEqual(len(body['nodes']), 1)
+
+        for node in body['nodes']:
+            self.assertNotIn('traits', node)
+
+    @decorators.attr(type='negative')
+    @decorators.idempotent_id('eb75b3c8-ac9c-4399-90a2-c0030bfde7a6')
+    def test_list_nodes_traits_field(self):
+        """Try to list nodes' traits field using older api version."""
+        exc = self.assertRaises(
+            lib_exc.UnexpectedResponseCode,
+            self.client.list_nodes, fields='traits')
+        self.assertEqual(406, exc.resp.status)
+
+    @decorators.attr(type='negative')
+    @decorators.idempotent_id('214ae7fc-149b-4657-b6bc-66353d49ade8')
+    def test_show_node_old_api(self):
+        """Show a node, ensure it has no traits."""
+        _, body = self.client.show_node(self.node['uuid'])
+        self.assertNotIn('traits', body)
diff --git a/zuul.d/project.yaml b/zuul.d/project.yaml
index 8736718..6bb6b07 100644
--- a/zuul.d/project.yaml
+++ b/zuul.d/project.yaml
@@ -1,5 +1,4 @@
 - project:
-    name: openstack/ironic-tempest-plugin
     check:
       jobs:
         - ironic-dsvm-standalone
diff --git a/zuul.d/stable-jobs.yaml b/zuul.d/stable-jobs.yaml
index 5e055f9..ed9d8df 100644
--- a/zuul.d/stable-jobs.yaml
+++ b/zuul.d/stable-jobs.yaml
@@ -2,35 +2,29 @@
 - job:
     name: ironic-dsvm-standalone-pike
     parent: ironic-dsvm-standalone
-    vars:
-      branch_override: stable/pike
+    override-checkout: stable/pike
 
 - job:
     name: ironic-tempest-dsvm-ipa-wholedisk-bios-agent_ipmitool-tinyipa-pike
     parent: ironic-tempest-dsvm-ipa-wholedisk-bios-agent_ipmitool-tinyipa
-    vars:
-      branch_override: stable/pike
+    override-checkout: stable/pike
 
 - job:
     name: ironic-tempest-dsvm-ipa-wholedisk-bios-agent_ipmitool-tinyipa-ocata
     parent: ironic-tempest-dsvm-ipa-wholedisk-bios-agent_ipmitool-tinyipa
-    vars:
-      branch_override: stable/ocata
+    override-checkout: stable/ocata
 
 - job:
     name: ironic-tempest-dsvm-ironic-inspector-pike
     parent: ironic-tempest-dsvm-ironic-inspector
-    vars:
-      branch_override: stable/pike
+    override-checkout: stable/pike
 
 - job:
     name: ironic-tempest-dsvm-ironic-inspector-ocata
     parent: ironic-tempest-dsvm-ironic-inspector
-    vars:
-      branch_override: stable/ocata
+    override-checkout: stable/ocata
 
 - job:
     name: ironic-inspector-grenade-dsvm-pike
     parent: ironic-inspector-grenade-dsvm
-    vars:
-      branch_override: stable/pike
+    override-checkout: stable/pike