• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1# Lint as: python2, python3
2# Copyright (c) 2013 The Chromium OS Authors. All rights reserved.
3# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
5
6from __future__ import absolute_import
7from __future__ import division
8from __future__ import print_function
9
10import logging
11import math
12import re
13import six
14
15from autotest_lib.client.bin import utils
16from autotest_lib.client.common_lib import error
17
18PLATFORM_LINUX = 'LINUX'
19PLATFORM_MACOS = 'MAC_OS'
20
21
22def _get_platform_delegate(platform):
23    if platform == PLATFORM_LINUX:
24        return LinuxPingDelegate
25    elif platform == PLATFORM_MACOS:
26        return MacPingDelegate
27    else:
28        raise error.TestError('%s is not a valid platform type', platform)
29
30
31def _regex_int_from_string(pattern, line):
32    """Retrieve an integer from a string, using regex.
33
34    @param pattern: The regular expression to apply to the input string.
35    @param line: String input to retrieve an integer from.
36    @return integer retrieved from the input string, or None if there is no
37        match.
38    """
39    m = re.search(pattern, line)
40    if m is not None:
41        return int(m.group(1))
42    return None
43
44
45def _regex_float_from_string(pattern, line):
46    """Retrieve a float from a string, using regex.
47
48    @param pattern: The regular expression to apply to the input string.
49    @param line: String input to retrieve a float from.
50    @return float retrieved from the input string, or None if there is no
51        match.
52    """
53    m = re.search(pattern, line)
54    if m is not None:
55        return float(m.group(1))
56    return None
57
58
59def _extract_ping_loss(output):
60    for line in output.splitlines():
61        if line.find('packets transmitted') > 0:
62            return line
63    return ''
64
65
66class MacPingDelegate(object):
67    """Implement ping functionality for MacOS hosts."""
68
69    @staticmethod
70    def ping_arguments(ping_config):
71        """
72        @param ping_config PingConfig object describing the ping test for which
73           arguments are needed.
74        @return list of parameters to ping.
75        """
76        args = []
77        args.append('-c %d' % ping_config.count)
78        if ping_config.size is not None:
79            args.append('-s %d' % ping_config.size)
80        if ping_config.interval is not None:
81            args.append('-i %f' % ping_config.interval)
82        if ping_config.source_iface is not None:
83            args.append('-b %s' % ping_config.source_iface)
84        if ping_config.qos is not None:
85            if ping_config.qos == 'be':
86                ping_config.append('-k 0')
87            elif ping_config.qos == 'bk':
88                ping_config.append('-k 1')
89            elif ping_config.qos == 'vi':
90                args.append('-k 2')
91            elif ping_config.qos == 'vo':
92                args.append('-k 3')
93            else:
94                raise error.TestFail('Unknown QoS value: %s' % ping_config.qos)
95
96        # The last argument is the IP address to ping.
97        args.append(ping_config.target_ip)
98        return args
99
100
101    @staticmethod
102    def parse_from_output(ping_output):
103        """Extract the ping results from stdout.
104
105        @param ping_output string stdout from a ping/ping6 command.
106
107        stdout from ping command looks like:
108
109        PING 8.8.8.8 (8.8.8.8): 56 data bytes
110        64 bytes from 8.8.8.8: icmp_seq=0 ttl=57 time=3.770 ms
111        64 bytes from 8.8.8.8: icmp_seq=1 ttl=57 time=4.165 ms
112        64 bytes from 8.8.8.8: icmp_seq=2 ttl=57 time=4.901 ms
113
114        --- 8.8.8.8 ping statistics ---
115        3 packets transmitted, 3 packets received, 0.0% packet loss
116        round-trip min/avg/max/stddev = 3.770/4.279/4.901/0.469 ms
117
118        stdout from ping6 command looks like:
119
120        16 bytes from fdd2:8741:1993:8::, icmp_seq=16 hlim=64 time=1.783 ms
121        16 bytes from fdd2:8741:1993:8::, icmp_seq=17 hlim=64 time=2.150 ms
122        16 bytes from fdd2:8741:1993:8::, icmp_seq=18 hlim=64 time=2.516 ms
123        16 bytes from fdd2:8741:1993:8::, icmp_seq=19 hlim=64 time=1.401 ms
124
125        --- fdd2:8741:1993:8:: ping6 statistics ---
126        20 packets transmitted, 20 packets received, 0.0% packet loss
127        round-trip min/avg/max/std-dev = 1.401/2.122/3.012/0.431 ms
128
129        This function will look for both 'stdev' and 'std-dev' in test results
130        to support both ping and ping6 commands.
131        """
132        loss_line = _extract_ping_loss(ping_output)
133        sent = _regex_int_from_string('([0-9]+) packets transmitted', loss_line)
134        received = _regex_int_from_string('([0-9]+) packets received',
135                                          loss_line)
136        loss = _regex_float_from_string('([0-9]+\.[0-9]+)% packet loss',
137                                        loss_line)
138        if None in (sent, received, loss):
139            raise error.TestFail('Failed to parse transmission statistics.')
140        m = re.search('round-trip min\/avg\/max\/std-?dev = ([0-9.]+)\/([0-9.]+)'
141                      '\/([0-9.]+)\/([0-9.]+) ms', ping_output)
142        if m is not None:
143            return PingResult(sent, received, loss,
144                              min_latency=float(m.group(1)),
145                              avg_latency=float(m.group(2)),
146                              max_latency=float(m.group(3)),
147                              dev_latency=float(m.group(4)))
148        if received > 0:
149            raise error.TestFail('Failed to parse latency statistics.')
150
151        return PingResult(sent, received, loss)
152
153
154class LinuxPingDelegate(object):
155    """Implement ping functionality specific to the linux platform."""
156    @staticmethod
157    def ping_arguments(ping_config):
158        """
159        @param ping_config PingConfig object describing the ping test for which
160           arguments are needed.
161        @return list of parameters to ping.
162        """
163        args = []
164        args.append('-c %d' % ping_config.count)
165        if ping_config.size is not None:
166            args.append('-s %d' % ping_config.size)
167        if ping_config.interval is not None:
168            args.append('-i %f' % ping_config.interval)
169        if ping_config.source_iface is not None:
170            args.append('-I %s' % ping_config.source_iface)
171        if ping_config.qos is not None:
172            if ping_config.qos == 'be':
173                args.append('-Q 0x04')
174            elif ping_config.qos == 'bk':
175                args.append('-Q 0x02')
176            elif ping_config.qos == 'vi':
177                args.append('-Q 0x08')
178            elif ping_config.qos == 'vo':
179                args.append('-Q 0x10')
180            else:
181                raise error.TestFail('Unknown QoS value: %s' % ping_config.qos)
182
183        # The last argument is the IP address to ping.
184        args.append(ping_config.target_ip)
185        return args
186
187
188    @staticmethod
189    def parse_from_output(ping_output):
190        """Extract the ping results from stdout.
191
192        @param ping_output string stdout from a ping command.
193        On error, some statistics may be missing entirely from the output.
194
195        An example of output with some errors is:
196
197        PING 192.168.0.254 (192.168.0.254) 56(84) bytes of data.
198        From 192.168.0.124 icmp_seq=1 Destination Host Unreachable
199        From 192.168.0.124 icmp_seq=2 Destination Host Unreachable
200        From 192.168.0.124 icmp_seq=3 Destination Host Unreachable
201        64 bytes from 192.168.0.254: icmp_req=4 ttl=64 time=1171 ms
202        [...]
203        64 bytes from 192.168.0.254: icmp_req=10 ttl=64 time=1.95 ms
204
205        --- 192.168.0.254 ping statistics ---
206        10 packets transmitted, 7 received, +3 errors, 30% packet loss,
207            time 9007ms
208        rtt min/avg/max/mdev = 1.806/193.625/1171.174/403.380 ms, pipe 3
209
210        A more normal run looks like:
211
212        PING google.com (74.125.239.137) 56(84) bytes of data.
213        64 bytes from 74.125.239.137: icmp_req=1 ttl=57 time=1.77 ms
214        64 bytes from 74.125.239.137: icmp_req=2 ttl=57 time=1.78 ms
215        [...]
216        64 bytes from 74.125.239.137: icmp_req=5 ttl=57 time=1.79 ms
217
218        --- google.com ping statistics ---
219        5 packets transmitted, 5 received, 0% packet loss, time 4007ms
220        rtt min/avg/max/mdev = 1.740/1.771/1.799/0.042 ms
221
222        We also sometimes see result lines like:
223        9 packets transmitted, 9 received, +1 duplicates, 0% packet loss,
224            time 90 ms
225
226        """
227        loss_line = _extract_ping_loss(ping_output)
228        sent = _regex_int_from_string('([0-9]+) packets transmitted', loss_line)
229        received = _regex_int_from_string('([0-9]+) received', loss_line)
230        loss = _regex_float_from_string('([0-9]+(\.[0-9]+)?)% packet loss',
231                                        loss_line)
232        if None in (sent, received, loss):
233            raise error.TestFail('Failed to parse transmission statistics.')
234
235        m = re.search('(round-trip|rtt) min[^=]*= '
236                      '([0-9.]+)/([0-9.]+)/([0-9.]+)/([0-9.]+)', ping_output)
237        if m is not None:
238            return PingResult(sent, received, loss,
239                              min_latency=float(m.group(2)),
240                              avg_latency=float(m.group(3)),
241                              max_latency=float(m.group(4)),
242                              dev_latency=float(m.group(5)))
243        if received > 0:
244            raise error.TestFail('Failed to parse latency statistics.')
245
246        return PingResult(sent, received, loss)
247
248
249class PingConfig(object):
250    """Describes the parameters for a ping command."""
251
252    DEFAULT_COUNT = 10
253    PACKET_WAIT_MARGIN_SECONDS = 120
254
255    def __init__(self, target_ip, count=DEFAULT_COUNT, size=None,
256                 interval=None, qos=None, source_iface=None,
257                 ignore_status=False, ignore_result=False):
258        super(PingConfig, self).__init__()
259        self.target_ip = target_ip
260        self.count = count
261        self.size = size
262        self.interval = interval
263        if qos:
264            qos = qos.lower()
265        self.qos = qos
266        self.source_iface = source_iface
267        self.ignore_status = ignore_status
268        self.ignore_result = ignore_result
269        interval_seconds = self.interval or 1
270        command_time = math.ceil(interval_seconds * self.count)
271        self.command_timeout_seconds = int(command_time +
272                                           self.PACKET_WAIT_MARGIN_SECONDS)
273
274
275class PingResult(object):
276    """Represents a parsed ping command result."""
277    def __init__(self, sent, received, loss,
278                 min_latency=-1.0, avg_latency=-1.0,
279                 max_latency=-1.0, dev_latency=-1.0):
280        """Construct a PingResult.
281
282        @param sent: int number of packets sent.
283        @param received: int number of replies received.
284        @param loss: int loss as a percentage (0-100)
285        @param min_latency: float min response latency in ms.
286        @param avg_latency: float average response latency in ms.
287        @param max_latency: float max response latency in ms.
288        @param dev_latency: float response latency deviation in ms.
289
290        """
291        super(PingResult, self).__init__()
292        self.sent = sent
293        self.received = received
294        self.loss = loss
295        self.min_latency = min_latency
296        self.avg_latency = avg_latency
297        self.max_latency = max_latency
298        self.dev_latency = dev_latency
299
300
301    def __repr__(self):
302        return '%s(%s)' % (self.__class__.__name__, ', '.join(
303                ['%s=%r' % item for item in six.iteritems(vars(self))]))
304
305
306class PingRunner(object):
307    """Delegate to run the ping command on a local or remote host."""
308    DEFAULT_PING_COMMAND = 'ping'
309    PING_LOSS_THRESHOLD = 20  # A percentage.
310
311
312    def __init__(self, command_ping=DEFAULT_PING_COMMAND, host=None,
313                 platform=PLATFORM_LINUX):
314        """Construct a PingRunner.
315
316        @param command_ping optional path or alias of the ping command.
317        @param host optional host object when a remote host is desired.
318
319        """
320        super(PingRunner, self).__init__()
321        self._run = utils.run
322        if host is not None:
323            self._run = host.run
324        self.command_ping = command_ping
325        self._platform_delegate = _get_platform_delegate(platform)
326
327
328    def simple_ping(self, host_name):
329        """Quickly test that a hostname or IPv4 address responds to ping.
330
331        @param host_name: string name or IPv4 address.
332        @return True if host_name responds to at least one ping.
333
334        """
335        ping_config = PingConfig(host_name, count=3, interval=0.5,
336                                 ignore_status=True, ignore_result=True)
337        ping_result = self.ping(ping_config)
338        if ping_result is None or ping_result.received == 0:
339            return False
340        return True
341
342
343    def ping(self, ping_config):
344        """Run ping with the given |ping_config|.
345
346        Will assert that the ping had reasonable levels of loss unless
347        requested not to in |ping_config|.
348
349        @param ping_config PingConfig object describing the ping to run.
350
351        """
352        command_pieces = ([self.command_ping] +
353                          self._platform_delegate.ping_arguments(ping_config))
354        command = ' '.join(command_pieces)
355        command_result = self._run(command,
356                                   timeout=ping_config.command_timeout_seconds,
357                                   ignore_status=True,
358                                   ignore_timeout=True)
359        if not command_result:
360            if ping_config.ignore_status:
361                logging.warning('Ping command timed out; cannot parse output.')
362                return PingResult(ping_config.count, 0, 100)
363
364            raise error.TestFail('Ping command timed out unexpectedly.')
365
366        if not command_result.stdout:
367            logging.warning('Ping command returned no output; stderr was %s.',
368                            command_result.stderr)
369            if ping_config.ignore_result:
370                return PingResult(ping_config.count, 0, 100)
371            raise error.TestFail('Ping command failed to yield any output')
372
373        if command_result.exit_status and not ping_config.ignore_status:
374            raise error.TestFail('Ping command failed with code=%d' %
375                                 command_result.exit_status)
376
377        ping_result = self._platform_delegate.parse_from_output(
378                command_result.stdout)
379        if ping_config.ignore_result:
380            return ping_result
381
382        if ping_result.loss > self.PING_LOSS_THRESHOLD:
383            raise error.TestFail('Lost ping packets: %r.' % ping_result)
384
385        logging.info('Ping successful.')
386        return ping_result
387