• 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_model_firmware_versions(metadata_json, board):
141    """
142    Get the firmware version for each model for a unibuild board.
143
144    @param metadata_json    The metadata_json dict parsed from the metadata.json
145                            file generated by the build.
146    @param board            The board name of the unibuild.
147    @return If no models found for a board, return {board: None}; elase, return
148            a dict mapping from model name to its upgrade firmware version.
149    """
150    model_firmware_versions = {}
151    key_path = ['board-metadata', board, 'models']
152    model_versions = _get_by_key_path(metadata_json, key_path)
153
154    if model_versions is not None:
155        for model, fw_versions in model_versions.iteritems():
156            fw_version = (fw_versions.get('main-readwrite-firmware-version') or
157                          fw_versions.get('main-readonly-firmware-version'))
158            model_firmware_versions[model] = fw_version
159    else:
160        model_firmware_versions[board] = None
161
162    return model_firmware_versions
163
164
165def get_firmware_versions(version_map, board, cros_version):
166    """
167    Get the firmware versions for a given board and CrOS version.
168
169    Typically, CrOS builds bundle firmware that is installed at update
170    time. The returned firmware version value will be `None` if the build isn't
171    found in storage, if there is no firmware found for the build, or if the
172    board is blacklisted from firmware updates in the test lab.
173
174    @param version_map    An AFE cros version map object; used to
175                          locate the build in storage.
176    @param board          The board for the firmware version to be
177                          determined.
178    @param cros_version   The CrOS version bundling the firmware.
179    @return A dict mapping from board to firmware version string for
180            non-unibuild board, or a dict mapping from models to firmware
181            versions for a unibuild board (see return type of
182            _get_model_firmware_versions)
183    """
184    if board in _FIRMWARE_UPGRADE_BLACKLIST:
185        return {board: None}
186    try:
187        image_path = version_map.format_image_name(board, cros_version)
188        uri = _BUILD_METADATA_PATTERN % image_path
189        metadata_json = _read_gs_json_data(uri)
190        unibuild = bool(_get_by_key_path(metadata_json, ['unibuild']))
191        if unibuild:
192            return _get_model_firmware_versions(metadata_json, board)
193        else:
194            key_path = ['board-metadata', board, 'main-firmware-version']
195            fw_version = _get_by_key_path(metadata_json, key_path)
196            return {board: fw_version}
197    except Exception as e:
198        # TODO(jrbarnette): If we get here, it likely means that
199        # the repair build for our board doesn't exist.  That can
200        # happen if a board doesn't release on the Beta channel for
201        # at least 6 months.
202        #
203        # We can't allow this error to propogate up the call chain
204        # because that will kill assigning versions to all the other
205        # boards that are still OK, so for now we ignore it.  We
206        # really should do better.
207        print ('Failed to get firmware version for board %s: %s.' % (board, e))
208        return {board: None}
209
210
211class _VersionUpdater(object):
212    """
213    Class to report and apply version changes.
214
215    This class is responsible for the low-level logic of applying
216    version upgrades and reporting them as command output.
217
218    This class exists to solve two problems:
219     1. To distinguish "normal" vs. "dry-run" modes.  Each mode has a
220        subclass; methods that perform actual AFE updates are
221        implemented for the normal mode subclass only.
222     2. To provide hooks for unit tests.  The unit tests override both
223        the reporting and modification behaviors, in order to test the
224        higher level logic that decides what changes are needed.
225
226    Methods meant merely to report changes to command output have names
227    starting with "report" or "_report".  Methods that are meant to
228    change the AFE in normal mode have names starting with "_do"
229    """
230
231    def __init__(self, afe):
232        image_types = [afe.CROS_IMAGE_TYPE, afe.FIRMWARE_IMAGE_TYPE]
233        self._version_maps = {
234            image_type: afe.get_stable_version_map(image_type)
235                for image_type in image_types
236        }
237        self._cros_map = self._version_maps[afe.CROS_IMAGE_TYPE]
238        self._selected_map = None
239
240    def select_version_map(self, image_type):
241        """
242        Select an AFE version map object based on `image_type`.
243
244        This creates and remembers an AFE version mapper object to be
245        used for making changes in normal mode.
246
247        @param image_type   Image type parameter for the version mapper
248                            object.
249        """
250        self._selected_map = self._version_maps[image_type]
251        return self._selected_map
252
253    def announce(self):
254        """Announce the start of processing to the user."""
255        pass
256
257    def report(self, message):
258        """
259        Report a pre-formatted message for the user.
260
261        The message is printed to stdout, followed by a newline.
262
263        @param message The message to be provided to the user.
264        """
265        print message
266
267    def report_default_changed(self, old_default, new_default):
268        """
269        Report that the default version mapping is changing.
270
271        This merely reports a text description of the pending change
272        without executing it.
273
274        @param old_default  The original default version.
275        @param new_default  The new default version to be applied.
276        """
277        self.report('Default %s -> %s' % (old_default, new_default))
278
279    def _report_board_changed(self, board, old_version, new_version):
280        """
281        Report a change in one board's assigned version mapping.
282
283        This merely reports a text description of the pending change
284        without executing it.
285
286        @param board        The board with the changing version.
287        @param old_version  The original version mapped to the board.
288        @param new_version  The new version to be applied to the board.
289        """
290        template = '    %-22s %s -> %s'
291        self.report(template % (board, old_version, new_version))
292
293    def report_board_unchanged(self, board, old_version):
294        """
295        Report that a board's version mapping is unchanged.
296
297        This reports that a board has a non-default mapping that will be
298        unchanged.
299
300        @param board        The board that is not changing.
301        @param old_version  The board's version mapping.
302        """
303        self._report_board_changed(board, '(no change)', old_version)
304
305    def _do_set_mapping(self, board, new_version):
306        """
307        Change one board's assigned version mapping.
308
309        @param board        The board with the changing version.
310        @param new_version  The new version to be applied to the board.
311        """
312        pass
313
314    def _do_delete_mapping(self, board):
315        """
316        Delete one board's assigned version mapping.
317
318        @param board        The board with the version to be deleted.
319        """
320        pass
321
322    def set_mapping(self, board, old_version, new_version):
323        """
324        Change and report a board version mapping.
325
326        @param board        The board with the changing version.
327        @param old_version  The original version mapped to the board.
328        @param new_version  The new version to be applied to the board.
329        """
330        self._report_board_changed(board, old_version, new_version)
331        self._do_set_mapping(board, new_version)
332
333    def upgrade_default(self, new_default):
334        """
335        Apply a default version change.
336
337        @param new_default  The new default version to be applied.
338        """
339        self._do_set_mapping(_DEFAULT_BOARD, new_default)
340
341    def delete_mapping(self, board, old_version):
342        """
343        Delete a board version mapping, and report the change.
344
345        @param board        The board with the version to be deleted.
346        @param old_version  The board's verson prior to deletion.
347        """
348        assert board != _DEFAULT_BOARD
349        self._report_board_changed(board,
350                                   old_version,
351                                   _DEFAULT_VERSION_TAG)
352        self._do_delete_mapping(board)
353
354
355class _DryRunUpdater(_VersionUpdater):
356    """Code for handling --dry-run execution."""
357
358    def announce(self):
359        self.report('Dry run:  no changes will be made.')
360
361
362class _NormalModeUpdater(_VersionUpdater):
363    """Code for handling normal execution."""
364
365    def _do_set_mapping(self, board, new_version):
366        self._selected_map.set_version(board, new_version)
367
368    def _do_delete_mapping(self, board):
369        self._selected_map.delete_version(board)
370
371
372def _read_gs_json_data(gs_uri):
373    """
374    Read and parse a JSON file from googlestorage.
375
376    This is a wrapper around `gsutil cat` for the specified URI.
377    The standard output of the command is parsed as JSON, and the
378    resulting object returned.
379
380    @return A JSON object parsed from `gs_uri`.
381    """
382    with open('/dev/null', 'w') as ignore_errors:
383        sp = subprocess.Popen(['gsutil', 'cat', gs_uri],
384                              stdout=subprocess.PIPE,
385                              stderr=ignore_errors)
386        try:
387            json_object = json.load(sp.stdout)
388        finally:
389            sp.stdout.close()
390            sp.wait()
391    return json_object
392
393
394def _make_omaha_versions(omaha_status):
395    """
396    Convert parsed omaha versions data to a versions mapping.
397
398    Returns a dictionary mapping board names to the currently preferred
399    version for the Beta channel as served by Omaha.  The mappings are
400    provided by settings in the JSON object `omaha_status`.
401
402    The board names are the names as known to Omaha:  If the board name
403    in the AFE contains '_', the corresponding Omaha name uses '-'
404    instead.  The boards mapped may include boards not in the list of
405    managed boards in the lab.
406
407    @return A dictionary mapping Omaha boards to Beta versions.
408    """
409    def _entry_valid(json_entry):
410        return json_entry['channel'] == 'beta'
411
412    def _get_omaha_data(json_entry):
413        board = json_entry['board']['public_codename']
414        milestone = json_entry['milestone']
415        build = json_entry['chrome_os_version']
416        version = 'R%d-%s' % (milestone, build)
417        return (board, version)
418
419    return dict(_get_omaha_data(e) for e in omaha_status['omaha_data']
420                    if _entry_valid(e))
421
422
423def _get_upgrade_versions(cros_versions, omaha_versions, boards):
424    """
425    Get the new stable versions to which we should update.
426
427    The new versions are returned as a tuple of a dictionary mapping
428    board names to versions, plus a new default board setting.  The
429    new default is determined as the most commonly used version
430    across the given boards.
431
432    The new dictionary will have a mapping for every board in `boards`.
433    That mapping will be taken from `cros_versions`, unless the board has
434    a mapping in `omaha_versions` _and_ the omaha version is more recent
435    than the AFE version.
436
437    @param cros_versions    The current board->version mappings in the
438                            AFE.
439    @param omaha_versions   The current board->version mappings from
440                            Omaha for the Beta channel.
441    @param boards           Set of boards to be upgraded.
442    @return Tuple of (mapping, default) where mapping is a dictionary
443            mapping boards to versions, and default is a version string.
444    """
445    upgrade_versions = {}
446    version_counts = {}
447    afe_default = cros_versions[_DEFAULT_BOARD]
448    for board in boards:
449        version = cros_versions.get(board, afe_default)
450        omaha_version = omaha_versions.get(board.replace('_', '-'))
451        if (omaha_version is not None and
452                utils.compare_versions(version, omaha_version) < 0):
453            version = omaha_version
454        upgrade_versions[board] = version
455        version_counts.setdefault(version, 0)
456        version_counts[version] += 1
457    return (upgrade_versions,
458            max(version_counts.items(), key=lambda x: x[1])[0])
459
460
461def _get_firmware_upgrades(cros_version_map, cros_versions):
462    """
463    Get the new firmware versions to which we should update.
464
465    @param cros_version_map An instance of frontend._CrosVersionMap.
466    @param cros_versions    Current board->cros version mappings in the
467                            AFE.
468    @return A dictionary mapping boards/models to firmware upgrade versions.
469            If the build is unibuild, the key is a model name; else, the key
470            is a board name.
471    """
472    firmware_upgrades = {}
473    for board, version in cros_versions.iteritems():
474        firmware_upgrades.update(get_firmware_versions(
475            cros_version_map, board, version))
476
477    return firmware_upgrades
478
479
480def _apply_cros_upgrades(updater, old_versions, new_versions,
481                         new_default):
482    """
483    Change CrOS stable version mappings in the AFE.
484
485    The input `old_versions` dictionary represents the content of the
486    `afe_stable_versions` database table; it contains mappings for a
487    default version, plus exceptions for boards with non-default
488    mappings.
489
490    The `new_versions` dictionary contains a mapping for every board,
491    including boards that will be mapped to the new default version.
492
493    This function applies the AFE changes necessary to produce the new
494    AFE mappings indicated by `new_versions` and `new_default`.  The
495    changes are ordered so that at any moment, every board is mapped
496    either according to the old or the new mapping.
497
498    @param updater        Instance of _VersionUpdater responsible for
499                          making the actual database changes.
500    @param old_versions   The current board->version mappings in the
501                          AFE.
502    @param new_versions   New board->version mappings obtained by
503                          applying Beta channel upgrades from Omaha.
504    @param new_default    The new default build for the AFE.
505    """
506    old_default = old_versions[_DEFAULT_BOARD]
507    if old_default != new_default:
508        updater.report_default_changed(old_default, new_default)
509    updater.report('Applying stable version changes:')
510    default_count = 0
511    for board, new_build in new_versions.items():
512        if new_build == new_default:
513            default_count += 1
514        elif board in old_versions and new_build == old_versions[board]:
515            updater.report_board_unchanged(board, new_build)
516        else:
517            old_build = old_versions.get(board)
518            if old_build is None:
519                old_build = _DEFAULT_VERSION_TAG
520            updater.set_mapping(board, old_build, new_build)
521    if old_default != new_default:
522        updater.upgrade_default(new_default)
523    for board, new_build in new_versions.items():
524        if new_build == new_default and board in old_versions:
525            updater.delete_mapping(board, old_versions[board])
526    updater.report('%d boards now use the default mapping' %
527                   default_count)
528
529
530def _apply_firmware_upgrades(updater, old_versions, new_versions):
531    """
532    Change firmware version mappings in the AFE.
533
534    The input `old_versions` dictionary represents the content of the
535    firmware mappings in the `afe_stable_versions` database table.
536    There is no default version; missing boards simply have no current
537    version.
538
539    This function applies the AFE changes necessary to produce the new
540    AFE mappings indicated by `new_versions`.
541
542    TODO(jrbarnette) This function ought to remove any mapping not found
543    in `new_versions`.  However, in theory, that's only needed to
544    account for boards that are removed from the lab, and that hasn't
545    happened yet.
546
547    @param updater        Instance of _VersionUpdater responsible for
548                          making the actual database changes.
549    @param old_versions   The current board->version mappings in the
550                          AFE.
551    @param new_versions   New board->version mappings obtained by
552                          applying Beta channel upgrades from Omaha.
553    """
554    unchanged = 0
555    no_version = 0
556    for board, new_firmware in new_versions.items():
557        if new_firmware is None:
558            no_version += 1
559        elif board not in old_versions:
560            updater.set_mapping(board, '(nothing)', new_firmware)
561        else:
562            old_firmware = old_versions[board]
563            if new_firmware != old_firmware:
564                updater.set_mapping(board, old_firmware, new_firmware)
565            else:
566                unchanged += 1
567    updater.report('%d boards have no firmware mapping' % no_version)
568    updater.report('%d boards are unchanged' % unchanged)
569
570
571def _parse_command_line(argv):
572    """
573    Parse the command line arguments.
574
575    Create an argument parser for this command's syntax, parse the
576    command line, and return the result of the ArgumentParser
577    parse_args() method.
578
579    @param argv Standard command line argument vector; argv[0] is
580                assumed to be the command name.
581    @return Result returned by ArgumentParser.parse_args().
582
583    """
584    parser = argparse.ArgumentParser(
585            prog=argv[0],
586            description='Update the stable repair version for all '
587                        'boards')
588    parser.add_argument('-n', '--dry-run', dest='updater_mode',
589                        action='store_const', const=_DryRunUpdater,
590                        help='print changes without executing them')
591    parser.add_argument('extra_boards', nargs='*', metavar='BOARD',
592                        help='Names of additional boards to be updated.')
593    arguments = parser.parse_args(argv[1:])
594    if not arguments.updater_mode:
595        arguments.updater_mode = _NormalModeUpdater
596    return arguments
597
598
599def main(argv):
600    """
601    Standard main routine.
602
603    @param argv  Command line arguments including `sys.argv[0]`.
604    """
605    arguments = _parse_command_line(argv)
606    afe = frontend_wrappers.RetryingAFE(server=None)
607    updater = arguments.updater_mode(afe)
608    updater.announce()
609    boards = (set(arguments.extra_boards) |
610              lab_inventory.get_managed_boards(afe))
611
612    cros_version_map = updater.select_version_map(afe.CROS_IMAGE_TYPE)
613    cros_versions = cros_version_map.get_all_versions()
614    omaha_versions = _make_omaha_versions(
615            _read_gs_json_data(_OMAHA_STATUS))
616    upgrade_versions, new_default = (
617        _get_upgrade_versions(cros_versions, omaha_versions, boards))
618    _apply_cros_upgrades(updater, cros_versions,
619                         upgrade_versions, new_default)
620
621    updater.report('\nApplying firmware updates:')
622    firmware_version_map = updater.select_version_map(afe.FIRMWARE_IMAGE_TYPE)
623    fw_versions = firmware_version_map.get_all_versions()
624    firmware_upgrades = _get_firmware_upgrades(
625            cros_version_map, upgrade_versions)
626    _apply_firmware_upgrades(updater, fw_versions, firmware_upgrades)
627
628
629if __name__ == '__main__':
630    main(sys.argv)
631