Tempest test for anaconda deploy

Provides a test and substrate changes to support integration
testing of the anaconda deployment interface from a "standalone"
perspect.

This is present in two forms, a "with stage2 ramdisk" and
"without stage2" test which is enabled, or not depening
on the underlying configuration.

This test also has two modes of operation, the first and
default being primarily a "did anaconda start and can I
ping the machine?" test mode. The second attempts to wait
for the node to reach an active state, although it is not
the default because an anaconda deployment, depending on
mode of use, even with a default configuration can take
a substantial amount of itme. The anaconda deployment
interface is also modeled for highly tuned configurations,
so the prime aspect is "does it boot? does anaconda start?"

Also:
* Removes the explicit requirement that test classes explicitly
  declare support for wholedisk_image or not.

Change-Id: I42933d26268b55737fa2508265643c1cd14651ea
diff --git a/ironic_tempest_plugin/common/waiters.py b/ironic_tempest_plugin/common/waiters.py
index 2b35279..e538cd8 100644
--- a/ironic_tempest_plugin/common/waiters.py
+++ b/ironic_tempest_plugin/common/waiters.py
@@ -74,7 +74,8 @@
         if node[attr] in status:
             return True
         elif (abort_on_error_state
-              and node['provision_state'].endswith(' failed')):
+              and (node['provision_state'].endswith(' failed')
+                   or node['provision_state'] == 'error')):
             msg = ('Node %(node)s reached failure state %(state)s while '
                    'waiting for %(attr)s=%(expected)s. '
                    'Error: %(error)s' %
@@ -159,3 +160,35 @@
         raise lib_exc.TimeoutException(msg)
 
     return result[0]
+
+
+def wait_node_value_in_field(client, node_id, field, value,
+                             raise_if_insufficent_access=True,
+                             timeout=None, interval=None):
+    """Waits for a node to have a field value appear.
+
+    :param client: an instance of tempest plugin BaremetalClient.
+    :param node_id: the UUID of the node
+    :param field: the field in the node object to examine
+    :param value: the value/key with-in the field to look for.
+    :param timeout: the timeout after which the check is considered as failed.
+    :param interval: an interval between show_node calls for status check.
+    """
+
+    def is_field_updated():
+        node = utils.get_node(client, node_id=node_id)
+        field_value = node[field]
+        if raise_if_insufficent_access and '** Redacted' in field_value:
+            msg = ('Unable to see contents of redacted field '
+                   'indicating insufficent access to execute this test.')
+            raise lib_exc.InsufficientAPIAccess(msg)
+        return value in field_value
+
+    if not test_utils.call_until_true(is_field_updated, timeout,
+                                      interval):
+        msg = ('Timed out waiting to get Ironic node by node_id '
+               '%(node_id)s within the required time (%(timeout)s s). '
+               'Field value %(value) did not appear in field %(field)s.'
+               % {'node_id': node_id, 'timeout': timeout,
+                  'field': field, 'value': value})
+        raise lib_exc.TimeoutException(msg)
diff --git a/ironic_tempest_plugin/config.py b/ironic_tempest_plugin/config.py
index d844af1..6b05402 100644
--- a/ironic_tempest_plugin/config.py
+++ b/ironic_tempest_plugin/config.py
@@ -89,6 +89,10 @@
     cfg.IntOpt('active_timeout',
                default=300,
                help="Timeout for Ironic node to completely provision"),
+    cfg.IntOpt('anaconda_active_timeout',
+               default=3600,
+               help="Timeout for Ironic node to completely provision "
+                    "when using the anaconda deployment interface."),
     cfg.IntOpt('association_timeout',
                default=30,
                help="Timeout for association of Nova instance and Ironic "
@@ -146,6 +150,28 @@
     cfg.StrOpt('rollback_import_location',
                help="Rollback import config location for configuration "
                     "molds. Optional. If not provided, rollback is skipped."),
+    # TODO(TheJulia): For now, anaconda can be url based and we can move in
+    # to being tested with glance as soon as we get a public stage2 image.
+    cfg.StrOpt('anaconda_image_ref',
+               help="URL of an anaconda repository to set as image_source"),
+    cfg.StrOpt('anaconda_kernel_ref',
+               help="URL of the kernel to utilize for anaconda deploys."),
+    cfg.StrOpt('anaconda_initial_ramdisk_ref',
+               help="URL of the initial ramdisk to utilize for anaconda "
+                    "deploy operations."),
+    cfg.StrOpt('anaconda_stage2_ramdisk_ref',
+               help="URL of the anaconda second stage ramdisk. Presence of "
+                    "this setting will also determine if a stage2 specific "
+                    "anaconda test is run, or not."),
+    cfg.StrOpt('anaconda_exit_test_at',
+               default='heartbeat',
+               choices=['heartbeat', 'active'],
+               help='When to end the anaconda test job at. Due to '
+                    'the use model of the anaconda driver, as well '
+                    'as the performance profile, the anaconda test is '
+                    'normally only executed until we observe a heartbeat '
+                    'operation indicating that anaconda *has* booted and '
+                    'successfully parsed the URL.'),
     cfg.ListOpt('enabled_drivers',
                 default=['fake', 'pxe_ipmitool', 'agent_ipmitool'],
                 help="List of Ironic enabled drivers."),
diff --git a/ironic_tempest_plugin/exceptions.py b/ironic_tempest_plugin/exceptions.py
index 50a4468..865ab08 100644
--- a/ironic_tempest_plugin/exceptions.py
+++ b/ironic_tempest_plugin/exceptions.py
@@ -27,3 +27,8 @@
 
 class RaidCleaningInventoryValidationFailed(exceptions.TempestException):
     message = "RAID cleaning storage inventory validation failed"
+
+
+class InsufficientAPIAccess(exceptions.TempestException):
+    message = ("Insufficent Access to the API exists. Please use a user "
+               "with an elevated level of access to execute this test.")
diff --git a/ironic_tempest_plugin/tests/scenario/baremetal_manager.py b/ironic_tempest_plugin/tests/scenario/baremetal_manager.py
index b897e34..ac4d5cc 100644
--- a/ironic_tempest_plugin/tests/scenario/baremetal_manager.py
+++ b/ironic_tempest_plugin/tests/scenario/baremetal_manager.py
@@ -124,6 +124,16 @@
                                                       instance_id)
 
     @classmethod
+    def wait_for_agent_heartbeat(cls, node_id, timeout=None):
+        ironic_waiters.wait_node_value_in_field(
+            cls.baremetal_client,
+            node_id=node_id,
+            field='driver_internal_info',
+            value='agent_last_heartbeat',
+            timeout=timeout or CONF.baremetal.deploywait_timeout,
+            interval=10)
+
+    @classmethod
     def get_node(cls, node_id=None, instance_id=None, api_version=None):
         return utils.get_node(cls.baremetal_client, node_id, instance_id,
                               api_version)
diff --git a/ironic_tempest_plugin/tests/scenario/baremetal_standalone_manager.py b/ironic_tempest_plugin/tests/scenario/baremetal_standalone_manager.py
index 27568a3..1ad1485 100644
--- a/ironic_tempest_plugin/tests/scenario/baremetal_standalone_manager.py
+++ b/ironic_tempest_plugin/tests/scenario/baremetal_standalone_manager.py
@@ -503,7 +503,7 @@
     # If we need to set provision state 'deleted' for the node  after test
     delete_node = True
 
-    mandatory_attr = ['driver', 'image_ref', 'wholedisk_image']
+    mandatory_attr = ['driver', 'image_ref']
 
     node = None
     node_ip = None
@@ -570,7 +570,11 @@
                 "in the list of enabled power interfaces %(enabled)s" % {
                     'iface': cls.power_interface,
                     'enabled': CONF.baremetal.enabled_power_interfaces})
