From fa2d7457a82068c075722d2f23dd96f6bc7321fd Mon Sep 17 00:00:00 2001 From: Roman Safronov Date: Wed, 6 Dec 2023 12:06:57 +0200 Subject: [PATCH] Add extra dhcp options tests Tests moved from downstream plugin with minimal adjustments. These tests require nmcli inside the server so advanced image changed to rocky-9 cloud image. Other alternative, e.g. centos-9 does not have a default user and requires additional customization for setting the user. Additionally, moved utils.py and constants.py. From config.py and base.py only relevant subset of code moved. Change-Id: I6b538c13a15e94d072091b3218b2cd48ff20a70c --- devstack/plugin.sh | 3 + tox.ini | 3 +- .../common/constants.py | 42 ++ .../common/utils.py | 214 ++++++++++ whitebox_neutron_tempest_plugin/config.py | 19 +- .../tests/scenario/base.py | 187 +++++++++ .../tests/scenario/test_extra_dhcp_opts.py | 369 ++++++++++++++++++ zuul.d/master_jobs.yaml | 11 +- 8 files changed, 841 insertions(+), 7 deletions(-) create mode 100644 devstack/plugin.sh create mode 100644 whitebox_neutron_tempest_plugin/common/constants.py create mode 100644 whitebox_neutron_tempest_plugin/common/utils.py create mode 100644 whitebox_neutron_tempest_plugin/tests/scenario/base.py create mode 100644 whitebox_neutron_tempest_plugin/tests/scenario/test_extra_dhcp_opts.py diff --git a/devstack/plugin.sh b/devstack/plugin.sh new file mode 100644 index 0000000..3a0226f --- /dev/null +++ b/devstack/plugin.sh @@ -0,0 +1,3 @@ +if [[ "$1" == "stack" ]] && [[ "$2" == "install" ]]; then + echo "tempest ALL=(ALL) NOPASSWD: ALL" | sudo tee /etc/sudoers.d/99_tempest +fi diff --git a/tox.ini b/tox.ini index 3852a6a..7881d4a 100644 --- a/tox.ini +++ b/tox.ini @@ -33,7 +33,8 @@ commands = {posargs} # H904: Delay string interpolations at logging calls enable-extensions = H106,H203,H204,H205,H904 # H405: multi line docstring summary not separated with an empty line -ignore = H405 +# W504: line break after binary operator +ignore = H405,W504 show-source = true exclude = ./.*,build,dist,doc,*egg*,releasenotes import-order-style = pep8 diff --git a/whitebox_neutron_tempest_plugin/common/constants.py b/whitebox_neutron_tempest_plugin/common/constants.py new file mode 100644 index 0000000..375d27b --- /dev/null +++ b/whitebox_neutron_tempest_plugin/common/constants.py @@ -0,0 +1,42 @@ +# Copyright 2020 Red Hat, Inc. +# All Rights Reserved. +# +# 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. + +GLOBAL_IP = '1.1.1.1' +NCAT_PORT = 65000 +NCAT_TIMEOUT = 30 +IP_HEADER_LENGTH = 20 +TCP_HEADER_LENGTH = 20 +UDP_HEADER_LENGTH = 8 +ETHERNET_HEADER_LENGTH = 18 +STATE_UP = 'up' +STATE_DOWN = 'down' +ACTION_STOP = 'stop' +ACTION_START = 'start' +DHCP_OPTIONS_NMCLI_TO_NEUTRON = { + 'dhcp_lease_time': 'lease-time', + 'domain_name': 'domain-name', + 'domain_name_servers': 'dns-server', + 'interface_mtu': 'mtu', + 'dhcp6_domain_search': 'domain-search', + 'dhcp6_name_servers': 'dns-server'} +DHCP_OPTIONS_NUMBER_TO_NAME = { + '26': 'mtu'} +DHCP_OPTIONS_NEUTRON_TO_OVN = { + 'domain-name': 'domain_name', + 'mtu': 'mtu', + 'dns-server': 'dns_server', + 'lease-time': 'lease_time', + 'domain-search': 'domain_search', + '26': 'mtu'} diff --git a/whitebox_neutron_tempest_plugin/common/utils.py b/whitebox_neutron_tempest_plugin/common/utils.py new file mode 100644 index 0000000..88369ef --- /dev/null +++ b/whitebox_neutron_tempest_plugin/common/utils.py @@ -0,0 +1,214 @@ +# Copyright 2020 Red Hat, Inc. +# All Rights Reserved. +# +# 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. + +import re +import subprocess +import time + +from neutron_tempest_plugin.common import shell +from neutron_tempest_plugin.common import utils as common_utils +from oslo_log import log +from tempest import config +from tempest.lib import exceptions + +from whitebox_neutron_tempest_plugin.common import constants + +CONF = config.CONF +LOG = log.getLogger(__name__) + + +def create_payload_file(ssh_client, size): + ssh_client.exec_command( + "head -c {0} /dev/zero > {0}".format(size)) + + +def get_temp_file(ssh_client): + output_file = ssh_client.exec_command( + 'mktemp').rstrip() + return output_file + + +def cat_remote_file(ssh_client, path): + return ssh_client.exec_command( + 'cat {}'.format(path)).rstrip() + + +def get_default_interface(ssh_client): + return ssh_client.exec_command( + "PATH=$PATH:/usr/sbin ip route get default %s | head -1 | " + "cut -d ' ' -f 5" % constants.GLOBAL_IP).rstrip() + + +def get_route_interface(ssh_client, dst_ip): + output = ssh_client.exec_command( + "PATH=$PATH:/usr/sbin ip route get default %s | head -1" % dst_ip) + if output: + for line in output.splitlines(): + fields = line.strip().split() + device_index = fields.index('dev') + 1 + return fields[device_index] + else: + return None + + +def make_sure_local_port_is_open(protocol, port): + shell.execute_local_command( + "sudo iptables-save | " + r"grep 'INPUT.*{protocol}.*\-\-dport {port} \-j ACCEPT' " + "&& true || " + "sudo iptables -I INPUT 1 -p {protocol} --dport {port} -j ACCEPT" + "".format(protocol=protocol, port=port)) + + +# Unlike ncat server function from the upstream plugin this ncat server +# turns itself off automatically after timeout +def run_ncat_server(ssh_client, udp): + output_file = get_temp_file(ssh_client) + cmd = "sudo timeout {0} nc -l {1} -p {2} > {3}".format( + constants.NCAT_TIMEOUT, udp, constants.NCAT_PORT, output_file) + LOG.debug("Starting nc server: '%s'", cmd) + ssh_client.open_session().exec_command(cmd) + return output_file + + +# Unlike ncat client function from the upstream plugin this ncat client +# is able to run from any host, not only locally +def run_ncat_client(ssh_client, host, udp, payload_size): + cmd = "nc -w 1 {0} {1} {2} < {3}".format( + host, udp, constants.NCAT_PORT, payload_size) + LOG.debug("Starting nc client: '%s'", cmd) + ssh_client.exec_command(cmd) + + +def flush_routing_cache(ssh_client): + ssh_client.exec_command("sudo ip route flush cache") + + +def kill_iperf_process(ssh_client): + cmd = "PATH=$PATH:/usr/sbin pkill iperf3" + try: + ssh_client.exec_command(cmd) + except exceptions.SSHExecCommandFailed: + pass + + +def configure_interface_up(client, port, interface=None, path=None): + """configures down interface with ip and activates it + + Parameters: + client (ssh.Client):ssh client which has interface to configure. + port (port):port object of interface. + interface (str):optional interface name on vm. + path (str):optional shell PATH variable. + """ + shell_path = path or "PATH=$PATH:/sbin" + test_interface = interface or client.exec_command( + "{};ip addr | grep {} -B 1 | head -1 | " + r"cut -d ':' -f 2 | sed 's/\ //g'".format( + shell_path, port['mac_address'])).rstrip() + + if CONF.neutron_plugin_options.default_image_is_advanced: + cmd = ("ip addr show {interface} | grep {ip} || " + "sudo dhclient {interface}").format( + ip=port['fixed_ips'][0]['ip_address'], + interface=test_interface) + else: + cmd = ("cat /sys/class/net/{interface}/operstate | " + "grep -q -v down && true || " + "({path}; sudo ip link set {interface} up && " + "sudo ip addr add {ip}/24 dev {interface})").format( + path=shell_path, + ip=port['fixed_ips'][0]['ip_address'], + interface=test_interface) + + common_utils.wait_until_true( + lambda: execute_command_safely(client, cmd), timeout=30, sleep=5) + + +def parse_dhcp_options_from_nmcli( + ssh_client, ip_version, + timeout=20.0, interval=5.0, expected_empty=False, vlan=None): + # first of all, test ssh connection is available - the time it takes until + # ssh connection can be established is not cosidered for the nmcli timeout + ssh_client.test_connection_auth() + # Add grep -v to exclude loopback interface because + # Managing the lookback interface using NetworkManager is included in + # RHEL9.2 image. Previous version is not included. + cmd_find_connection = 'nmcli -g NAME con show --active | grep -v "^lo"' + if vlan is not None: + cmd_find_connection += ' | grep {}'.format(vlan) + cmd_show_dhcp = ('sudo nmcli -f DHCP{} con show ' + '"$({})"').format(ip_version, cmd_find_connection) + + start_time = time.time() + while True: + try: + output = ssh_client.exec_command(cmd_show_dhcp) + except exceptions.SSHExecCommandFailed: + LOG.warning('Failed to run nmcli on VM - retrying...') + else: + if not output and not expected_empty: + LOG.warning('nmcli result on VM is empty - retrying...') + else: + break + if time.time() - start_time > timeout: + message = ('Failed to run nmcli on VM after {} ' + 'seconds'.format(timeout)) + raise exceptions.TimeoutException(message) + time.sleep(interval) + + if not output: + return None + obtained_dhcp_opts = {} + for line in output.splitlines(): + newline = re.sub(r'^DHCP{}.OPTION\[[0-9]+\]:\s+'.format(ip_version), + '', line.strip()) + option = newline.split('=')[0].strip() + value = newline.split('=')[1].strip() + if option in constants.DHCP_OPTIONS_NMCLI_TO_NEUTRON: + option = constants.DHCP_OPTIONS_NMCLI_TO_NEUTRON[option] + obtained_dhcp_opts[option] = value + return obtained_dhcp_opts + + +def execute_command_safely(ssh_client, command): + try: + output = ssh_client.exec_command(command) + except exceptions.SSHExecCommandFailed as err: + LOG.warning('command failed: %s', command) + LOG.exception(err) + return False + LOG.debug('command executed successfully: %s\n' + 'command output:\n%s', + command, output) + return True + + +def host_responds_to_ping(ip, count=3): + cmd = "ping -c{} {}".format(count, ip) + try: + subprocess.check_output(['bash', '-c', cmd]) + except subprocess.CalledProcessError: + return False + return True + + +def run_local_cmd(cmd, timeout=10): + command = "timeout " + str(timeout) + " " + cmd + LOG.debug("Running local command '{}'".format(command)) + output, errors = subprocess.Popen( + command, shell=True, stdout=subprocess.PIPE, + stderr=subprocess.PIPE).communicate() + return output, errors diff --git a/whitebox_neutron_tempest_plugin/config.py b/whitebox_neutron_tempest_plugin/config.py index e085785..099f14d 100644 --- a/whitebox_neutron_tempest_plugin/config.py +++ b/whitebox_neutron_tempest_plugin/config.py @@ -21,4 +21,21 @@ whitebox_neutron_plugin_options = cfg.OptGroup( title="Whitebox neutron tempest plugin config options" ) -WhiteboxNeutronPluginOptions = [] +WhiteboxNeutronPluginOptions = [ + cfg.StrOpt('openstack_type', + default='devstack', + help='Type of openstack deployment, ' + 'e.g. devstack, tripeo, podified'), + cfg.StrOpt('pki_private_key', + default='/etc/pki/tls/private/ovn_controller.key', + help='File with private key. Need for TLS-everywhere ' + 'environments.'), + cfg.StrOpt('pki_certificate', + default='/etc/pki/tls/certs/ovn_controller.crt', + help='File with certificate for private key. Need for ' + 'TLS-everywhere environments.'), + cfg.StrOpt('pki_ca_cert', + default='/etc/ipa/ca.crt', + help='File with peer CA certificate. Need for TLS-everywhere ' + 'environments.') +] diff --git a/whitebox_neutron_tempest_plugin/tests/scenario/base.py b/whitebox_neutron_tempest_plugin/tests/scenario/base.py new file mode 100644 index 0000000..c1ef754 --- /dev/null +++ b/whitebox_neutron_tempest_plugin/tests/scenario/base.py @@ -0,0 +1,187 @@ +# Copyright 2019 Red Hat, Inc. +# All Rights Reserved. +# +# 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. +import re + +import netaddr +from neutron_tempest_plugin.scenario import base +from neutron_tempest_plugin.common import utils as common_utils +from oslo_log import log +from tempest import config + +from whitebox_neutron_tempest_plugin.common import utils as local_utils + +CONF = config.CONF +LOG = log.getLogger(__name__) +WB_CONF = config.CONF.whitebox_neutron_plugin_options + + +class BaseTempestWhiteboxTestCase(base.BaseTempestTestCase): + credentials = ['primary', 'admin'] + + @classmethod + def resource_setup(cls): + super(BaseTempestWhiteboxTestCase, cls).resource_setup() + uri = CONF.identity.uri + cls.is_ipv6 = True if netaddr.valid_ipv6( + uri[uri.find("[") + 1:uri.find("]")]) else False + cls.image_ref = CONF.compute.image_ref + cls.flavor_ref = CONF.compute.flavor_ref + cls.username = CONF.validation.image_ssh_user + + @classmethod + def run_on_master_controller(cls, cmd): + if WB_CONF.openstack_type == 'devstack': + output, errors = local_utils.run_local_cmd(cmd) + LOG.debug("Stderr: {}".format(errors.decode())) + output = output.decode() + LOG.debug("Output: {}".format(output)) + return output.strip() + + +class BaseTempestTestCaseOvn(BaseTempestWhiteboxTestCase): + + @classmethod + def resource_setup(cls): + super(BaseTempestTestCaseOvn, cls).resource_setup() + agents = cls.os_admin.network.AgentsClient().list_agents()['agents'] + ovn_agents = [agent for agent in agents if 'ovn' in agent['binary']] + if not ovn_agents: + raise cls.skipException( + "OVN agents not found. This test is supported only on " + "openstack environments with OVN support.") + + cls.nbctl, cls.sbctl = cls._get_ovn_dbs() + cls.nbmonitorcmd, cls.sbmonitorcmd = cls._get_ovn_db_monitor_cmds() + + @classmethod + def _get_ovn_db_monitor_cmds(cls): + regex = r'--db=(.*)$' + # this regex search will return the connection string (tcp:IP:port or + # ssl:IP:port) and in case of TLS, will also include the TLS options + nb_monitor_connection_opts = re.search(regex, cls.nbctl).group(1) + sb_monitor_connection_opts = re.search(regex, cls.sbctl).group(1) + monitorcmdprefix = 'sudo timeout 300 ovsdb-client monitor -f json ' + return (monitorcmdprefix + nb_monitor_connection_opts, + monitorcmdprefix + sb_monitor_connection_opts) + + @classmethod + def _get_ovn_dbs(cls): + ssl_params = '' + if WB_CONF.openstack_type == 'tripleo': + cmd = ("sudo ovs-vsctl get open . external_ids:ovn-remote | " + "sed -e 's/\"//g'") + sbdb = cls.run_on_master_controller(cmd) + if 'ssl' in sbdb: + ssl_params = '-p {} -c {} -C {} '.format( + WB_CONF.pki_private_key, + WB_CONF.pki_certificate, + WB_CONF.pki_ca_cert) + nbdb = sbdb.replace('6642', '6641') + cmd = 'ovn-{}ctl --db={} %s' % (ssl_params) + cmd = 'sudo %s exec ovn_controller %s' % (cls.container_app, cmd) + if WB_CONF.openstack_type == 'devstack': + sbdb = "unix:/usr/local/var/run/ovn/ovnsb_db.sock" + nbdb = sbdb.replace('sb', 'nb') + cmd = ("sudo ovn-{}ctl --db={}") + return [cmd.format('nb', nbdb), cmd.format('sb', sbdb)] + + def get_router_gateway_chassis(self, router_port_id): + cmd = "{} get port_binding cr-lrp-{} chassis".format( + self.sbctl, router_port_id) + LOG.debug("Waiting until port is bound to chassis") + self.chassis_id = None + + def _port_binding_exist(): + self.chassis_id = self.run_on_master_controller(cmd) + LOG.debug("chassis_id = '{}'".format(self.chassis_id)) + if self.chassis_id != '[]': + return True + return False + + try: + common_utils.wait_until_true(lambda: _port_binding_exist(), + timeout=30, sleep=5) + except common_utils.WaitTimeout: + self.fail("Port is not bound to chassis") + cmd = "{} get chassis {} hostname".format(self.sbctl, self.chassis_id) + LOG.debug("Running '{}' on the master node".format(cmd)) + res = self.run_on_master_controller(cmd) + return res.replace('"', '').split('.')[0] + + def get_router_gateway_chassis_list(self, router_port_id): + cmd = (self.nbctl + " lrp-get-gateway-chassis lrp-" + router_port_id) + data = self.run_on_master_controller(cmd) + return [re.sub(r'.*_(.*?)\s.*', r'\1', s) for s in data.splitlines()] + + def get_router_gateway_chassis_by_id(self, chassis_id): + res = self.run_on_master_controller( + self.sbctl + " get chassis " + chassis_id + " hostname").rstrip() + return res.replace('"', '').split('.')[0] + + def get_router_port_gateway_mtu(self, router_port_id): + cmd = (self.nbctl + " get logical_router_port lrp-" + router_port_id + + " options:gateway_mtu") + return int( + self.run_on_master_controller(cmd).rstrip().strip('"')) + + def get_item_uuid(self, db, item, search_string): + ovn_db = self.sbctl if db == 'sb' else self.nbctl + cmd = (ovn_db + " find " + item + " " + search_string + + " | grep _uuid | awk '{print $3}'") + return self.run_on_master_controller(cmd) + + def get_datapath_tunnel_key(self, search_string): + cmd = (self.sbctl + " find datapath_binding " + search_string + + " | grep tunnel_key | awk '{print $3}'") + return self.run_on_master_controller(cmd) + + def get_logical_switch(self, port): + """Returns logical switch name that port is connected to + + Fuction gets the logical switch name without its ID from the + `ovn-nbctl lsp-get-ls ` command + """ + cmd = '{cmd} lsp-get-ls {port}'.format(cmd=self.nbctl, port=port) + output = self.run_on_master_controller(cmd) + ls_name = re.search('neutron-[^)]*', output) + if ls_name: + return ls_name.group() + else: + return '' + + def get_physical_net(self, port): + """Returns physical network name that port has configured with + + Physical network name is saved as option in the logical switch port + record in OVN north database. It can be queried with + `ovn-nbctl lsp-get-options ` command but this output may + contain more than one option so it is better to get the value with + `ovn-nbctl get Logical_Switch_Port options:network_name` + command + """ + cmd = '{cmd} get Logical_Switch_Port {port} '\ + 'options:network_name'.format(cmd=self.nbctl, port=port) + return self.run_on_master_controller(cmd) + + def verify_that_segment_deleted(self, segment_id): + """Checks that the segment id is not in the OVN database + + There shouldn't be 'provnet-' port in the OVN database + after the segment has been deleted + """ + cmd = '{cmd} find Logical_Switch_Port '\ + 'name=provnet-{sid}'.format(cmd=self.nbctl, sid=segment_id) + output = self.run_on_master_controller(cmd) + self.assertEqual(output, '') diff --git a/whitebox_neutron_tempest_plugin/tests/scenario/test_extra_dhcp_opts.py b/whitebox_neutron_tempest_plugin/tests/scenario/test_extra_dhcp_opts.py new file mode 100644 index 0000000..0caff3a --- /dev/null +++ b/whitebox_neutron_tempest_plugin/tests/scenario/test_extra_dhcp_opts.py @@ -0,0 +1,369 @@ +# Copyright 2021 Red Hat, Inc. +# All Rights Reserved. +# +# 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. +import re + +from neutron_lib import constants as lib_constants +from neutron_tempest_plugin.common import ssh +from neutron_tempest_plugin import config +from neutron_tempest_plugin.scenario import base +from neutron_tempest_plugin.scenario import constants as neutron_constants +from oslo_log import log +from tempest.common import waiters +from tempest.lib.common.utils import data_utils +from tempest.lib import decorators + +from whitebox_neutron_tempest_plugin.common import constants +from whitebox_neutron_tempest_plugin.common import utils +from whitebox_neutron_tempest_plugin.tests.scenario import base as ds_base + + +CONF = config.CONF +LOG = log.getLogger(__name__) +IPV4 = lib_constants.IP_VERSION_4 +IPV6 = lib_constants.IP_VERSION_6 + + +class ExtraDhcpOptionsTest(base.BaseTempestTestCase): + credentials = ['primary', 'admin'] + required_extensions = ['extra_dhcp_opt'] + ipv4_cidr_pattern = '192.168.{}.0/24' + ipv6_cidr_pattern = '2001:{:x}::/64' + + @classmethod + def resource_setup(cls): + super(ExtraDhcpOptionsTest, cls).resource_setup() + cls.rand_name = data_utils.rand_name( + cls.__name__.rsplit('.', 1)[-1]) + cls.keypair = cls.create_keypair(name=cls.rand_name) + cls.security_group = cls.create_security_group(name=cls.rand_name) + cls.create_loginable_secgroup_rule( + cls.security_group['id'], client=cls.client) + + if CONF.neutron_plugin_options.default_image_is_advanced: + cls.flavor_ref = CONF.compute.flavor_ref + cls.image_ref = CONF.compute.image_ref + cls.username = CONF.validation.image_ssh_user + else: + cls.flavor_ref = \ + CONF.neutron_plugin_options.advanced_image_flavor_ref + cls.image_ref = CONF.neutron_plugin_options.advanced_image_ref + cls.username = CONF.neutron_plugin_options.advanced_image_ssh_user + + if (not cls.flavor_ref) or (not cls.image_ref): + raise cls.skipException( + 'No advance image/flavor available for these tests') + + def setUp(self): + super(ExtraDhcpOptionsTest, self).setUp() + self.subnet = None + self.port = None + self.server = None + self.dhcp_disabled = {} + + def _check_created_extra_dhcp_opts(self): + # obtain the extra_dhcp_opts from Neutron API + retrieved = self.client.show_port( + self.port['id'])['port']['extra_dhcp_opts'] + self.assertEqual(len(retrieved), len(self.extra_dhcp_opts)) + for retrieved_option in retrieved: + for option in self.extra_dhcp_opts: + # default ip_version is 4 + ip_version = option.get('ip_version', IPV4) + if (retrieved_option['opt_value'] == option['opt_value'] and + retrieved_option['opt_name'] == option['opt_name'] and + retrieved_option['ip_version'] == ip_version): + break + else: + self.fail('Extra DHCP option not found in port %s' % + str(retrieved_option)) + + def _create_port_and_check_dhcp_opts( + self, + ipv6=False, + ra_address_mode=None, + dhcp4_enabled=True, + dhcp6_enabled=True): + rand_name_test = self.rand_name + '-' + self._testMethodName + network = self.create_network(name=rand_name_test) + + subnet_index = len(self.reserved_subnet_cidrs) + self.subnet = self.create_subnet( + network=network, + cidr=self.ipv4_cidr_pattern.format(subnet_index), + name=rand_name_test, + enable_dhcp=dhcp4_enabled) + router = self.create_router_by_client() + self.create_router_interface(router['id'], self.subnet['id']) + if ipv6: + if dhcp6_enabled: + subnetv6 = self.create_subnet( + network=network, + ip_version=IPV6, + name=rand_name_test + '-ipv6', + cidr=self.ipv6_cidr_pattern.format(subnet_index), + ipv6_ra_mode=ra_address_mode, + ipv6_address_mode=ra_address_mode) + else: + # when dhcp6 is disabled, neither ipv6_ra_mode nor + # ipv6_address_mode should be provided + subnetv6 = self.create_subnet( + network=network, + ip_version=IPV6, + name=rand_name_test + '-ipv6', + cidr=self.ipv6_cidr_pattern.format(subnet_index), + enable_dhcp=dhcp6_enabled) + self.create_router_interface(router['id'], subnetv6['id']) + + # a port is created with the extra_dhcp_options from the test case + self.port = self.create_port( + network=network, name=rand_name_test, + security_groups=[self.security_group['id']], + extra_dhcp_opts=self.extra_dhcp_opts) + + self._check_created_extra_dhcp_opts() + + def _create_server_and_fip(self): + networks = [{'port': self.port['id']}] + # config_drive is needed when dhcp4 is disabled in order to provide + # an IPv4 to the server to be able to connect via ssh to it + config_drive = not self.subnet['enable_dhcp'] + self.server = self.create_server( + flavor_ref=self.flavor_ref, + image_ref=self.image_ref, + key_name=self.keypair['name'], + networks=networks, + config_drive=config_drive, + name=self.rand_name + '-' + self._testMethodName)['server'] + + vm_fip = self.create_floatingip(port=self.port)['floating_ip_address'] + return ssh.Client(vm_fip, + self.username, + pkey=self.keypair['private_key']) + + def _test_extra_dhcp_opts_ipv4_ipv6(self, ra_address_mode): + self._create_port_and_check_dhcp_opts( + ipv6=True, ra_address_mode=ra_address_mode) + vm_ssh_client = self._create_server_and_fip() + + nmcli_dhcp_options = {} + nmcli_dhcp_options[IPV4] = utils.parse_dhcp_options_from_nmcli( + vm_ssh_client, IPV4) + nmcli_dhcp_options[IPV6] = utils.parse_dhcp_options_from_nmcli( + vm_ssh_client, IPV6, timeout=100) + + for extra_dhcp_opt in self.extra_dhcp_opts: + ip_version = extra_dhcp_opt.get('ip_version', IPV4) + + # if opt_name is an integer value, then we need to obtain its + # corresponding option name + try: + int(extra_dhcp_opt['opt_name']) + except ValueError: + # it is not an integer + opt_name = extra_dhcp_opt['opt_name'] + else: + opt_name = constants.DHCP_OPTIONS_NUMBER_TO_NAME[ + extra_dhcp_opt['opt_name']] + + self.assertIn(opt_name, nmcli_dhcp_options[ip_version]) + # some formatting is needed before the value comparison: + # - remove extra " from Neutron option + # - remove extra \ from nmcli option + self.assertEqual( + extra_dhcp_opt['opt_value'].replace('"', ''), + nmcli_dhcp_options[ip_version][opt_name].replace('\\', '')) + + @decorators.idempotent_id('8f52b4dc-faae-4f1d-b113-d2f3e86bf0d6') + def test_extra_dhcp_opts_ipv4_ipv6_stateful(self): + self.extra_dhcp_opts = [ + {'opt_value': '2001:4860:4860::8888', + 'opt_name': 'dns-server', + 'ip_version': IPV6}, + {'opt_value': '8.8.8.8', + 'opt_name': 'dns-server', + 'ip_version': IPV4}, + {'opt_value': '1600', + 'opt_name': '26'}] # 26 is option code for mtu + + self._test_extra_dhcp_opts_ipv4_ipv6('dhcpv6-stateful') + + @decorators.idempotent_id('e9e32249-6148-4565-b7b1-e64c77c9f4ec') + def test_extra_dhcp_opts_ipv4_ipv6_stateless(self): + self.extra_dhcp_opts = [ + {'opt_value': '"ipv6.domain"', # domains should be between " + 'opt_name': 'domain-search', + 'ip_version': IPV6}, + {'opt_value': '"ipv4.domain"', # ditto + 'opt_name': 'domain-name', + 'ip_version': IPV4}] + + self._test_extra_dhcp_opts_ipv4_ipv6('dhcpv6-stateless') + + @decorators.idempotent_id('ef41d6d8-f2bf-44e4-9f4d-bb8a3fed50ad') + def test_extra_dhcp_opts_disabled_enabled_dhcp4(self): + domain_value = 'ipv4.domain' + domain_opt = 'domain-name' + self.extra_dhcp_opts = [ + {'opt_value': '"{}"'.format(domain_value), + 'opt_name': domain_opt, + 'ip_version': IPV4}] + + # dhcp is disabled from the iPv4 subnet created + self._create_port_and_check_dhcp_opts(dhcp4_enabled=False) + vm_ssh_client = self._create_server_and_fip() + # ipv4.domain is not expected + vm_resolv_conf = vm_ssh_client.exec_command( + "sudo dhclient && cat /etc/resolv.conf") + self.assertIsNone(re.search(r'^search\s+{}$'.format(domain_value), + vm_resolv_conf, + re.MULTILINE)) + + # enable dhcp for the IPv4 subnet and reboot the VM + self.subnets[-1] = self.client.update_subnet( + self.subnets[-1]['id'], enable_dhcp=True)['subnet'] + self.os_primary.servers_client.reboot_server( + self.server['id'], type='SOFT') + waiters.wait_for_server_status(self.os_primary.servers_client, + self.server['id'], + neutron_constants.SERVER_STATUS_ACTIVE) + # ipv4.domain is expected + vm_resolv_conf = vm_ssh_client.exec_command( + "sudo dhclient && cat /etc/resolv.conf") + self.assertIsNotNone(re.search(r'^search\s+{}$'.format(domain_value), + vm_resolv_conf, + re.MULTILINE)) + + @decorators.idempotent_id('abb12899-690a-407d-99d4-49eca030ce94') + def test_extra_dhcp_opts_disabled_dhcp6(self): + domain_value = 'ipv6.domain' + domain_opt = 'domain-search' + self.extra_dhcp_opts = [ + {'opt_value': '"{}"'.format(domain_value), + 'opt_name': domain_opt, + 'ip_version': IPV6}] + + # dhcp is disabled from the iPv6 subnet created + self._create_port_and_check_dhcp_opts(ipv6=True, dhcp6_enabled=False) + vm_ssh_client = self._create_server_and_fip() + + # empty nmcli_dhcp6_options is expected + nmcli_dhcp6_options = utils.parse_dhcp_options_from_nmcli( + vm_ssh_client, IPV6, timeout=100, expected_empty=True) + self.assertIsNone(nmcli_dhcp6_options) + + +class OvnExtraDhcpOptionsTest(ExtraDhcpOptionsTest, + ds_base.BaseTempestTestCaseOvn): + def _check_created_extra_dhcp_opts(self): + def _check_options_from_ovn_nbdb(dhcp_option_uuids): + cmd2_pattern = '{} get dhcp_options {} options:{}' + + # are dhcpv4 and dhcpv6 enabled? + subnets_dhcp_enabled = {subnet['ip_version']: subnet['enable_dhcp'] + for subnet in self.subnets} + + for extra_dhcp_opt in self.extra_dhcp_opts: + ip_version = extra_dhcp_opt.get('ip_version', IPV4) + if (not subnets_dhcp_enabled[ip_version] or + self.dhcp_disabled.get(ip_version) is True or + extra_dhcp_opt['opt_name'] == 'dhcp_disabled'): + continue + ovn_opt_name = constants.DHCP_OPTIONS_NEUTRON_TO_OVN[ + extra_dhcp_opt['opt_name']] + # if the option is not found in the OVN DB, the command will + # fail and then the test will fail, which is correct + ovn_opt_value = self.run_on_master_controller( + cmd2_pattern.format( + self.nbctl, + dhcp_option_uuids[ip_version], + ovn_opt_name)) + # some formatting is needed before the value comparison: + # - remove extra " from Neutron option + # - remove extra \ and " from OVN option + self.assertEqual( + extra_dhcp_opt['opt_value'].replace('"', ''), + ovn_opt_value.replace('"', '').replace('\\', '').strip()) + + # Verify no dhcpvX_option_uuid exist when DHCPvX is disabled + # Value '[]' means no entry in OVN NBDB dhcp_options table + for ip_version in subnets_dhcp_enabled: + if (subnets_dhcp_enabled[ip_version] is False or + self.dhcp_disabled.get(ip_version) is True): + self.assertEqual('[]', dhcp_option_uuids[ip_version]) + + # first, call parent's method + super(OvnExtraDhcpOptionsTest, self)._check_created_extra_dhcp_opts() + + cmd1_pattern = '{} find logical_switch_port name={}' + output = self.run_on_master_controller( + cmd1_pattern.format(self.nbctl, self.port['id'])) + dhcp_option_uuids = {} + + for line in output.splitlines(): + if re.search(r'^dhcpv4_options', line): + ip_version = IPV4 + elif re.search(r'^dhcpv6_options', line): + ip_version = IPV6 + else: + # skip this line + continue + dhcp_option_uuids[ip_version] = re.sub( + r'^dhcpv{}_options\s+: '.format(ip_version), + '', + line).strip() + + _check_options_from_ovn_nbdb(dhcp_option_uuids) + + @decorators.idempotent_id('30ef3221-e46b-4358-b550-d98c08272e1c') + def test_extra_dhcp_opts_dhcp_disabled_option(self): + self.extra_dhcp_opts = [ + {'opt_value': '2001:4860:4860::8888', + 'opt_name': 'dns-server', + 'ip_version': IPV6}, + {'opt_value': '8.8.8.8', + 'opt_name': 'dns-server', + 'ip_version': IPV4}, + {'opt_value': 'True', + 'opt_name': 'dhcp_disabled', + 'ip_version': IPV6}, + {'opt_value': 'False', + 'opt_name': 'dhcp_disabled', + 'ip_version': IPV4}] + self.dhcp_disabled[IPV6] = True + self.dhcp_disabled[IPV4] = False + + # create port and check extra-dhcp-opts + self._create_port_and_check_dhcp_opts( + ipv6=True, ra_address_mode='dhcpv6-stateless') + + # update port and check extra-dhcp-opts + self.extra_dhcp_opts = [ + {'opt_value': '2001:4860:4860::8888', + 'opt_name': 'dns-server', + 'ip_version': IPV6}, + {'opt_value': '8.8.8.8', + 'opt_name': 'dns-server', + 'ip_version': IPV4}, + {'opt_value': 'False', + 'opt_name': 'dhcp_disabled', + 'ip_version': IPV6}, + {'opt_value': 'True', + 'opt_name': 'dhcp_disabled', + 'ip_version': IPV4}] + self.dhcp_disabled[IPV6] = False + self.dhcp_disabled[IPV4] = True + self.port = self.client.update_port( + self.port['id'], extra_dhcp_opts=self.extra_dhcp_opts)['port'] + self._check_created_extra_dhcp_opts() diff --git a/zuul.d/master_jobs.yaml b/zuul.d/master_jobs.yaml index 016ffa3..fcd5032 100644 --- a/zuul.d/master_jobs.yaml +++ b/zuul.d/master_jobs.yaml @@ -24,14 +24,15 @@ USE_PYTHON3: true NETWORK_API_EXTENSIONS: "{{ (network_api_extensions_common + network_api_extensions_tempest) | join(',') }}" PHYSICAL_NETWORK: public - IMAGE_URLS: https://cloud-images.ubuntu.com/minimal/releases/focal/release/ubuntu-20.04-minimal-cloudimg-amd64.img + IMAGE_URLS: https://download.rockylinux.org/pub/rocky/9/images/x86_64/Rocky-9-GenericCloud.latest.x86_64.qcow2 CIRROS_VERSION: 0.6.2 DEFAULT_IMAGE_NAME: cirros-0.6.2-x86_64-uec DEFAULT_IMAGE_FILE_NAME: cirros-0.6.2-x86_64-uec.tar.gz - ADVANCED_IMAGE_NAME: ubuntu-20.04-minimal-cloudimg-amd64 - ADVANCED_INSTANCE_TYPE: ntp_image_256M - ADVANCED_INSTANCE_USER: ubuntu - CUSTOMIZE_IMAGE: true + ADVANCED_IMAGE_NAME: Rocky-9-GenericCloud.latest.x86_64 + ADVANCED_INSTANCE_TYPE: ds1G + ADVANCED_INSTANCE_USER: rocky + GLANCE_LIMIT_IMAGE_SIZE_TOTAL: 2000 + CUSTOMIZE_IMAGE: false BUILD_TIMEOUT: 784 # TODO(lucasagomes): Re-enable MOD_WSGI after # https://bugs.launchpad.net/neutron/+bug/1912359 is implemented