diff --git a/extra-requirements.txt b/extra-requirements.txt index 80bfa1260..8f409b2b4 100644 --- a/extra-requirements.txt +++ b/extra-requirements.txt @@ -1,6 +1,7 @@ # Additional requirements not in openstack/requirements project ansi2html # LGPLv3+ +dpkt # BSD pandas # BSD podman-py # Apache-2.0 pytest-cov # MIT diff --git a/lower-constraints.txt b/lower-constraints.txt index df1b0ec47..2d3c01662 100644 --- a/lower-constraints.txt +++ b/lower-constraints.txt @@ -2,6 +2,7 @@ decorator===4.4.2 docker==4.4.1 +dpkt >= 1.8.8 fixtures==3.0.0 Jinja2==2.11.2 keystoneauth1==4.3.0 diff --git a/tobiko/shell/ip.py b/tobiko/shell/ip.py index ba013296d..abf926e68 100644 --- a/tobiko/shell/ip.py +++ b/tobiko/shell/ip.py @@ -102,6 +102,15 @@ def list_network_interfaces(**execute_params): return interfaces +def get_network_main_route_device(dest_ip, **execute_params): + output = execute_ip(['route', 'get', dest_ip], **execute_params) + if output: + for line in output.splitlines(): + fields = line.strip().split() + device_index = fields.index('dev') + 1 + return fields[device_index] + + IP_COMMAND = sh.shell_command(['/sbin/ip']) diff --git a/tobiko/shell/sh/_execute.py b/tobiko/shell/sh/_execute.py index 83e837989..3d8b8f71d 100644 --- a/tobiko/shell/sh/_execute.py +++ b/tobiko/shell/sh/_execute.py @@ -114,6 +114,7 @@ class ShellExecuteResult(collections.namedtuple( def _indent(text, space=' ', newline='\n'): + text = str(text) return space + (newline + space).join(text.split(newline)) diff --git a/tobiko/shell/sh/_process.py b/tobiko/shell/sh/_process.py index d98d85986..3af7cfa0e 100644 --- a/tobiko/shell/sh/_process.py +++ b/tobiko/shell/sh/_process.py @@ -425,7 +425,12 @@ def merge_dictionaries(*dictionaries): def str_from_stream(stream): if stream is not None: - return str(stream) + try: + return str(stream) + except UnicodeDecodeError: + LOG.exception('Unable to decode as a string - ' + 'Returning the raw data') + return stream.data else: return None diff --git a/tobiko/shell/tcpdump/__init__.py b/tobiko/shell/tcpdump/__init__.py new file mode 100644 index 000000000..31a6c1237 --- /dev/null +++ b/tobiko/shell/tcpdump/__init__.py @@ -0,0 +1,26 @@ +# 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.tcpdump import _assert +from tobiko.shell.tcpdump import _execute + + +assert_pcap_is_empty = _assert.assert_pcap_is_empty +assert_pcap_is_not_empty = _assert.assert_pcap_is_not_empty + +start_capture = _execute.start_capture +get_pcap = _execute.get_pcap diff --git a/tobiko/shell/tcpdump/_assert.py b/tobiko/shell/tcpdump/_assert.py new file mode 100644 index 000000000..c1c1f3ac5 --- /dev/null +++ b/tobiko/shell/tcpdump/_assert.py @@ -0,0 +1,45 @@ +# 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 __future__ import division + +import dpkt +from oslo_log import log + +import tobiko + + +LOG = log.getLogger(__name__) + + +def assert_pcap_content(pcap: dpkt.pcap.Reader, expect_empty: bool): + actual_empty = True + for _ in pcap: + actual_empty = False + break + testcase = tobiko.get_test_case() + LOG.debug(f'Is the obtained pcap file empty? {actual_empty}') + testcase.assertEqual(expect_empty, actual_empty) + + +def assert_pcap_is_empty(pcap: dpkt.pcap.Reader): + LOG.debug('This test expects an empty pcap capture') + assert_pcap_content(pcap, True) + + +def assert_pcap_is_not_empty(pcap: dpkt.pcap.Reader): + LOG.debug('This test expects a non-empty pcap capture') + assert_pcap_content(pcap, False) diff --git a/tobiko/shell/tcpdump/_execute.py b/tobiko/shell/tcpdump/_execute.py new file mode 100644 index 000000000..6090de391 --- /dev/null +++ b/tobiko/shell/tcpdump/_execute.py @@ -0,0 +1,71 @@ +# 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 __future__ import division + +import io + +import dpkt +from oslo_log import log + +from tobiko.shell.tcpdump import _interface +from tobiko.shell.tcpdump import _parameters +from tobiko.shell import sh +from tobiko.shell import ssh + + +LOG = log.getLogger(__name__) + + +def start_capture(capture_file: str, + interface: str = None, + capture_filter: str = None, + capture_timeout: int = None, + ssh_client: ssh.SSHClientType = None) \ + -> sh.ShellProcessFixture: + + parameters = _parameters.tcpdump_parameters( + capture_file=capture_file, + interface=interface, + capture_filter=capture_filter, + capture_timeout=capture_timeout) + + command = _interface.get_tcpdump_command(parameters) + + # when ssh_client is None, an ssh session is created on localhost + + # using a process we run a fire and forget tcpdump command + process = sh.process(command=command, + ssh_client=ssh_client, + sudo=True) + process.execute() + return process + + +def stop_capture(process): + process.kill() + process.close() + + +def get_pcap(process, + capture_file: str, + ssh_client: ssh.SSHClientType = None) -> dpkt.pcap.Reader: + stop_capture(process) + + stdout = sh.execute( + f'cat {capture_file}', ssh_client=ssh_client, sudo=True).stdout + pcap = dpkt.pcap.Reader(io.BytesIO(stdout)) + return pcap diff --git a/tobiko/shell/tcpdump/_interface.py b/tobiko/shell/tcpdump/_interface.py new file mode 100644 index 000000000..cedfd20a3 --- /dev/null +++ b/tobiko/shell/tcpdump/_interface.py @@ -0,0 +1,46 @@ +# 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.tcpdump import _parameters + + +def get_tcpdump_command(parameters: _parameters.TcpdumpParameters): + interface = TcpdumpInterface() + return interface.get_tcpdump_command(parameters) + + +class TcpdumpInterface: + + def get_tcpdump_command( + self, parameters: _parameters.TcpdumpParameters) -> str: + command = 'tcpdump -s0 -Un' + if parameters.capture_timeout is not None: + command = f'timeout {parameters.capture_timeout} ' + command + options = self.get_tcpdump_options(parameters=parameters) + return command + ' ' + options + + def get_tcpdump_options( + self, + parameters: _parameters.TcpdumpParameters) -> str: + options = f'-w {parameters.capture_file}' + if parameters.interface is not None: + options += f' -i {parameters.interface}' + else: + options += ' -i any' + if parameters.capture_filter is not None: + options += f' {parameters.capture_filter}' + return options diff --git a/tobiko/shell/tcpdump/_parameters.py b/tobiko/shell/tcpdump/_parameters.py new file mode 100644 index 000000000..d9e1ec576 --- /dev/null +++ b/tobiko/shell/tcpdump/_parameters.py @@ -0,0 +1,38 @@ +# 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 typing + + +class TcpdumpParameters(typing.NamedTuple): + capture_file: str + interface: typing.Optional[str] = None + capture_filter: typing.Optional[str] = None + capture_timeout: typing.Optional[int] = None + + +def tcpdump_parameters( + capture_file: str, + interface: str = None, + capture_filter: str = None, + capture_timeout: int = None): + """Get tcpdump parameters + """ + return TcpdumpParameters(capture_file=capture_file, + interface=interface, + capture_filter=capture_filter, + capture_timeout=capture_timeout) diff --git a/tobiko/tests/scenario/neutron/test_qos.py b/tobiko/tests/scenario/neutron/test_qos.py index 580ab0191..057fe670e 100644 --- a/tobiko/tests/scenario/neutron/test_qos.py +++ b/tobiko/tests/scenario/neutron/test_qos.py @@ -14,18 +14,24 @@ # under the License. from __future__ import absolute_import +import time + from oslo_log import log import testtools import tobiko +from tobiko import config from tobiko.openstack import keystone -from tobiko.openstack import stacks from tobiko.openstack import neutron +from tobiko.openstack import stacks +from tobiko.shell import ip from tobiko.shell import iperf3 from tobiko.shell import ping from tobiko.shell import sh +from tobiko.shell import tcpdump +CONF = config.CONF LOG = log.getLogger(__name__) @@ -39,8 +45,34 @@ class QoSNetworkTest(testtools.TestCase): policy = tobiko.required_setup_fixture(stacks.QosPolicyStackFixture) server = tobiko.required_setup_fixture(stacks.QosServerStackFixture) - def test_ping(self): + def test_ping_dscp(self): + capture_file = sh.execute('mktemp', sudo=True).stdout.strip() + interface = ip.get_network_main_route_device( + self.server.floating_ip_address) + + # IPv4 tcpdump DSCP filters explanation: + # ip[1] refers to the byte 1 (the TOS byte) of the IP header + # 0xfc = 11111100 is the mask to get only DSCP value from the ToS + # As DSCP mark is most significant 6 bits we do right shift (>>) + # twice in order to divide by 4 and compare with the decimal value + # See details at http://darenmatthews.com/blog/?p=1199 + dscp_mark = CONF.tobiko.neutron.dscp_mark + capture_filter = (f"'(ip src {self.server.floating_ip_address} and " + f"(ip[1] & 0xfc) >> 2 == {dscp_mark})'") + + # start a capture + process = tcpdump.start_capture( + capture_file=capture_file, + interface=interface, + capture_filter=capture_filter, + capture_timeout=60) + time.sleep(1) + # send a ping to the server ping.assert_reachable_hosts([self.server.floating_ip_address],) + # stop tcpdump and get the pcap capture + pcap = tcpdump.get_pcap(process, capture_file=capture_file) + # check the capture is not empty + tcpdump.assert_pcap_is_not_empty(pcap=pcap) def test_network_qos_policy_id(self): """Verify network policy ID"""