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