普通文本  |  802行  |  30.81 KB

#!/usr/bin/env python
# Copyright 2015 The Chromium OS Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.

"""Install an initial test image on a set of DUTs.

The methods in this module are meant for two nominally distinct use
cases that share a great deal of code internally.  The first use
case is for deployment of DUTs that have just been placed in the lab
for the first time.  The second use case is for use after repairing
a servo.

Newly deployed DUTs may be in a somewhat anomalous state:
  * The DUTs are running a production base image, not a test image.
    By extension, the DUTs aren't reachable over SSH.
  * The DUTs are not necessarily in the AFE database.  DUTs that
    _are_ in the database should be locked.  Either way, the DUTs
    cannot be scheduled to run tests.
  * The servos for the DUTs need not be configured with the proper
    board.

More broadly, it's not expected that the DUT will be working at the
start of this operation.  If the DUT isn't working at the end of the
operation, an error will be reported.

The script performs the following functions:
  * Configure the servo for the target board, and test that the
    servo is generally in good order.
  * For the full deployment case, install dev-signed RO firmware
    from the designated stable test image for the DUTs.
  * For both cases, use servo to install the stable test image from
    USB.
  * If the DUT isn't in the AFE database, add it.

The script imposes these preconditions:
  * Every DUT has a properly connected servo.
  * Every DUT and servo have proper DHCP and DNS configurations.
  * Every servo host is up and running, and accessible via SSH.
  * There is a known, working test image that can be staged and
    installed on the target DUTs via servo.
  * Every DUT has the same board.
  * For the full deployment case, every DUT must be in dev mode,
    and configured to allow boot from USB with ctrl+U.

The implementation uses the `multiprocessing` module to run all
installations in parallel, separate processes.

"""

import atexit
from collections import namedtuple
import functools
import json
import logging
import multiprocessing
import os
import shutil
import sys
import tempfile
import time
import traceback

from chromite.lib import gs

import common
from autotest_lib.client.common_lib import error
from autotest_lib.client.common_lib import host_states
from autotest_lib.client.common_lib import time_utils
from autotest_lib.client.common_lib import utils
from autotest_lib.server import constants
from autotest_lib.server import frontend
from autotest_lib.server import hosts
from autotest_lib.server.cros.dynamic_suite.constants import VERSION_PREFIX
from autotest_lib.server.hosts import afe_store
from autotest_lib.server.hosts import servo_host
from autotest_lib.site_utils.deployment import commandline
from autotest_lib.site_utils.stable_images import assign_stable_images


_LOG_FORMAT = '%(asctime)s | %(levelname)-10s | %(message)s'

_DEFAULT_POOL = constants.Labels.POOL_PREFIX + 'suites'

_DIVIDER = '\n============\n'

_LOG_BUCKET_NAME = 'chromeos-install-logs'

_OMAHA_STATUS = 'gs://chromeos-build-release-console/omaha_status.json'

# Lock reasons we'll pass when locking DUTs, depending on the
# host's prior state.
_LOCK_REASON_EXISTING = 'Repairing or deploying an existing host'
_LOCK_REASON_NEW_HOST = 'Repairing or deploying a new host'

_ReportResult = namedtuple('_ReportResult', ['hostname', 'message'])


class _NoAFEServoPortError(Exception):
    """Exception when there is no servo port stored in the AFE."""


class _MultiFileWriter(object):

    """Group file objects for writing at once."""

    def __init__(self, files):
        """Initialize _MultiFileWriter.

        @param files  Iterable of file objects for writing.
        """
        self._files = files

    def write(self, s):
        """Write a string to the files.

        @param s  Write this string.
        """
        for file in self._files:
            file.write(s)


def _get_upload_log_path(arguments):
    return 'gs://{bucket}/{name}'.format(
        bucket=_LOG_BUCKET_NAME,
        name=commandline.get_default_logdir_name(arguments))


def _upload_logs(dirpath, gspath):
    """Upload report logs to Google Storage.

    @param dirpath  Path to directory containing the logs.
    @param gspath   Path to GS bucket.
    """
    ctx = gs.GSContext()
    ctx.Copy(dirpath, gspath, recursive=True)


