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