Merge "Replace Ocata jobs with Rocky"
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 1f8d95e..f60361a 100644
--- a/ironic_tempest_plugin/services/baremetal/v1/json/baremetal_client.py
+++ b/ironic_tempest_plugin/services/baremetal/v1/json/baremetal_client.py
@@ -104,6 +104,11 @@
         return self._list_request('drivers')
 
     @base.handle_errors
+    def list_conductors(self, **kwargs):
+        """List all registered conductors."""
+        return self._list_request('conductors', **kwargs)
+
+    @base.handle_errors
     def show_node(self, uuid, api_version=None):
         """Gets a specific node.
 
@@ -199,6 +204,14 @@
         """
         return self._show_request('drivers', driver_name)
 
+    def show_conductor(self, hostname):
+        """Gets a specific conductor.
+
+        :param hostname: Hostname of conductor.
+        :return: Serialized conductor as a dictionary.
+        """
+        return self._show_request('conductors', hostname)
+
     @base.handle_errors
     def create_node(self, chassis_id=None, **kwargs):
         """Create a baremetal node with the specified parameters.
@@ -434,7 +447,12 @@
                            'deploy_interface',
                            'rescue_interface',
                            'instance_uuid',
-                           'resource_class')
+                           'resource_class',
+                           'protected',
+                           'protected_reason',
+                           # TODO(dtantsur): maintenance is set differently
+                           # in newer API versions.
+                           'maintenance')
         if not patch:
             patch = self._make_patch(node_attributes, **kwargs)
 
diff --git a/ironic_tempest_plugin/tests/api/admin/base.py b/ironic_tempest_plugin/tests/api/admin/base.py
index 8850c55..6f8586f 100644
--- a/ironic_tempest_plugin/tests/api/admin/base.py
+++ b/ironic_tempest_plugin/tests/api/admin/base.py
@@ -18,6 +18,7 @@
 from tempest import test
 
 from ironic_tempest_plugin import clients
+from ironic_tempest_plugin.common import waiters
 from ironic_tempest_plugin.tests.api.admin import api_microversion_fixture
 
 CONF = config.CONF
@@ -104,12 +105,19 @@
         cls.created_objects = {}
         for resource in RESOURCE_TYPES:
             cls.created_objects[resource] = set()
+        cls.deployed_nodes = set()
 
     @classmethod
     def resource_cleanup(cls):
         """Ensure that all created objects get destroyed."""
-
         try:
+            for node in cls.deployed_nodes:
+                try:
+                    cls.set_node_provision_state(node, 'deleted',
+                                                 ['available', None])
+                except lib_exc.BadRequest:
+                    pass
+
             for resource in RESOURCE_TYPES:
                 uuids = cls.created_objects[resource]
                 delete_method = getattr(cls.client, 'delete_%s' % resource)
@@ -184,6 +192,63 @@
         return resp, body
 
     @classmethod
+    def set_node_provision_state(cls, node_id, target, expected, timeout=None,
+                                 interval=None):
+        """Sets the node's provision state.
+
+        :param node_id: The unique identifier of the node.
+        :param target: Target provision state.
+        :param expected: Expected final provision state or list of states.
+        :param timeout: The timeout for reaching the expected state.
+            Defaults to client.build_timeout.
+        :param interval: An interval between show_node calls for status check.
+            Defaults to client.build_interval.
+        """
+        cls.client.set_node_provision_state(node_id, target)
+        waiters.wait_for_bm_node_status(cls.client, node_id,
+                                        'provision_state', expected,
+                                        timeout=timeout, interval=interval)
+
+    @classmethod
+    def provide_node(cls, node_id, cleaning_timeout=None):
+        """Make the node available.
+
+        :param node_id: The unique identifier of the node.
+        :param cleaning_timeout: The timeout to wait for cleaning.
+            Defaults to client.build_timeout.
+        """
+        _, body = cls.client.show_node(node_id)
+        current_state = body['provision_state']
+        if current_state == 'enroll':
+            cls.set_node_provision_state(node_id, 'manage', 'manageable',
+                                         timeout=60, interval=1)
+            current_state = 'manageable'
+        if current_state == 'manageable':
+            cls.set_node_provision_state(node_id, 'provide',
+                                         ['available', None],
+                                         timeout=cleaning_timeout)
+            current_state = 'available'
+        if current_state not in ('available', None):
+            raise RuntimeError("Cannot reach state 'available': node %(node)s "
+                               "is in unexpected state %(state)s" %
+                               {'node': node_id, 'state': current_state})
+
+    @classmethod
+    def deploy_node(cls, node_id, cleaning_timeout=None, deploy_timeout=None):
+        """Deploy the node.
+
+        :param node_id: The unique identifier of the node.
+        :param cleaning_timeout: The timeout to wait for cleaning.
+            Defaults to client.build_timeout.
+        :param deploy_timeout: The timeout to wait for deploy.
+            Defaults to client.build_timeout.
+        """
+        cls.provide_node(node_id, cleaning_timeout=cleaning_timeout)
+        cls.set_node_provision_state(node_id, 'active', 'active',
+                                     timeout=deploy_timeout)
+        cls.deployed_nodes.add(node_id)
+
+    @classmethod
     @creates('port')
     def create_port(cls, node_id, address, extra=None, uuid=None,
                     portgroup_uuid=None, physical_network=None):
diff --git a/ironic_tempest_plugin/tests/api/admin/test_conductor.py b/ironic_tempest_plugin/tests/api/admin/test_conductor.py
new file mode 100644
index 0000000..ee9f439
--- /dev/null
+++ b/ironic_tempest_plugin/tests/api/admin/test_conductor.py
@@ -0,0 +1,56 @@
+#    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.
+
+from tempest import config
+from tempest.lib import decorators
+
+from ironic_tempest_plugin.tests.api.admin import base
+
+CONF = config.CONF
+
+
+class TestConductors(base.BaseBaremetalTest):
+    """Tests for conductors."""
+
+    min_microversion = '1.49'
+
+    @decorators.idempotent_id('b6d62be4-53a6-43c6-ae78-bff9d1b4efc1')
+    def test_list_conductors(self):
+        _, conductors = self.client.list_conductors()
+        self.assertTrue(len(conductors['conductors']) > 0)
+        cond = conductors['conductors'].pop()
+        self.assertIn('hostname', cond)
+        self.assertIn('conductor_group', cond)
+        self.assertIn('alive', cond)
+        self.assertNotIn('drivers', cond)
+
+    @decorators.idempotent_id('ca3de366-d80a-4e97-b19b-42d594e8d148')
+    def test_list_conductors_detail(self):
+        _, conductors = self.client.list_conductors(detail=True)
+        self.assertTrue(len(conductors['conductors']) > 0)
+        cond = conductors['conductors'].pop()
+        self.assertIn('hostname', cond)
+        self.assertIn('conductor_group', cond)
+        self.assertIn('alive', cond)
+        self.assertIn('drivers', cond)
+
+    @decorators.idempotent_id('7e1829e2-3945-4508-a3d9-c8ebe9463fd8')
+    def test_show_conductor(self):
+        _, conductors = self.client.list_conductors()
+        self.assertTrue(len(conductors['conductors']) > 0)
+        conductor = conductors['conductors'].pop()
+
+        _, conductor = self.client.show_conductor(conductor['hostname'])
+        self.assertIn('hostname', conductor)
+        self.assertIn('conductor_group', conductor)
+        self.assertIn('alive', conductor)
+        self.assertIn('drivers', conductor)
diff --git a/ironic_tempest_plugin/tests/api/admin/test_nodes.py b/ironic_tempest_plugin/tests/api/admin/test_nodes.py
index 8c1343f..31a9d61 100644
--- a/ironic_tempest_plugin/tests/api/admin/test_nodes.py
+++ b/ironic_tempest_plugin/tests/api/admin/test_nodes.py
@@ -170,6 +170,11 @@
         _, loaded_node = self.client.show_node(self.node['uuid'])
         self.assertNotIn('fault', loaded_node)
 
+    @decorators.idempotent_id('e5470656-bb65-4173-be83-2df3fc9aed24')
+    def test_conductor_hidden(self):
+        _, loaded_node = self.client.show_node(self.node['uuid'])
+        self.assertNotIn('conductor', loaded_node)
+
 
 class TestNodesResourceClass(base.BaseBaremetalTest):
 
@@ -850,3 +855,123 @@
         self.assertRaises(
             lib_exc.BadRequest,
             self.client.list_nodes, fault='somefake')
+
+
+class TestNodeProtected(base.BaseBaremetalTest):
+    """Tests for protected baremetal nodes."""
+
+    min_microversion = '1.48'
+
+    def setUp(self):
+        super(TestNodeProtected, self).setUp()
+
+        _, self.chassis = self.create_chassis()
+        _, self.node = self.create_node(self.chassis['uuid'])
+        self.provide_node(self.node['uuid'])
+
+    def tearDown(self):
+        try:
+            self.client.update_node(self.node['uuid'], protected=False)
+        except Exception:
+            pass
+        super(TestNodeProtected, self).tearDown()
+
+    @decorators.idempotent_id('52f0cb1c-ad7b-43dc-8e22-a76438b67716')
+    def test_node_protected_set_unset(self):
+        self.deploy_node(self.node['uuid'])
+        _, self.node = self.client.show_node(self.node['uuid'])
+        self.assertFalse(self.node['protected'])
+        self.assertIsNone(self.node['protected_reason'])
+
+        self.client.update_node(self.node['uuid'], protected=True,
+                                protected_reason='reason!')
+        _, self.node = self.client.show_node(self.node['uuid'])
+        self.assertTrue(self.node['protected'])
+        self.assertEqual('reason!', self.node['protected_reason'])
+
+        self.client.update_node(self.node['uuid'], protected=False)
+        _, self.node = self.client.show_node(self.node['uuid'])
+        self.assertFalse(self.node['protected'])
+        self.assertIsNone(self.node['protected_reason'])
+
+    @decorators.idempotent_id('8fbd101e-90e6-4843-b41a-556b34802972')
+    def test_node_protected(self):
+        self.deploy_node(self.node['uuid'])
+        self.client.update_node(self.node['uuid'], protected=True)
+
+        self.assertRaises(lib_exc.Forbidden,
+                          self.set_node_provision_state,
+                          self.node['uuid'], 'deleted', 'available')
+        self.assertRaises(lib_exc.Forbidden,
+                          self.set_node_provision_state,
+                          self.node['uuid'], 'rebuild', 'active')
+
+    @decorators.idempotent_id('04a21b51-2991-4213-8c2f-a96cfdada802')
+    def test_node_protected_from_deletion(self):
+        self.deploy_node(self.node['uuid'])
+        self.client.update_node(self.node['uuid'], protected=True,
+                                maintenance=True)
+
+        self.assertRaises(lib_exc.Forbidden,
+                          self.client.delete_node,
+                          self.node['uuid'])
+
+    @decorators.attr(type='negative')
+    @decorators.idempotent_id('1c819f4c-6c1d-4150-ba4a-3b0dcb3c8694')
+    def test_node_protected_negative(self):
+        # Cannot be set for available nodes
+        self.assertRaises(lib_exc.Conflict,
+                          self.client.update_node,
+                          self.node['uuid'], protected=True)
+
+        self.deploy_node(self.node['uuid'])
+
+        # Reason cannot be set for nodes that are not protected
+        self.assertRaises(lib_exc.BadRequest,
+                          self.client.update_node,
+                          self.node['uuid'], protected_reason='reason!')
+
+
+class TestNodesProtectedOldApi(base.BaseBaremetalTest):
+
+    def setUp(self):
+        super(TestNodesProtectedOldApi, self).setUp()
+        _, self.chassis = self.create_chassis()
+        _, self.node = self.create_node(self.chassis['uuid'])
+        self.deploy_node(self.node['uuid'])
+        _, self.node = self.client.show_node(self.node['uuid'])
+
+    @decorators.attr(type='negative')
+    @decorators.idempotent_id('08971546-27cc-40ab-851e-ba7bb52c00ab')
+    def test_node_protected_old_api(self):
+        exc = self.assertRaises(
+            lib_exc.RestClientException,
+            self.client.update_node, self.node['uuid'], protected=True)
+        # 400 for old ironic, 406 for new ironic with old microversion.
+        self.assertIn(exc.resp.status, (400, 406))
+
+
+class TestNodeConductor(base.BaseBaremetalTest):
+    """Tests for conductor field of baremetal nodes."""
+
+    min_microversion = '1.49'
+
+    def setUp(self):
+        super(TestNodeConductor, self).setUp()
+
+        _, self.chassis = self.create_chassis()
+        _, self.node = self.create_node(self.chassis['uuid'])
+
+    @decorators.idempotent_id('1af888b2-2a19-43da-8181-a5381d6ff536')
+    def test_conductor_exposed(self):
+        _, loaded_node = self.client.show_node(self.node['uuid'])
+        self.assertIn('conductor', loaded_node)
+
+    @decorators.idempotent_id('53bcef99-2989-4755-aa8f-c31037cd15de')
+    def test_list_nodes_by_conductor(self):
+        _, loaded_node = self.client.show_node(self.node['uuid'])
+        hostname = loaded_node['conductor']
+
+        _, nodes = self.client.list_nodes(conductor=hostname)
+        self.assertIn(self.node['uuid'],
+                      [n['uuid'] for n in nodes['nodes']])
diff --git a/setup.cfg b/setup.cfg
index 99f34dc..53ca735 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -4,7 +4,7 @@
 description-file =
     README.rst
 author = OpenStack
-author-email = openstack-dev@lists.openstack.org
+author-email = openstack-discuss@lists.openstack.org
 home-page = https://docs.openstack.org/ironic-tempest-plugin/latest/
 classifier =
     Environment :: OpenStack