diff --git a/tobiko/config.py b/tobiko/config.py index f28fa28d5..6ce12c08f 100644 --- a/tobiko/config.py +++ b/tobiko/config.py @@ -36,6 +36,7 @@ CONFIG_MODULES = ['tobiko.openstack.glance.config', 'tobiko.openstack.topology.config', 'tobiko.shell.ssh.config', 'tobiko.shell.ping.config', + 'tobiko.shell.iperf.config', 'tobiko.shell.sh.config', 'tobiko.tripleo.config'] diff --git a/tobiko/openstack/stacks/_centos.py b/tobiko/openstack/stacks/_centos.py index 3c009a5df..b119cab4b 100644 --- a/tobiko/openstack/stacks/_centos.py +++ b/tobiko/openstack/stacks/_centos.py @@ -16,6 +16,7 @@ from __future__ import absolute_import import tobiko from tobiko import config from tobiko.openstack import glance +from tobiko.openstack import nova from tobiko.openstack.stacks import _nova @@ -61,4 +62,9 @@ class CentosExternalServerStackFixture(CentosServerStackFixture, class CentosQosServerStackFixture(CentosServerStackFixture, _nova.QosServerStackFixture): - pass + + @property + def cloud_config(self): + return nova.cloud_config( + super(CentosQosServerStackFixture, self).cloud_config, + packages=['iperf3']) diff --git a/tobiko/shell/iperf/__init__.py b/tobiko/shell/iperf/__init__.py new file mode 100644 index 000000000..4fccae854 --- /dev/null +++ b/tobiko/shell/iperf/__init__.py @@ -0,0 +1,21 @@ +# Copyright (c) 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. +from __future__ import absolute_import + +from tobiko.shell.iperf import _assert + + +assert_bw_limit = _assert.assert_bw_limit diff --git a/tobiko/shell/iperf/_assert.py b/tobiko/shell/iperf/_assert.py new file mode 100644 index 000000000..d94309b5c --- /dev/null +++ b/tobiko/shell/iperf/_assert.py @@ -0,0 +1,55 @@ +# Copyright (c) 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. +from __future__ import absolute_import +from __future__ import division + +from oslo_log import log + +import tobiko +from tobiko import config +from tobiko.shell.iperf import _iperf + + +CONF = config.CONF +LOG = log.getLogger(__name__) + + +def calculate_bw(iperf_measures): + # first interval is removed because BW measured during it is not + # limited - it takes ~ 1 second to traffic shaping algorithm to apply + # bw limit properly (buffer is empty when traffic starts being sent) + intervals = iperf_measures['intervals'][1:] + + bits_received = sum([interval['sum']['bytes'] * 8 + for interval in intervals]) + totaltime = sum([interval['sum']['seconds'] for interval in intervals]) + # bw in bits per second + return bits_received / totaltime + + +def assert_bw_limit(ssh_client, ssh_server, **params): + iperf_measures = _iperf.iperf(ssh_client, ssh_server, **params) + measured_bw = calculate_bw(iperf_measures) + + testcase = tobiko.get_test_case() + bw_limit = float(params.get('bw_limit') or + CONF.tobiko.neutron.bwlimit_kbps * 1000.) + LOG.debug('measured_bw = %f', measured_bw) + LOG.debug('bw_limit = %f', bw_limit) + # a 5% of upper deviation is allowed + testcase.assertLess(measured_bw, bw_limit * 1.05) + # an 8% of lower deviation is allowed + testcase.assertGreater(measured_bw, bw_limit * 0.92) diff --git a/tobiko/shell/iperf/_interface.py b/tobiko/shell/iperf/_interface.py new file mode 100644 index 000000000..66e448f90 --- /dev/null +++ b/tobiko/shell/iperf/_interface.py @@ -0,0 +1,185 @@ +# Copyright (c) 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. +from __future__ import absolute_import + +from oslo_log import log + +import tobiko +from tobiko.shell import sh + + +LOG = log.getLogger(__name__) + + +def install_iperf(ssh_client): + def iperf_help(): + cmd = 'iperf3 --help' + try: + return sh.execute(cmd, + expect_exit_status=None, + ssh_client=ssh_client) + except FileNotFoundError: + return sh.execute_result(command=cmd, + exit_status=127, + stdout='command not found') + result = iperf_help() + usage = ((result.stdout and str(result.stdout)) or + (result.stderr and str(result.stderr)) or "").strip() + if result.exit_status != 0 and 'command not found' in usage.lower(): + install_command = '{install_tool} install -y iperf3' + install_tools = ('yum', 'apt') + for install_tool in install_tools: + try: + result = sh.execute( + command=install_command.format(install_tool=install_tool), + ssh_client=ssh_client, + sudo=True) + except sh.ShellError: + LOG.debug(f'Unable to install iperf3 using {install_tool}') + else: + LOG.debug(f'iperf3 successfully installed with {install_tool}') + break + + if iperf_help().exit_status != 0: + raise RuntimeError('iperf3 command was not installed successfully') + elif result.exit_status != 0: + raise RuntimeError('Error executing iperf3 command') + else: + LOG.debug('iperf3 already installed') + + +def get_iperf_command(parameters, ssh_client): + interface = get_iperf_interface(ssh_client=ssh_client) + return interface.get_iperf_command(parameters) + + +def get_iperf_interface(ssh_client): + manager = tobiko.setup_fixture(IperfInterfaceManager) + interface = manager.get_iperf_interface(ssh_client=ssh_client) + tobiko.check_valid_type(interface, IperfInterface) + return interface + + +class IperfInterfaceManager(tobiko.SharedFixture): + def __init__(self): + super(IperfInterfaceManager, self).__init__() + self.client_interfaces = {} + self.interfaces = [] + self.default_interface = IperfInterface() + + def add_iperf_interface(self, interface): + LOG.debug('Register iperf interface %r', interface) + self.interfaces.append(interface) + + def get_iperf_interface(self, ssh_client): + try: + return self.client_interfaces[ssh_client] + except KeyError: + pass + + install_iperf(ssh_client) + LOG.debug('Assign default iperf interface to SSH client %r', + ssh_client) + self.client_interfaces[ssh_client] = self.default_interface + return self.default_interface + + +class IperfInterface(object): + def get_iperf_command(self, parameters): + command = sh.shell_command(['iperf3'] + + self.get_iperf_options(parameters)) + LOG.debug(f'Got iperf command: {command}') + return command + + def get_iperf_options(self, parameters): + options = [] + + port = parameters.port + if port: + options += self.get_port_option(port) + + timeout = parameters.timeout + if timeout and parameters.mode == 'client': + options += self.get_timeout_option(timeout) + + output_format = parameters.output_format + if output_format: + options += self.get_output_format_option(output_format) + + bitrate = parameters.bitrate + if bitrate and parameters.mode == 'client': + options += self.get_bitrate_option(bitrate) + + download = parameters.download + if download and parameters.mode == 'client': + options += self.get_download_option(download) + + protocol = parameters.protocol + if protocol and parameters.mode == 'client': + options += self.get_protocol_option(protocol) + + options += self.get_mode_option(parameters) + + return options + + @staticmethod + def get_mode_option(parameters): + mode = parameters.mode + if not mode or mode not in ('client', 'server'): + raise ValueError('iperf mode values allowed: [client|server]') + elif mode == 'client' and not parameters.ip: + raise ValueError('iperf client mode requires a destination ' + 'IP address') + elif mode == 'client': + return ['-c', parameters.ip] + else: # mode == 'server' + return ['-s', '-D'] # server mode is executed with daemon mode + + @staticmethod + def get_download_option(download): + if download: + return ['-R'] + else: + return [] + + @staticmethod + def get_protocol_option(protocol): + if protocol == 'tcp': + return [] + elif protocol == 'udp': + return ['-u'] + else: + raise ValueError('iperf protocol values allowed: [tcp|udp]') + + @staticmethod + def get_timeout_option(timeout): + return ['-t', timeout] + + @staticmethod + def get_output_format_option(output_format): + if output_format == 'json': + return ['-J'] + else: + raise ValueError('iperf output format values allowed: ' + '[json]') + + @staticmethod + def get_port_option(port): + return ['-p', port] + + @staticmethod + def get_bitrate_option(bitrate): + return ['-b', bitrate] diff --git a/tobiko/shell/iperf/_iperf.py b/tobiko/shell/iperf/_iperf.py new file mode 100644 index 000000000..9d4f57ed1 --- /dev/null +++ b/tobiko/shell/iperf/_iperf.py @@ -0,0 +1,75 @@ +# Copyright (c) 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. +from __future__ import absolute_import + +import json +import time + +from oslo_log import log + +from tobiko.shell.iperf import _interface +from tobiko.shell.iperf import _parameters +from tobiko.shell import sh + + +LOG = log.getLogger(__name__) + + +def iperf(ssh_client, ssh_server, **iperf_params): + """Run iperf on both client and server machines and return obtained + statistics + + :param ssh_client: ssh connection to client + :param ssh_server: ssh connection to server + :param **iperf_params: parameters to be forwarded to get_statistics() + function + :returns: dict + """ + parameters_server = _parameters.get_iperf_parameters( + mode='server', **iperf_params) + # no output expected + execute_iperf_server(parameters_server, ssh_server) + + time.sleep(0.1) + parameters_client = _parameters.get_iperf_parameters( + mode='client', ip=ssh_server.host, **iperf_params) + # output is a dictionary + output = execute_iperf_client(parameters_client, ssh_client) + + return output + + +def execute_iperf_server(parameters, ssh_client): + # kill any iperf3 process running before executing it again + sh.execute(command='pkill iperf3', + ssh_client=ssh_client, + expect_exit_status=None) + time.sleep(1) + + # server is executed in background and no output is expected + command = _interface.get_iperf_command(parameters=parameters, + ssh_client=ssh_client) + sh.execute(command=command, ssh_client=ssh_client) + + +def execute_iperf_client(parameters, ssh_client): + command = _interface.get_iperf_command(parameters=parameters, + ssh_client=ssh_client) + result = sh.execute(command=command, + ssh_client=ssh_client, + timeout=parameters.timeout + 5., + expect_exit_status=None) + return json.loads(result.stdout) diff --git a/tobiko/shell/iperf/_parameters.py b/tobiko/shell/iperf/_parameters.py new file mode 100644 index 000000000..c3a5a7ba7 --- /dev/null +++ b/tobiko/shell/iperf/_parameters.py @@ -0,0 +1,58 @@ +# Copyright (c) 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. +from __future__ import absolute_import + +import collections + +from tobiko import config + + +CONF = config.CONF + + +def get_iperf_parameters(mode, ip=None, **iperf_params): + """Get iperf parameters + mode allowed values: client or server + ip is only needed for client mode + """ + return IperfParameters( + mode=mode, + ip=ip, + port=iperf_params.get('port', CONF.tobiko.iperf.port), + timeout=iperf_params.get('timeout', CONF.tobiko.iperf.timeout), + output_format=iperf_params.get('output_format', + CONF.tobiko.iperf.output_format), + download=iperf_params.get('download', CONF.tobiko.iperf.download), + bitrate=iperf_params.get('bitrate', CONF.tobiko.iperf.bitrate), + protocol=iperf_params.get('protocol', CONF.tobiko.iperf.protocol)) + + +class IperfParameters(collections.namedtuple('IperfParameters', + ['mode', + 'ip', + 'port', + 'timeout', + 'output_format', + 'download', + 'bitrate', + 'protocol'])): + """Recollect parameters to be used to format iperf command line + + IperfParameters class is a data model recollecting parameters used to + create an iperf command line. It provides the feature of copying default + values from another instance of IperfParameters passed using constructor + parameter 'default'. + """ diff --git a/tobiko/shell/iperf/config.py b/tobiko/shell/iperf/config.py new file mode 100644 index 000000000..570360c0a --- /dev/null +++ b/tobiko/shell/iperf/config.py @@ -0,0 +1,49 @@ +# Copyright 2021 Red Hat +# +# 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 __future__ import absolute_import + +import itertools + +from oslo_config import cfg + +GROUP_NAME = "iperf" +OPTIONS = [ + cfg.IntOpt('port', + default=1234, + help="Port number"), + cfg.StrOpt('protocol', + default='tcp', + choices=['tcp', 'udp'], + help="tcp and udp values are supported"), + cfg.StrOpt('output_format', + default='json', + choices=['', 'json'], + help="output format"), + cfg.IntOpt('bitrate', + default=20000000, + help="target bit rate"), + cfg.BoolOpt('download', + default=True, + help="direction download (True) or upload (False)"), + cfg.IntOpt('timeout', + default=10, + help="timeout of the iperf test")] + + +def register_tobiko_options(conf): + conf.register_opts(group=cfg.OptGroup(GROUP_NAME), opts=OPTIONS) + + +def list_options(): + return [(GROUP_NAME, itertools.chain(OPTIONS))] diff --git a/tobiko/shell/sh/__init__.py b/tobiko/shell/sh/__init__.py index fc6847e43..2598c735d 100644 --- a/tobiko/shell/sh/__init__.py +++ b/tobiko/shell/sh/__init__.py @@ -42,6 +42,7 @@ ShellStdinClosed = _exception.ShellStdinClosed execute = _execute.execute execute_process = _execute.execute_process +execute_result = _execute.execute_result ShellExecuteResult = _execute.ShellExecuteResult HostNameError = _hostname.HostnameError diff --git a/tobiko/tests/scenario/neutron/test_qos.py b/tobiko/tests/scenario/neutron/test_qos.py index 9d181c90f..e24745c8b 100644 --- a/tobiko/tests/scenario/neutron/test_qos.py +++ b/tobiko/tests/scenario/neutron/test_qos.py @@ -20,6 +20,7 @@ import testtools import tobiko from tobiko.openstack import stacks from tobiko.openstack import topology +from tobiko.shell import iperf from tobiko.tripleo import containers from tobiko.tripleo import overcloud @@ -41,11 +42,24 @@ class QoSBasicTest(testtools.TestCase): if (overcloud.has_overcloud() and topology.verify_osp_version('16.0', lower=True) and containers.ovn_used_on_overcloud()): - self.skip("QoS not supported in this setup") + self.skipTest("QoS not supported in this setup") def test_qos_basic(self): - # Verify QoS Policy attached to the network corresponds with the QoS - # Policy previously created + '''Verify QoS Policy attached to the network corresponds with the QoS + Policy previously created''' self.assertEqual(self.stack.network_stack.qos_stack.qos_policy_id, self.stack.network_stack.qos_policy_id) self.assertIsNone(self.stack.port_details['qos_policy_id']) + + def test_qos_bw_limit(self): + '''Verify BW limit using the iperf tool + The test is executed from the undercloud node (client) to the VM + instance (server)''' + if not tobiko.tripleo.has_undercloud(): + # TODO(eolivare): this test does not support devstack environments + # yet and that should be fixed + tobiko.skip_test('test not supported on devstack environments') + + ssh_client = None # localhost will act as client + ssh_server = self.stack.peer_ssh_client + iperf.assert_bw_limit(ssh_client, ssh_server)