
Moved tests from downstream plugin as they are, with necessary adjustments. - Changed paths where needed. - Updated test uuids. - Moved also relevant config options and base functions. Change-Id: I509641a72ae87e6aed8582426a09ab87390c72fe
514 lines
22 KiB
Python
514 lines
22 KiB
Python
# 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 base64
|
|
import random
|
|
import re
|
|
import time
|
|
|
|
import netaddr
|
|
from neutron_lib import constants
|
|
from neutron_tempest_plugin.common import ssh
|
|
from neutron_tempest_plugin.common import utils as common_utils
|
|
from neutron_tempest_plugin.scenario import base
|
|
from oslo_log import log
|
|
from tempest.common import utils
|
|
from tempest import config
|
|
from tempest.lib.common.utils import data_utils
|
|
|
|
from whitebox_neutron_tempest_plugin.common import tcpdump_capture as capture
|
|
from whitebox_neutron_tempest_plugin.common import utils as local_utils
|
|
|
|
CONF = config.CONF
|
|
LOG = log.getLogger(__name__)
|
|
WB_CONF = 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
|
|
agents = cls.os_admin.network.AgentsClient().list_agents()['agents']
|
|
ovn_agents = [agent for agent in agents if 'ovn' in agent['binary']]
|
|
cls.has_ovn_support = True if ovn_agents else False
|
|
sriov_agents = [
|
|
agent for agent in agents if 'sriov' in agent['binary']]
|
|
cls.has_sriov_support = True if sriov_agents else False
|
|
|
|
@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()
|
|
|
|
def get_host_for_server(self, server_id):
|
|
server_details = self.os_admin.servers_client.show_server(server_id)
|
|
return server_details['server']['OS-EXT-SRV-ATTR:host']
|
|
|
|
@classmethod
|
|
def get_external_gateway(cls):
|
|
if CONF.network.public_network_id:
|
|
subnets = cls.os_admin.network_client.list_subnets(
|
|
network_id=CONF.network.public_network_id)
|
|
|
|
for subnet in subnets['subnets']:
|
|
if (subnet['gateway_ip'] and
|
|
subnet['ip_version'] == constants.IP_VERSION_4):
|
|
return subnet['gateway_ip']
|
|
|
|
def _create_server_for_topology(
|
|
self, network_id=None, port_type=None,
|
|
different_host=None, port_qos_policy_id=None):
|
|
if not network_id:
|
|
network_id = self.network['id']
|
|
if port_type:
|
|
kwargs = {'binding:vnic_type': port_type,
|
|
'qos_policy_id': port_qos_policy_id}
|
|
port = self.create_port(
|
|
network={'id': network_id}, **kwargs)
|
|
networks = [{'port': port['id']}]
|
|
else:
|
|
networks = [{'uuid': network_id}]
|
|
|
|
params = {
|
|
'flavor_ref': self.flavor_ref,
|
|
'image_ref': self.image_ref,
|
|
'key_name': self.keypair['name'],
|
|
'networks': networks,
|
|
'security_groups': [
|
|
{'name': self.secgroup['security_group']['name']}],
|
|
'name': data_utils.rand_name(self._testMethodName)
|
|
}
|
|
if port_type == 'direct-physical':
|
|
net_vlan = self.client.show_network(
|
|
network_id)['network']['provider:segmentation_id']
|
|
params['user_data'] = build_user_data(net_vlan)
|
|
params['config_drive'] = True
|
|
if (different_host and CONF.compute.min_compute_nodes > 1):
|
|
params['scheduler_hints'] = {
|
|
'different_host': different_host['id']}
|
|
server = self.create_server(**params)['server']
|
|
if different_host and CONF.compute.min_compute_nodes > 1:
|
|
if (self.get_host_for_server(different_host['id']) ==
|
|
self.get_host_for_server(server['id'])):
|
|
raise self.skipException(
|
|
'Failed to run the VM on a different hypervisor, make '
|
|
'sure that DifferentHostFilter is in the list of '
|
|
'enabled nova scheduler filters')
|
|
|
|
port = self.client.list_ports(device_id=server['id'])['ports'][0]
|
|
if network_id == CONF.network.public_network_id:
|
|
access_ip_address = port['fixed_ips'][0]['ip_address']
|
|
else:
|
|
access_ip_address = self.create_floatingip(
|
|
port=port)['floating_ip_address']
|
|
|
|
server['ssh_client'] = ssh.Client(access_ip_address,
|
|
self.username,
|
|
pkey=self.keypair['private_key'])
|
|
return server
|
|
|
|
def _create_vms_by_topology(
|
|
self, topology='internal', port_type=None, ipv6=False,
|
|
different_host=True, num_vms_created=2):
|
|
|
|
"""Function for creating desired topology for the test
|
|
|
|
Available topologies:
|
|
* internal(default): sender and receiver are on tenant network
|
|
* external: sender and receiver are on external(public) network
|
|
* east-west: sender and receiver are on different tenant networks
|
|
* north-south: sender is on external and receiver on tenant network
|
|
|
|
:param topology(str): one of 4 available topologies to use (see list
|
|
above)
|
|
:param port_type(str): type of port to use. If omitted, default port
|
|
type will be used. Can be set to 'direct' or 'direct-physical'
|
|
for SR-IOV environments.
|
|
:param different_host(bool): whether to force vms to run on different
|
|
host.
|
|
:param num_vms_created(int): number of vms to create, 1 or 2.
|
|
default is 2.
|
|
:returns: sender if num_vms_created is 1, else server and receiver
|
|
"""
|
|
# num_vms_created can be 1 or 2
|
|
self.assertIn(num_vms_created, [1, 2], "num_vms_created can be 1 or 2")
|
|
|
|
def _create_local_network():
|
|
network = self.create_network()
|
|
subnet_index = len(self.reserved_subnet_cidrs)
|
|
cidr = '192.168.%d.0/24' % subnet_index
|
|
subnet = self.create_subnet(network, cidr=cidr)
|
|
self.create_router_interface(router['id'], subnet['id'])
|
|
if ipv6:
|
|
ipv6_cidr = '2001:{:x}::/64'.format(200 + subnet_index)
|
|
ra_address_mode = 'dhcpv6-stateless'
|
|
ipv6_subnet = self.create_subnet(
|
|
network, cidr=ipv6_cidr, ip_version=6,
|
|
ipv6_ra_mode=ra_address_mode,
|
|
ipv6_address_mode=ra_address_mode)
|
|
self.create_router_interface(router['id'], ipv6_subnet['id'])
|
|
|
|
return network
|
|
|
|
if topology != 'external':
|
|
if hasattr(self, "router") and self.router:
|
|
router = self.router
|
|
else:
|
|
router = self.create_router_by_client()
|
|
|
|
if topology == 'external' or topology == 'north-south':
|
|
external_network = self.client.show_network(
|
|
CONF.network.public_network_id)['network']
|
|
if not external_network['shared']:
|
|
skip_reason = "External network is not shared"
|
|
self.skipTest(skip_reason)
|
|
src_network = external_network
|
|
else:
|
|
src_network = _create_local_network()
|
|
|
|
sender = self._create_server_for_topology(
|
|
network_id=src_network['id'],
|
|
port_type=port_type)
|
|
|
|
if topology == 'external' or topology == 'internal':
|
|
dst_network = src_network
|
|
else:
|
|
dst_network = _create_local_network()
|
|
|
|
different_host = sender if different_host else None
|
|
if num_vms_created == 1:
|
|
return sender
|
|
receiver = self._create_server_for_topology(
|
|
different_host=different_host, network_id=dst_network['id'],
|
|
port_type=port_type)
|
|
return sender, receiver
|
|
|
|
|
|
class TrafficFlowTest(BaseTempestWhiteboxTestCase):
|
|
force_tenant_isolation = False
|
|
|
|
@classmethod
|
|
@utils.requires_ext(extension="router", service="network")
|
|
def skip_checks(cls):
|
|
super(TrafficFlowTest, cls).skip_checks()
|
|
if not CONF.network.public_network_id:
|
|
raise cls.skipException(
|
|
'The public_network_id option must be specified.')
|
|
if not WB_CONF.run_traffic_flow_tests:
|
|
raise cls.skipException(
|
|
"CONF.whitebox_neutron_plugin_options."
|
|
"run_traffic_flow_tests set to False.")
|
|
|
|
@classmethod
|
|
def resource_setup(cls):
|
|
super(TrafficFlowTest, cls).resource_setup()
|
|
cls.gateway_external_ip = cls.get_external_gateway()
|
|
if not cls.gateway_external_ip:
|
|
raise cls.skipException("IPv4 gateway is not configured "
|
|
"for public network or public_network_id "
|
|
"is not configured.")
|
|
|
|
def _start_captures(self, interface, filters):
|
|
for node in self.nodes:
|
|
node['capture'] = capture.TcpdumpCapture(
|
|
node['client'], interface, filters)
|
|
self.useFixture(node['capture'])
|
|
time.sleep(2)
|
|
|
|
def _stop_captures(self):
|
|
for node in self.nodes:
|
|
node['capture'].stop()
|
|
|
|
def check_east_west_icmp_flow(
|
|
self, dst_ip, expected_routing_nodes, expected_macs, ssh_client):
|
|
"""Check that traffic routed as expected within a tenant network
|
|
Both directions are supported.
|
|
Traffic is captured on
|
|
CONF.whitebox_neutron_plugin_options.node_tunnel_interface.
|
|
Use values:
|
|
genev_sys_6081 for OVN
|
|
vxlanxx for ML2/OVS with VXLAN tunnels
|
|
<vlanid> for ML2/OVS with VLAN tunnels
|
|
|
|
:param dst_ip(str): Destination IP address that we check route to
|
|
:param expected_routing_nodes(list): Hostnames of expected gateways,
|
|
nodes on tunnel interface of which we expect
|
|
to find ethernet frames with packets that we send
|
|
:param expected_macs(tuple): pair of MAC addresses of ports that we
|
|
expect to find on the captured packets
|
|
:param ssh_client(Client): SSH client object of the origin of traffic
|
|
(the one that we send traffic from)
|
|
|
|
"""
|
|
interface = CONF.whitebox_neutron_plugin_options.node_tunnel_interface
|
|
|
|
# create filters
|
|
if type(expected_macs) is tuple:
|
|
filters = 'icmp and ether host {0} and ether host {1}'.format(
|
|
expected_macs[0],
|
|
expected_macs[1])
|
|
elif type(expected_macs) is list:
|
|
filters = ('"icmp and ((ether host {0} and ether host {1}) '
|
|
'or (ether host {2} and ether host {3}))"').format(
|
|
expected_macs[0][0],
|
|
expected_macs[0][1],
|
|
expected_macs[1][0],
|
|
expected_macs[1][1])
|
|
else:
|
|
raise TypeError(expected_macs)
|
|
|
|
self._start_captures(interface, filters)
|
|
self.check_remote_connectivity(ssh_client, dst_ip, ping_count=2)
|
|
self._stop_captures()
|
|
LOG.debug('Expected routing nodes: {}'.format(
|
|
','.join(expected_routing_nodes)))
|
|
actual_routing_nodes = [node['name']
|
|
for node in self.nodes if
|
|
not node['capture'].is_empty()]
|
|
LOG.debug('Actual routing nodes: {}'.format(
|
|
','.join(actual_routing_nodes)))
|
|
self.assertCountEqual(expected_routing_nodes, actual_routing_nodes)
|
|
|
|
def check_north_south_icmp_flow(
|
|
self, dst_ip, expected_routing_nodes, expected_mac, ssh_client,
|
|
ignore_outbound=False):
|
|
"""Check that traffic routed as expected between internal and external
|
|
networks. Both directions are supported.
|
|
|
|
:param dst_ip(str): Destination IP address that we check route to
|
|
:param expected_routing_nodes(list): Hostnames of expected gateways,
|
|
nodes on external interface of which we expect
|
|
to find ethernet frames with packets that we send
|
|
:param expected_mac(str): MAC address of a port that we expect to find
|
|
on the expected gateway external interface
|
|
:param ssh_client(Client): SSH client object of the origin of traffic
|
|
(the one that we send traffic from)
|
|
:param ignore_outbound(bool): Whether to ignore outbound packets.
|
|
This helps to avoid false positives.
|
|
"""
|
|
interface = WB_CONF.node_ext_interface
|
|
inbound = '-Qin' if ignore_outbound else ''
|
|
size = None
|
|
if not WB_CONF.bgp:
|
|
filters = '{} icmp and ether host {}'.format(inbound, expected_mac)
|
|
else:
|
|
filters = "{} icmp and icmp[0] == 8".format(inbound)
|
|
size = random.randint(0, 50)
|
|
# Adjust payload size adding icmp header size
|
|
if netaddr.valid_ipv6(dst_ip):
|
|
size += 44
|
|
else:
|
|
size += 28
|
|
# Filter including ip size packet
|
|
filters += " and ip[2:2]=={} and ip dst {}".format(size, dst_ip)
|
|
|
|
self._start_captures(interface, filters)
|
|
# if the host is localhost, don't use remote connectivity,
|
|
# ping directly on the host
|
|
if ssh_client.host in (
|
|
'localhost', '127.0.0.1', '0:0:0:0:0:0:0:1', '::1'):
|
|
self.ping_ip_address(dst_ip, mtu=size, should_succeed=True)
|
|
# tcpdump requires a delay between capturing packets and writing
|
|
# them to its file.
|
|
time.sleep(2)
|
|
else:
|
|
self.check_remote_connectivity(
|
|
ssh_client, dst_ip, mtu=size, ping_count=2)
|
|
self._stop_captures()
|
|
LOG.debug('Expected routing nodes: {}'.format(expected_routing_nodes))
|
|
actual_routing_nodes = [node['name']
|
|
for node in self.nodes if
|
|
not node['capture'].is_empty()]
|
|
LOG.debug('Actual routing nodes: {}'.format(
|
|
','.join(actual_routing_nodes)))
|
|
self.assertCountEqual(expected_routing_nodes, actual_routing_nodes)
|
|
|
|
|
|
class BaseTempestTestCaseOvn(BaseTempestWhiteboxTestCase):
|
|
|
|
@classmethod
|
|
def resource_setup(cls):
|
|
super(BaseTempestTestCaseOvn, cls).resource_setup()
|
|
if not cls.has_ovn_support:
|
|
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 <PORT_NAME>` 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 <PORT_NAME>` 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 <PORT_NAME> 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-<SEGEMTN_ID>' 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, '')
|
|
|
|
|
|
# user_data_cmd is used to generate a VLAN interface on VM instances with PF
|
|
# ports
|
|
user_data_cmd = """
|
|
#cloud-config
|
|
write_files:
|
|
- path: "/etc/sysconfig/network-scripts/ifcfg-%s"
|
|
owner: "root"
|
|
permissions: "777"
|
|
content: |
|
|
DEVICE="%s"
|
|
BOOTPROTO="dhcp"
|
|
ONBOOT="yes"
|
|
VLAN="yes"
|
|
PERSISTENT_DHCLIENT="yes"
|
|
runcmd:
|
|
- [ sh, -c , "systemctl restart NetworkManager" ]
|
|
"""
|
|
user_data_cmd = user_data_cmd.replace('\t', '')
|
|
|
|
|
|
def build_user_data(net_vlan):
|
|
"""user_data is required when direct-physical (PF) ports are used
|
|
"""
|
|
if_full_name = '%s.%s' % \
|
|
(WB_CONF.default_instance_interface,
|
|
net_vlan)
|
|
user_data = base64.b64encode((
|
|
user_data_cmd % (if_full_name, if_full_name)).encode("utf-8"))
|
|
return user_data
|