| # Lint as: python2, python3 |
| # Copyright 2022 The ChromiumOS Authors |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| # |
| # Expects to be run in an environment with sudo and no interactive password |
| # prompt, such as within the Chromium OS development chroot. |
| """This is the base host class for attached devices""" |
| |
| import logging |
| |
| import common |
| |
| from autotest_lib.client.bin import utils |
| from autotest_lib.client.common_lib import error |
| from autotest_lib.server.hosts import ssh_host |
| |
| |
| class AttachedDeviceHost(ssh_host.SSHHost): |
| """Host class for all attached devices(e.g. Android)""" |
| |
| # Since we currently use labstation as phone host, the repair logic |
| # of labstation checks /var/lib/servod/ path to make reboot decision. |
| #TODO(b:226151633): use a separated path after adjust repair logic. |
| TEMP_FILE_DIR = '/var/lib/servod/' |
| LOCK_FILE_POSTFIX = "_in_use" |
| REBOOT_TIMEOUT_SECONDS = 240 |
| USB_POLL_INTERVAL_SECONDS = 2 |
| |
| def _initialize(self, |
| hostname, |
| serial_number, |
| phone_station_ssh_port=None, |
| *args, |
| **dargs): |
| """Construct a AttachedDeviceHost object. |
| |
| Args: |
| hostname: Hostname of the attached device host. |
| serial_number: Usb serial number of the associated |
| device(e.g. Android). |
| phone_station_ssh_port: port for ssh to phone station, it |
| use default 22 if the value is None. |
| """ |
| self.serial_number = serial_number |
| if phone_station_ssh_port: |
| dargs['port'] = int(phone_station_ssh_port) |
| super(AttachedDeviceHost, self)._initialize(hostname=hostname, |
| *args, |
| **dargs) |
| |
| # When run local test against a remote DUT in lab, user may use |
| # port forwarding to bypass corp ssh relay. So the hostname may |
| # be localhost while the command intended to run on a remote DUT, |
| # we can differentiate this by checking if a non-default port |
| # is specified. |
| self._is_localhost = (self.hostname in {'localhost', "127.0.0.1"} |
| and phone_station_ssh_port is None) |
| # Commands on the the host must be run by the superuser. |
| # Our account on a remote host is root, but if our target is |
| # localhost then we might be running unprivileged. If so, |
| # `sudo` will have to be added to the commands. |
| self._sudo_required = False |
| if self._is_localhost: |
| self._sudo_required = utils.system_output('id -u') != '0' |
| |
| # We need to lock the attached device host to prevent other task |
| # perform any interruptive actions(e.g. reboot) since they can |
| # be shared by multiple devices |
| self._is_locked = False |
| self._lock_file = (self.TEMP_FILE_DIR + self.serial_number + |
| self.LOCK_FILE_POSTFIX) |
| if not self.wait_up(self.REBOOT_TIMEOUT_SECONDS): |
| raise error.AutoservError( |
| 'Attached device host %s is not reachable via ssh.' % |
| self.hostname) |
| if not self._is_localhost: |
| self._lock() |
| self.wait_ready() |
| |
| def _lock(self): |
| logging.debug('Locking host %s by touching %s file', self.hostname, |
| self._lock_file) |
| self.run('mkdir -p %s' % self.TEMP_FILE_DIR) |
| self.run('touch %s' % self._lock_file) |
| self._is_locked = True |
| |
| def _unlock(self): |
| logging.debug('Unlocking host by removing %s file', self._lock_file) |
| self.run('rm %s' % self._lock_file, ignore_status=True) |
| self._is_locked = False |
| |
| def make_ssh_command(self, |
| user='root', |
| port=22, |
| opts='', |
| hosts_file=None, |
| connect_timeout=None, |
| alive_interval=None, |
| alive_count_max=None, |
| connection_attempts=None): |
| """Override default make_ssh_command to use tuned options. |
| |
| Tuning changes: |
| - ConnectTimeout=30; maximum of 30 seconds allowed for an SSH |
| connection failure. Consistency with remote_access.py. |
| |
| - ServerAliveInterval=180; which causes SSH to ping connection every |
| 180 seconds. In conjunction with ServerAliveCountMax ensures |
| that if the connection dies, Autotest will bail out quickly. |
| |
| - ServerAliveCountMax=3; consistency with remote_access.py. |
| |
| - ConnectAttempts=4; reduce flakiness in connection errors; |
| consistency with remote_access.py. |
| |
| - UserKnownHostsFile=/dev/null; we don't care about the keys. |
| |
| - SSH protocol forced to 2; needed for ServerAliveInterval. |
| |
| Args: |
| user: User name to use for the ssh connection. |
| port: Port on the target host to use for ssh connection. |
| opts: Additional options to the ssh command. |
| hosts_file: Ignored. |
| connect_timeout: Ignored. |
| alive_interval: Ignored. |
| alive_count_max: Ignored. |
| connection_attempts: Ignored. |
| |
| Returns: |
| An ssh command with the requested settings. |
| """ |
| options = ' '.join([opts, '-o Protocol=2']) |
| return super(AttachedDeviceHost, |
| self).make_ssh_command(user=user, |
| port=port, |
| opts=options, |
| hosts_file='/dev/null', |
| connect_timeout=30, |
| alive_interval=180, |
| alive_count_max=3, |
| connection_attempts=4) |
| |
| def _make_scp_cmd(self, sources, dest): |
| """Format scp command. |
| |
| Given a list of source paths and a destination path, produces the |
| appropriate scp command for encoding it. Remote paths must be |
| pre-encoded. Overrides _make_scp_cmd in AbstractSSHHost |
| to allow additional ssh options. |
| |
| Args: |
| sources: A list of source paths to copy from. |
| dest: Destination path to copy to. |
| |
| Returns: |
| An scp command that copies |sources| on local machine to |
| |dest| on the remote host. |
| """ |
| command = ('scp -rq %s -o BatchMode=yes -o StrictHostKeyChecking=no ' |
| '-o UserKnownHostsFile=/dev/null %s %s "%s"') |
| port = self.port |
| if port is None: |
| logging.info('AttachedDeviceHost: defaulting to port 22.' |
| ' See b/204502754.') |
| port = 22 |
| args = ( |
| self._main_ssh.ssh_option, |
| ("-P %s" % port), |
| sources, |
| dest, |
| ) |
| return command % args |
| |
| def run(self, |
| command, |
| timeout=3600, |
| ignore_status=False, |
| stdout_tee=utils.TEE_TO_LOGS, |
| stderr_tee=utils.TEE_TO_LOGS, |
| connect_timeout=30, |
| ssh_failure_retry_ok=False, |
| options='', |
| stdin=None, |
| verbose=True, |
| args=()): |
| """Run a command on the attached device host. |
| |
| Extends method `run` in SSHHost. If the host is a remote device, |
| it will call `run` in SSHost without changing anything. |
| If the host is 'localhost', it will call utils.system_output. |
| |
| Args: |
| command: The command line string. |
| timeout: Time limit in seconds before attempting to |
| kill the running process. The run() function |
| will take a few seconds longer than 'timeout' |
| to complete if it has to kill the process. |
| ignore_status: Do not raise an exception, no matter |
| what the exit code of the command is. |
| stdout_tee: Where to tee the stdout. |
| stderr_tee: Where to tee the stderr. |
| connect_timeout: SSH connection timeout (in seconds) |
| Ignored if host is 'localhost'. |
| options: String with additional ssh command options |
| Ignored if host is 'localhost'. |
| ssh_failure_retry_ok: when True and ssh connection failure is |
| suspected, OK to retry command (but not |
| compulsory, and likely not needed here) |
| stdin: Stdin to pass (a string) to the executed command. |
| verbose: Log the commands. |
| args: Sequence of strings to pass as arguments to command by |
| quoting them in " and escaping their contents if |
| necessary. |
| |
| Returns: |
| A utils.CmdResult object. |
| |
| Raises: |
| AutoservRunError: If the command failed. |
| AutoservSSHTimeout: SSH connection has timed out. Only applies |
| when the host is not 'localhost'. |
| """ |
| run_args = { |
| 'command': command, |
| 'timeout': timeout, |
| 'ignore_status': ignore_status, |
| 'stdout_tee': stdout_tee, |
| 'stderr_tee': stderr_tee, |
| # connect_timeout n/a for localhost |
| # options n/a for localhost |
| # ssh_failure_retry_ok n/a for localhost |
| 'stdin': stdin, |
| 'verbose': verbose, |
| 'args': args, |
| } |
| if self._is_localhost: |
| if self._sudo_required: |
| run_args['command'] = 'sudo -n sh -c "%s"' % utils.sh_escape( |
| command) |
| try: |
| return utils.run(**run_args) |
| except error.CmdError as e: |
| logging.error(e) |
| raise error.AutoservRunError('command execution error', |
| e.result_obj) |
| else: |
| run_args['connect_timeout'] = connect_timeout |
| run_args['options'] = options |
| run_args['ssh_failure_retry_ok'] = ssh_failure_retry_ok |
| return super(AttachedDeviceHost, self).run(**run_args) |
| |
| def wait_ready(self, required_uptime=300): |
| """Wait ready for the host if it has been rebooted recently. |
| |
| It may take a few minutes until the system and usb components |
| re-enumerated and become ready after attached device reboot, |
| so we need to make sure the host has been up for a given amount |
| of time before trying to start any actions. |
| |
| Args: |
| required_uptime: Minimum uptime in seconds that we can |
| consider an attached device host be ready. |
| Raises: |
| AutoservRunError: If the host has been rebooted recently |
| and it is not ready after timeout. |
| """ |
| uptime = float(self.check_uptime()) |
| # Limit the maximum wait time. |
| wait_time = min(required_uptime - uptime, required_uptime) |
| if wait_time <= 0: |
| return |
| logging.info('The attached device host was recently rebooted.' |
| ' Checking USB components.') |
| utils.poll_for_condition( |
| lambda: self._is_ready(wait_time), |
| timeout=wait_time, |
| sleep_interval=self.USB_POLL_INTERVAL_SECONDS, |
| exception=error.AutoservRunError( |
| 'Attached device host %s is not ready.' % |
| self.hostname, None)) |
| |
| def _is_ready(self, timeout): |
| """Return True if USB component with a serial number of the attached |
| device is available. |
| |
| Args: |
| timeout: Time limit in seconds before killing the running process. |
| """ |
| result = self.run('lsusb -v | grep %s' % self.serial_number, |
| timeout=timeout, |
| ignore_status=True, |
| stdout_tee=None) |
| return result.exit_status == 0 and result.stdout.strip() |
| |
| def close(self): |
| try: |
| if self._is_locked: |
| self._unlock() |
| except error.AutoservSSHTimeout: |
| logging.error('Unlock attached device host failed due to ssh' |
| ' timeout. It may caused by the host went down' |
| ' during the task.') |
| finally: |
| super(AttachedDeviceHost, self).close() |