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