def _get_omaha_build(board):
    """Get the currently preferred Beta channel build for `board`.

    Open and read through the JSON file provided by GoldenEye that
    describes what version Omaha is currently serving for all boards
    on all channels.  Find the entry for `board` on the Beta channel,
    and return that version string.

    @param board  The board to look up from GoldenEye.

    @return Returns a Chrome OS version string in standard form
            R##-####.#.#.  Will return `None` if no Beta channel
            entry is found.
    """
    ctx = gs.GSContext()
    omaha_status = json.loads(ctx.Cat(_OMAHA_STATUS))
    omaha_board = board.replace('_', '-')
    for e in omaha_status['omaha_data']:
        if (e['channel'] == 'beta' and
                e['board']['public_codename'] == omaha_board):
            milestone = e['chrome_version'].split('.')[0]
            build = e['chrome_os_version']
            return 'R%s-%s' % (milestone, build)
    return None


def _update_build(afe, report_log, arguments):
    """Update the stable_test_versions table.

    This calls the `set_stable_version` RPC call to set the stable
    repair version selected by this run of the command.  Additionally,
    this updates the stable firmware for the board.  The repair version
    is selected from three possible versions:
      * The stable test version currently in the AFE database.
      * The version Omaha is currently serving as the Beta channel
        build.
      * The version supplied by the user.
    The actual version selected will be whichever of these three is
    the most up-to-date version.

    The stable firmware version will be set to whatever firmware is
    bundled in the selected repair image. If the selected repair image bundles
    firmware for more than one model, then the firmware for every model in the
    build will be updated.

    This function will log information about the available versions
    prior to selection.  After selection the repair and firmware
    versions slected will be logged.

    @param afe          AFE object for RPC calls.
    @param report_log   File-like object for logging report output.
    @param arguments    Command line arguments with options.

    @return Returns the version selected.
    """
    # Gather the current AFE and Omaha version settings, and report them
    # to the user.
    cros_version_map = afe.get_stable_version_map(afe.CROS_IMAGE_TYPE)
    fw_version_map = afe.get_stable_version_map(afe.FIRMWARE_IMAGE_TYPE)
    afe_cros = cros_version_map.get_version(arguments.board)
    afe_fw = fw_version_map.get_version(arguments.board)
    omaha_cros = _get_omaha_build(arguments.board)
    report_log.write('AFE    version is %s.\n' % afe_cros)
    report_log.write('Omaha  version is %s.\n' % omaha_cros)
    report_log.write('AFE   firmware is %s.\n' % afe_fw)
    cros_version = afe_cros

    # Check whether we should upgrade the repair build to either
    # the Omaha or the user's requested build.  If we do, we must
    # also update the firmware version.
    if (omaha_cros is not None
            and (cros_version is None or
                 utils.compare_versions(cros_version, omaha_cros) < 0)):
        cros_version = omaha_cros
    if arguments.build and arguments.build != cros_version:
        if (cros_version is None
                or utils.compare_versions(cros_version, arguments.build) < 0):
            cros_version = arguments.build
        else:
            report_log.write('Selected version %s is too old; '
                             'using version %s'
                             % (arguments.build, cros_version))

    afe_fw_versions = {arguments.board: afe_fw}
    fw_versions = assign_stable_images.get_firmware_versions(
        cros_version_map, arguments.board, cros_version)
    # At this point `cros_version` is our new repair build, and
    # `fw_version` is our new target firmware.  Call the AFE back with
    # updates as necessary.
    if not arguments.nostable:
        if cros_version != afe_cros:
            cros_version_map.set_version(arguments.board, cros_version)

            if fw_versions != afe_fw_versions:
                for model, fw_version in fw_versions.iteritems():
                    if fw_version is not None:
                        fw_version_map.set_version(model, fw_version)
                    else:
                        fw_version_map.delete_version(model)

    # Report the new state of the world.
    report_log.write(_DIVIDER)
    report_log.write('Repair CrOS version for board %s is now %s.\n' %
                     (arguments.board, cros_version))
    for model, fw_version in fw_versions.iteritems():
        report_log.write('Firmware version for model %s is now %s.\n' %
                         (model, fw_version))
    return cros_version


