diff --git a/tobiko/config.py b/tobiko/config.py index 6c18ef554..09181cdb8 100644 --- a/tobiko/config.py +++ b/tobiko/config.py @@ -27,7 +27,8 @@ LOG = log.getLogger(__name__) CONFIG_MODULES = ['tobiko.openstack.keystone.config', 'tobiko.openstack.neutron.config', 'tobiko.openstack.nova.config', - 'tobiko.shell.sh.config'] + 'tobiko.shell.sh.config', + 'tobiko.shell.ping.config'] CONFIG_DIRS = [os.getcwd(), os.path.expanduser("~/.tobiko"), diff --git a/tobiko/shell/ping/__init__.py b/tobiko/shell/ping/__init__.py new file mode 100644 index 000000000..cdc28a442 --- /dev/null +++ b/tobiko/shell/ping/__init__.py @@ -0,0 +1,41 @@ +# 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 tobiko.shell.ping import _exception +from tobiko.shell.ping import _parameters +from tobiko.shell.ping import _ping +from tobiko.shell.ping import _statistics + + +PingException = _exception.PingException +PingError = _exception.PingError +LocalPingError = _exception.LocalPingError +BadAddressPingError = _exception.BadAddressPingError +UnknowHostError = _exception.UnknowHostError +PingFailed = _exception.PingFailed + +ping_parameters = _parameters.ping_parameters +get_ping_parameters = _parameters.get_ping_parameters +default_ping_parameters = _parameters.default_ping_parameters + +ping = _ping.ping +ping_until_delivered = _ping.ping_until_delivered +ping_until_undelivered = _ping.ping_until_undelivered +ping_until_received = _ping.ping_until_received +ping_until_unreceived = _ping.ping_until_unreceived + +PingStatistics = _statistics.PingStatistics diff --git a/tobiko/shell/ping/_exception.py b/tobiko/shell/ping/_exception.py new file mode 100644 index 000000000..514070d91 --- /dev/null +++ b/tobiko/shell/ping/_exception.py @@ -0,0 +1,53 @@ +# 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 + +import tobiko + + +class PingException(tobiko.TobikoException): + """Base ping command exception""" + + +class PingError(PingException): + """Base ping error""" + message = ("%(details)s") + + +class LocalPingError(PingError): + """Raised when local error happens""" + + +class SendToPingError(PingError): + """Raised when sendto error happens""" + + +class UnknowHostError(PingError): + """Raised when unable to resolve host name""" + + +class BadAddressPingError(PingError): + """Raised when passing wrong address to ping command""" + message = ("Bad address: %(address)r") + + +class PingFailed(PingError, tobiko.FailureException): + """Raised when ping timeout expires before reaching expected message count + + """ + message = ("Timeout of %(timeout)d seconds expired after counting only " + "%(count)d out of expected %(expected_count)d ICMP messages of " + "type %(message_type)r.") diff --git a/tobiko/shell/ping/_parameters.py b/tobiko/shell/ping/_parameters.py new file mode 100644 index 000000000..a283e2b18 --- /dev/null +++ b/tobiko/shell/ping/_parameters.py @@ -0,0 +1,201 @@ +# 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 + + +class PingParameters(object): + """Recollect parameters to be used to format ping command line + + PingParameters class is a data model recollecting parameters used to + create a ping command line. It provides the feature of copying default + values from another instance of PingParameters passed using constructor + parameter 'default'. + """ + + def __init__(self, host=None, count=None, deadline=None, + fragmentation=None, interval=None, is_cirros_image=None, + ip_version=None, payload_size=None, packet_size=None, + source=None, timeout=None): + self.count = count + self.deadline = deadline + self.host = host + self.fragmentation = fragmentation + self.interval = interval + self.ip_version = ip_version + self.is_cirros_image = is_cirros_image + self.packet_size = packet_size + self.payload_size = payload_size + self.source = source + self.timeout = timeout + + def __repr__(self): + return "PingParameters({!s})".format( + ", ".join("{!s}={!r}".format(k, v) + for k, v in self.__dict__.items())) + + +def get_ping_parameters(default=None, **ping_params): + """Get ping parameters eventually merging them with given extra parameters + + The only difference with init_parameters function is that in case + default parameter is not None not any extra parameters is given, then + it simply return given default instance, without performing any validation. + """ + if default and not ping_params: + return default + else: + return ping_parameters(default=default, **ping_params) + + +def ping_parameters(default=None, count=None, deadline=None, + fragmentation=None, host=None, interval=None, + is_cirros_image=None, ip_version=None, packet_size=None, + payload_size=None, source=None, timeout=None): + """Validate parameters and initialize a new PingParameters instance + + :param default: (PingParameters or None) instance from where to take + default values when other value is not provided. If None (that is the + default value) it will copy default parameters from + DEFAULT_PING_PARAMETERS + + :param count: (int or None) number of ping ICMP message expecting to be + received before having a success. Default value can be configured using + 'count' option in [ping] config section. + + :param host: (str or None) IP address or host name to send ICMP + messages to. It is required to format a valid ping command, therefore + no default value exists for this parameter. + + :param deadline: (int or None) positive number representing the maximum + number of seconds ping command can send ICMP messages before stop + executing. Default value can be configured using 'deadline' option + in [ping] config section. + + :param fragmentation: (bool or None) when False this would tell ping + to forbid ICMP messages fragmentation. Default value can be configured + using 'fragmentation' option in [ping] config section. + + :param interval: (int or None) interval of time before sending following + ICMP message. Default value can be configured using 'interval' option + in [ping] config section. + + :param is_cirros_image: (bool or None) when True means that ping command + has to be formated for being executed on a CirrOS based guess instance. + + :param ip_version: (4, 6 or None) If not None it makes sure it will + use specified IP version for sending ICMP packages. + + :param packet_size: ICMP message size. Default value can be configured + using 'package_size' option in [ping] config section. + + :param payload_size: (int or None) if not None, it specifies ICMP message + size minus ICMP and IP header size. + + :param source: (str or None) IP address or interface name from where + to send ICMP message. + + :param timeout: (int or None) time in seconds after which ping operation + would raise PingFailed exception. + + :raises TypeError: in case some parameter cannot be converted to right + expected type + + :raises ValueError: in case some parameter has an unexpected value + """ + + if packet_size: + if payload_size: + msg = ("Can't set 'package_size' and 'payload_size' parameters " + "at the same time: package_size={!r}, payload_size={!r}" + ).format(packet_size, payload_size) + raise ValueError(msg) + + count = count or 1 + if count < 1: + msg = ("Count is not positive: count={!r}").format(count) + raise ValueError(msg) + + if default is not False: + default = default or default_ping_parameters() + # Copy default parameters + count = count or default.count + if deadline is None: + deadline = default.deadline + host = host or default.host + if fragmentation is None: + fragmentation = default.fragmentation + interval = interval or default.interval + ip_version = ip_version or default.ip_version + packet_size = packet_size or default.packet_size + payload_size = payload_size or default.payload_size + source = source or default.source + timeout = timeout or default.timeout + + count = int(count) + if count < 1: + msg = "'count' parameter cannot be smaller than 1" + raise ValueError(msg) + + deadline = int(deadline) + if deadline < 0: + msg = ("'deadline' parameter cannot be smaller than 0 " + "(deadline={!r})").format(deadline) + raise ValueError(msg) + + interval = int(interval) + if interval < 1: + msg = ("'interval' parameter cannot be smaller than 1 " + "(interval={!r})").format(interval) + raise ValueError(msg) + + timeout = int(timeout) + if timeout < 1: + msg = ("'timeout' parameter cannot be smaller than 1 " + "(timeout={!r})").format(timeout) + raise ValueError(msg) + + if is_cirros_image: + if fragmentation is False: + msg = ("'fragmentation' parameter cannot be False when " + "pinging from a CirrOS image (is_cirros_image={!r})" + ).format(is_cirros_image) + raise ValueError(msg) + + if interval != 1: + msg = ("Cannot specify 'interval' parameter when pinging from a " + "CirrOS image (interval={!r}, is_cirros_image={!r})" + ).format(interval, is_cirros_image) + raise ValueError(msg) + + return PingParameters(count=count, host=host, + deadline=deadline, fragmentation=fragmentation, + interval=interval, is_cirros_image=is_cirros_image, + ip_version=ip_version, packet_size=packet_size, + payload_size=payload_size, source=source, + timeout=timeout) + + +def default_ping_parameters(): + from tobiko import config + CONF = config.CONF + return ping_parameters( + default=False, + count=CONF.tobiko.ping.count, + deadline=CONF.tobiko.ping.deadline, + fragmentation=CONF.tobiko.ping.fragmentation, + interval=CONF.tobiko.ping.interval, + packet_size=CONF.tobiko.ping.packet_size, + timeout=CONF.tobiko.ping.timeout) diff --git a/tobiko/shell/ping/_ping.py b/tobiko/shell/ping/_ping.py new file mode 100644 index 000000000..3b69083f4 --- /dev/null +++ b/tobiko/shell/ping/_ping.py @@ -0,0 +1,361 @@ +# 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 + +import time + +import netaddr +from neutron_lib import constants +from oslo_log import log + +from tobiko.shell import sh +from tobiko.shell.ping import _exception +from tobiko.shell.ping import _parameters +from tobiko.shell.ping import _statistics + + +LOG = log.getLogger(__name__) + + +TRANSMITTED = 'transmitted' +DELIVERED = 'delivered' +UNDELIVERED = 'undelivered' +RECEIVED = 'received' +UNRECEIVED = 'unreceived' + + +def ping(host, until=TRANSMITTED, **ping_params): + """Send ICMP messages to host address until timeout + + :param host: destination host address + :param **ping_params: parameters to be forwarded to get_statistics() + function + :returns: PingStatistics + """ + return get_statistics(host=host, until=until, **ping_params) + + +def ping_until_delivered(host, **ping_params): + """Send 'count' ICMP messages + + Send 'count' ICMP messages + + ICMP messages are considered delivered when they have been + transmitted without being counted as errors. + + :param host: destination host address + :param **ping_params: parameters to be forwarded to get_statistics() + function + :returns: PingStatistics + :raises: PingFailed in case timeout expires before delivering all + expected count messages + """ + return ping(host=host, until=DELIVERED, **ping_params) + + +def ping_until_undelivered(host, **ping_params): + """Send ICMP messages until it fails to deliver messages + + Send ICMP messages until it fails to deliver 'count' messages + + ICMP messages are considered undelivered when they have been + transmitted and they have been counted as error in ping statistics (for + example because of errors into the route to remote address). + + :param host: destination host address + :param **ping_params: parameters to be forwarded to get_statistics() + function + :returns: PingStatistics + :raises: PingFailed in case timeout expires before failing delivering + expected 'count' of messages + """ + return ping(host=host, until=UNDELIVERED, **ping_params) + + +def ping_until_received(host, **ping_params): + """Send ICMP messages until it receives messages back + + Send ICMP messages until it receives 'count' messages back + + ICMP messages are considered received when they have been + transmitted without any routing errors and they are received back + + :param host: destination host address + :param **ping_params: parameters to be forwarded to get_statistics() + function + :returns: PingStatistics + :raises: PingFailed in case timeout expires before receiving all + expected 'count' of messages + """ + return ping(host=host, until=RECEIVED, **ping_params) + + +def ping_until_unreceived(host, **ping_params): + """Send ICMP messages until it fails to receive messages + + Send ICMP messages until it fails to receive 'count' messages back. + + ICMP messages are considered unreceived when they have been + transmitted without any routing error but they failed to be received + back (for example because of network filtering). + + :param host: destination host address + :param **ping_params: parameters to be forwarded to get_statistics() + function + :returns: PingStatistics + :raises: PingFailed in case timeout expires before failed receiving + expected 'count' of messages + """ + return ping(host=host, until=UNRECEIVED, **ping_params) + + +def get_statistics(parameters=None, ssh_client=None, until=None, + **ping_params): + parameters = _parameters.get_ping_parameters(default=parameters, + **ping_params) + statistics = _statistics.PingStatistics() + for partial_statistics in iter_statistics(parameters=parameters, + ssh_client=ssh_client, + until=until): + statistics += partial_statistics + LOG.debug("%r", statistics) + + return statistics + + +def iter_statistics(parameters=None, ssh_client=None, until=None, + **ping_params): + parameters = _parameters.get_ping_parameters(default=parameters, + **ping_params) + now = time.time() + end_of_time = now + parameters.timeout + deadline = parameters.deadline + transmitted = 0 + received = 0 + undelivered = 0 + count = 0 + + while deadline > 0 and count < parameters.count: + # splitting total timeout interval into smaller deadline intervals will + # cause ping command to be executed more times allowing to handle + # temporary packets routing problems + if until == RECEIVED: + execute_parameters = _parameters.get_ping_parameters( + default=parameters, + deadline=deadline, + count=(parameters.count - count)) + else: + # Deadline ping parameter cause ping to be executed until count + # messages are received or deadline is expired + # Therefore to count messages not of received type we have to + # simulate deadline parameter limiting the maximum number of + # transmitted messages + execute_parameters = _parameters.get_ping_parameters( + default=parameters, + deadline=deadline, + count=min(parameters.count - count, + parameters.interval * deadline)) + + # Command timeout would typically give ping command additional seconds + # to safely reach deadline before shell command timeout expires, while + # in the same time adding an extra verification to forbid using more + # time than expected considering the time required to make SSH + # connection and running a remote shell + try: + result = execute_ping(parameters=execute_parameters, + ssh_client=ssh_client, + timeout=end_of_time - now) + except sh.ShellTimeoutExpired: + pass + + else: + if result.exit_status is not None: + statistics = _statistics.parse_ping_statistics( + output=result.stdout, begin_interval=now, + end_interval=time.time()) + yield statistics + + transmitted += statistics.transmitted + received += statistics.received + undelivered += statistics.undelivered + count = {None: 0, + TRANSMITTED: transmitted, + DELIVERED: transmitted - undelivered, + UNDELIVERED: undelivered, + RECEIVED: received, + UNRECEIVED: transmitted - received}[until] + + now = time.time() + deadline = min(int(end_of_time - now), parameters.deadline) + + if until and count < parameters.count: + raise _exception.PingFailed(count=count, + expected_count=parameters.count, + timeout=parameters.timeout, + message_type=until) + + +def execute_ping(parameters, ssh_client=None, **params): + if not ssh_client: + is_cirros_image = params.setdefault('is_cirros_image', False) + if is_cirros_image: + raise ValueError("'ssh_client' parameter is required when " + "to execute ping on a CirrOS image.") + + command = get_ping_command(parameters) + result = sh.execute(command=command, ssh_client=ssh_client, + timeout=parameters.timeout, check=False) + if result.exit_status: + handle_ping_command_error(error=result.stderr) + return result + + +def get_ping_command(parameters): + options = [] + + ip_version = parameters.ip_version + host = parameters.host + if host: + try: + host = netaddr.IPAddress(host) + except netaddr.core.AddrFormatError: + # NOTE: host could be an host name not an IP address so + # this is fine + LOG.debug("Unable to obtain IP version from host address: %r", + host) + else: + if ip_version != host.version: + if ip_version: + raise ValueError("Mismatching destination IP version.") + else: + ip_version = host.version + else: + raise ValueError("Ping host destination hasn't been specified") + + source = parameters.source + if source: + try: + source = netaddr.IPAddress(source) + except netaddr.core.AddrFormatError: + # NOTE: source could be a device name and not an IP address + # so this is fine + LOG.debug("Unable to obtain IP version from source address: " + "%r", source) + else: + if ip_version != source.version: + if ip_version: + raise ValueError("Mismatching source IP version.") + else: + ip_version = source.version + options += ['-I', source] + + is_cirros_image = parameters.is_cirros_image + ping_command = 'ping' + if not ip_version: + LOG.warning("Unable to obtain IP version from neither source " + "or destination IP addresses: assuming IPv4") + ip_version = constants.IP_VERSION_4 + + elif ip_version == constants.IP_VERSION_6: + if is_cirros_image: + options += ['-6'] + else: + ping_command = 'ping6' + + 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.payload_size + packet_size = parameters.packet_size + if not payload_size and packet_size: + payload_size = get_icmp_payload_size(package_size=int(packet_size), + ip_version=ip_version) + + 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: + if is_cirros_image: + msg = ("'is_cirros_image' parameter must be set to False" + " when 'fragmention' parameter is False " + "(is_cirros_image={!r})").format(is_cirros_image) + raise ValueError(msg) + options += ['-M', 'do'] + + return [ping_command] + options + [host] + + +def handle_ping_command_error(error): + for error in error.splitlines(): + error = error.strip() + if error: + prefix = 'ping: ' + if error.startswith('ping: '): + text = error[len(prefix):] + + prefix = 'bad address ' + if text.startswith(prefix): + address = text[len(prefix):].replace("'", '').strip() + raise _exception.BadAddressPingError(address=address) + + prefix = 'local error: ' + if text.startswith(prefix): + details = text[len(prefix):].strip() + raise _exception.LocalPingError(details=details) + + prefix = 'sendto: ' + if text.startswith(prefix): + details = text[len(prefix):].strip() + raise _exception.SendToPingError(details=details) + + prefix = 'unknown host ' + if text.startswith(prefix): + details = text[len(prefix):].strip() + raise _exception.UnknowHostError(details=details) + + suffix = ': Name or service not known' + if text.endswith(suffix): + details = text[:-len(suffix)].strip() + raise _exception.UnknowHostError(details=details) + + raise _exception.PingError(details=text) + + +IP_HEADER_SIZE = {4: 20, 6: 40} +ICMP_HEADER_SIZE = {4: 8, 6: 4} + + +def get_icmp_payload_size(package_size, ip_version): + """Return the maximum size of ping payload that will fit into MTU.""" + header_size = IP_HEADER_SIZE[ip_version] + ICMP_HEADER_SIZE[ip_version] + if package_size < header_size: + message = ("package size {package_size!s} is smaller than package " + "header size {header_size!s}").format( + package_size=package_size, + header_size=header_size) + raise ValueError(message) + return package_size - header_size diff --git a/tobiko/shell/ping/_statistics.py b/tobiko/shell/ping/_statistics.py new file mode 100644 index 000000000..1e698b1b2 --- /dev/null +++ b/tobiko/shell/ping/_statistics.py @@ -0,0 +1,192 @@ +# 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 + +import re + +from oslo_log import log +import netaddr + +import tobiko + + +LOG = log.getLogger(__name__) + + +def parse_ping_statistics(output, begin_interval=None, end_interval=None): + lines = output.split('\n') + line_it = iter(lines) + try: + source, destination = parse_ping_header(line_it) + except Exception as ex: + LOG.debug('Error parsing ping output header: %s', ex) + source = destination = None + + try: + transmitted, received, errors = parse_ping_footer(line_it) + except Exception as ex: + LOG.debug('Error parsing ping output footer: %s', ex) + transmitted = received = errors = 0 + + return PingStatistics(source=source, destination=destination, + transmitted=transmitted, received=received, + undelivered=errors, end_interval=end_interval, + begin_interval=begin_interval) + + +def parse_ping_header(line_it): + for line in line_it: + if line.startswith('PING '): + header = line + break + else: + raise ValueError('Ping output header not found') + + header_fields = [f.strip() for f in header.split()] + header_fields = [(f[:-1] if f.endswith(':') else f) + for f in header_fields] + destination = header_fields[2] + if destination[0] == '(': + destination = destination[1:] + if destination[-1] == ')': + destination = destination[:-1] + destination = netaddr.IPAddress(destination) + + field_it = iter(header_fields[3:]) + source = None + + for field in field_it: + if field == 'from': + source = next(field_it).strip() + if source.endswith(':'): + source = source[:-1] + source = netaddr.IPAddress(source) + break + + return source, destination + + +def parse_ping_footer(line_it): + for line in line_it: + if line.startswith('--- ') and line.endswith(' ping statistics ---'): + # parse footer + footer = next(line_it).strip() + break + else: + raise ValueError('Ping output footer not found') + + transmitted = received = errors = 0 + for field in footer.split(','): + try: + if 'transmitted' in field: + transmitted = extract_integer(field) + elif 'received' in field: + received = extract_integer(field) + elif 'error' in field: + errors = extract_integer(field) + except Exception as ex: + LOG.exception('Error parsing ping output footer: %s', ex) + return transmitted, received, errors + + +def extract_integer(field): + for number in extract_integers(field): + return number + raise ValueError("Integer not found in {!r}".format(field)) + + +MATCH_NUMBERS_RE = re.compile('([0-9]).') + + +def extract_integers(field): + for match_obj in MATCH_NUMBERS_RE.finditer(field): + yield int(field[match_obj.start():match_obj.end()]) + + +class PingStatistics(object): + """Ping command statistics + + """ + + def __init__(self, source=None, destination=None, transmitted=0, + received=0, undelivered=0, begin_interval=None, + end_interval=None): + self.source = source + self.destination = destination + self.transmitted = transmitted + self.received = received + self.undelivered = undelivered + self.begin_interval = begin_interval + self.end_interval = end_interval + + @property + def unreceived(self): + return max(0, self.transmitted - self.received) + + @property + def delivered(self): + return max(0, self.transmitted - self.undelivered) + + @property + def loss(self): + transmitted = max(0, float(self.transmitted)) + if transmitted: + return float(self.unreceived) / transmitted + else: + return 0. + + def __bool__(self): + return bool(self.received) + + def __add__(self, other): + begin_interval = min(i for i in [self.begin_interval, + other.begin_interval] if i) + end_interval = max(i for i in [self.end_interval, + other.end_interval] if i) + return PingStatistics( + source=self.source or other.source, + destination=self.destination or other.destination, + transmitted=self.transmitted + other.transmitted, + received=self.received + other.received, + undelivered=self.undelivered + other.undelivered, + begin_interval=begin_interval, + end_interval=end_interval) + + def __repr__(self): + return "PingStatistics({!s})".format( + ", ".join("{!s}={!r}".format(k, v) + for k, v in self.__dict__.items())) + + def assert_transmitted(self): + if not self.transmitted: + tobiko.fail("Any package has been transmitted to %(destination)r", + destination=self.destination) + + def assert_not_transmitted(self): + if self.transmitted: + tobiko.fail("Some packages has been transmitted to " + "%(destination)r", destination=self.destination) + + def assert_replied(self): + if not self.received: + tobiko.fail("Any reply package has been received from " + "%(destination)r", destination=self.destination) + + def assert_not_replied(self): + if self.received: + tobiko.fail("Some reply packages has been received from " + "%(destination)r", destination=self.destination) diff --git a/tobiko/shell/ping/config.py b/tobiko/shell/ping/config.py new file mode 100644 index 000000000..5f4bc6ed3 --- /dev/null +++ b/tobiko/shell/ping/config.py @@ -0,0 +1,47 @@ +# Copyright 2019 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 + +from oslo_config import cfg + + +def register_tobiko_options(conf): + + conf.register_opts( + group=cfg.OptGroup('ping'), + opts=[cfg.IntOpt('count', + default=1, + help="Number of ICMP messages to wait before ending " + "ping command execution"), + cfg.IntOpt('deadline', + default=5, + 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 " + "be delivered in smaller fragments"), + cfg.StrOpt('interval', + default=1, + help="Seconds of time interval between " + "consecutive before ICMP messages"), + cfg.IntOpt('packet_size', + default=None, + help="Size in bytes of ICMP messages (including " + "headers and payload)"), + cfg.IntOpt('timeout', + default=60., + help="Maximum time in seconds a sequence of ICMP " + "messages is sent to a destination host before " + "reporting as a failure")]) diff --git a/tobiko/tests/shell/test_ping.py b/tobiko/tests/shell/test_ping.py new file mode 100644 index 000000000..4687f070f --- /dev/null +++ b/tobiko/tests/shell/test_ping.py @@ -0,0 +1,94 @@ +# 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 + +import netaddr + +from tobiko import config +from tobiko.shell import ping +from tobiko.tests import unit + + +CONF = config.CONF + + +class PingTest(unit.TobikoUnitTest): + + def setUp(self): + super(PingTest, self).setUp() + self.config = self.patch_object(CONF.tobiko, 'ping', + count=1, + deadline=5, + timeout=60, + fragmentation=True, + interval=1, + packet_size=None) + + def test_ping_recheable_address(self): + result = ping.ping('127.0.0.1', count=3) + self.assertIsNone(result.source) + self.assertEqual(netaddr.IPAddress('127.0.0.1'), result.destination) + result.assert_transmitted() + result.assert_replied() + + def test_ping_reachable_hostname(self): + result = ping.ping('example.org', count=3) + self.assertIsNone(result.source) + # self.assertIsNotNone(result.destination) + result.assert_transmitted() + result.assert_replied() + + def test_ping_unreachable_address(self): + result = ping.ping('1.2.3.4', count=3) + self.assertIsNone(result.source) + self.assertEqual(netaddr.IPAddress('1.2.3.4'), result.destination) + result.assert_transmitted() + result.assert_not_replied() + + def test_ping_unreachable_hostname(self): + ex = self.assertRaises(ping.UnknowHostError, ping.ping, + 'unreachable-host', count=3) + self.assertEqual('unreachable-host', ex.details) + + def test_ping_until_received(self): + result = ping.ping_until_received('127.0.0.1', count=3) + self.assertIsNone(result.source) + self.assertEqual(netaddr.IPAddress('127.0.0.1'), result.destination) + result.assert_transmitted() + result.assert_replied() + + def test_ping_until_received_unreachable(self): + ex = self.assertRaises(ping.PingFailed, ping.ping_until_received, + '1.2.3.4', count=3, timeout=6) + self.assertEqual(6, ex.timeout) + self.assertEqual(0, ex.count) + self.assertEqual(3, ex.expected_count) + self.assertEqual('received', ex.message_type) + + def test_ping_until_unreceived_recheable(self): + ex = self.assertRaises(ping.PingFailed, ping.ping_until_unreceived, + '127.0.0.1', count=3, timeout=6) + self.assertEqual(6, ex.timeout) + self.assertEqual(0, ex.count) + self.assertEqual(3, ex.expected_count) + self.assertEqual('unreceived', ex.message_type) + + def test_ping_until_unreceived_unrecheable(self): + result = ping.ping_until_unreceived('1.2.3.4', count=3) + self.assertIsNone(result.source) + self.assertEqual(netaddr.IPAddress('1.2.3.4'), result.destination) + result.assert_transmitted() + result.assert_not_replied()