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_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 165 def 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 211 class _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 355 class _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 362 class _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 372 def _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 394 def _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 423 def _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 461 def _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 480 def _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 530 def _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 571 def _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 599 def 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 629 if __name__ == '__main__': 630 main(sys.argv) 631