def _create_host(hostname, afe, afe_host):
    """Create a CrosHost object for a DUT to be installed.

    @param hostname  Hostname of the target DUT.
    @param afe       A frontend.AFE object.
    @param afe_host  AFE Host object for the DUT.
    """
    machine_dict = {
            'hostname': hostname,
            'afe_host': afe_host,
            'host_info_store': afe_store.AfeStore(hostname, afe),
    }
    servo_args = hosts.CrosHost.get_servo_arguments({})
    return hosts.create_host(machine_dict, servo_args=servo_args)


def _try_lock_host(afe_host):
    """Lock a host in the AFE, and report whether it succeeded.

    The lock action is logged regardless of success; failures are
    logged if they occur.

    @param afe_host AFE Host instance to be locked.

    @return `True` on success, or `False` on failure.
    """
    try:
        logging.warning('Locking host now.')
        afe_host.modify(locked=True,
                        lock_reason=_LOCK_REASON_EXISTING)
    except Exception as e:
        logging.exception('Failed to lock: %s', e)
        return False
    return True


def _try_unlock_host(afe_host):
    """Unlock a host in the AFE, and report whether it succeeded.

    The unlock action is logged regardless of success; failures are
    logged if they occur.

    @param afe_host AFE Host instance to be unlocked.

    @return `True` on success, or `False` on failure.
    """
    try:
        logging.warning('Unlocking host.')
        afe_host.modify(locked=False, lock_reason='')
    except Exception as e:
        logging.exception('Failed to unlock: %s', e)
        return False
    return True


def _update_host_attributes(afe, hostname, host_attrs):
    """Update the attributes for a given host.

    @param afe          AFE object for RPC calls.
    @param hostname     Host name of the DUT.
    @param host_attrs   Dictionary with attributes to be applied to the
                        host.
    """
    # Grab the servo hostname/port/serial from `host_attrs` if supplied.
    # For new servo V4 deployments, we require the user to supply the
    # attributes (because there are no appropriate defaults).  So, if
    # none are supplied, we assume it can't be V4, and apply the
    # defaults for servo V3.
    host_attr_servo_host = host_attrs.get(servo_host.SERVO_HOST_ATTR)
    host_attr_servo_port = host_attrs.get(servo_host.SERVO_PORT_ATTR)
    host_attr_servo_serial = host_attrs.get(servo_host.SERVO_SERIAL_ATTR)
    servo_hostname = (host_attr_servo_host or
                      servo_host.make_servo_hostname(hostname))
    servo_port = (host_attr_servo_port or
                  str(servo_host.ServoHost.DEFAULT_PORT))
    afe.set_host_attribute(servo_host.SERVO_HOST_ATTR,
                           servo_hostname,
                           hostname=hostname)
    afe.set_host_attribute(servo_host.SERVO_PORT_ATTR,
                           servo_port,
                           hostname=hostname)
    if host_attr_servo_serial:
        afe.set_host_attribute(servo_host.SERVO_SERIAL_ATTR,
                               host_attr_servo_serial,
                               hostname=hostname)