-        if not cls.wholedisk_image and CONF.baremetal.use_provision_network:
+        if (cls.wholedisk_image is not None
+                and not cls.wholedisk_image
+                and CONF.baremetal.use_provision_network):
+            # We only want to enter here if cls.wholedisk_image is set to
+            # a value. If None, skip, if True/False go from there.
             raise cls.skipException(
                 'Partitioned images are not supported with multitenancy.')
 
@@ -796,3 +800,79 @@
         self.boot_node_ramdisk(ramdisk_ref, iso)
         self.assertTrue(self.ping_ip_address(self.node_ip,
                                              should_succeed=should_succeed))
+
+    @classmethod
+    def boot_node_anaconda(cls, image_ref, kernel_ref, ramdisk_ref,
+                           stage2_ref=None):
+        """Boot ironic using a ramdisk node.
+
+        The following actions are executed:
+          * Create/Pick networks to boot node in.
+          * Create Neutron port and attach it to node.
+          * Update node image_source.
+          * Deploy node.
+          * Wait until node is deployed.
+
+        :param ramdisk_ref: Reference to user image or ramdisk to boot
+                            the node with.
+        :param iso: Boolean, default False, to indicate if the image ref
+                    us actually an ISO image.
+        """
+        if image_ref is None or kernel_ref is None or ramdisk_ref is None:
+            raise cls.skipException('Skipping anaconda tests as an image ref '
+                                    'was not supplied')
+
+        network, subnet, router = cls.create_networks()
+        n_port = cls.create_neutron_port(network_id=network['id'])
+        cls.vif_attach(node_id=cls.node['uuid'], vif_id=n_port['id'])
+        p_root = '/instance_info/'
+        patch = [{'path': p_root + 'image_source',
+                  'op': 'add',
+                  'value': image_ref},
+                 {'path': p_root + 'kernel',
+                  'op': 'add',
+                  'value': kernel_ref},
+                 {'path': p_root + 'ramdisk',
+                  'op': 'add',
+                  'value': ramdisk_ref}]
+        if stage2_ref:
+            patch.append(
+                {
+                    'path': p_root + 'stage2',
+                    'op': 'add',
+                    'value': stage2_ref,
+                }
+            )
+        cls.update_node(cls.node['uuid'], patch=patch)
+        cls.set_node_provision_state(cls.node['uuid'], 'active')
+        if CONF.validation.connect_method == 'floating':
+            cls.node_ip = cls.add_floatingip_to_node(cls.node['uuid'])
+        elif CONF.validation.connect_method == 'fixed':
+            cls.node_ip = cls.get_server_ip(cls.node['uuid'])
+        else:
+            m = ('Configuration option "[validation]/connect_method" '
+                 'must be set.')
+            raise lib_exc.InvalidConfiguration(m)
+        cls.wait_power_state(cls.node['uuid'],
+                             bm.BaremetalPowerStates.POWER_ON)
+
+        if CONF.baremetal.anaconda_exit_test_at == 'heartbeat':
+            cls.wait_for_agent_heartbeat(
+                cls.node['uuid'],
+                timeout=CONF.baremetal.anaconda_active_timeout)
+        elif CONF.baremetal.anaconda_exit_test_at == 'active':
+            cls.wait_provisioning_state(
+                cls.node['uuid'],
+                bm.BaremetalProvisionStates.ACTIVE,
+                timeout=CONF.baremetal.anaconda_active_timeout,
+                interval=30)
+
+    def boot_and_verify_anaconda_node(self,
+                                      image_ref=None,
+                                      kernel_ref=None,
+                                      ramdisk_ref=None,
+                                      stage2_ref=None,
+                                      should_succeed=True):
+        self.boot_node_anaconda(image_ref, kernel_ref, ramdisk_ref)
+        self.assertTrue(self.ping_ip_address(self.node_ip,
+                                             should_succeed=should_succeed))
diff --git a/ironic_tempest_plugin/tests/scenario/ironic_standalone/test_basic_ops.py b/ironic_tempest_plugin/tests/scenario/ironic_standalone/test_basic_ops.py
index e2ead99..fcb3746 100644
--- a/ironic_tempest_plugin/tests/scenario/ironic_standalone/test_basic_ops.py
+++ b/ironic_tempest_plugin/tests/scenario/ironic_standalone/test_basic_ops.py
@@ -654,3 +654,49 @@
         self.wait_provisioning_state(self.node['uuid'], 'active',
                                      timeout=CONF.baremetal.active_timeout,
                                      interval=30)
