xref: /aosp_15_r20/external/autotest/server/cros/network/arping_runner.py (revision 9c5db1993ded3edbeafc8092d69fe5de2ee02df7)
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
6import re
7
8from autotest_lib.client.common_lib import error
9from autotest_lib.client.common_lib.cros import path_utils
10
11
12class ArpingRunner(object):
13    """Delegate to run arping on a remote host."""
14
15    DEFAULT_COUNT = 10
16    SSH_TIMEOUT_MARGIN = 120
17
18
19    def __init__(self, host, ping_interface):
20        self._host = host
21        self._arping_command = path_utils.must_be_installed(
22                '/usr/bin/arping', host=host)
23        self._ping_interface = ping_interface
24
25
26    def arping(self, target_ip, count=None, timeout_seconds=None):
27        """Run arping on a remote host.
28
29        @param target_ip: string IP address to use as the ARP target.
30        @param count: int number of ARP packets to send.  The command
31            will take roughly |count| seconds to complete, since arping
32            sends a packet out once a second.
33        @param timeout_seconds: int number of seconds to wait for arping
34            to complete.  Override the default of one second per packet.
35            Note that this doesn't change packet spacing.
36
37        """
38        if count is None:
39            count = self.DEFAULT_COUNT
40        if timeout_seconds is None:
41            timeout_seconds  = count
42        command_pieces = [self._arping_command]
43        command_pieces.append('-b')  # Default to only sending broadcast ARPs.
44        command_pieces.append('-w %d' % timeout_seconds)
45        command_pieces.append('-c %d' % count)
46        command_pieces.append('-I %s %s' % (self._ping_interface, target_ip))
47        result = self._host.run(
48                ' '.join(command_pieces),
49                timeout=timeout_seconds + self.SSH_TIMEOUT_MARGIN,
50                ignore_status=True)
51        return ArpingResult(result.stdout)
52
53
54class ArpingResult(object):
55    """Can parse raw arping output and present a summary."""
56
57    DEFAULT_LOSS_THRESHOLD = 30.0
58
59
60    def __init__(self, stdout):
61        """Construct an ArpingResult from the stdout of arping.
62
63        A successful run looks something like this:
64
65        ARPING 192.168.2.193 from 192.168.2.254 eth0
66        Unicast reply from 192.168.2.193 [14:7D:C5:E1:53:83] 2.842ms
67        Unicast reply from 192.168.2.193 [14:7D:C5:E1:53:83] 5.851ms
68        Unicast reply from 192.168.2.193 [14:7D:C5:E1:53:83] 2.565ms
69        Unicast reply from 192.168.2.193 [14:7D:C5:E1:53:83] 2.595ms
70        Unicast reply from 192.168.2.193 [14:7D:C5:E1:53:83] 2.534ms
71        Unicast reply from 192.168.2.193 [14:7D:C5:E1:53:83] 3.217ms
72        Unicast request from 192.168.2.193 [14:7D:C5:E1:53:83] 748.657ms
73        Sent 6 probes (6 broadcast(s))
74        Received 7 response(s) (1 request(s))
75
76        @param stdout string raw stdout of arping command.
77
78        """
79        latencies = []
80        responders = set()
81        num_sent = None
82        regex = re.compile(r'(([0-9]{1,3}\.){3}[0-9]{1,3}) '
83                           r'\[(([0-9A-Fa-f]{1,2}:){5}[0-9A-Fa-f]{1,2})\] +'
84                           r'([0-9\.]+)ms')
85        requests = 0
86        for line in stdout.splitlines():
87            if line.find('Unicast reply from') == 0:
88                match = re.search(regex, line.strip())
89                if match is None:
90                    raise error.TestError('arping result parsing code failed '
91                                          'to anticipate line: ' % line)
92
93                responder_ip = match.group(1)  # Maybe useful in the future?
94                responder_mac = match.group(3)
95                latency = float(match.group(5))
96                latencies.append(latency)
97                responders.add(responder_mac)
98            if line.find('Unicast request from') == 0:
99                # We don't care about these really, but they mess up our
100                # primitive line counting.
101                requests += 1
102            elif line.find('Sent ') == 0:
103                num_sent = int(line.split()[1])
104            elif line.find('Received ') == 0:
105                count = int(line.split()[1])
106                if count != len(latencies) + requests:
107                    raise error.TestFail('Failed to parse accurate latencies '
108                                         'from stdout: %r.  Got %d, '
109                                         'wanted %d.' % (stdout, len(latencies),
110                                                         count))
111        if num_sent is None:
112            raise error.TestFail('Failed to parse number of arpings sent '
113                                 'from %r' % stdout)
114
115        if num_sent < 1:
116            raise error.TestFail('No arpings sent.')
117
118        self.loss = 100.0 * float(num_sent - len(latencies)) / num_sent
119        self.average_latency = 0.0
120        if latencies:
121            self.average_latency = sum(latencies) / len(latencies)
122        self.latencies = latencies
123        self.responders = responders
124
125
126    def was_successful(self, max_average_latency=None, valid_responders=None,
127                       max_loss=DEFAULT_LOSS_THRESHOLD):
128        """Checks if the arping was some definition of successful.
129
130        @param max_average_latency float maximum value for average latency in
131                milliseconds.
132        @param valid_responders iterable object of responder MAC addresses.
133                We'll check that we got only responses from valid responders.
134        @param max_loss float maximum loss expressed as a percentage.
135        @return True iff all criterion set to not None values hold.
136
137        """
138        if (max_average_latency is not None and
139                self.average_latency > max_average_latency):
140            return False
141
142        if (valid_responders is not None and
143                self.responders.difference(valid_responders)):
144            return False
145
146        if max_loss is not None and self.loss > max_loss:
147            return False
148
149        return True
150
151
152    def __repr__(self):
153        return ('%s(loss=%r, average_latency=%r, latencies=%r, responders=%r)' %
154                (self.__class__.__name__, self.loss, self.average_latency,
155                 self.latencies, self.responders))
156