• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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