def _get_afe_host(afe, hostname, host_attrs, arguments):
    """Get an AFE Host object for the given host.

    If the host is found in the database, return the object
    from the RPC call with the updated attributes in host_attr_dict.

    If no host is found, create one with appropriate servo
    attributes and the given board label.

    @param afe          AFE object for RPC calls.
    @param hostname     Host name of the DUT.
    @param host_attrs   Dictionary with attributes to be applied to the
                        host.
    @param arguments    Command line arguments with options.

    @return A tuple of the afe_host, plus a flag. The flag indicates
            whether the Host should be unlocked if subsequent operations
            fail.  (Hosts are always unlocked after success).
    """
    hostlist = afe.get_hosts([hostname])
    unlock_on_failure = False
    if hostlist:
        afe_host = hostlist[0]
        if not afe_host.locked:
            if _try_lock_host(afe_host):
                unlock_on_failure = True
            else:
                raise Exception('Failed to lock host')
        if afe_host.status not in host_states.IDLE_STATES:
            if unlock_on_failure and not _try_unlock_host(afe_host):
                raise Exception('Host is in use, and failed to unlock it')
            raise Exception('Host is in use by Autotest')
        # This host was pre-existing; if the user didn't supply
        # attributes, don't update them, because the defaults may
        # not be correct.
        if host_attrs:
            _update_host_attributes(afe, hostname, host_attrs)
    else:
        afe_host = afe.create_host(hostname,
                                   locked=True,
                                   lock_reason=_LOCK_REASON_NEW_HOST)
        afe_host.add_labels([constants.Labels.BOARD_PREFIX + arguments.board])
        _update_host_attributes(afe, hostname, host_attrs)
    afe_host = afe.get_hosts([hostname])[0]
    return afe_host, unlock_on_failure


def _install_firmware(host):
    """Install dev-signed firmware after removing write-protect.

    At start, it's assumed that hardware write-protect is disabled,
    the DUT is in dev mode, and the servo's USB stick already has a
    test image installed.

    The firmware is installed by powering on and typing ctrl+U on
    the keyboard in order to boot the the test image from USB.  Once
    the DUT is booted, we run a series of commands to install the
    read-only firmware from the test image.  Then we clear debug
    mode, and shut down.

    @param host   Host instance to use for servo and ssh operations.
    """
    servo = host.servo
    # First power on.  We sleep to allow the firmware plenty of time
    # to display the dev-mode screen; some boards take their time to
    # be ready for the ctrl+U after power on.
    servo.get_power_state_controller().power_off()
    servo.switch_usbkey('dut')
    servo.get_power_state_controller().power_on()
    time.sleep(10)
    # Dev mode screen should be up now:  type ctrl+U and wait for
    # boot from USB to finish.
    servo.ctrl_u()
    if not host.wait_up(timeout=host.USB_BOOT_TIMEOUT):
        raise Exception('DUT failed to boot in dev mode for '
                        'firmware update')
    # Disable software-controlled write-protect for both FPROMs, and
    # install the RO firmware.
    for fprom in ['host', 'ec']:
        host.run('flashrom -p %s --wp-disable' % fprom,
                 ignore_status=True)
    host.run('chromeos-firmwareupdate --mode=factory')
    # Get us out of dev-mode and clear GBB flags.  GBB flags are
    # non-zero because boot from USB was enabled.
    host.run('/usr/share/vboot/bin/set_gbb_flags.sh 0',
             ignore_status=True)
    host.run('crossystem disable_dev_request=1',
             ignore_status=True)
    host.halt()


def _install_test_image(host, arguments):
    """Install a test image to the DUT.

    Install a stable test image on the DUT using the full servo
    repair flow.

    @param host       Host instance for the DUT being installed.
    @param arguments  Command line arguments with options.
    """
    # Don't timeout probing for the host usb device, there could be a bunch
    # of servos probing at the same time on the same servo host.  And
    # since we can't pass None through the xml rpcs, use 0 to indicate None.
    if not host.servo.probe_host_usb_dev(timeout=0):
        raise Exception('No USB stick detected on Servo host')
    try:
        if not arguments.noinstall:
            if not arguments.nostage:
                host.servo.image_to_servo_usb(
                        host.stage_image_for_servo())
            if arguments.full_deploy:
                _install_firmware(host)
            host.servo_install()
    except error.AutoservRunError as e:
        logging.exception('Failed to install: %s', e)
        raise Exception('chromeos-install failed')
    finally:
        host.close()


def _install_and_update_afe(afe, hostname, host_attrs, arguments):
    """Perform all installation and AFE updates.

    First, lock the host if it exists and is unlocked.  Then,
    install the test image on the DUT.  At the end, unlock the
    DUT, unless the installation failed and the DUT was locked
    before we started.

    If installation succeeds, make sure the DUT is in the AFE,
    and make sure that it has basic labels.

    @param afe          AFE object for RPC calls.
    @param hostname     Host name of the DUT.
    @param host_attrs   Dictionary with attributes to be applied to the
                        host.
    @param arguments    Command line arguments with options.
    """
    afe_host, unlock_on_failure = _get_afe_host(afe, hostname, host_attrs,
                                                arguments)
    try:
        host = _create_host(hostname, afe, afe_host)
        _install_test_image(host, arguments)
        host.labels.update_labels(host)
        platform_labels = afe.get_labels(
                host__hostname=hostname, platform=True)
        if not platform_labels:
            platform = host.get_platform()
            new_labels = afe.get_labels(name=platform)
            if not new_labels:
                afe.create_label(platform, platform=True)
            afe_host.add_labels([platform])
        version = [label for label in afe_host.labels
                       if label.startswith(VERSION_PREFIX)]
        if version:
            afe_host.remove_labels(version)
    except Exception as e:
        if unlock_on_failure and not _try_unlock_host(afe_host):
            logging.error('Failed to unlock host!')
        raise

    if not _try_unlock_host(afe_host):
        raise Exception('Install succeeded, but failed to unlock the DUT.')


def _install_dut(arguments, host_attr_dict, hostname):
    """Deploy or repair a single DUT.

    @param arguments       Command line arguments with options.
    @param host_attr_dict  Dict mapping hostnames to attributes to be
                           stored in the AFE.
    @param hostname        Host name of the DUT to install on.

    @return On success, return `None`.  On failure, return a string
            with an error message.
    """
    # In some cases, autotest code that we call during install may
    # put stuff onto stdout with 'print' statements.  Most notably,
    # the AFE frontend may print 'FAILED RPC CALL' (boo, hiss).  We
    # want nothing from this subprocess going to the output we
    # inherited from our parent, so redirect stdout and stderr, before
    # we make any AFE calls.  Note that this is reasonable because we're
    # in a subprocess.

    logpath = os.path.join(arguments.logdir, hostname + '.log')
    logfile = open(logpath, 'w')
    sys.stderr = sys.stdout = logfile
    _configure_logging_to_file(logfile)

    afe = frontend.AFE(server=arguments.web)
    try:
        _install_and_update_afe(afe, hostname,
                                host_attr_dict.get(hostname, {}),
                                arguments)
    except Exception as e:
        logging.exception('Original exception: %s', e)
        return str(e)
    return None


def _report_hosts(report_log, heading, host_results_list):
    """Report results for a list of hosts.

    To improve visibility, results are preceded by a header line,
    followed by a divider line.  Then results are printed, one host
    per line.

    @param report_log         File-like object for logging report
                              output.
    @param heading            The header string to be printed before
                              results.
    @param host_results_list  A list of _ReportResult tuples
                              to be printed one per line.
    """
    if not host_results_list:
        return
    report_log.write(heading)
    report_log.write(_DIVIDER)
    for result in host_results_list:
        report_log.write('{result.hostname:30} {result.message}\n'
                         .format(result=result))
    report_log.write('\n')


def _report_results(afe, report_log, hostnames, results):
    """Gather and report a summary of results from installation.

    Segregate results into successes and failures, reporting
    each separately.  At the end, report the total of successes
    and failures.

    @param afe          AFE object for RPC calls.
    @param report_log   File-like object for logging report output.
    @param hostnames    List of the hostnames that were tested.
    @param results      List of error messages, in the same order
                        as the hostnames.  `None` means the
                        corresponding host succeeded.
    """
    successful_hosts = []
    success_reports = []
    failure_reports = []
    for result, hostname in zip(results, hostnames):
        if result is None:
            successful_hosts.append(hostname)
        else:
            failure_reports.append(_ReportResult(hostname, result))
    if successful_hosts:
        afe.reverify_hosts(hostnames=successful_hosts)
        for h in afe.get_hosts(hostnames=successful_hosts):
            for label in h.labels:
                if label.startswith(constants.Labels.POOL_PREFIX):
                    result = _ReportResult(h.hostname,
                                           'Host already in %s' % label)
                    success_reports.append(result)
                    break
            else:
                h.add_labels([_DEFAULT_POOL])
                result = _ReportResult(h.hostname,
                                       'Host added to %s' % _DEFAULT_POOL)
                success_reports.append(result)
    report_log.write(_DIVIDER)
    _report_hosts(report_log, 'Successes', success_reports)
    _report_hosts(report_log, 'Failures', failure_reports)
    report_log.write(
        'Installation complete:  %d successes, %d failures.\n' %
        (len(success_reports), len(failure_reports)))


