Implement ping command wrapper.
Change-Id: Ica9ccfe0bd38bd05e5c6f9c18069f885fdc0e925
This commit is contained in:
parent
04c3e72748
commit
0c9d9e9760
@ -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"),
|
||||
|
41
tobiko/shell/ping/__init__.py
Normal file
41
tobiko/shell/ping/__init__.py
Normal file
@ -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
|
53
tobiko/shell/ping/_exception.py
Normal file
53
tobiko/shell/ping/_exception.py
Normal file
@ -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.")
|
201
tobiko/shell/ping/_parameters.py
Normal file
201
tobiko/shell/ping/_parameters.py
Normal file
@ -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)
|
361
tobiko/shell/ping/_ping.py
Normal file
361
tobiko/shell/ping/_ping.py
Normal file
@ -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
|
192
tobiko/shell/ping/_statistics.py
Normal file
192
tobiko/shell/ping/_statistics.py
Normal file
@ -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)
|
47
tobiko/shell/ping/config.py
Normal file
47
tobiko/shell/ping/config.py
Normal file
@ -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")])
|
94
tobiko/tests/shell/test_ping.py
Normal file
94
tobiko/tests/shell/test_ping.py
Normal file
@ -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()
|
Loading…
Reference in New Issue
Block a user