diff --git a/tobiko/shell/ping/__init__.py b/tobiko/shell/ping/__init__.py index fc2bcafa5..d4bded107 100644 --- a/tobiko/shell/ping/__init__.py +++ b/tobiko/shell/ping/__init__.py @@ -16,6 +16,7 @@ from __future__ import absolute_import from tobiko.shell.ping import _exception +from tobiko.shell.ping import _interface from tobiko.shell.ping import _parameters from tobiko.shell.ping import _ping from tobiko.shell.ping import _statistics @@ -28,6 +29,10 @@ BadAddressPingError = _exception.BadAddressPingError UnknowHostError = _exception.UnknowHostError PingFailed = _exception.PingFailed +skip_if_missing_fragment_ping_option = ( + _interface.skip_if_missing_fragment_ping_option) +has_ping_fragment_option = _interface.has_fragment_ping_option + ping_parameters = _parameters.ping_parameters get_ping_parameters = _parameters.get_ping_parameters default_ping_parameters = _parameters.default_ping_parameters diff --git a/tobiko/shell/ping/_exception.py b/tobiko/shell/ping/_exception.py index d4a2daab4..18dd51b5d 100644 --- a/tobiko/shell/ping/_exception.py +++ b/tobiko/shell/ping/_exception.py @@ -51,3 +51,7 @@ class PingFailed(PingError, tobiko.FailureException): message = ("timeout of {timeout} seconds expired after counting only " "{count} out of expected {expected_count} ICMP messages of " "type {message_type!r}") + + +class UnsupportedPingOption(PingError): + pass diff --git a/tobiko/shell/ping/_interface.py b/tobiko/shell/ping/_interface.py new file mode 100644 index 000000000..bd644af52 --- /dev/null +++ b/tobiko/shell/ping/_interface.py @@ -0,0 +1,336 @@ +# 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 oslo_log import log +from neutron_lib import constants + +import tobiko +from tobiko.shell.ping import _exception +from tobiko.shell.ping import _parameters +from tobiko.shell import sh + + +LOG = log.getLogger(__name__) + + +def get_ping_command(parameters, ssh_client): + interface = get_ping_interface(ssh_client=ssh_client) + return interface.get_ping_command(parameters) + + +def get_ping_interface(ssh_client): + manager = tobiko.setup_fixture(PingInterfaceManager) + interface = manager.get_ping_interface(ssh_client=ssh_client) + tobiko.check_valid_type(interface, PingInterface) + return interface + + +def has_fragment_ping_option(ssh_client=None): + interface = get_ping_interface(ssh_client=ssh_client) + return interface.has_fragment_option + + +skip_if_missing_fragment_ping_option = tobiko.skip_unless( + "requires (don't) fragment Ping option", + has_fragment_ping_option) + + +class PingInterfaceManager(tobiko.SharedFixture): + + def __init__(self): + super(PingInterfaceManager, self).__init__() + self.client_interfaces = {} + self.interfaces = [] + self.default_interface = PingInterface() + + def add_ping_interface(self, interface): + LOG.debug('Register ping interface %r', interface) + self.interfaces.append(interface) + + def get_ping_interface(self, ssh_client): + try: + return self.client_interfaces[ssh_client] + except KeyError: + pass + + LOG.debug('Looking for ping interface for SSH client %s', ssh_client) + usage = get_ping_usage(ssh_client) + interface = find_ping_interface(usage=usage, + interfaces=self.interfaces) + interface = interface or self.default_interface + LOG.debug('Assign Ping interface %r to SSH client %r', + interface.ping_interface_name, ssh_client) + self.client_interfaces[ssh_client] = interface + return interface + + +def get_ping_usage(ssh_client): + result = sh.execute('ping --help', expect_exit_status=None, + ssh_client=ssh_client) + usage = ((result.stdout and str(result.stdout)) or + (result.stderr and str(result.stderr)) or "").strip() + if usage: + LOG.debug('Got ping usage text:\n%s\n', usage) + else: + LOG.warning("Unable to get usage message from ping command:\n" + "%r", result) + return usage + + +def find_ping_interface(usage, interfaces): + if usage: + for interface in interfaces: + if interface.match_ping_usage(usage): + return interface + + LOG.warning("No such ping interface class from usage message:\n" + "%r", usage) + return None + + +def ping_interface(interface_class): + assert issubclass(interface_class, PingInterface) + manager = tobiko.setup_fixture(PingInterfaceManager) + manager.add_ping_interface(interface=interface_class()) + return interface_class + + +class PingInterface(object): + + ping_command = 'ping' + ping_interface_name = 'default' + ping_usage = None + + def match_ping_usage(self, usage): + # pylint: disable=unused-argument + return False + + def get_ping_command(self, parameters): + destination = parameters.host + if not destination: + raise ValueError("Ping host destination hasn't been specified") + return ([self.ping_command] + + self.get_ping_options(parameters) + + [destination]) + + def get_ping_options(self, parameters): + options = [] + + ip_version = _parameters.get_ping_ip_version(parameters) + if ip_version == constants.IP_VERSION_4: + options += self.get_ipv4_option() + elif ip_version == constants.IP_VERSION_6: + options += self.get_ipv6_option() + elif ip_version is not None: + message = 'Invalid IP version: {!r}'.format(ip_version) + raise ValueError(message) + + interface = parameters.source + if interface: + options += self.get_interface_option(interface) + + deadline = parameters.deadline + if deadline > 0: + options += self.get_deadline_option(deadline) + + count = parameters.count + if count > 0: + options += self.get_count_option(count) + + size = _parameters.get_ping_payload_size(parameters) + if size: + options += self.get_size_option(size) + + interval = parameters.interval + if interval > 1: + options += self.get_interval_option(interval) + + fragment = parameters.fragmentation + if fragment is not None: + options += self.get_fragment_option(fragment=fragment) + + return options + + def get_ipv4_option(self): + return [] + + def get_ipv6_option(self): + return [] + + def get_interface_option(self, interface): + return ['-I', interface] + + def get_deadline_option(self, deadline): + return ['-w', deadline, '-W', deadline] + + def get_count_option(self, count): + return ['-c', int(count)] + + def get_size_option(self, size): + return ['-s', int(size)] + + def get_interval_option(self, interval): + return ['i', int(interval)] + + has_fragment_option = False + + def get_fragment_option(self, fragment): + details = ("{!r} ping implementation doesn't support " + "'fragment={!r}' option").format(self.ping_interface_name, + fragment) + raise _exception.UnsupportedPingOption(details=details) + + +class IpVersionPingInterface(PingInterface): + + def get_ipv4_option(self): + return ['-4'] + + def get_ipv6_option(self): + return ['-6'] + + +IPUTILS_PING_USAGE = """ +ping: invalid option -- '-' +Usage: ping [-aAbBdDfhLnOqrRUvV] [-c count] [-i interval] [-I interface] + [-m mark] [-M pmtudisc_option] [-l preload] [-p pattern] [-Q tos] + [-s packetsize] [-S sndbuf] [-t ttl] [-T timestamp_option] + [-w deadline] [-W timeout] [hop1 ...] destination +""".strip() + + +@ping_interface +class IpUtilsPingInterface(PingInterface): + + ping_interface_name = 'iputils' + + def match_ping_usage(self, usage): + return usage.startswith(IPUTILS_PING_USAGE) + + has_fragment_option = True + + def get_fragment_option(self, fragment): + if fragment: + return ['-M', 'dont'] + else: + return ['-M', 'do'] + + +IP_VERSION_IPUTILS_PING_USAGE = """ +ping: invalid option -- '-' +Usage: ping [-aAbBdDfhLnOqrRUvV64] [-c count] [-i interval] [-I interface] + [-m mark] [-M pmtudisc_option] [-l preload] [-p pattern] [-Q tos] + [-s packetsize] [-S sndbuf] [-t ttl] [-T timestamp_option] + [-w deadline] [-W timeout] [hop1 ...] destination +Usage: ping -6 [-aAbBdDfhLnOqrRUvV] [-c count] [-i interval] [-I interface] + [-l preload] [-m mark] [-M pmtudisc_option] + [-N nodeinfo_option] [-p pattern] [-Q tclass] [-s packetsize] + [-S sndbuf] [-t ttl] [-T timestamp_option] [-w deadline] + [-W timeout] destination +""".strip() + + +@ping_interface +class IpUtilsIpVersionPingInterface(IpUtilsPingInterface, + IpVersionPingInterface): + + ping_interface_name = 'ip_version_iputils' + + def match_ping_usage(self, usage): + return usage.startswith(IP_VERSION_IPUTILS_PING_USAGE) + + +BUSYBOX_PING_USAGE = """ +ping: unrecognized option `--usage' +BusyBox v1.23.2 (2017-11-20 02:37:12 UTC) multi-call binary. + +Usage: ping [OPTIONS] HOST + +Send ICMP ECHO_REQUEST packets to network hosts + + -4,-6 Force IP or IPv6 name resolution + -c CNT Send only CNT pings + -s SIZE Send SIZE data bytes in packets (default:56) + -t TTL Set TTL + -I IFACE/IP Use interface or IP address as source + -W SEC Seconds to wait for the first response (default:10) + (after all -c CNT packets are sent) + -w SEC Seconds until ping exits (default:infinite) + (can exit earlier with -c CNT) + -q Quiet, only display output at start + and when finished + -p Pattern to use for payload +""".strip() + + +@ping_interface +class BusyBoxPingInterface(IpVersionPingInterface): + + ping_interface_name = 'BusyBox' + + def match_ping_usage(self, usage): + return usage.startswith("BusyBox") + + +INET_TOOLS_PING_USAGE = """ +Usage: ping [OPTION...] HOST ... +Send ICMP ECHO_REQUEST packets to network hosts. + + Options controlling ICMP request types: + --address send ICMP_ADDRESS packets (root only) + --echo send ICMP_ECHO packets (default) + --mask same as --address + --timestamp send ICMP_TIMESTAMP packets + -t, --type=TYPE send TYPE packets + + Options valid for all request types: + + -c, --count=NUMBER stop after sending NUMBER packets + -d, --debug set the SO_DEBUG option + -i, --interval=NUMBER wait NUMBER seconds between sending each packet + -n, --numeric do not resolve host addresses + -r, --ignore-routing send directly to a host on an attached network + --ttl=N specify N as time-to-live + -T, --tos=NUM set type of service (TOS) to NUM + -v, --verbose verbose output + -w, --timeout=N stop after N seconds + -W, --linger=N number of seconds to wait for response + + Options valid for --echo requests: + + -f, --flood flood ping (root only) + --ip-timestamp=FLAG IP timestamp of type FLAG, which is one of + "tsonly" and "tsaddr" + -l, --preload=NUMBER send NUMBER packets as fast as possible before + falling into normal mode of behavior (root only) + -p, --pattern=PATTERN fill ICMP packet with given pattern (hex) + -q, --quiet quiet output + -R, --route record route + -s, --size=NUMBER send NUMBER data octets + + -?, --help give this help list + --usage give a short usage message + -V, --version print program version +""".strip() + + +@ping_interface +class InetToolsPingInterface(PingInterface): + + def match_ping_usage(self, usage): + return usage.startswith(INET_TOOLS_PING_USAGE) diff --git a/tobiko/shell/ping/_ping.py b/tobiko/shell/ping/_ping.py index e71d8d960..9c61e561a 100644 --- a/tobiko/shell/ping/_ping.py +++ b/tobiko/shell/ping/_ping.py @@ -17,10 +17,11 @@ from __future__ import absolute_import import time -from neutron_lib import constants from oslo_log import log + from tobiko.shell import sh +from tobiko.shell.ping import _interface from tobiko.shell.ping import _exception from tobiko.shell.ping import _parameters from tobiko.shell.ping import _statistics @@ -234,8 +235,8 @@ def iter_statistics(parameters=None, ssh_client=None, until=None, check=True, def execute_ping(parameters, ssh_client=None, check=True): - command = get_ping_command(parameters) - + command = _interface.get_ping_command(parameters=parameters, + ssh_client=ssh_client) result = sh.execute(command=command, ssh_client=ssh_client, timeout=parameters.deadline + 2., @@ -246,47 +247,6 @@ def execute_ping(parameters, ssh_client=None, check=True): return result -def get_ping_command(parameters): - options = [] - - ip_version = _parameters.get_ping_ip_version(parameters) - - ping_command = 'ping' - if ip_version == constants.IP_VERSION_6: - ping_command = 'ping6' - - host = parameters.host - if not host: - raise ValueError("Ping host destination hasn't been specified") - - source = parameters.source - if source: - options += ['-I', source] - - deadline = parameters.deadline - if deadline > 0: - options += ['-w', deadline] - options += ['-W', deadline] - - count = parameters.count - if count > 0: - options += ['-c', int(count)] - - payload_size = _parameters.get_ping_payload_size(parameters) - if payload_size: - options += ['-s', int(payload_size)] - - interval = parameters.interval - if interval > 1: - options += ['-i', int(interval)] - - fragmentation = parameters.fragmentation - if fragmentation is False: - options += ['-M', 'do'] - - return [ping_command] + options + [host] - - def handle_ping_command_error(error): for error in error.splitlines(): error = error.strip() diff --git a/tobiko/shell/ping/config.py b/tobiko/shell/ping/config.py index 51cf669a4..24f9497a5 100644 --- a/tobiko/shell/ping/config.py +++ b/tobiko/shell/ping/config.py @@ -28,8 +28,8 @@ OPTIONS = [ help="Max seconds waited from ping command before " "self terminating himself"), cfg.StrOpt('fragmentation', - default=True, - help="If disable it will not allow ICMP messages to " + default=None, + help="If False it will not allow ICMP messages to " "be delivered in smaller fragments"), cfg.StrOpt('interval', default=1, diff --git a/tobiko/tests/scenario/neutron/test_floating_ip.py b/tobiko/tests/scenario/neutron/test_floating_ip.py index 00d4642ba..33529de11 100644 --- a/tobiko/tests/scenario/neutron/test_floating_ip.py +++ b/tobiko/tests/scenario/neutron/test_floating_ip.py @@ -93,6 +93,7 @@ class FloatingIPTest(testtools.TestCase): # --- test net-mtu and net-mtu-writable extensions ------------------------ + @ping.skip_if_missing_fragment_ping_option @neutron.skip_if_missing_networking_extensions('net-mtu') def test_ping_with_net_mtu(self): """Test connectivity to floating IP address with MTU sized packets""" @@ -162,6 +163,7 @@ class FloatingIPWithPortSecurityTest(FloatingIPTest): count=5, check=False).assert_not_replied() + @ping.skip_if_missing_fragment_ping_option @neutron.skip_if_missing_networking_extensions('net-mtu') def test_ping_with_net_mtu(self): """Test connectivity to floating IP address"""