From 793608a85704bd6a14707349347e130fe9c97b58 Mon Sep 17 00:00:00 2001 From: Julia Kreger Date: Wed, 11 Oct 2023 12:40:31 -0700 Subject: [PATCH] Add test for dhcp-less vmedia based deployment Creating test to help facilitate the fix of bug 2032377 and ultimately help ensure we have a backwards compatible fix. The scenario: * Creates a shiny new network without dhcp, and adds a router. * Sets that network as the provisioning network for the node. * Creates a port on that network, assigns that port as the VIF. * Creates a configuration drive network_data.json file payload * Triggers deployment, utilizing the aformentioned network_data payload and configuration drive. * Once deployment has complete, attempts to ping the remote host. * Once pingable, and this is configurable, it will then attempt to rebuild the remote host, basically forcing the condition covered in bug #2032377. * Rebuild is completed, and the host is attempted to be pinged, again. To facilitate this, three configuration options have been added. Two are external network settings. Because there is no way in a devstack configuration of neutron to advertise the next hop router, we have to have a pre-assigned/configured IP on the external network we can attach a router to. We also need to know the external network ID. Then there is basically a option flag if we wish to exercise the pattern for bug 2032377. Ideally, this would be always, but with the complexity and fact a non-stock IPA image is required, one sort of needs to know and then configure as appropriate. 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)