+
+
+class BaremetalRedfishIPxeAnacondaNoGlance(
+        bsm.BaremetalStandaloneScenarioTest):
+
+    api_microversion = '1.78'  # to set the deploy_interface
+    driver = 'redfish'
+    deploy_interface = 'anaconda'
+    boot_interface = 'ipxe'
+    image_ref = CONF.baremetal.anaconda_image_ref
+    wholedisk_image = None
+
+    @classmethod
+    def skip_checks(cls):
+        super(BaremetalRedfishIPxeAnacondaNoGlance, cls).skip_checks()
+        if 'anaconda' not in CONF.baremetal.enabled_deploy_interfaces:
+            skip_msg = ("Skipping the test case since anaconda is not "
+                        "enabled.")
+            raise cls.skipException(skip_msg)
+
+    def test_ip_access_to_server_without_stage2(self):
+        # Tests deploy from a URL, or a pre-existing anaconda reference in
+        # glance.
+        if CONF.baremetal.anaconda_stage2_ramdisk_ref is not None:
+            skip_msg = ("Skipping the test case as an anaconda stage2 "
+                        "ramdisk has been defined, and that test can "
+                        "run instead.")
+            raise self.skipException(skip_msg)
+
+        self.boot_and_verify_anaconda_node(
+            image_ref=self.image_ref,
+            kernel_ref=CONF.baremetal.anaconda_kernel_ref,
+            ramdisk_ref=CONF.baremetal.anaconda_initial_ramdisk_ref)
+
+    def test_ip_access_to_server_using_stage2(self):
+        # Tests anaconda with a second stage ramdisk
+        if CONF.baremetal.anaconda_stage2_ramdisk_ref is None:
+            skip_msg = ("Skipping as stage2 ramdisk ref pointer has "
+                        "not been configured.")
+            raise self.skipException(skip_msg)
+
+        self.boot_and_verify_anaconda_node(
+            image_ref=self.image_ref,
+            kernel_ref=CONF.baremetal.anaconda_kernel_ref,
+            ramdisk_ref=CONF.baremetal.anaconda_initial_ramdisk_ref,
+            stage2_ref=CONF.baremetal.anaconda_stage2_ramdisk_ref)
diff --git a/ironic_tempest_plugin/tests/scenario/test_baremetal_basic_ops.py b/ironic_tempest_plugin/tests/scenario/test_baremetal_basic_ops.py
index 95430ce..dcfc023 100644
--- a/ironic_tempest_plugin/tests/scenario/test_baremetal_basic_ops.py
+++ b/ironic_tempest_plugin/tests/scenario/test_baremetal_basic_ops.py
@@ -189,7 +189,9 @@
 
     def validate_image(self):
         iinfo = self.node['instance_info']
-        if self.wholedisk_image:
+        if self.wholedisk_image is not None and self.wholedisk_image:
+            # If None, we have nothing to do here. If False, we don't
+            # want to fall into this either.
             self.assertNotIn('kernel', iinfo)
             self.assertNotIn('ramdisk', iinfo)
         else: