Home | History | Annotate | Download | only in stable_images
      1 #!/usr/bin/python
      2 # Copyright 2016 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 """
      7 Automatically update the afe_stable_versions table.
      8 
      9 This command updates the stable repair version for selected boards
     10 in the lab.  For each board, if the version that Omaha is serving
     11 on the Beta channel for the board is more recent than the current
     12 stable version in the AFE database, then the AFE is updated to use
     13 the version on Omaha.
     14 
     15 The upgrade process is applied to every "managed board" in the test
     16 lab.  Generally, a managed board is a board with both spare and
     17 critical scheduling pools.
     18 
     19 See `autotest_lib.site_utils.lab_inventory` for the full definition
     20 of "managed board".
     21 
     22 The command supports a `--dry-run` option that reports changes that
     23 would be made, without making the actual RPC calls to change the
     24 database.
     25 
     26 """
     27 
     28 import argparse
     29 import json
     30 import subprocess
     31 import sys
     32 
     33 import common
     34 from autotest_lib.client.common_lib import utils
     35 from autotest_lib.server.cros.dynamic_suite import frontend_wrappers
     36 from autotest_lib.site_utils import lab_inventory
     37 
     38 
     39 # _OMAHA_STATUS - URI of a file in GoogleStorage with a JSON object
     40 # summarizing all versions currently being served by Omaha.
     41 #
     42 # The principle data is in an array named 'omaha_data'.  Each entry
     43 # in the array contains information relevant to one image being
     44 # served by Omaha, including the following information:
     45 #   * The board name of the product, as known to Omaha.
     46 #   * The channel associated with the image.
     47 #   * The Chrome and Chrome OS version strings for the image
     48 #     being served.
     49 #
     50 _OMAHA_STATUS = 'gs://chromeos-build-release-console/omaha_status.json'
     51 
     52 
     53 # _BUILD_METADATA_PATTERN - Format string for the URI of a file in
     54 # GoogleStorage with a JSON object that contains metadata about
     55 # a given build.  The metadata includes the version of firmware
     56 # bundled with the build.
     57 #
     58 _BUILD_METADATA_PATTERN = 'gs://chromeos-image-archive/%s/metadata.json'
     59 
     60 
     61 # _DEFAULT_BOARD - The distinguished board name used to identify a
     62 # stable version mapping that is used for any board without an explicit
     63 # mapping of its own.
     64 #
     65 # _DEFAULT_VERSION_TAG - A string used to signify that there is no
     66 # mapping for a board, in other words, the board is mapped to the
     67 # default version.
     68 #
     69 _DEFAULT_BOARD = 'DEFAULT'
     70 _DEFAULT_VERSION_TAG = '(default)'
     71 
     72 
     73 # _FIRMWARE_UPGRADE_BLACKLIST - a set of boards that are exempt from
     74 # automatic stable firmware version assignment.  This blacklist is
     75 # here out of an abundance of caution, on the general principle of "if
     76 # it ain't broke, don't fix it."  Specifically, these are old, legacy
     77 # boards and:
     78 #   * They're working fine with whatever firmware they have in the lab
     79 #     right now.  Moreover, because of their age, we can expect that
     80 #     they will never get any new firmware updates in future.
     81 #   * Servo support is spotty or missing, so there's no certainty
     82 #     that DUTs bricked by a firmware update can be repaired.
     83 #   * Because of their age, they are somewhere between hard and
     84 #     impossible to replace.  In some cases, they are also already
     85 #     in short supply.
     86 #
     87 # N.B.  HARDCODED BOARD NAMES ARE EVIL!!!  This blacklist uses hardcoded
     88 # names because it's meant to define a list of legacies that will shrivel
     89 # and die over time.
     90 #
     91 # DO NOT ADD TO THIS LIST.  If there's a new use case that requires
     92 # extending the blacklist concept, you should find a maintainable
     93 # solution that deletes this code.
     94 #
     95 # TODO(jrbarnette):  When any board is past EOL, and removed from the
     96 # lab, it can be removed from the blacklist.  When all the boards are
     97 # past EOL, the blacklist should be removed.
     98 
     99 _FIRMWARE_UPGRADE_BLACKLIST = set([
    100         'butterfly',
    101         'daisy',
    102         'daisy_skate',
    103         'daisy_spring',
    104         'lumpy',
    105         'parrot',
    106         'parrot_ivb',
    107         'peach_pi',
    108         'peach_pit',
    109         'stout',
    110         'stumpy',
    111         'x86-alex',
    112         'x86-mario',
    113         'x86-zgb',
    114     ])
    115 
    116 
    117 def _get_by_key_path(dictdict, key_path):
    118     """
    119     Traverse a sequence of keys in a dict of dicts.
    120 
    121     The `dictdict` parameter is a dict of nested dict values, and
    122     `key_path` a list of keys.
    123 
    124     A single-element key path returns `dictdict[key_path[0]]`, a
    125     two-element path returns `dictdict[key_path[0]][key_path[1]]`, and
    126     so forth.  If any key in the path is not found, return `None`.
    127 
    128     @param dictdict   A dictionary of nested dictionaries.
    129     @param key_path   The sequence of keys to look up in `dictdict`.
    130     @return The value found by successive dictionary lookups, or `None`.
    131     """
    132     value = dictdict
    133     for key in key_path:
    134         value = value.get(key)
    135         if value is None:
    136             break
    137     return value
    138 
    139 
    140 def get_firmware_version(version_map, board, cros_version):
    141     """
    142     Get the firmware version for a given board and CrOS version.
    143 
    144     Typically, CrOS builds bundle firmware that is installed at update
    145     time.  This function returns a version string for the firmware
    146     installed in a particular build.
    147 
    148     The returned value will be `None` if the build isn't found in
    149     storage, if there is no firmware found for the build, or if the
    150     board is blacklisted from firmware updates in the test lab.
    151 
    152     @param version_map    An AFE cros version map object; used to
    153                           locate the build in storage.
    154     @param board          The board for the firmware version to be
    155                           determined.
    156     @param cros_version   The CrOS version bundling the firmware.
    157     @return The version string of the firmware for `board` that's
    158             bundled with `cros_version`, or `None`.
    159     """
    160     if board in _FIRMWARE_UPGRADE_BLACKLIST:
    161         return None
    162     try:
    163         image_path = version_map.format_image_name(board, cros_version)
    164         uri = _BUILD_METADATA_PATTERN % image_path
    165         key_path = ['board-metadata', board, 'main-firmware-version']
    166         return _get_by_key_path(_read_gs_json_data(uri), key_path)
    167     except:
    168         # TODO(jrbarnette): If we get here, it likely means that
    169         # the repair build for our board doesn't exist.  That can
    170         # happen if a board doesn't release on the Beta channel for
    171         # at least 6 months.
    172         #
    173         # We can't allow this error to propogate up the call chain
    174         # because that will kill assigning versions to all the other
    175         # boards that are still OK, so for now we ignore it.  We
    176         # really should do better.
    177         return None
    178 
    179 
    180 class _VersionUpdater(object):
    181     """
    182     Class to report and apply version changes.
    183 
    184     This class is responsible for the low-level logic of applying
    185     version upgrades and reporting them as command output.
    186 
    187     This class exists to solve two problems:
    188      1. To distinguish "normal" vs. "dry-run" modes.  Each mode has a
    189         subclass; methods that perform actual AFE updates are
    190         implemented for the normal mode subclass only.
    191      2. To provide hooks for unit tests.  The unit tests override both
    192         the reporting and modification behaviors, in order to test the
    193         higher level logic that decides what changes are needed.
    194 
    195     Methods meant merely to report changes to command output have names
    196     starting with "report" or "_report".  Methods that are meant to
    197     change the AFE in normal mode have names starting with "_do"
    198     """
    199 
    200     def __init__(self, afe):
    201         image_types = [afe.CROS_IMAGE_TYPE, afe.FIRMWARE_IMAGE_TYPE]
    202         self._version_maps = {
    203             image_type: afe.get_stable_version_map(image_type)
    204                 for image_type in image_types
    205         }
    206         self._cros_map = self._version_maps[afe.CROS_IMAGE_TYPE]
    207         self._selected_map = None
    208 
    209     def select_version_map(self, image_type):
    210         """
    211         Select an AFE version map object based on `image_type`.
    212 
    213         This creates and remembers an AFE version mapper object to be
    214         used for making changes in normal mode.
    215 
    216         @param image_type   Image type parameter for the version mapper
    217                             object.
    218         @returns The full set of mappings for the image type.
    219         """
    220         self._selected_map = self._version_maps[image_type]
    221         return self._selected_map.get_all_versions()
    222 
    223     def get_firmware_version(self, board, version):
    224         """
    225         Get the firmware version of a given board and CrOS version.
    226 
    227         Returns the string naming the firmware version for the given
    228         `board` and `version`.
    229 
    230         The returned string is generally in a form like
    231         "Google_Kip.5216.227.78".
    232 
    233         @returns A firmware version string.
    234         """
    235         return get_firmware_version(self._cros_map, board, version)
    236 
    237     def announce(self):
    238         """Announce the start of processing to the user."""
    239         pass
    240 
    241     def report(self, message):
    242         """
    243         Report a pre-formatted message for the user.
    244 
    245         The message is printed to stdout, followed by a newline.
    246 
    247         @param message The message to be provided to the user.
    248         """
    249         print message
    250 
    251     def report_default_changed(self, old_default, new_default):
    252         """
    253         Report that the default version mapping is changing.
    254 
    255         This merely reports a text description of the pending change
    256         without executing it.
    257 
    258         @param old_default  The original default version.
    259         @param new_default  The new default version to be applied.
    260         """
    261         self.report('Default %s -> %s' % (old_default, new_default))
    262 
    263     def _report_board_changed(self, board, old_version, new_version):
    264         """
    265         Report a change in one board's assigned version mapping.
    266 
    267         This merely reports a text description of the pending change
    268         without executing it.
    269 
    270         @param board        The board with the changing version.
    271         @param old_version  The original version mapped to the board.
    272         @param new_version  The new version to be applied to the board.
    273         """
    274         template = '    %-22s %s -> %s'
    275         self.report(template % (board, old_version, new_version))
    276 
    277     def report_board_unchanged(self, board, old_version):
    278         """
    279         Report that a board's version mapping is unchanged.
    280 
    281         This reports that a board has a non-default mapping that will be
    282         unchanged.
    283 
    284         @param board        The board that is not changing.
    285         @param old_version  The board's version mapping.
    286         """
    287         self._report_board_changed(board, '(no change)', old_version)
    288 
    289     def _do_set_mapping(self, board, new_version):
    290         """
    291         Change one board's assigned version mapping.
    292 
    293         @param board        The board with the changing version.
    294         @param new_version  The new version to be applied to the board.
    295         """
    296         pass
    297 
    298     def _do_delete_mapping(self, board):
    299         """
    300         Delete one board's assigned version mapping.
    301 
    302         @param board        The board with the version to be deleted.
    303         """
    304         pass
    305 
    306     def set_mapping(self, board, old_version, new_version):
    307         """
    308         Change and report a board version mapping.
    309 
    310         @param board        The board with the changing version.
    311         @param old_version  The original version mapped to the board.
    312         @param new_version  The new version to be applied to the board.
    313         """
    314         self._report_board_changed(board, old_version, new_version)
    315         self._do_set_mapping(board, new_version)
    316 
    317     def upgrade_default(self, new_default):
    318         """
    319         Apply a default version change.
    320 
    321         @param new_default  The new default version to be applied.
    322         """
    323         self._do_set_mapping(_DEFAULT_BOARD, new_default)
    324 
    325     def delete_mapping(self, board, old_version):
    326         """
    327         Delete a board version mapping, and report the change.
    328 
    329         @param board        The board with the version to be deleted.
    330         @param old_version  The board's verson prior to deletion.
    331         """
    332         assert board != _DEFAULT_BOARD
    333         self._report_board_changed(board,
    334                                    old_version,
    335                                    _DEFAULT_VERSION_TAG)
    336         self._do_delete_mapping(board)
    337 
    338 
    339 class _DryRunUpdater(_VersionUpdater):
    340     """Code for handling --dry-run execution."""
    341 
    342     def announce(self):
    343         self.report('Dry run:  no changes will be made.')
    344 
    345 
    346 class _NormalModeUpdater(_VersionUpdater):
    347     """Code for handling normal execution."""
    348 
    349     def _do_set_mapping(self, board, new_version):
    350         self._selected_map.set_version(board, new_version)
    351 
    352     def _do_delete_mapping(self, board):
    353         self._selected_map.delete_version(board)
    354 
    355 
    356 def _read_gs_json_data(gs_uri):
    357     """
    358     Read and parse a JSON file from googlestorage.
    359 
    360     This is a wrapper around `gsutil cat` for the specified URI.
    361     The standard output of the command is parsed as JSON, and the
    362     resulting object returned.
    363 
    364     @return A JSON object parsed from `gs_uri`.
    365     """
    366     with open('/dev/null', 'w') as ignore_errors:
    367         sp = subprocess.Popen(['gsutil', 'cat', gs_uri],
    368                               stdout=subprocess.PIPE,
    369                               stderr=ignore_errors)
    370         try:
    371             json_object = json.load(sp.stdout)
    372         finally:
    373             sp.stdout.close()
    374             sp.wait()
    375     return json_object
    376 
    377 
    378 def _make_omaha_versions(omaha_status):
    379     """
    380     Convert parsed omaha versions data to a versions mapping.
    381 
    382     Returns a dictionary mapping board names to the currently preferred
    383     version for the Beta channel as served by Omaha.  The mappings are
    384     provided by settings in the JSON object `omaha_status`.
    385 
    386     The board names are the names as known to Omaha:  If the board name
    387     in the AFE contains '_', the corresponding Omaha name uses '-'
    388     instead.  The boards mapped may include boards not in the list of
    389     managed boards in the lab.
    390 
    391     @return A dictionary mapping Omaha boards to Beta versions.
    392     """
    393     def _entry_valid(json_entry):
    394         return json_entry['channel'] == 'beta'
    395 
    396     def _get_omaha_data(json_entry):
    397         board = json_entry['board']['public_codename']
    398         milestone = json_entry['milestone']
    399         build = json_entry['chrome_os_version']
    400         version = 'R%d-%s' % (milestone, build)
    401         return (board, version)
    402 
    403     return dict(_get_omaha_data(e) for e in omaha_status['omaha_data']
    404                     if _entry_valid(e))
    405 
    406 
    407 def _get_upgrade_versions(afe_versions, omaha_versions, boards):
    408     """
    409     Get the new stable versions to which we should update.
    410 
    411     The new versions are returned as a tuple of a dictionary mapping
    412     board names to versions, plus a new default board setting.  The
    413     new default is determined as the most commonly used version
    414     across the given boards.
    415 
    416     The new dictionary will have a mapping for every board in `boards`.
    417     That mapping will be taken from `afe_versions`, unless the board has
    418     a mapping in `omaha_versions` _and_ the omaha version is more recent
    419     than the AFE version.
    420 
    421     @param afe_versions     The current board->version mappings in the
    422                             AFE.
    423     @param omaha_versions   The current board->version mappings from
    424                             Omaha for the Beta channel.
    425     @param boards           Set of boards to be upgraded.
    426     @return Tuple of (mapping, default) where mapping is a dictionary
    427             mapping boards to versions, and default is a version string.
    428     """
    429     upgrade_versions = {}
    430     version_counts = {}
    431     afe_default = afe_versions[_DEFAULT_BOARD]
    432     for board in boards:
    433         version = afe_versions.get(board, afe_default)
    434         omaha_version = omaha_versions.get(board.replace('_', '-'))
    435         if (omaha_version is not None and
    436                 utils.compare_versions(version, omaha_version) < 0):
    437             version = omaha_version
    438         upgrade_versions[board] = version
    439         version_counts.setdefault(version, 0)
    440         version_counts[version] += 1
    441     return (upgrade_versions,
    442             max(version_counts.items(), key=lambda x: x[1])[0])
    443 
    444 
    445 def _get_firmware_upgrades(updater, cros_versions):
    446     """
    447     Get the new firmware versions to which we should update.
    448 
    449     The new versions are returned in a dictionary mapping board names to
    450     firmware versions.  The new dictionary will have a mapping for every
    451     board in `cros_versions`, excluding boards named in
    452     `_FIRMWARE_UPGRADE_BLACKLIST`.
    453 
    454     The firmware for each board is determined from the JSON metadata for
    455     the CrOS build for that board, as specified in `cros_versions`.
    456 
    457     @param updater          An instance of _VersionUpdater.
    458     @param cros_versions    Current board->cros version mappings in the
    459                             AFE.
    460     @return  A dictionary mapping boards to firmware versions.
    461     """
    462     return {
    463         board: updater.get_firmware_version(board, version)
    464             for board, version in cros_versions.iteritems()
    465     }
    466 
    467 
    468 def _apply_cros_upgrades(updater, old_versions, new_versions,
    469                          new_default):
    470     """
    471     Change CrOS stable version mappings in the AFE.
    472 
    473     The input `old_versions` dictionary represents the content of the
    474     `afe_stable_versions` database table; it contains mappings for a
    475     default version, plus exceptions for boards with non-default
    476     mappings.
    477 
    478     The `new_versions` dictionary contains a mapping for every board,
    479     including boards that will be mapped to the new default version.
    480 
    481     This function applies the AFE changes necessary to produce the new
    482     AFE mappings indicated by `new_versions` and `new_default`.  The
    483     changes are ordered so that at any moment, every board is mapped
    484     either according to the old or the new mapping.
    485 
    486     @param updater        Instance of _VersionUpdater responsible for
    487                           making the actual database changes.
    488     @param old_versions   The current board->version mappings in the
    489                           AFE.
    490     @param new_versions   New board->version mappings obtained by
    491                           applying Beta channel upgrades from Omaha.
    492     @param new_default    The new default build for the AFE.
    493     """
    494     old_default = old_versions[_DEFAULT_BOARD]
    495     if old_default != new_default:
    496         updater.report_default_changed(old_default, new_default)
    497     updater.report('Applying stable version changes:')
    498     default_count = 0
    499     for board, new_build in new_versions.items():
    500         if new_build == new_default:
    501             default_count += 1
    502         elif board in old_versions and new_build == old_versions[board]:
    503             updater.report_board_unchanged(board, new_build)
    504         else:
    505             old_build = old_versions.get(board)
    506             if old_build is None:
    507                 old_build = _DEFAULT_VERSION_TAG
    508             updater.set_mapping(board, old_build, new_build)
    509     if old_default != new_default:
    510         updater.upgrade_default(new_default)
    511     for board, new_build in new_versions.items():
    512         if new_build == new_default and board in old_versions:
    513             updater.delete_mapping(board, old_versions[board])
    514     updater.report('%d boards now use the default mapping' %
    515                    default_count)
    516 
    517 
    518 def _apply_firmware_upgrades(updater, old_versions, new_versions):
    519     """
    520     Change firmware version mappings in the AFE.
    521 
    522     The input `old_versions` dictionary represents the content of the
    523     firmware mappings in the `afe_stable_versions` database table.
    524     There is no default version; missing boards simply have no current
    525     version.
    526 
    527     This function applies the AFE changes necessary to produce the new
    528     AFE mappings indicated by `new_versions`.
    529 
    530     TODO(jrbarnette) This function ought to remove any mapping not found
    531     in `new_versions`.  However, in theory, that's only needed to
    532     account for boards that are removed from the lab, and that hasn't
    533     happened yet.
    534 
    535     @param updater        Instance of _VersionUpdater responsible for
    536                           making the actual database changes.
    537     @param old_versions   The current board->version mappings in the
    538                           AFE.
    539     @param new_versions   New board->version mappings obtained by
    540                           applying Beta channel upgrades from Omaha.
    541     """
    542     unchanged = 0
    543     no_version = 0
    544     for board, new_firmware in new_versions.items():
    545         if new_firmware is None:
    546             no_version += 1
    547         elif board not in old_versions:
    548             updater.set_mapping(board, '(nothing)', new_firmware)
    549         else:
    550             old_firmware = old_versions[board]
    551             if new_firmware != old_firmware:
    552                 updater.set_mapping(board, old_firmware, new_firmware)
    553             else:
    554                 unchanged += 1
    555     updater.report('%d boards have no firmware mapping' % no_version)
    556     updater.report('%d boards are unchanged' % unchanged)
    557 
    558 
    559 def _parse_command_line(argv):
    560     """
    561     Parse the command line arguments.
    562 
    563     Create an argument parser for this command's syntax, parse the
    564     command line, and return the result of the ArgumentParser
    565     parse_args() method.
    566 
    567     @param argv Standard command line argument vector; argv[0] is
    568                 assumed to be the command name.
    569     @return Result returned by ArgumentParser.parse_args().
    570 
    571     """
    572     parser = argparse.ArgumentParser(
    573             prog=argv[0],
    574             description='Update the stable repair version for all '
    575                         'boards')
    576     parser.add_argument('-n', '--dry-run', dest='updater_mode',
    577                         action='store_const', const=_DryRunUpdater,
    578                         help='print changes without executing them')
    579     parser.add_argument('extra_boards', nargs='*', metavar='BOARD',
    580                         help='Names of additional boards to be updated.')
    581     arguments = parser.parse_args(argv[1:])
    582     if not arguments.updater_mode:
    583         arguments.updater_mode = _NormalModeUpdater
    584     return arguments
    585 
    586 
    587 def main(argv):
    588     """
    589     Standard main routine.
    590 
    591     @param argv  Command line arguments including `sys.argv[0]`.
    592     """
    593     arguments = _parse_command_line(argv)
    594     afe = frontend_wrappers.RetryingAFE(server=None)
    595     updater = arguments.updater_mode(afe)
    596     updater.announce()
    597     boards = (set(arguments.extra_boards) |
    598               lab_inventory.get_managed_boards(afe))
    599 
    600     afe_versions = updater.select_version_map(afe.CROS_IMAGE_TYPE)
    601     omaha_versions = _make_omaha_versions(
    602             _read_gs_json_data(_OMAHA_STATUS))
    603     upgrade_versions, new_default = (
    604         _get_upgrade_versions(afe_versions, omaha_versions, boards))
    605     _apply_cros_upgrades(updater, afe_versions,
    606                          upgrade_versions, new_default)
    607 
    608     updater.report('\nApplying firmware updates:')
    609     fw_versions = updater.select_version_map(
    610             afe.FIRMWARE_IMAGE_TYPE)
    611     firmware_upgrades = _get_firmware_upgrades(updater, upgrade_versions)
    612     _apply_firmware_upgrades(updater, fw_versions, firmware_upgrades)
    613 
    614 
    615 if __name__ == '__main__':
    616     main(sys.argv)
    617