1# Copyright (c) 2013 The Chromium OS Authors. All rights reserved.
2# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
4
5import logging
6import math
7import re
8
9from autotest_lib.client.bin import utils
10from autotest_lib.client.common_lib import error
11
12
13class PingConfig(object):
14    """Describes the parameters for a ping command."""
15
16    DEFAULT_COUNT = 10
17    PACKET_WAIT_MARGIN_SECONDS = 120
18
19    @property
20    def ping_args(self):
21        """@return list of parameters to ping."""
22        args = []
23        args.append('-c %d' % self.count)
24        if self.size is not None:
25            args.append('-s %d' % self.size)
26        if self.interval is not None:
27            args.append('-i %f' % self.interval)
28        if self.qos is not None:
29            if self.qos == 'be':
30                args.append('-Q 0x04')
31            elif self.qos == 'bk':
32                args.append('-Q 0x02')
33            elif self.qos == 'vi':
34                args.append('-Q 0x08')
35            elif self.qos == 'vo':
36                args.append('-Q 0x10')
37            else:
38                raise error.TestFail('Unknown QoS value: %s' % self.qos)
39
40        # The last argument is the IP addres to ping.
41        args.append(self.target_ip)
42        return args
43
44
45    def __init__(self, target_ip, count=DEFAULT_COUNT, size=None,
46                 interval=None, qos=None,
47                 ignore_status=False, ignore_result=False):
48        super(PingConfig, self).__init__()
49        self.target_ip = target_ip
50        self.count = count
51        self.size = size
52        self.interval = interval
53        if qos:
54            qos = qos.lower()
55        self.qos = qos
56        self.ignore_status = ignore_status
57        self.ignore_result = ignore_result
58        interval_seconds = self.interval or 1
59        command_time = math.ceil(interval_seconds * self.count)
60        self.command_timeout_seconds = int(command_time +
61                                           self.PACKET_WAIT_MARGIN_SECONDS)
62
63
64class PingResult(object):
65    """Represents a parsed ping command result.
66
67    On error, some statistics may be missing entirely from the output.
68
69    An example of output with some errors is:
70
71    PING 192.168.0.254 (192.168.0.254) 56(84) bytes of data.
72    From 192.168.0.124 icmp_seq=1 Destination Host Unreachable
73    From 192.168.0.124 icmp_seq=2 Destination Host Unreachable
74    From 192.168.0.124 icmp_seq=3 Destination Host Unreachable
75    64 bytes from 192.168.0.254: icmp_req=4 ttl=64 time=1171 ms
76    [...]
77    64 bytes from 192.168.0.254: icmp_req=10 ttl=64 time=1.95 ms
78
79    --- 192.168.0.254 ping statistics ---
80    10 packets transmitted, 7 received, +3 errors, 30% packet loss, time 9007ms
81    rtt min/avg/max/mdev = 1.806/193.625/1171.174/403.380 ms, pipe 3
82
83    A more normal run looks like:
84
85    PING google.com (74.125.239.137) 56(84) bytes of data.
86    64 bytes from 74.125.239.137: icmp_req=1 ttl=57 time=1.77 ms
87    64 bytes from 74.125.239.137: icmp_req=2 ttl=57 time=1.78 ms
88    [...]
89    64 bytes from 74.125.239.137: icmp_req=5 ttl=57 time=1.79 ms
90
91    --- google.com ping statistics ---
92    5 packets transmitted, 5 received, 0% packet loss, time 4007ms
93    rtt min/avg/max/mdev = 1.740/1.771/1.799/0.042 ms
94
95    We also sometimes see result lines like:
96    9 packets transmitted, 9 received, +1 duplicates, 0% packet loss, time 90 ms
97
98    """
99
100    @staticmethod
101    def _regex_int_from_string(regex, value):
102        m = re.search(regex, value)
103        if m is None:
104            return None
105
106        return int(m.group(1))
107
108
109    @staticmethod
110    def parse_from_output(ping_output):
111        """Construct a PingResult from ping command output.
112
113        @param ping_output string stdout from a ping command.
114
115        """
116        loss_line = (filter(lambda x: x.find('packets transmitted') > 0,
117                            ping_output.splitlines()) or [''])[0]
118        sent = PingResult._regex_int_from_string('([0-9]+) packets transmitted',
119                                                 loss_line)
120        received = PingResult._regex_int_from_string('([0-9]+) received',
121                                                     loss_line)
122        loss = PingResult._regex_int_from_string('([0-9]+)% packet loss',
123                                                 loss_line)
124        if None in (sent, received, loss):
125            raise error.TestFail('Failed to parse transmission statistics.')
126
127        m = re.search('(round-trip|rtt) min[^=]*= '
128                      '([0-9.]+)/([0-9.]+)/([0-9.]+)/([0-9.]+)', ping_output)
129        if m is not None:
130            return PingResult(sent, received, loss,
131                              min_latency=float(m.group(2)),
132                              avg_latency=float(m.group(3)),
133                              max_latency=float(m.group(4)),
134                              dev_latency=float(m.group(5)))
135        if received > 0:
136            raise error.TestFail('Failed to parse latency statistics.')
137
138        return PingResult(sent, received, loss)
139
140
141    def __init__(self, sent, received, loss,
142                 min_latency=-1.0, avg_latency=-1.0,
143                 max_latency=-1.0, dev_latency=-1.0):
144        """Construct a PingResult.
145
146        @param sent: int number of packets sent.
147        @param received: int number of replies received.
148        @param loss: int loss as a percentage (0-100)
149        @param min_latency: float min response latency in ms.
150        @param avg_latency: float average response latency in ms.
151        @param max_latency: float max response latency in ms.
152        @param dev_latency: float response latency deviation in ms.
153
154        """
155        super(PingResult, self).__init__()
156        self.sent = sent
157        self.received = received
158        self.loss = loss
159        self.min_latency = min_latency
160        self.avg_latency = avg_latency
161        self.max_latency = max_latency
162        self.dev_latency = dev_latency
163
164
165    def __repr__(self):
166        return '%s(%s)' % (self.__class__.__name__,
167                           ', '.join(['%s=%r' % item
168                                      for item in vars(self).iteritems()]))
169
170
171class PingRunner(object):
172    """Delegate to run the ping command on a local or remote host."""
173    DEFAULT_PING_COMMAND = 'ping'
174    PING_LOSS_THRESHOLD = 20  # A percentage.
175
176
177    def __init__(self, command_ping=DEFAULT_PING_COMMAND, host=None):
178        """Construct a PingRunner.
179
180        @param command_ping optional path or alias of the ping command.
181        @param host optional host object when a remote host is desired.
182
183        """
184        super(PingRunner, self).__init__()
185        self._run = utils.run
186        if host is not None:
187            self._run = host.run
188        self.command_ping = command_ping
189
190
191    def simple_ping(self, host_name):
192        """Quickly test that a hostname or IPv4 address responds to ping.
193
194        @param host_name: string name or IPv4 address.
195        @return True iff host_name responds to at least one ping.
196
197        """
198        ping_config = PingConfig(host_name, count=3,
199                                 interval=0.5, ignore_result=True,
200                                 ignore_status=True)
201        ping_result = self.ping(ping_config)
202        if ping_result is None or ping_result.received == 0:
203            return False
204        return True
205
206
207    def ping(self, ping_config):
208        """Run ping with the given |ping_config|.
209
210        Will assert that the ping had reasonable levels of loss unless
211        requested not to in |ping_config|.
212
213        @param ping_config PingConfig object describing the ping to run.
214
215        """
216        command_pieces = [self.command_ping] + ping_config.ping_args
217        command = ' '.join(command_pieces)
218        command_result = self._run(command,
219                                   timeout=ping_config.command_timeout_seconds,
220                                   ignore_status=True,
221                                   ignore_timeout=True)
222        if not command_result:
223            if ping_config.ignore_status:
224                logging.warning('Ping command timed out; cannot parse output.')
225                return PingResult(ping_config.count, 0, 100)
226
227            raise error.TestFail('Ping command timed out unexpectedly.')
228
229        if not command_result.stdout:
230            logging.warning('Ping command returned no output; stderr was %s.',
231                            command_result.stderr)
232            if ping_config.ignore_result:
233                return PingResult(ping_config.count, 0, 100)
234            raise error.TestFail('Ping command failed to yield any output')
235
236        if command_result.exit_status and not ping_config.ignore_status:
237            raise error.TestFail('Ping command failed with code=%d' %
238                                 command_result.exit_status)
239
240        ping_result = PingResult.parse_from_output(command_result.stdout)
241        if ping_config.ignore_result:
242            return ping_result
243
244        if ping_result.loss > self.PING_LOSS_THRESHOLD:
245            raise error.TestFail('Lost ping packets: %r.' % ping_result)
246
247        logging.info('Ping successful.')
248        return ping_result
249