def _clear_root_logger_handlers():
    """Remove all handlers from root logger."""
    root_logger = logging.getLogger()
    for h in root_logger.handlers:
        root_logger.removeHandler(h)


def _configure_logging_to_file(logfile):
    """Configure the logging module for `install_duts()`.

    @param log_file  Log file object.
    """
    _clear_root_logger_handlers()
    handler = logging.StreamHandler(logfile)
    formatter = logging.Formatter(_LOG_FORMAT, time_utils.TIME_FMT)
    handler.setFormatter(formatter)
    root_logger = logging.getLogger()
    root_logger.addHandler(handler)


def _get_used_servo_ports(servo_hostname, afe):
    """
    Return a list of used servo ports for the given servo host.

    @param servo_hostname:  Hostname of the servo host to check for.
    @param afe:             AFE instance.

    @returns a list of used ports for the given servo host.
    """
    used_ports = []
    host_list = afe.get_hosts_by_attribute(
            attribute=servo_host.SERVO_HOST_ATTR, value=servo_hostname)
    for host in host_list:
        afe_host = afe.get_hosts(hostname=host)
        if afe_host:
            servo_port = afe_host[0].attributes.get(servo_host.SERVO_PORT_ATTR)
            if servo_port:
                used_ports.append(int(servo_port))
    return used_ports


def _get_free_servo_port(servo_hostname, used_servo_ports, afe):
    """
    Get a free servo port for the servo_host.

    @param servo_hostname:    Hostname of the servo host.
    @param used_servo_ports:  Dict of dicts that contain the list of used ports
                              for the given servo host.
    @param afe:               AFE instance.

    @returns a free servo port if servo_hostname is non-empty, otherwise an
        empty string.
    """
    used_ports = []
    servo_port = servo_host.ServoHost.DEFAULT_PORT
    # If no servo hostname was specified we can assume we're dealing with a
    # servo v3 or older deployment since the servo hostname can be
    # inferred from the dut hostname (by appending '-servo' to it).  We only
    # need to find a free port if we're using a servo v4 since we can use the
    # default port for v3 and older.
    if not servo_hostname:
        return ''
    # If we haven't checked this servo host yet, check the AFE if other duts
    # used this servo host and grab the ports specified for them.
    elif servo_hostname not in used_servo_ports:
        used_ports = _get_used_servo_ports(servo_hostname, afe)
    else:
        used_ports = used_servo_ports[servo_hostname]
    used_ports.sort()
    if used_ports:
        # Range is taken from servod.py in hdctools.
        start_port = servo_host.ServoHost.DEFAULT_PORT
        end_port = start_port - 99
        # We'll choose first port available in descending order.
        for port in xrange(start_port, end_port - 1, -1):
            if port not in used_ports:
              servo_port = port
              break
    used_ports.append(servo_port)
    used_servo_ports[servo_hostname] = used_ports
    return servo_port


def _get_afe_servo_port(host_info, afe):
    """
    Get the servo port from the afe if it matches the same servo host hostname.

    @param host_info   HostInfo tuple (hostname, host_attr_dict).

    @returns Servo port (int) if servo host hostname matches the one specified
    host_info.host_attr_dict, otherwise None.

    @raises _NoAFEServoPortError: When there is no stored host info or servo
        port host attribute in the AFE for the given host.
    """
    afe_hosts = afe.get_hosts(hostname=host_info.hostname)
    if not afe_hosts:
        raise _NoAFEServoPortError

    servo_port = afe_hosts[0].attributes.get(servo_host.SERVO_PORT_ATTR)
    afe_servo_host = afe_hosts[0].attributes.get(servo_host.SERVO_HOST_ATTR)
    host_info_servo_host = host_info.host_attr_dict.get(
        servo_host.SERVO_HOST_ATTR)

    if afe_servo_host == host_info_servo_host and servo_port:
        return int(servo_port)
    else:
        raise _NoAFEServoPortError


