• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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"""
7Automatically update the afe_stable_versions table.
8
9This command updates the stable repair version for selected boards
10in the lab.  For each board, if the version that Omaha is serving
11on the Beta channel for the board is more recent than the current
12stable version in the AFE database, then the AFE is updated to use
13the version on Omaha.
14
15The upgrade process is applied to every "managed board" in the test
16lab.  Generally, a managed board is a board with both spare and
17critical scheduling pools.
18
19See `autotest_lib.site_utils.lab_inventory` for the full definition
20of "managed board".
21
22The command supports a `--dry-run` option that reports changes that
23would be made, without making the actual RPC calls to change the
24database.
25
26"""
27
28import argparse
29import json
30import subprocess
31import sys
32
33import common
34from autotest_lib.client.common_lib import utils
35from autotest_lib.server.cros.dynamic_suite import frontend_wrappers
36from 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
117def _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
140def 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
180class _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
339class _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
346class _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
356def _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
378def _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
407def _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
445def _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
468def _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
518def _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
559def _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
587def 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
615if __name__ == '__main__':
616    main(sys.argv)
617