1#!/usr/bin/env python3 2# 3# Copyright 2019 - The Android Open Source Project 4# 5# Licensed under the Apache License, Version 2.0 (the "License"); 6# you may not use this file except in compliance with the License. 7# You may obtain a copy of the License at 8# 9# http://www.apache.org/licenses/LICENSE-2.0 10# 11# Unless required by applicable law or agreed to in writing, software 12# distributed under the License is distributed on an "AS IS" BASIS, 13# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14# See the License for the specific language governing permissions and 15# limitations under the License. 16 17import backoff 18import os 19import logging 20import paramiko 21import psutil 22import socket 23import tarfile 24import tempfile 25import time 26import usbinfo 27 28from acts import utils 29from acts.controllers.fuchsia_lib.base_lib import DeviceOffline 30from acts.libs.proc import job 31from acts.utils import get_fuchsia_mdns_ipv6_address 32 33logging.getLogger("paramiko").setLevel(logging.WARNING) 34# paramiko-ng will throw INFO messages when things get disconnect or cannot 35# connect perfectly the first time. In this library those are all handled by 36# either retrying and/or throwing an exception for the appropriate case. 37# Therefore, in order to reduce confusion in the logs the log level is set to 38# WARNING. 39 40MDNS_LOOKUP_RETRY_MAX = 3 41FASTBOOT_TIMEOUT = 30 42AFTER_FLASH_BOOT_TIME = 30 43WAIT_FOR_EXISTING_FLASH_TO_FINISH_SEC = 360 44PROCESS_CHECK_WAIT_TIME_SEC = 30 45 46FUCHSIA_SDK_URL = "gs://fuchsia-sdk/development" 47FUCHSIA_RELEASE_TESTING_URL = "gs://fuchsia-release-testing/images" 48 49 50def get_private_key(ip_address, ssh_config): 51 """Tries to load various ssh key types. 52 53 Args: 54 ip_address: IP address of ssh server. 55 ssh_config: ssh_config location for the ssh server. 56 Returns: 57 The ssh private key 58 """ 59 exceptions = [] 60 try: 61 logging.debug('Trying to load SSH key type: ed25519') 62 return paramiko.ed25519key.Ed25519Key( 63 filename=get_ssh_key_for_host(ip_address, ssh_config)) 64 except paramiko.SSHException as e: 65 exceptions.append(e) 66 logging.debug('Failed loading SSH key type: ed25519') 67 68 try: 69 logging.debug('Trying to load SSH key type: rsa') 70 return paramiko.RSAKey.from_private_key_file( 71 filename=get_ssh_key_for_host(ip_address, ssh_config)) 72 except paramiko.SSHException as e: 73 exceptions.append(e) 74 logging.debug('Failed loading SSH key type: rsa') 75 76 raise Exception('No valid ssh key type found', exceptions) 77 78 79@backoff.on_exception( 80 backoff.constant, 81 (paramiko.ssh_exception.SSHException, 82 paramiko.ssh_exception.AuthenticationException, socket.timeout, 83 socket.error, ConnectionRefusedError, ConnectionResetError), 84 interval=1.5, 85 max_tries=4) 86def create_ssh_connection(ip_address, 87 ssh_username, 88 ssh_config, 89 ssh_port=22, 90 connect_timeout=10, 91 auth_timeout=10, 92 banner_timeout=10): 93 """Creates and ssh connection to a Fuchsia device 94 95 Args: 96 ip_address: IP address of ssh server. 97 ssh_username: Username for ssh server. 98 ssh_config: ssh_config location for the ssh server. 99 ssh_port: port for the ssh server. 100 connect_timeout: Timeout value for connecting to ssh_server. 101 auth_timeout: Timeout value to wait for authentication. 102 banner_timeout: Timeout to wait for ssh banner. 103 104 Returns: 105 A paramiko ssh object 106 """ 107 if not utils.can_ping(job, ip_address): 108 raise DeviceOffline("Device %s is not reachable via " 109 "the network." % ip_address) 110 ssh_key = get_private_key(ip_address=ip_address, ssh_config=ssh_config) 111 ssh_client = paramiko.SSHClient() 112 ssh_client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) 113 ssh_client.connect(hostname=ip_address, 114 username=ssh_username, 115 allow_agent=False, 116 pkey=ssh_key, 117 port=ssh_port, 118 timeout=connect_timeout, 119 auth_timeout=auth_timeout, 120 banner_timeout=banner_timeout) 121 ssh_client.get_transport().set_keepalive(1) 122 return ssh_client 123 124 125def ssh_is_connected(ssh_client): 126 """Checks to see if the SSH connection is alive. 127 Args: 128 ssh_client: A paramiko SSH client instance. 129 Returns: 130 True if connected, False or None if not connected. 131 """ 132 return ssh_client and ssh_client.get_transport().is_active() 133 134 135def get_ssh_key_for_host(host, ssh_config_file): 136 """Gets the SSH private key path from a supplied ssh_config_file and the 137 host. 138 Args: 139 host (str): The ip address or host name that SSH will connect to. 140 ssh_config_file (str): Path to the ssh_config_file that will be used 141 to connect to the host. 142 143 Returns: 144 path: A path to the private key for the SSH connection. 145 """ 146 ssh_config = paramiko.SSHConfig() 147 user_config_file = os.path.expanduser(ssh_config_file) 148 if os.path.exists(user_config_file): 149 with open(user_config_file) as f: 150 ssh_config.parse(f) 151 user_config = ssh_config.lookup(host) 152 153 if 'identityfile' not in user_config: 154 raise ValueError('Could not find identity file in %s.' % ssh_config) 155 156 path = os.path.expanduser(user_config['identityfile'][0]) 157 if not os.path.exists(path): 158 raise FileNotFoundError('Specified IdentityFile %s for %s in %s not ' 159 'existing anymore.' % (path, host, ssh_config)) 160 return path 161 162 163class SshResults: 164 """Class representing the results from a SSH command to mimic the output 165 of the job.Result class in ACTS. This is to reduce the changes needed from 166 swapping the ssh connection in ACTS to paramiko. 167 168 Attributes: 169 stdin: The file descriptor to the input channel of the SSH connection. 170 stdout: The file descriptor to the stdout of the SSH connection. 171 stderr: The file descriptor to the stderr of the SSH connection. 172 exit_status: The file descriptor of the SSH command. 173 """ 174 175 def __init__(self, stdin, stdout, stderr, exit_status): 176 self._raw_stdout = stdout.read() 177 self._stdout = self._raw_stdout.decode('utf-8', errors='replace') 178 self._stderr = stderr.read().decode('utf-8', errors='replace') 179 self._exit_status = exit_status.recv_exit_status() 180 181 @property 182 def stdout(self): 183 return self._stdout 184 185 @property 186 def raw_stdout(self): 187 return self._raw_stdout 188 189 @property 190 def stderr(self): 191 return self._stderr 192 193 @property 194 def exit_status(self): 195 return self._exit_status 196 197 198def flash(fuchsia_device, 199 use_ssh=False, 200 fuchsia_reconnect_after_reboot_time=5): 201 """A function to flash, not pave, a fuchsia_device 202 203 Args: 204 fuchsia_device: An ACTS fuchsia_device 205 206 Returns: 207 True if successful. 208 """ 209 if not fuchsia_device.authorized_file: 210 raise ValueError('A ssh authorized_file must be present in the ' 211 'ACTS config to flash fuchsia_devices.') 212 # This is the product type from the fx set command. 213 # Do 'fx list-products' to see options in Fuchsia source tree. 214 if not fuchsia_device.product_type: 215 raise ValueError('A product type must be specified to flash ' 216 'fuchsia_devices.') 217 # This is the board type from the fx set command. 218 # Do 'fx list-boards' to see options in Fuchsia source tree. 219 if not fuchsia_device.board_type: 220 raise ValueError('A board type must be specified to flash ' 221 'fuchsia_devices.') 222 if not fuchsia_device.build_number: 223 fuchsia_device.build_number = 'LATEST' 224 if (utils.is_valid_ipv4_address(fuchsia_device.orig_ip) 225 or utils.is_valid_ipv6_address(fuchsia_device.orig_ip)): 226 raise ValueError('The fuchsia_device ip must be the mDNS name to be ' 227 'able to flash.') 228 229 file_to_download = None 230 image_archive_path = None 231 image_path = None 232 233 if not fuchsia_device.specific_image: 234 product_build = fuchsia_device.product_type 235 if fuchsia_device.build_type: 236 product_build = f'{product_build}_{fuchsia_device.build_type}' 237 if 'LATEST' in fuchsia_device.build_number: 238 sdk_version = 'sdk' 239 if 'LATEST_F' in fuchsia_device.build_number: 240 f_branch = fuchsia_device.build_number.split('LATEST_F', 1)[1] 241 sdk_version = f'f{f_branch}_sdk' 242 file_to_download = ( 243 f'{FUCHSIA_RELEASE_TESTING_URL}/' 244 f'{sdk_version}-{product_build}.{fuchsia_device.board_type}-release.tgz' 245 ) 246 else: 247 # Must be a fully qualified build number (e.g. 5.20210721.4.1215) 248 file_to_download = ( 249 f'{FUCHSIA_SDK_URL}/{fuchsia_device.build_number}/images/' 250 f'{product_build}.{fuchsia_device.board_type}-release.tgz') 251 elif 'gs://' in fuchsia_device.specific_image: 252 file_to_download = fuchsia_device.specific_image 253 elif os.path.isdir(fuchsia_device.specific_image): 254 image_path = fuchsia_device.specific_image 255 elif tarfile.is_tarfile(fuchsia_device.specific_image): 256 image_archive_path = fuchsia_device.specific_image 257 else: 258 raise ValueError( 259 f'Invalid specific_image "{fuchsia_device.specific_image}"') 260 261 if image_path: 262 reboot_to_bootloader(fuchsia_device, use_ssh, 263 fuchsia_reconnect_after_reboot_time) 264 logging.info( 265 f'Flashing {fuchsia_device.orig_ip} with {image_path} using authorized keys "{fuchsia_device.authorized_file}".' 266 ) 267 run_flash_script(fuchsia_device, image_path) 268 else: 269 suffix = fuchsia_device.board_type 270 with tempfile.TemporaryDirectory(suffix=suffix) as image_path: 271 if file_to_download: 272 logging.info(f'Downloading {file_to_download} to {image_path}') 273 job.run(f'gsutil cp {file_to_download} {image_path}') 274 image_archive_path = os.path.join( 275 image_path, os.path.basename(file_to_download)) 276 277 if image_archive_path: 278 # Use tar command instead of tarfile.extractall, as it takes too long. 279 job.run(f'tar xfvz {image_archive_path} -C {image_path}', 280 timeout=120) 281 282 reboot_to_bootloader(fuchsia_device, use_ssh, 283 fuchsia_reconnect_after_reboot_time) 284 285 logging.info( 286 f'Flashing {fuchsia_device.orig_ip} with {image_archive_path} using authorized keys "{fuchsia_device.authorized_file}".' 287 ) 288 run_flash_script(fuchsia_device, image_path) 289 return True 290 291 292def reboot_to_bootloader(fuchsia_device, 293 use_ssh=False, 294 fuchsia_reconnect_after_reboot_time=5): 295 if use_ssh: 296 logging.info('Sending reboot command via SSH to ' 297 'get into bootloader.') 298 with utils.SuppressLogOutput(): 299 fuchsia_device.clean_up_services() 300 # Sending this command will put the device in fastboot 301 # but it does not guarantee the device will be in fastboot 302 # after this command. There is no check so if there is an 303 # expectation of the device being in fastboot, then some 304 # other check needs to be done. 305 fuchsia_device.send_command_ssh( 306 'dm rb', 307 timeout=fuchsia_reconnect_after_reboot_time, 308 skip_status_code_check=True) 309 else: 310 pass 311 ## Todo: Add elif for SL4F if implemented in SL4F 312 313 time_counter = 0 314 while time_counter < FASTBOOT_TIMEOUT: 315 logging.info('Checking to see if fuchsia_device(%s) SN: %s is in ' 316 'fastboot. (Attempt #%s Timeout: %s)' % 317 (fuchsia_device.orig_ip, fuchsia_device.serial_number, 318 str(time_counter + 1), FASTBOOT_TIMEOUT)) 319 for usb_device in usbinfo.usbinfo(): 320 if (usb_device['iSerialNumber'] == fuchsia_device.serial_number 321 and usb_device['iProduct'] == 'USB_download_gadget'): 322 logging.info( 323 'fuchsia_device(%s) SN: %s is in fastboot.' % 324 (fuchsia_device.orig_ip, fuchsia_device.serial_number)) 325 time_counter = FASTBOOT_TIMEOUT 326 time_counter = time_counter + 1 327 if time_counter == FASTBOOT_TIMEOUT: 328 for fail_usb_device in usbinfo.usbinfo(): 329 logging.debug(fail_usb_device) 330 raise TimeoutError( 331 'fuchsia_device(%s) SN: %s ' 332 'never went into fastboot' % 333 (fuchsia_device.orig_ip, fuchsia_device.serial_number)) 334 time.sleep(1) 335 336 end_time = time.time() + WAIT_FOR_EXISTING_FLASH_TO_FINISH_SEC 337 # Attempt to wait for existing flashing process to finish 338 while time.time() < end_time: 339 flash_process_found = False 340 for proc in psutil.process_iter(): 341 if "bash" in proc.name() and "flash.sh" in proc.cmdline(): 342 logging.info( 343 "Waiting for existing flash.sh process to complete.") 344 time.sleep(PROCESS_CHECK_WAIT_TIME_SEC) 345 flash_process_found = True 346 if not flash_process_found: 347 break 348 349 350def run_flash_script(fuchsia_device, flash_dir): 351 try: 352 flash_output = job.run( 353 f'bash {flash_dir}/flash.sh --ssh-key={fuchsia_device.authorized_file} -s {fuchsia_device.serial_number}', 354 timeout=120) 355 logging.debug(flash_output.stderr) 356 except job.TimeoutError as err: 357 raise TimeoutError(err) 358 359 logging.info('Waiting %s seconds for device' 360 ' to come back up after flashing.' % AFTER_FLASH_BOOT_TIME) 361 time.sleep(AFTER_FLASH_BOOT_TIME) 362 logging.info('Updating device to new IP addresses.') 363 mdns_ip = None 364 for retry_counter in range(MDNS_LOOKUP_RETRY_MAX): 365 mdns_ip = get_fuchsia_mdns_ipv6_address(fuchsia_device.orig_ip) 366 if mdns_ip: 367 break 368 else: 369 time.sleep(1) 370 if mdns_ip and utils.is_valid_ipv6_address(mdns_ip): 371 logging.info('IP for fuchsia_device(%s) changed from %s to %s' % 372 (fuchsia_device.orig_ip, fuchsia_device.ip, mdns_ip)) 373 fuchsia_device.ip = mdns_ip 374 fuchsia_device.address = "http://[{}]:{}".format( 375 fuchsia_device.ip, fuchsia_device.sl4f_port) 376 fuchsia_device.init_address = fuchsia_device.address + "/init" 377 fuchsia_device.cleanup_address = fuchsia_device.address + "/cleanup" 378 fuchsia_device.print_address = fuchsia_device.address + "/print_clients" 379 fuchsia_device.init_libraries() 380 else: 381 raise ValueError('Invalid IP: %s after flashing.' % 382 fuchsia_device.orig_ip) 383