def _get_host_attributes(host_info_list, afe):
    """
    Get host attributes if a hostname_file was supplied.

    @param host_info_list   List of HostInfo tuples (hostname, host_attr_dict).

    @returns Dict of attributes from host_info_list.
    """
    host_attributes = {}
    # We need to choose servo ports for these hosts but we need to make sure
    # we don't choose ports already used. We'll store all used ports in a
    # dict of lists where the key is the servo_host and the val is a list of
    # ports used.
    used_servo_ports = {}
    for host_info in host_info_list:
        host_attr_dict = host_info.host_attr_dict
        # If the host already has an entry in the AFE that matches the same
        # servo host hostname and the servo port is set, use that port.
        try:
            host_attr_dict[servo_host.SERVO_PORT_ATTR] = _get_afe_servo_port(
                host_info, afe)
        except _NoAFEServoPortError:
            host_attr_dict[servo_host.SERVO_PORT_ATTR] = _get_free_servo_port(
                host_attr_dict[servo_host.SERVO_HOST_ATTR], used_servo_ports,
                afe)
        host_attributes[host_info.hostname] = host_attr_dict
    return host_attributes


def install_duts(argv, full_deploy):
    """Install a test image on DUTs, and deploy them.

    This handles command line parsing for both the repair and
    deployment commands.  The two operations are largely identical;
    the main difference is that full deployment includes flashing
    dev-signed firmware on the DUT prior to installing the test
    image.

    @param argv         Command line arguments to be parsed.
    @param full_deploy  If true, do the full deployment that includes
                        flashing dev-signed RO firmware onto the DUT.
    """
    # Override tempfile.tempdir.  Some of the autotest code we call
    # will create temporary files that don't get cleaned up.  So, we
    # put the temp files in our results directory, so that we can
    # clean up everything in one fell swoop.
    tempfile.tempdir = tempfile.mkdtemp()
    # MALCOLM:
    #   Be comforted.
    #   Let's make us med'cines of our great revenge,
    #   To cure this deadly grief.
    atexit.register(shutil.rmtree, tempfile.tempdir)

    arguments = commandline.parse_command(argv, full_deploy)
    if not arguments:
        sys.exit(1)
    sys.stderr.write('Installation output logs in %s\n' % arguments.logdir)

    # We don't want to distract the user with logging output, so we catch
    # logging output in a file.
    logging_file_path = os.path.join(arguments.logdir, 'debug.log')
    logfile = open(logging_file_path, 'w')
    _configure_logging_to_file(logfile)

    report_log_path = os.path.join(arguments.logdir, 'report.log')
    with open(report_log_path, 'w') as report_log_file:
        report_log = _MultiFileWriter([report_log_file, sys.stdout])
        afe = frontend.AFE(server=arguments.web)
        current_build = _update_build(afe, report_log, arguments)
        host_attr_dict = _get_host_attributes(arguments.host_info_list, afe)
        install_pool = multiprocessing.Pool(len(arguments.hostnames))
        install_function = functools.partial(_install_dut, arguments,
                                             host_attr_dict)
        results_list = install_pool.map(install_function, arguments.hostnames)
        _report_results(afe, report_log, arguments.hostnames, results_list)

        gspath = _get_upload_log_path(arguments)
        report_log.write('Logs will be uploaded to %s\n' % (gspath,))

    try:
        _upload_logs(arguments.logdir, gspath)
    except Exception as e:
        upload_failure_log_path = os.path.join(arguments.logdir,
                                               'gs_upload_failure.log')
        with open(upload_failure_log_path, 'w') as file:
            traceback.print_exc(limit=None, file=file)
        sys.stderr.write('Failed to upload logs;'
                         ' failure details are stored in {}.\n'
                         .format(upload_failure_log_path))