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