From 4ef0b8d3f698071db71743b44ba4f76a4cc9bbf5 Mon Sep 17 00:00:00 2001 From: Julia Kreger Date: Wed, 11 Oct 2023 12:40:31 -0700 Subject: [PATCH] WIP: Add test for dhcp-less vmedia based deployment Creating test to help facilitate the fix of bug 2032377. Change-Id: Ic848b8051e4d863f30d47c833d334afc879e4f20 --- ironic_tempest_plugin/config.py | 19 +++ .../scenario/baremetal_standalone_manager.py | 131 +++++++++++++-- .../ironic_standalone/test_advanced_ops.py | 149 ++++++++++++++++++ 3 files changed, 286 insertions(+), 13 deletions(-) create mode 100644 ironic_tempest_plugin/tests/scenario/ironic_standalone/test_advanced_ops.py diff --git a/ironic_tempest_plugin/config.py b/ironic_tempest_plugin/config.py index 8fbfbd5e..e1a7c5ea 100644 --- a/ironic_tempest_plugin/config.py +++ b/ironic_tempest_plugin/config.py @@ -238,6 +238,17 @@ BaremetalGroup = [ cfg.StrOpt('default_boot_option', # No good default here, we need to actually set it. help="The default boot option the testing nodes are using."), + cfg.BoolOpt("rebuild_remote_dhcpless", + default=True, + help="If we should issue a rebuild request when testing " + "dhcpless virtual media deployments. This may be useful " + "if bug 2032377 is not fixed in the agent ramdisk."), + cfg.StrOpt("public_subnet_id", + help="The public subnet ID where routers will be bound for " + "testing purposes with the dhcp-less test scenario."), + cfg.StrOpt("public_subnet_ip", + help="The public subnet IP to bind the public router to for " + "dhcp-less testing.") ] BaremetalFeaturesGroup = [ @@ -258,6 +269,14 @@ BaremetalFeaturesGroup = [ default=False, help="Defines if in-band RAID can be built in deploy time " "(possible starting with Victoria)."), + cfg.BoolOpt('dhcpless_vmedia', + default=False, + help="Defines if it is possible to execute DHCP-Less " + "deployment of baremetal nodes through virtual media. " + "This test requires full OS images with configuration " + "support for embedded network metadata through glean " + "or cloud-init, and thus cannot be executed with " + "most default job configurations."), ] BaremetalIntrospectionGroup = [ diff --git a/ironic_tempest_plugin/tests/scenario/baremetal_standalone_manager.py b/ironic_tempest_plugin/tests/scenario/baremetal_standalone_manager.py index 1ad14859..04e2e5a8 100644 --- a/ironic_tempest_plugin/tests/scenario/baremetal_standalone_manager.py +++ b/ironic_tempest_plugin/tests/scenario/baremetal_standalone_manager.py @@ -15,6 +15,7 @@ # License for the specific language governing permissions and limitations # under the License. +import ipaddress import random from oslo_utils import uuidutils @@ -264,9 +265,67 @@ class BaremetalStandaloneManager(bm.BaremetalScenarioTest, 'ironic node uuid %s' % node['instance_uuid']) raise lib_exc.TimeoutException(msg) + @classmethod + def gen_config_drive_net_info(cls, node_id, n_port): + # Find the port with the vif. + use_port = None + _, body = cls.baremetal_client.list_node_ports(node_id) + for port in body['ports']: + _, p = cls.baremetal_client.show_port(port['uuid']) + if 'tenant_vif_port_id' in p['internal_info']: + use_port = p + break + if not use_port: + m = ('Unable to determine proper mac address to use for config ' + 'to apply for the virtual media port test.') + raise lib_exc.InvalidConfiguration(m) + vif_mac_address = use_port['address'] + if CONF.validation.ip_version_for_ssh == 4: + ip_version = "ipv4" + else: + ip_version = "ipv6" + ip_address = n_port['fixed_ips'][0]['ip_address'] + subnet_id = n_port['fixed_ips'][0]['subnet_id'] + subnet = cls.os_primary.subnets_client.show_subnet( + subnet_id).get('subnet') + ip_netmask = str(ipaddress.ip_network(subnet.get('cidr')).netmask) + if ip_version == "ipv4": + route = [{ + "netmask": "0.0.0.0", + "network": "0.0.0.0", + "gateway": subnet.get('gateway_ip'), + }] + else: + # Eh... the data structure doesn't really allow for + # this to be easy since a default route with v6 + # is just referred to as ::/0 + # so network and netmask would be ::, which is + # semi-mind-breaking. Anyway, route advertisers are + # expected in this case. + route = [] + + return { + "links": [{"id": "port-test", + "type": "vif", + "ethernet_mac_address": vif_mac_address}], + "networks": [ + { + "id": "network0", + "type": ip_version, + "link": "port-test", + "ip_address": ip_address, + "netmask": ip_netmask, + "network_id": "network0", + "routes": route + } + ], + "services": [] + } + @classmethod def boot_node(cls, image_ref=None, image_checksum=None, - boot_option=None): + boot_option=None, config_drive_networking=False, + fallback_network=None): """Boot ironic node. The following actions are executed: @@ -282,7 +341,13 @@ class BaremetalStandaloneManager(bm.BaremetalScenarioTest, :param boot_option: The defaut boot option to utilize. If not specified, the ironic deployment default shall be utilized. + :param config_drive_networking: If we should load configuration drive + with network_data values. + :param fallback_network: Network to use if we are not able to detect + a network for use. """ + config_drive = {} + if image_ref is None: image_ref = cls.image_ref if image_checksum is None: @@ -291,8 +356,22 @@ class BaremetalStandaloneManager(bm.BaremetalScenarioTest, boot_option = cls.boot_option network, subnet, router = cls.create_networks() - n_port = cls.create_neutron_port(network_id=network['id']) + try: + n_port = cls.create_neutron_port(network_id=network['id']) + + except TypeError: + if fallback_network: + n_port = cls.create_neutron_port( + network_id=fallback_network) + else: + raise cls.vif_attach(node_id=cls.node['uuid'], vif_id=n_port['id']) + config_drive = None + if config_drive_networking: + config_drive = {} + config_drive['network_data'] = cls.gen_config_drive_net_info( + cls.node['uuid'], n_port) + patch = [{'path': '/instance_info/image_source', 'op': 'add', 'value': image_ref}] @@ -308,9 +387,15 @@ class BaremetalStandaloneManager(bm.BaremetalScenarioTest, patch.append({'path': '/instance_info/capabilities', 'op': 'add', 'value': {'boot_option': boot_option}}) - # TODO(vsaienko) add testing for custom configdrive cls.update_node(cls.node['uuid'], patch=patch) - cls.set_node_provision_state(cls.node['uuid'], 'active') + + if not config_drive: + cls.set_node_provision_state(cls.node['uuid'], 'active') + else: + cls.set_node_provision_state( + cls.node['uuid'], 'active', + configdrive=config_drive) + cls.wait_power_state(cls.node['uuid'], bm.BaremetalPowerStates.POWER_ON) cls.wait_provisioning_state(cls.node['uuid'], @@ -332,7 +417,12 @@ class BaremetalStandaloneManager(bm.BaremetalScenarioTest, cls.detach_all_vifs_from_node(node_id, force_delete=force_delete) if cls.delete_node or force_delete: - cls.set_node_provision_state(node_id, 'deleted') + node_state = cls.get_node(node_id)['provision_state'] + if node_state != bm.BaremetalProvisionStates.AVAILABLE: + # Check the state before making the call, to permit tests to + # drive node into a clean state before exiting the test, which + # is needed for some tests because of complex tests. + cls.set_node_provision_state(node_id, 'deleted') # NOTE(vsaienko) We expect here fast switching from deleted to # available as automated cleaning is disabled so poll status # each 1s. @@ -579,9 +669,16 @@ class BaremetalStandaloneScenarioTest(BaremetalStandaloneManager): 'Partitioned images are not supported with multitenancy.') @classmethod - def set_node_to_active(cls, image_ref=None, image_checksum=None): - cls.boot_node(image_ref, image_checksum) - if CONF.validation.connect_method == 'floating': + def set_node_to_active(cls, image_ref=None, image_checksum=None, + fallback_network=None, + config_drive_networking=None, + method_to_get_ip=None): + cls.boot_node(image_ref, image_checksum, + fallback_network=fallback_network, + config_drive_networking=config_drive_networking) + if method_to_get_ip: + cls.node_ip = method_to_get_ip(cls.node['uuid']) + elif 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']) @@ -622,11 +719,7 @@ class BaremetalStandaloneScenarioTest(BaremetalStandaloneManager): cls.update_node_driver(cls.node['uuid'], cls.driver, **boot_kwargs) @classmethod - def resource_cleanup(cls): - if CONF.validation.connect_method == 'floating': - if cls.node_ip: - cls.cleanup_floating_ip(cls.node_ip) - + def cleanup_vif_attachments(cls): vifs = cls.get_node_vifs(cls.node['uuid']) # Remove ports before deleting node, to catch regression for cases # when user did this prior unprovision node. @@ -635,6 +728,18 @@ class BaremetalStandaloneScenarioTest(BaremetalStandaloneManager): cls.ports_client.delete_port(vif) except lib_exc.NotFound: pass + + @classmethod + def resource_cleanup(cls): + if CONF.validation.connect_method == 'floating': + if cls.node_ip: + try: + cls.cleanup_floating_ip(cls.node_ip) + except IndexError: + # There is no fip to actually remove in this case. + pass + + cls.cleanup_vif_attachments() cls.terminate_node(cls.node['uuid']) cls.unreserve_node(cls.node) base.reset_baremetal_api_microversion() diff --git a/ironic_tempest_plugin/tests/scenario/ironic_standalone/test_advanced_ops.py b/ironic_tempest_plugin/tests/scenario/ironic_standalone/test_advanced_ops.py new file mode 100644 index 00000000..7db9ae14 --- /dev/null +++ b/ironic_tempest_plugin/tests/scenario/ironic_standalone/test_advanced_ops.py @@ -0,0 +1,149 @@ +# 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.common import utils +from tempest import config +from tempest.lib.common.utils import data_utils +from tempest.lib import decorators + +from ironic_tempest_plugin.tests.scenario import \ + baremetal_standalone_manager as bsm + +CONF = config.CONF + + +class BaremetalRedfishDHCPLessDeploy(bsm.BaremetalStandaloneScenarioTest): + + api_microversion = '1.59' # Ussuri for redfish-virtual-media + driver = 'redfish' + deploy_interface = 'direct' + boot_interface = 'redfish-virtual-media' + image_ref = CONF.baremetal.whole_disk_image_ref + image_checksum = CONF.baremetal.whole_disk_image_checksum + wholedisk_image = True + + @classmethod + def skip_checks(cls): + super(BaremetalRedfishDHCPLessDeploy, cls).skip_checks() + if CONF.baremetal_feature_enabled.dhcpless_vmedia: + raise cls.skipException("This test requires a full OS image to " + "be deployed, and thus must be " + "explicitly enabled for testing.") + + if (not CONF.baremetal.public_subnet_id + or not CONF.baremetal.public_subnet_ip): + raise cls.skipException( + "This test requires a public sunbet ID, and public subnet " + "IP to use on that subnet to execute. Please see the " + "baremetal configuration options public_subnet_id " + "and public_subnet_ip respectively, and populate with " + "appropriate values to execute this test.") + + def create_tenant_network(self, clients, tenant_cidr, ip_version): + # NOTE(TheJulia): self.create_network is an internal method + # which just gets the info, doesn't actually create a network. + network = self.create_network( + networks_client=self.os_admin.networks_client, + project_id=clients.credentials.project_id, + shared=True) + + router = self.get_router( + client=clients.routers_client, + project_id=clients.credentials.tenant_id, + external_gateway_info={ + 'network_id': CONF.network.public_network_id, + 'external_fixed_ips': [ + {'subnet_id': CONF.baremetal.public_subnet_id, + 'ip_address': CONF.baremetal.public_subnet_ip}] + }) + result = clients.subnets_client.create_subnet( + name=data_utils.rand_name('subnet'), + network_id=network['id'], + tenant_id=clients.credentials.tenant_id, + ip_version=CONF.validation.ip_version_for_ssh, + cidr=tenant_cidr, enable_dhcp=False) + subnet = result['subnet'] + clients.routers_client.add_router_interface(router['id'], + subnet_id=subnet['id']) + self.addCleanup(clients.subnets_client.delete_subnet, subnet['id']) + self.addCleanup(clients.routers_client.remove_router_interface, + router['id'], subnet_id=subnet['id']) + return network, subnet, router + + def deploy_vmedia_dhcpless(self, rebuild=False): + """Helper to facilitate vmedia testing. + + * Create Network/router without DHCP + * Set provisioning_network for this node. + * Set cleanup to undo the provisionign network setup. + * Launch instance. + * Requirement: Instance OS image supports network config from + network_data embedded in the OS. i.e. a real image, not + cirros. + * If so enabled, rebuild the node, Verify rebuild completed. + * Via cleanup: Teardown Network/Router + """ + + # Get the latest state for the node. + self.node = self.get_node(self.node['uuid']) + prior_prov_net = self.node['driver_info'].get('provisioning_network') + + ip_version = CONF.validation.ip_version_for_ssh + tenant_cidr = '10.0.6.0/24' + if ip_version == 6: + tenant_cidr = 'fd00:33::/64' + + network, subnet, router = self.create_tenant_network( + self.os_admin, tenant_cidr, ip_version=ip_version) + if prior_prov_net: + self.update_node(self.node['uuid'], + [{'op': 'replace', + 'path': '/driver_info/provisioning_network', + 'value': network['id']}]) + self.addCleanup(self.update_node, + self.node['uuid'], + [{'op': 'replace', + 'path': '/driver_info/provisioning_network', + 'value': prior_prov_net}]) + else: + self.update_node(self.node['uuid'], + [{'op': 'add', + 'path': '/driver_info/provisioning_network', + 'value': network['id']}]) + self.addCleanup(self.update_node, + self.node['uuid'], + [{'op': 'remove', + 'path': '/driver_info/provisioning_network'}]) + + self.set_node_to_active(self.image_ref, self.image_checksum, + fallback_network=network['id'], + config_drive_networking=True, + method_to_get_ip=self.get_server_ip) + + # node_ip is set by the prior call to set_node_to_active + self.assertTrue(self.ping_ip_address(self.node_ip)) + + if rebuild: + self.set_node_provision_state(self.node['uuid'], 'rebuild') + self.wait_provisioning_state(self.node['uuid'], 'active', + timeout=CONF.baremetal.active_timeout, + interval=30) + # Assert we were able to ping after rebuilding. + self.assertTrue(self.ping_ip_address(self.node_ip)) + # Force delete so we remove the vifs + self.terminate_node(self.node['uuid'], force_delete=True) + + @decorators.idempotent_id('1f420ef3-99bd-46c7-b859-ce9c2892697f') + @utils.services('image', 'network') + def test_ip_access_to_server(self): + self.deploy_vmedia_dhcpless( + rebuild=CONF.baremetal.rebuild_remote_dhcpless)