• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1#!/usr/bin/env python
2# Copyright 2015 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"""Install an initial test image on a set of DUTs.
7
8The methods in this module are meant for two nominally distinct use
9cases that share a great deal of code internally.  The first use
10case is for deployment of DUTs that have just been placed in the lab
11for the first time.  The second use case is for use after repairing
12a servo.
13
14Newly deployed DUTs may be in a somewhat anomalous state:
15  * The DUTs are running a production base image, not a test image.
16    By extension, the DUTs aren't reachable over SSH.
17  * The DUTs are not necessarily in the AFE database.  DUTs that
18    _are_ in the database should be locked.  Either way, the DUTs
19    cannot be scheduled to run tests.
20  * The servos for the DUTs need not be configured with the proper
21    board.
22
23More broadly, it's not expected that the DUT will be working at the
24start of this operation.  If the DUT isn't working at the end of the
25operation, an error will be reported.
26
27The script performs the following functions:
28  * Configure the servo for the target board, and test that the
29    servo is generally in good order.
30  * For the full deployment case, install dev-signed RO firmware
31    from the designated stable test image for the DUTs.
32  * For both cases, use servo to install the stable test image from
33    USB.
34  * If the DUT isn't in the AFE database, add it.
35
36The script imposes these preconditions:
37  * Every DUT has a properly connected servo.
38  * Every DUT and servo have proper DHCP and DNS configurations.
39  * Every servo host is up and running, and accessible via SSH.
40  * There is a known, working test image that can be staged and
41    installed on the target DUTs via servo.
42  * Every DUT has the same board.
43  * For the full deployment case, every DUT must be in dev mode,
44    and configured to allow boot from USB with ctrl+U.
45
46The implementation uses the `multiprocessing` module to run all
47installations in parallel, separate processes.
48
49"""
50
51import atexit
52from collections import namedtuple
53import functools
54import json
55import logging
56import multiprocessing
57import os
58import shutil
59import sys
60import tempfile
61import time
62import traceback
63
64from chromite.lib import gs
65
66import common
67from autotest_lib.client.common_lib import error
68from autotest_lib.client.common_lib import host_states
69from autotest_lib.client.common_lib import time_utils
70from autotest_lib.client.common_lib import utils
71from autotest_lib.server import frontend
72from autotest_lib.server import hosts
73from autotest_lib.server.cros.dynamic_suite.constants import VERSION_PREFIX
74from autotest_lib.server.hosts import afe_store
75from autotest_lib.server.hosts import servo_host
76from autotest_lib.site_utils.deployment import commandline
77from autotest_lib.site_utils.stable_images import assign_stable_images
78from autotest_lib.site_utils.suite_scheduler.constants import Labels
79
80
81_LOG_FORMAT = '%(asctime)s | %(levelname)-10s | %(message)s'
82
83_DEFAULT_POOL = Labels.POOL_PREFIX + 'suites'
84
85_DIVIDER = '\n============\n'
86
87_LOG_BUCKET_NAME = 'chromeos-install-logs'
88
89_OMAHA_STATUS = 'gs://chromeos-build-release-console/omaha_status.json'
90
91# Lock reasons we'll pass when locking DUTs, depending on the
92# host's prior state.
93_LOCK_REASON_EXISTING = 'Repairing or deploying an existing host'
94_LOCK_REASON_NEW_HOST = 'Repairing or deploying a new host'
95
96_ReportResult = namedtuple('_ReportResult', ['hostname', 'message'])
97
98
99class _NoAFEServoPortError(Exception):
100    """Exception when there is no servo port stored in the AFE."""
101
102
103class _MultiFileWriter(object):
104
105    """Group file objects for writing at once."""
106
107    def __init__(self, files):
108        """Initialize _MultiFileWriter.
109
110        @param files  Iterable of file objects for writing.
111        """
112        self._files = files
113
114    def write(self, s):
115        """Write a string to the files.
116
117        @param s  Write this string.
118        """
119        for file in self._files:
120            file.write(s)
121
122
123def _get_upload_log_path(arguments):
124    return 'gs://{bucket}/{name}'.format(
125        bucket=_LOG_BUCKET_NAME,
126        name=commandline.get_default_logdir_name(arguments))
127
128
129def _upload_logs(dirpath, gspath):
130    """Upload report logs to Google Storage.
131
132    @param dirpath  Path to directory containing the logs.
133    @param gspath   Path to GS bucket.
134    """
135    ctx = gs.GSContext()
136    ctx.Copy(dirpath, gspath, recursive=True)
137
138
139def _get_omaha_build(board):
140    """Get the currently preferred Beta channel build for `board`.
141
142    Open and read through the JSON file provided by GoldenEye that
143    describes what version Omaha is currently serving for all boards
144    on all channels.  Find the entry for `board` on the Beta channel,
145    and return that version string.
146
147    @param board  The board to look up from GoldenEye.
148
149    @return Returns a Chrome OS version string in standard form
150            R##-####.#.#.  Will return `None` if no Beta channel
151            entry is found.
152    """
153    ctx = gs.GSContext()
154    omaha_status = json.loads(ctx.Cat(_OMAHA_STATUS))
155    omaha_board = board.replace('_', '-')
156    for e in omaha_status['omaha_data']:
157        if (e['channel'] == 'beta' and
158                e['board']['public_codename'] == omaha_board):
159            milestone = e['chrome_version'].split('.')[0]
160            build = e['chrome_os_version']
161            return 'R%s-%s' % (milestone, build)
162    return None
163
164
165def _update_build(afe, report_log, arguments):
166    """Update the stable_test_versions table.
167
168    This calls the `set_stable_version` RPC call to set the stable
169    repair version selected by this run of the command.  Additionally,
170    this updates the stable firmware for the board.  The repair version
171    is selected from three possible versions:
172      * The stable test version currently in the AFE database.
173      * The version Omaha is currently serving as the Beta channel
174        build.
175      * The version supplied by the user.
176    The actual version selected will be whichever of these three is
177    the most up-to-date version.
178
179    The stable firmware version will be set to whatever firmware is
180    bundled in the selected repair image.
181
182    This function will log information about the available versions
183    prior to selection.  After selection the repair and firmware
184    versions slected will be logged.
185
186    @param afe          AFE object for RPC calls.
187    @param report_log   File-like object for logging report output.
188    @param arguments    Command line arguments with options.
189
190    @return Returns the version selected.
191    """
192    # Gather the current AFE and Omaha version settings, and report them
193    # to the user.
194    cros_version_map = afe.get_stable_version_map(afe.CROS_IMAGE_TYPE)
195    fw_version_map = afe.get_stable_version_map(afe.FIRMWARE_IMAGE_TYPE)
196    afe_cros = cros_version_map.get_version(arguments.board)
197    afe_fw = fw_version_map.get_version(arguments.board)
198    omaha_cros = _get_omaha_build(arguments.board)
199    report_log.write('AFE    version is %s.\n' % afe_cros)
200    report_log.write('Omaha  version is %s.\n' % omaha_cros)
201    report_log.write('AFE   firmware is %s.\n' % afe_fw)
202    cros_version = afe_cros
203    fw_version = afe_fw
204
205    # Check whether we should upgrade the repair build to either
206    # the Omaha or the user's requested build.  If we do, we must
207    # also update the firmware version.
208    if (omaha_cros is not None and
209             utils.compare_versions(cros_version, omaha_cros) < 0):
210        cros_version = omaha_cros
211        fw_version = None
212    if arguments.build and arguments.build != cros_version:
213        if utils.compare_versions(cros_version, arguments.build) < 0:
214            cros_version = arguments.build
215            fw_version = None
216        else:
217            report_log.write('Selected version %s is too old; '
218                             'using version %s'
219                             % (arguments.build, cros_version))
220    if fw_version is None:
221        fw_version = assign_stable_images.get_firmware_version(
222                cros_version_map, arguments.board, cros_version)
223
224    # At this point `cros_version` is our new repair build, and
225    # `fw_version` is our new target firmware.  Call the AFE back with
226    # updates as necessary.
227    if not arguments.nostable:
228        if cros_version != afe_cros:
229            cros_version_map.set_version(arguments.board, cros_version)
230        if fw_version != afe_fw:
231            if fw_version is not None:
232                fw_version_map.set_version(arguments.board,
233                                           fw_version)
234            else:
235                fw_version_map.delete_version(arguments.board)
236
237    # Report the new state of the world.
238    report_log.write(_DIVIDER)
239    report_log.write('Repair version for board %s is now %s.\n' %
240                     (arguments.board, cros_version))
241    report_log.write('Firmware       for board %s is now %s.\n' %
242                     (arguments.board, fw_version))
243    return cros_version
244
245
246def _create_host(hostname, afe, afe_host):
247    """Create a CrosHost object for a DUT to be installed.
248
249    @param hostname  Hostname of the target DUT.
250    @param afe       A frontend.AFE object.
251    @param afe_host  AFE Host object for the DUT.
252    """
253    machine_dict = {
254            'hostname': hostname,
255            'afe_host': afe_host,
256            'host_info_store': afe_store.AfeStore(hostname, afe),
257    }
258    servo_args = hosts.CrosHost.get_servo_arguments({})
259    return hosts.create_host(machine_dict, servo_args=servo_args)
260
261
262def _try_lock_host(afe_host):
263    """Lock a host in the AFE, and report whether it succeeded.
264
265    The lock action is logged regardless of success; failures are
266    logged if they occur.
267
268    @param afe_host AFE Host instance to be locked.
269
270    @return `True` on success, or `False` on failure.
271    """
272    try:
273        logging.warning('Locking host now.')
274        afe_host.modify(locked=True,
275                        lock_reason=_LOCK_REASON_EXISTING)
276    except Exception as e:
277        logging.exception('Failed to lock: %s', e)
278        return False
279    return True
280
281
282def _try_unlock_host(afe_host):
283    """Unlock a host in the AFE, and report whether it succeeded.
284
285    The unlock action is logged regardless of success; failures are
286    logged if they occur.
287
288    @param afe_host AFE Host instance to be unlocked.
289
290    @return `True` on success, or `False` on failure.
291    """
292    try:
293        logging.warning('Unlocking host.')
294        afe_host.modify(locked=False, lock_reason='')
295    except Exception as e:
296        logging.exception('Failed to unlock: %s', e)
297        return False
298    return True
299
300
301def _update_host_attributes(afe, hostname, host_attr_dict):
302    """Update the attributes for a given host.
303
304    @param afe             AFE object for RPC calls.
305    @param hostname        Name of the host to be updated.
306    @param host_attr_dict  Dict of host attributes to store in the AFE.
307    """
308    # Let's grab the servo hostname/port/serial from host_attr_dict
309    # if possible.
310    host_attr_servo_host = None
311    host_attr_servo_port = None
312    host_attr_servo_serial = None
313    if hostname in host_attr_dict:
314        host_attr_servo_host = host_attr_dict[hostname].get(
315                servo_host.SERVO_HOST_ATTR)
316        host_attr_servo_port = host_attr_dict[hostname].get(
317                servo_host.SERVO_PORT_ATTR)
318        host_attr_servo_serial = host_attr_dict[hostname].get(
319                servo_host.SERVO_SERIAL_ATTR)
320
321    servo_hostname = (host_attr_servo_host or
322                      servo_host.make_servo_hostname(hostname))
323    servo_port = (host_attr_servo_port or
324                  str(servo_host.ServoHost.DEFAULT_PORT))
325    afe.set_host_attribute(servo_host.SERVO_HOST_ATTR,
326                           servo_hostname,
327                           hostname=hostname)
328    afe.set_host_attribute(servo_host.SERVO_PORT_ATTR,
329                           servo_port,
330                           hostname=hostname)
331    if host_attr_servo_serial:
332        afe.set_host_attribute(servo_host.SERVO_SERIAL_ATTR,
333                               host_attr_servo_serial,
334                               hostname=hostname)
335
336
337def _get_afe_host(afe, hostname, arguments, host_attr_dict):
338    """Get an AFE Host object for the given host.
339
340    If the host is found in the database, return the object
341    from the RPC call with the updated attributes in host_attr_dict.
342
343    If no host is found, create one with appropriate servo
344    attributes and the given board label.
345
346    @param afe             AFE from which to get the host.
347    @param hostname        Name of the host to look up or create.
348    @param arguments       Command line arguments with options.
349    @param host_attr_dict  Dict of host attributes to store in the AFE.
350
351    @return A tuple of the afe_host, plus a flag. The flag indicates
352            whether the Host should be unlocked if subsequent operations
353            fail.  (Hosts are always unlocked after success).
354    """
355    hostlist = afe.get_hosts([hostname])
356    unlock_on_failure = False
357    if hostlist:
358        afe_host = hostlist[0]
359        if not afe_host.locked:
360            if _try_lock_host(afe_host):
361                unlock_on_failure = True
362            else:
363                raise Exception('Failed to lock host')
364        if afe_host.status not in host_states.IDLE_STATES:
365            if unlock_on_failure and not _try_unlock_host(afe_host):
366                raise Exception('Host is in use, and failed to unlock it')
367            raise Exception('Host is in use by Autotest')
368    else:
369        afe_host = afe.create_host(hostname,
370                                   locked=True,
371                                   lock_reason=_LOCK_REASON_NEW_HOST)
372        afe_host.add_labels([Labels.BOARD_PREFIX + arguments.board])
373
374    _update_host_attributes(afe, hostname, host_attr_dict)
375    afe_host = afe.get_hosts([hostname])[0]
376    return afe_host, unlock_on_failure
377
378
379def _install_firmware(host):
380    """Install dev-signed firmware after removing write-protect.
381
382    At start, it's assumed that hardware write-protect is disabled,
383    the DUT is in dev mode, and the servo's USB stick already has a
384    test image installed.
385
386    The firmware is installed by powering on and typing ctrl+U on
387    the keyboard in order to boot the the test image from USB.  Once
388    the DUT is booted, we run a series of commands to install the
389    read-only firmware from the test image.  Then we clear debug
390    mode, and shut down.
391
392    @param host   Host instance to use for servo and ssh operations.
393    """
394    servo = host.servo
395    # First power on.  We sleep to allow the firmware plenty of time
396    # to display the dev-mode screen; some boards take their time to
397    # be ready for the ctrl+U after power on.
398    servo.get_power_state_controller().power_off()
399    servo.switch_usbkey('dut')
400    servo.get_power_state_controller().power_on()
401    time.sleep(10)
402    # Dev mode screen should be up now:  type ctrl+U and wait for
403    # boot from USB to finish.
404    servo.ctrl_u()
405    if not host.wait_up(timeout=host.USB_BOOT_TIMEOUT):
406        raise Exception('DUT failed to boot in dev mode for '
407                        'firmware update')
408    # Disable software-controlled write-protect for both FPROMs, and
409    # install the RO firmware.
410    for fprom in ['host', 'ec']:
411        host.run('flashrom -p %s --wp-disable' % fprom,
412                 ignore_status=True)
413    host.run('chromeos-firmwareupdate --mode=factory')
414    # Get us out of dev-mode and clear GBB flags.  GBB flags are
415    # non-zero because boot from USB was enabled.
416    host.run('/usr/share/vboot/bin/set_gbb_flags.sh 0',
417             ignore_status=True)
418    host.run('crossystem disable_dev_request=1',
419             ignore_status=True)
420    host.halt()
421
422
423def _install_test_image(host, arguments):
424    """Install a test image to the DUT.
425
426    Install a stable test image on the DUT using the full servo
427    repair flow.
428
429    @param host       Host instance for the DUT being installed.
430    @param arguments  Command line arguments with options.
431    """
432    # Don't timeout probing for the host usb device, there could be a bunch
433    # of servos probing at the same time on the same servo host.  And
434    # since we can't pass None through the xml rpcs, use 0 to indicate None.
435    if not host.servo.probe_host_usb_dev(timeout=0):
436        raise Exception('No USB stick detected on Servo host')
437    try:
438        if not arguments.noinstall:
439            if not arguments.nostage:
440                host.servo.image_to_servo_usb(
441                        host.stage_image_for_servo())
442            if arguments.full_deploy:
443                _install_firmware(host)
444            host.servo_install()
445    except error.AutoservRunError as e:
446        logging.exception('Failed to install: %s', e)
447        raise Exception('chromeos-install failed')
448    finally:
449        host.close()
450
451
452def _install_and_update_afe(afe, hostname, arguments, host_attr_dict):
453    """Perform all installation and AFE updates.
454
455    First, lock the host if it exists and is unlocked.  Then,
456    install the test image on the DUT.  At the end, unlock the
457    DUT, unless the installation failed and the DUT was locked
458    before we started.
459
460    If installation succeeds, make sure the DUT is in the AFE,
461    and make sure that it has basic labels.
462
463    @param afe               AFE object for RPC calls.
464    @param hostname          Host name of the DUT.
465    @param arguments         Command line arguments with options.
466    @param host_attr_dict    Dict of host attributes to store in the AFE.
467    """
468    afe_host, unlock_on_failure = _get_afe_host(afe, hostname, arguments,
469                                                host_attr_dict)
470    try:
471        host = _create_host(hostname, afe, afe_host)
472        _install_test_image(host, arguments)
473        host.labels.update_labels(host)
474        platform_labels = afe.get_labels(
475                host__hostname=hostname, platform=True)
476        if not platform_labels:
477            platform = host.get_platform()
478            new_labels = afe.get_labels(name=platform)
479            if not new_labels:
480                afe.create_label(platform, platform=True)
481            afe_host.add_labels([platform])
482        version = [label for label in afe_host.labels
483                       if label.startswith(VERSION_PREFIX)]
484        if version:
485            afe_host.remove_labels(version)
486    except Exception as e:
487        if unlock_on_failure and not _try_unlock_host(afe_host):
488            logging.error('Failed to unlock host!')
489        raise
490
491    if not _try_unlock_host(afe_host):
492        raise Exception('Install succeeded, but failed to unlock the DUT.')
493
494
495def _install_dut(arguments, host_attr_dict, hostname):
496    """Deploy or repair a single DUT.
497
498    Implementation note: This function is expected to run in a
499    subprocess created by a multiprocessing Pool object.  As such,
500    it can't (shouldn't) write to shared files like `sys.stdout`.
501
502    @param hostname        Host name of the DUT to install on.
503    @param arguments       Command line arguments with options.
504    @param host_attr_dict  Dict of host attributes to store in the AFE.
505
506    @return On success, return `None`.  On failure, return a string
507            with an error message.
508    """
509    logpath = os.path.join(arguments.logdir, hostname + '.log')
510    logfile = open(logpath, 'w')
511
512    # In some cases, autotest code that we call during install may
513    # put stuff onto stdout with 'print' statements.  Most notably,
514    # the AFE frontend may print 'FAILED RPC CALL' (boo, hiss).  We
515    # want nothing from this subprocess going to the output we
516    # inherited from our parent, so redirect stdout and stderr here,
517    # before we make any AFE calls.  Note that this does what we
518    # want only because we're in a subprocess.
519    sys.stderr = sys.stdout = logfile
520    _configure_logging_to_file(logfile)
521
522    afe = frontend.AFE(server=arguments.web)
523    try:
524        _install_and_update_afe(afe, hostname, arguments, host_attr_dict)
525    except Exception as e:
526        logging.exception('Original exception: %s', e)
527        return str(e)
528    return None
529
530
531def _report_hosts(report_log, heading, host_results_list):
532    """Report results for a list of hosts.
533
534    To improve visibility, results are preceded by a header line,
535    followed by a divider line.  Then results are printed, one host
536    per line.
537
538    @param report_log         File-like object for logging report
539                              output.
540    @param heading            The header string to be printed before
541                              results.
542    @param host_results_list  A list of _ReportResult tuples
543                              to be printed one per line.
544    """
545    if not host_results_list:
546        return
547    report_log.write(heading)
548    report_log.write(_DIVIDER)
549    for result in host_results_list:
550        report_log.write('{result.hostname:30} {result.message}\n'
551                         .format(result=result))
552    report_log.write('\n')
553
554
555def _report_results(afe, report_log, hostnames, results):
556    """Gather and report a summary of results from installation.
557
558    Segregate results into successes and failures, reporting
559    each separately.  At the end, report the total of successes
560    and failures.
561
562    @param afe          AFE object for RPC calls.
563    @param report_log   File-like object for logging report output.
564    @param hostnames    List of the hostnames that were tested.
565    @param results      List of error messages, in the same order
566                        as the hostnames.  `None` means the
567                        corresponding host succeeded.
568    """
569    successful_hosts = []
570    success_reports = []
571    failure_reports = []
572    for result, hostname in zip(results, hostnames):
573        if result is None:
574            successful_hosts.append(hostname)
575        else:
576            failure_reports.append(_ReportResult(hostname, result))
577    if successful_hosts:
578        afe.reverify_hosts(hostnames=successful_hosts)
579        for h in afe.get_hosts(hostnames=successful_hosts):
580            for label in h.labels:
581                if label.startswith(Labels.POOL_PREFIX):
582                    result = _ReportResult(h.hostname,
583                                           'Host already in %s' % label)
584                    success_reports.append(result)
585                    break
586            else:
587                h.add_labels([_DEFAULT_POOL])
588                result = _ReportResult(h.hostname,
589                                       'Host added to %s' % _DEFAULT_POOL)
590                success_reports.append(result)
591    report_log.write(_DIVIDER)
592    _report_hosts(report_log, 'Successes', success_reports)
593    _report_hosts(report_log, 'Failures', failure_reports)
594    report_log.write(
595        'Installation complete:  %d successes, %d failures.\n' %
596        (len(success_reports), len(failure_reports)))
597
598
599def _clear_root_logger_handlers():
600    """Remove all handlers from root logger."""
601    root_logger = logging.getLogger()
602    for h in root_logger.handlers:
603        root_logger.removeHandler(h)
604
605
606def _configure_logging_to_file(logfile):
607    """Configure the logging module for `install_duts()`.
608
609    @param log_file  Log file object.
610    """
611    _clear_root_logger_handlers()
612    handler = logging.StreamHandler(logfile)
613    formatter = logging.Formatter(_LOG_FORMAT, time_utils.TIME_FMT)
614    handler.setFormatter(formatter)
615    root_logger = logging.getLogger()
616    root_logger.addHandler(handler)
617
618
619def _get_used_servo_ports(servo_hostname, afe):
620    """
621    Return a list of used servo ports for the given servo host.
622
623    @param servo_hostname:  Hostname of the servo host to check for.
624    @param afe:             AFE instance.
625
626    @returns a list of used ports for the given servo host.
627    """
628    used_ports = []
629    host_list = afe.get_hosts_by_attribute(
630            attribute=servo_host.SERVO_HOST_ATTR, value=servo_hostname)
631    for host in host_list:
632        afe_host = afe.get_hosts(hostname=host)
633        if afe_host:
634            servo_port = afe_host[0].attributes.get(servo_host.SERVO_PORT_ATTR)
635            if servo_port:
636                used_ports.append(int(servo_port))
637    return used_ports
638
639
640def _get_free_servo_port(servo_hostname, used_servo_ports, afe):
641    """
642    Get a free servo port for the servo_host.
643
644    @param servo_hostname:    Hostname of the servo host.
645    @param used_servo_ports:  Dict of dicts that contain the list of used ports
646                              for the given servo host.
647    @param afe:               AFE instance.
648
649    @returns a free servo port if servo_hostname is non-empty, otherwise an
650        empty string.
651    """
652    used_ports = []
653    servo_port = servo_host.ServoHost.DEFAULT_PORT
654    # If no servo hostname was specified we can assume we're dealing with a
655    # servo v3 or older deployment since the servo hostname can be
656    # inferred from the dut hostname (by appending '-servo' to it).  We only
657    # need to find a free port if we're using a servo v4 since we can use the
658    # default port for v3 and older.
659    if not servo_hostname:
660        return ''
661    # If we haven't checked this servo host yet, check the AFE if other duts
662    # used this servo host and grab the ports specified for them.
663    elif servo_hostname not in used_servo_ports:
664        used_ports = _get_used_servo_ports(servo_hostname, afe)
665    else:
666        used_ports = used_servo_ports[servo_hostname]
667    used_ports.sort()
668    if used_ports:
669        # Range is taken from servod.py in hdctools.
670        start_port = servo_host.ServoHost.DEFAULT_PORT
671        end_port = start_port - 99
672        # We'll choose first port available in descending order.
673        for port in xrange(start_port, end_port - 1, -1):
674            if port not in used_ports:
675              servo_port = port
676              break
677    used_ports.append(servo_port)
678    used_servo_ports[servo_hostname] = used_ports
679    return servo_port
680
681
682def _get_afe_servo_port(host_info, afe):
683    """
684    Get the servo port from the afe if it matches the same servo host hostname.
685
686    @param host_info   HostInfo tuple (hostname, host_attr_dict).
687
688    @returns Servo port (int) if servo host hostname matches the one specified
689    host_info.host_attr_dict, otherwise None.
690
691    @raises _NoAFEServoPortError: When there is no stored host info or servo
692        port host attribute in the AFE for the given host.
693    """
694    afe_hosts = afe.get_hosts(hostname=host_info.hostname)
695    if not afe_hosts:
696        raise _NoAFEServoPortError
697
698    servo_port = afe_hosts[0].attributes.get(servo_host.SERVO_PORT_ATTR)
699    afe_servo_host = afe_hosts[0].attributes.get(servo_host.SERVO_HOST_ATTR)
700    host_info_servo_host = host_info.host_attr_dict.get(
701        servo_host.SERVO_HOST_ATTR)
702
703    if afe_servo_host == host_info_servo_host and servo_port:
704        return int(servo_port)
705    else:
706        raise _NoAFEServoPortError
707
708
709def _get_host_attributes(host_info_list, afe):
710    """
711    Get host attributes if a hostname_file was supplied.
712
713    @param host_info_list   List of HostInfo tuples (hostname, host_attr_dict).
714
715    @returns Dict of attributes from host_info_list.
716    """
717    host_attributes = {}
718    # We need to choose servo ports for these hosts but we need to make sure
719    # we don't choose ports already used. We'll store all used ports in a
720    # dict of lists where the key is the servo_host and the val is a list of
721    # ports used.
722    used_servo_ports = {}
723    for host_info in host_info_list:
724        host_attr_dict = host_info.host_attr_dict
725        # If the host already has an entry in the AFE that matches the same
726        # servo host hostname and the servo port is set, use that port.
727        try:
728            host_attr_dict[servo_host.SERVO_PORT_ATTR] = _get_afe_servo_port(
729                host_info, afe)
730        except _NoAFEServoPortError:
731            host_attr_dict[servo_host.SERVO_PORT_ATTR] = _get_free_servo_port(
732                host_attr_dict[servo_host.SERVO_HOST_ATTR], used_servo_ports,
733                afe)
734        host_attributes[host_info.hostname] = host_attr_dict
735    return host_attributes
736
737
738def install_duts(argv, full_deploy):
739    """Install a test image on DUTs, and deploy them.
740
741    This handles command line parsing for both the repair and
742    deployment commands.  The two operations are largely identical;
743    the main difference is that full deployment includes flashing
744    dev-signed firmware on the DUT prior to installing the test
745    image.
746
747    @param argv         Command line arguments to be parsed.
748    @param full_deploy  If true, do the full deployment that includes
749                        flashing dev-signed RO firmware onto the DUT.
750    """
751    # Override tempfile.tempdir.  Some of the autotest code we call
752    # will create temporary files that don't get cleaned up.  So, we
753    # put the temp files in our results directory, so that we can
754    # clean up everything in one fell swoop.
755    tempfile.tempdir = tempfile.mkdtemp()
756    # MALCOLM:
757    #   Be comforted.
758    #   Let's make us med'cines of our great revenge,
759    #   To cure this deadly grief.
760    atexit.register(shutil.rmtree, tempfile.tempdir)
761
762    arguments = commandline.parse_command(argv, full_deploy)
763    if not arguments:
764        sys.exit(1)
765    sys.stderr.write('Installation output logs in %s\n' % arguments.logdir)
766
767    # We don't want to distract the user with logging output, so we catch
768    # logging output in a file.
769    logging_file_path = os.path.join(arguments.logdir, 'debug.log')
770    logfile = open(logging_file_path, 'w')
771    _configure_logging_to_file(logfile)
772
773    report_log_path = os.path.join(arguments.logdir, 'report.log')
774    with open(report_log_path, 'w') as report_log_file:
775        report_log = _MultiFileWriter([report_log_file, sys.stdout])
776        afe = frontend.AFE(server=arguments.web)
777        current_build = _update_build(afe, report_log, arguments)
778        host_attr_dict = _get_host_attributes(arguments.host_info_list, afe)
779        install_pool = multiprocessing.Pool(len(arguments.hostnames))
780        install_function = functools.partial(_install_dut, arguments,
781                                             host_attr_dict)
782        results_list = install_pool.map(install_function, arguments.hostnames)
783        _report_results(afe, report_log, arguments.hostnames, results_list)
784
785        gspath = _get_upload_log_path(arguments)
786        report_log.write('Logs will be uploaded to %s\n' % (gspath,))
787
788    try:
789        _upload_logs(arguments.logdir, gspath)
790    except Exception as e:
791        upload_failure_log_path = os.path.join(arguments.logdir,
792                                               'gs_upload_failure.log')
793        with open(upload_failure_log_path, 'w') as file:
794            traceback.print_exc(limit=None, file=file)
795        sys.stderr.write('Failed to upload logs;'
796                         ' failure details are stored in {}.\n'
797                         .format(upload_failure_log_path))
798