xref: /aosp_15_r20/external/autotest/server/hosts/attached_device_host.py (revision 9c5db1993ded3edbeafc8092d69fe5de2ee02df7)
1# Lint as: python2, python3
2# Copyright (c) 2022 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#
6# Expects to be run in an environment with sudo and no interactive password
7# prompt, such as within the Chromium OS development chroot.
8"""This is the base host class for attached devices"""
9
10import logging
11import time
12
13import common
14
15from autotest_lib.client.bin import utils
16from autotest_lib.client.common_lib import error
17from autotest_lib.server.hosts import ssh_host
18
19
20class AttachedDeviceHost(ssh_host.SSHHost):
21    """Host class for all attached devices(e.g. Android)"""
22
23    # Since we currently use labstation as phone host, the repair logic
24    # of labstation checks /var/lib/servod/ path to make reboot decision.
25    #TODO(b:226151633): use a separated path after adjust repair logic.
26    TEMP_FILE_DIR = '/var/lib/servod/'
27    LOCK_FILE_POSTFIX = "_in_use"
28    REBOOT_TIMEOUT_SECONDS = 240
29
30    def _initialize(self,
31                    hostname,
32                    serial_number,
33                    phone_station_ssh_port=None,
34                    *args,
35                    **dargs):
36        """Construct a AttachedDeviceHost object.
37
38        Args:
39            hostname: Hostname of the attached device host.
40            serial_number: Usb serial number of the associated
41                           device(e.g. Android).
42            phone_station_ssh_port: port for ssh to phone station, it
43                                    use default 22 if the value is None.
44        """
45        self.serial_number = serial_number
46        if phone_station_ssh_port:
47            dargs['port'] = int(phone_station_ssh_port)
48        super(AttachedDeviceHost, self)._initialize(hostname=hostname,
49                                                    *args,
50                                                    **dargs)
51
52        # When run local test against a remote DUT in lab, user may use
53        # port forwarding to bypass corp ssh relay. So the hostname may
54        # be localhost while the command intended to run on a remote DUT,
55        # we can differentiate this by checking if a non-default port
56        # is specified.
57        self._is_localhost = (self.hostname in {'localhost', "127.0.0.1"}
58                              and phone_station_ssh_port is None)
59        # Commands on the the host must be run by the superuser.
60        # Our account on a remote host is root, but if our target is
61        # localhost then we might be running unprivileged.  If so,
62        # `sudo` will have to be added to the commands.
63        self._sudo_required = False
64        if self._is_localhost:
65            self._sudo_required = utils.system_output('id -u') != '0'
66
67        # We need to lock the attached device host to prevent other task
68        # perform any interruptive actions(e.g. reboot) since they can
69        # be shared by multiple devices
70        self._is_locked = False
71        self._lock_file = (self.TEMP_FILE_DIR + self.serial_number +
72                           self.LOCK_FILE_POSTFIX)
73        if not self.wait_up(self.REBOOT_TIMEOUT_SECONDS):
74            raise error.AutoservError(
75                    'Attached device host %s is not reachable via ssh.' %
76                    self.hostname)
77        if not self._is_localhost:
78            self._lock()
79            self.wait_ready()
80
81    def _lock(self):
82        logging.debug('Locking host %s by touching %s file', self.hostname,
83                      self._lock_file)
84        self.run('mkdir -p %s' % self.TEMP_FILE_DIR)
85        self.run('touch %s' % self._lock_file)
86        self._is_locked = True
87
88    def _unlock(self):
89        logging.debug('Unlocking host by removing %s file', self._lock_file)
90        self.run('rm %s' % self._lock_file, ignore_status=True)
91        self._is_locked = False
92
93    def make_ssh_command(self,
94                         user='root',
95                         port=22,
96                         opts='',
97                         hosts_file=None,
98                         connect_timeout=None,
99                         alive_interval=None,
100                         alive_count_max=None,
101                         connection_attempts=None):
102        """Override default make_ssh_command to use tuned options.
103
104        Tuning changes:
105          - ConnectTimeout=30; maximum of 30 seconds allowed for an SSH
106          connection failure. Consistency with remote_access.py.
107
108          - ServerAliveInterval=180; which causes SSH to ping connection every
109          180 seconds. In conjunction with ServerAliveCountMax ensures
110          that if the connection dies, Autotest will bail out quickly.
111
112          - ServerAliveCountMax=3; consistency with remote_access.py.
113
114          - ConnectAttempts=4; reduce flakiness in connection errors;
115          consistency with remote_access.py.
116
117          - UserKnownHostsFile=/dev/null; we don't care about the keys.
118
119          - SSH protocol forced to 2; needed for ServerAliveInterval.
120
121        Args:
122            user: User name to use for the ssh connection.
123            port: Port on the target host to use for ssh connection.
124            opts: Additional options to the ssh command.
125            hosts_file: Ignored.
126            connect_timeout: Ignored.
127            alive_interval: Ignored.
128            alive_count_max: Ignored.
129            connection_attempts: Ignored.
130
131        Returns:
132            An ssh command with the requested settings.
133        """
134        options = ' '.join([opts, '-o Protocol=2'])
135        return super(AttachedDeviceHost,
136                     self).make_ssh_command(user=user,
137                                            port=port,
138                                            opts=options,
139                                            hosts_file='/dev/null',
140                                            connect_timeout=30,
141                                            alive_interval=180,
142                                            alive_count_max=3,
143                                            connection_attempts=4)
144
145    def _make_scp_cmd(self, sources, dest):
146        """Format scp command.
147
148        Given a list of source paths and a destination path, produces the
149        appropriate scp command for encoding it. Remote paths must be
150        pre-encoded. Overrides _make_scp_cmd in AbstractSSHHost
151        to allow additional ssh options.
152
153        Args:
154            sources: A list of source paths to copy from.
155            dest: Destination path to copy to.
156
157        Returns:
158            An scp command that copies |sources| on local machine to
159            |dest| on the remote host.
160        """
161        command = ('scp -rq %s -o BatchMode=yes -o StrictHostKeyChecking=no '
162                   '-o UserKnownHostsFile=/dev/null %s %s "%s"')
163        port = self.port
164        if port is None:
165            logging.info('AttachedDeviceHost: defaulting to port 22.'
166                         ' See b/204502754.')
167            port = 22
168        args = (
169                self._main_ssh.ssh_option,
170                ("-P %s" % port),
171                sources,
172                dest,
173        )
174        return command % args
175
176    def run(self,
177            command,
178            timeout=3600,
179            ignore_status=False,
180            stdout_tee=utils.TEE_TO_LOGS,
181            stderr_tee=utils.TEE_TO_LOGS,
182            connect_timeout=30,
183            ssh_failure_retry_ok=False,
184            options='',
185            stdin=None,
186            verbose=True,
187            args=()):
188        """Run a command on the attached device host.
189
190        Extends method `run` in SSHHost. If the host is a remote device,
191        it will call `run` in SSHost without changing anything.
192        If the host is 'localhost', it will call utils.system_output.
193
194        Args:
195            command: The command line string.
196            timeout: Time limit in seconds before attempting to
197                     kill the running process. The run() function
198                     will take a few seconds longer than 'timeout'
199                     to complete if it has to kill the process.
200            ignore_status: Do not raise an exception, no matter
201                           what the exit code of the command is.
202            stdout_tee: Where to tee the stdout.
203            stderr_tee: Where to tee the stderr.
204            connect_timeout: SSH connection timeout (in seconds)
205                             Ignored if host is 'localhost'.
206            options: String with additional ssh command options
207                     Ignored if host is 'localhost'.
208            ssh_failure_retry_ok: when True and ssh connection failure is
209                                  suspected, OK to retry command (but not
210                                  compulsory, and likely not needed here)
211            stdin: Stdin to pass (a string) to the executed command.
212            verbose: Log the commands.
213            args: Sequence of strings to pass as arguments to command by
214                  quoting them in " and escaping their contents if
215                  necessary.
216
217        Returns:
218            A utils.CmdResult object.
219
220        Raises:
221            AutoservRunError: If the command failed.
222            AutoservSSHTimeout: SSH connection has timed out. Only applies
223                                when the host is not 'localhost'.
224        """
225        run_args = {
226                'command': command,
227                'timeout': timeout,
228                'ignore_status': ignore_status,
229                'stdout_tee': stdout_tee,
230                'stderr_tee': stderr_tee,
231                # connect_timeout     n/a for localhost
232                # options             n/a for localhost
233                # ssh_failure_retry_ok n/a for localhost
234                'stdin': stdin,
235                'verbose': verbose,
236                'args': args,
237        }
238        if self._is_localhost:
239            if self._sudo_required:
240                run_args['command'] = 'sudo -n sh -c "%s"' % utils.sh_escape(
241                        command)
242            try:
243                return utils.run(**run_args)
244            except error.CmdError as e:
245                logging.error(e)
246                raise error.AutoservRunError('command execution error',
247                                             e.result_obj)
248        else:
249            run_args['connect_timeout'] = connect_timeout
250            run_args['options'] = options
251            run_args['ssh_failure_retry_ok'] = ssh_failure_retry_ok
252            return super(AttachedDeviceHost, self).run(**run_args)
253
254    def wait_ready(self, required_uptime=300):
255        """Wait ready for the host if it has been rebooted recently.
256
257        It may take a few minutes until the system and usb components
258        re-enumerated and become ready after a attached device reboot,
259        so we need to make sure the host has been up for a given a mount
260        of time before trying to start any actions.
261
262        Args:
263            required_uptime: Minimum uptime in seconds that we can
264                             consider an attached device host be ready.
265        """
266        uptime = float(self.check_uptime())
267        # To prevent unexpected output from check_uptime() that causes long
268        # sleep, make sure the maximum wait time <= required_uptime.
269        diff = min(required_uptime - uptime, required_uptime)
270        if diff > 0:
271            logging.info(
272                    'The attached device host was just rebooted, wait %s'
273                    ' seconds for all system services ready and usb'
274                    ' components re-enumerated.', diff)
275            #TODO(b:226401363): Use a poll to ensure all dependencies are ready.
276            time.sleep(diff)
277
278    def close(self):
279        try:
280            if self._is_locked:
281                self._unlock()
282        except error.AutoservSSHTimeout:
283            logging.error('Unlock attached device host failed due to ssh'
284                          ' timeout. It may caused by the host went down'
285                          ' during the task.')
286        finally:
287            super(AttachedDeviceHost, self).close()
288