1 # Copyright 2015 The Chromium OS Authors. All rights reserved. 2 # Use of this source code is governed by a BSD-style license that can be 3 # found in the LICENSE file. 4 5 """Argument processing for the DUT deployment tool. 6 7 The argument processing is mostly a conventional client of 8 `argparse`, except that if the command is invoked without required 9 arguments, code here will start a line-oriented text dialog with the 10 user to get the arguments. 11 12 These are the arguments: 13 * (required) Board of the DUTs to be deployed. 14 * (required) Hostnames of the DUTs to be deployed. 15 * (optional) Version of the test image to be made the stable 16 repair image for the board to be deployed. If omitted, the 17 existing setting is retained. 18 19 The interactive dialog is invoked if the board and hostnames 20 are omitted from the command line. 21 22 """ 23 24 import argparse 25 import collections 26 import csv 27 import datetime 28 import os 29 import re 30 import subprocess 31 import sys 32 33 import dateutil.tz 34 35 import common 36 from autotest_lib.server.hosts import servo_host 37 38 # _BUILD_URI_FORMAT 39 # A format template for a Google storage URI that designates 40 # one build. The template is to be filled in with a board 41 # name and build version number. 42 43 _BUILD_URI_FORMAT = 'gs://chromeos-image-archive/%s-release/%s' 44 45 46 # _BUILD_PATTERNS 47 # For user convenience, argument parsing allows various formats 48 # for build version strings. The function _normalize_build_name() 49 # is used to convert the recognized syntaxes into the name as 50 # it appears in Google storage. 51 # 52 # _BUILD_PATTERNS describe the recognized syntaxes for user-supplied 53 # build versions, and information about how to convert them. See the 54 # normalize function for details. 55 # 56 # For user-supplied build versions, the following forms are supported: 57 # #### - Indicates a canary; equivalent to ####.0.0. 58 # ####.#.# - A full build version without the leading R##- prefix. 59 # R##-###.#.# - Canonical form of a build version. 60 61 _BUILD_PATTERNS = [ 62 (re.compile(r'^R\d+-\d+\.\d+\.\d+$'), None), 63 (re.compile(r'^\d+\.\d+\.\d+$'), 'LATEST-%s'), 64 (re.compile(r'^\d+$'), 'LATEST-%s.0.0'), 65 ] 66 67 68 # _VALID_HOSTNAME_PATTERNS 69 # A list of REs describing patterns that are acceptable as names 70 # for DUTs in the test lab. Names that don't match one of the 71 # patterns will be rejected as invalid. 72 73 _VALID_HOSTNAME_PATTERNS = [ 74 re.compile(r'chromeos\d+-row\d+-rack\d+-host\d+') 75 ] 76 77 78 # _EXPECTED_NUMBER_OF_HOST_INFO 79 # The number of items per line when parsing the hostname_file csv file. 80 _EXPECTED_NUMBER_OF_HOST_INFO = 8 81 82 # HostInfo 83 # Namedtuple to store host info for processing when creating host in the afe. 84 HostInfo = collections.namedtuple('HostInfo', ['hostname', 'host_attr_dict']) 85 86 87 def _build_path_exists(board, buildpath): 88 """Return whether a given build file exists in Google storage. 89 90 The `buildpath` refers to a specific file associated with 91 release builds for `board`. The path may be one of the "LATEST" 92 files (e.g. "LATEST-7356.0.0"), or it could refer to a build 93 artifact (e.g. "R46-7356.0.0/image.zip"). 94 95 The function constructs the full GS URI from the arguments, and 96 then tests for its existence with `gsutil ls`. 97 98 @param board Board to be tested. 99 @param buildpath Partial path of a file in Google storage. 100 101 @return Return a true value iff the designated file exists. 102 """ 103 try: 104 gsutil_cmd = [ 105 'gsutil', 'ls', 106 _BUILD_URI_FORMAT % (board, buildpath) 107 ] 108 status = subprocess.call(gsutil_cmd, 109 stdout=open('/dev/null', 'w'), 110 stderr=subprocess.STDOUT) 111 return status == 0 112 except: 113 return False 114 115 116 def _normalize_build_name(board, build): 117 """Convert a user-supplied build version to canonical form. 118 119 Canonical form looks like R##-####.#.#, e.g. R46-7356.0.0. 120 Acceptable user-supplied forms are describe under 121 _BUILD_PATTERNS, above. The returned value will be the name of 122 a directory containing build artifacts from a release builder 123 for the board. 124 125 Walk through `_BUILD_PATTERNS`, trying to convert a user 126 supplied build version name into a directory name for valid 127 build artifacts. Searching stops at the first pattern matched, 128 regardless of whether the designated build actually exists. 129 130 `_BUILD_PATTERNS` is a list of tuples. The first element of the 131 tuple is an RE describing a valid user input. The second 132 element of the tuple is a format pattern for a "LATEST" filename 133 in storage that can be used to obtain the full build version 134 associated with the user supplied version. If the second element 135 is `None`, the user supplied build version is already in canonical 136 form. 137 138 @param board Board to be tested. 139 @param build User supplied version name. 140 141 @return Return the name of a directory in canonical form, or 142 `None` if the build doesn't exist. 143 """ 144 for regex, fmt in _BUILD_PATTERNS: 145 if not regex.match(build): 146 continue 147 if fmt is not None: 148 try: 149 gsutil_cmd = [ 150 'gsutil', 'cat', 151 _BUILD_URI_FORMAT % (board, fmt % build) 152 ] 153 return subprocess.check_output( 154 gsutil_cmd, stderr=open('/dev/null', 'w')) 155 except: 156 return None 157 elif _build_path_exists(board, '%s/image.zip' % build): 158 return build 159 else: 160 return None 161 return None 162 163 164 def _validate_board(board): 165 """Return whether a given board exists in Google storage. 166 167 For purposes of this function, a board exists if it has a 168 "LATEST-master" file in its release builder's directory. 169 170 N.B. For convenience, this function prints an error message 171 on stderr in certain failure cases. This is currently useful 172 for argument processing, but isn't really ideal if the callers 173 were to get more complicated. 174 175 @param board The board to be tested for existence. 176 @return Return a true value iff the board exists. 177 """ 178 # In this case, the board doesn't exist, but we don't want 179 # an error message. 180 if board is None: 181 return False 182 # Check Google storage; report failures on stderr. 183 if _build_path_exists(board, 'LATEST-master'): 184 return True 185 else: 186 sys.stderr.write('Board %s doesn\'t exist.\n' % board) 187 return False 188 189 190 def _validate_build(board, build): 191 """Return whether a given build exists in Google storage. 192 193 N.B. For convenience, this function prints an error message 194 on stderr in certain failure cases. This is currently useful 195 for argument processing, but isn't really ideal if the callers 196 were to get more complicated. 197 198 @param board The board to be tested for a build 199 @param build The version of the build to be tested for. This 200 build may be in a user-specified (non-canonical) 201 form. 202 @return If the given board+build exists, return its canonical 203 (normalized) version string. If the build doesn't 204 exist, return a false value. 205 """ 206 canonical_build = _normalize_build_name(board, build) 207 if not canonical_build: 208 sys.stderr.write( 209 'Build %s is not a valid build version for %s.\n' % 210 (build, board)) 211 return canonical_build 212 213 214 def _validate_hostname(hostname): 215 """Return whether a given hostname is valid for the test lab. 216 217 This is a sanity check meant to guarantee that host names follow 218 naming requirements for the test lab. 219 220 N.B. For convenience, this function prints an error message 221 on stderr in certain failure cases. This is currently useful 222 for argument processing, but isn't really ideal if the callers 223 were to get more complicated. 224 225 @param hostname The host name to be checked. 226 @return Return a true value iff the hostname is valid. 227 """ 228 for p in _VALID_HOSTNAME_PATTERNS: 229 if p.match(hostname): 230 return True 231 sys.stderr.write( 232 'Hostname %s doesn\'t match a valid location name.\n' % 233 hostname) 234 return False 235 236 237 def _is_hostname_file_valid(hostname_file): 238 """Check that the hostname file is valid. 239 240 The hostname file is deemed valid if: 241 - the file exists. 242 - the file is non-empty. 243 244 @param hostname_file Filename of the hostname file to check. 245 246 @return `True` if the hostname file is valid, False otherse. 247 """ 248 return os.path.exists(hostname_file) and os.path.getsize(hostname_file) > 0 249 250 251 def _validate_arguments(arguments): 252 """Check command line arguments, and account for defaults. 253 254 Check that all command-line argument constraints are satisfied. 255 If errors are found, they are reported on `sys.stderr`. 256 257 If there are any fields with defined defaults that couldn't be 258 calculated when we constructed the argument parser, calculate 259 them now. 260 261 @param arguments Parsed results from 262 `ArgumentParser.parse_args()`. 263 @return Return `True` if there are no errors to report, or 264 `False` if there are. 265 """ 266 # If both hostnames and hostname_file are specified, complain about that. 267 if arguments.hostnames and arguments.hostname_file: 268 sys.stderr.write( 269 'DUT hostnames and hostname file both specified, only ' 270 'specify one or the other.\n') 271 return False 272 if (arguments.hostname_file and 273 not _is_hostname_file_valid(arguments.hostname_file)): 274 sys.stderr.write( 275 'Specified hostname file must exist and be non-empty.\n') 276 return False 277 if (not arguments.hostnames and not arguments.hostname_file and 278 (arguments.board or arguments.build)): 279 sys.stderr.write( 280 'DUT hostnames are required with board or build.\n') 281 return False 282 if arguments.board is not None: 283 if not _validate_board(arguments.board): 284 return False 285 if (arguments.build is not None and 286 not _validate_build(arguments.board, arguments.build)): 287 return False 288 return True 289 290 291 def _read_with_prompt(input, prompt): 292 """Print a prompt and then read a line of text. 293 294 @param input File-like object from which to read the line. 295 @param prompt String to print to stderr prior to reading. 296 @return Returns a string, stripped of whitespace. 297 """ 298 full_prompt = '%s> ' % prompt 299 sys.stderr.write(full_prompt) 300 return input.readline().strip() 301 302 303 def _read_board(input, default_board): 304 """Read a valid board name from user input. 305 306 Prompt the user to supply a board name, and read one line. If 307 the line names a valid board, return the board name. If the 308 line is blank and `default_board` is a non-empty string, returns 309 `default_board`. Retry until a valid input is obtained. 310 311 `default_board` isn't checked; the caller is responsible for 312 ensuring its validity. 313 314 @param input File-like object from which to read the 315 board. 316 @param default_board Value to return if the user enters a 317 blank line. 318 @return Returns `default_board` or a validated board name. 319 """ 320 if default_board: 321 board_prompt = 'board name [%s]' % default_board 322 else: 323 board_prompt = 'board name' 324 new_board = None 325 while not _validate_board(new_board): 326 new_board = _read_with_prompt(input, board_prompt).lower() 327 if new_board: 328 sys.stderr.write('Checking for valid board.\n') 329 elif default_board: 330 return default_board 331 return new_board 332 333 334 def _read_build(input, board): 335 """Read a valid build version from user input. 336 337 Prompt the user to supply a build version, and read one line. 338 If the line names an existing version for the given board, 339 return the canonical build version. If the line is blank, 340 return `None` (indicating the build shouldn't change). 341 342 @param input File-like object from which to read the build. 343 @param board Board for the build. 344 @return Returns canonical build version, or `None`. 345 """ 346 build = False 347 prompt = 'build version (optional)' 348 while not build: 349 build = _read_with_prompt(input, prompt) 350 if not build: 351 return None 352 sys.stderr.write('Checking for valid build.\n') 353 build = _validate_build(board, build) 354 return build 355 356 357 def _read_hostnames(input): 358 """Read a list of host names from user input. 359 360 Prompt the user to supply a list of host names. Any number of 361 lines are allowed; input is terminated at the first blank line. 362 Any number of hosts names are allowed on one line. Names are 363 separated by whitespace. 364 365 Only valid host names are accepted. Invalid host names are 366 ignored, and a warning is printed. 367 368 @param input File-like object from which to read the names. 369 @return Returns a list of validated host names. 370 """ 371 hostnames = [] 372 y_n = 'yes' 373 while not 'no'.startswith(y_n): 374 sys.stderr.write('enter hosts (blank line to end):\n') 375 while True: 376 new_hosts = input.readline().strip().split() 377 if not new_hosts: 378 break 379 for h in new_hosts: 380 if _validate_hostname(h): 381 hostnames.append(h) 382 if not hostnames: 383 sys.stderr.write('Must provide at least one hostname.\n') 384 continue 385 prompt = 'More hosts? [y/N]' 386 y_n = _read_with_prompt(input, prompt).lower() or 'no' 387 return hostnames 388 389 390 def _read_arguments(input, arguments): 391 """Dialog to read all needed arguments from the user. 392 393 The user is prompted in turn for a board, a build, and 394 hostnames. Responses are stored in `arguments`. The user is 395 given opportunity to accept or reject the responses before 396 continuing. 397 398 @param input File-like object from which to read user 399 responses. 400 @param arguments Namespace object returned from 401 `ArgumentParser.parse_args()`. Results are 402 stored here. 403 """ 404 y_n = 'no' 405 while not 'yes'.startswith(y_n): 406 arguments.board = _read_board(input, arguments.board) 407 arguments.build = _read_build(input, arguments.board) 408 prompt = '%s build %s? [Y/n]' % ( 409 arguments.board, arguments.build) 410 y_n = _read_with_prompt(input, prompt).lower() or 'yes' 411 arguments.hostnames = _read_hostnames(input) 412 413 414 def get_default_logdir_name(arguments): 415 """Get default log directory name. 416 417 @param arguments Namespace object returned from argument parsing. 418 @return A filename as a string. 419 """ 420 return '{time}-{board}'.format( 421 time=arguments.start_time.isoformat(), 422 board=arguments.board) 423 424 425 class _ArgumentParser(argparse.ArgumentParser): 426 """ArgumentParser extended with boolean option pairs.""" 427 428 # Arguments required when adding an option pair. 429 _REQUIRED_PAIR_ARGS = {'dest', 'default'} 430 431 def add_argument_pair(self, yes_flags, no_flags, **kwargs): 432 """Add a pair of argument flags for a boolean option. 433 434 @param yes_flags Iterable of flags to turn option on. 435 May also be a single string. 436 @param no_flags Iterable of flags to turn option off. 437 May also be a single string. 438 @param *kwargs Other arguments to pass to add_argument() 439 """ 440 missing_args = self._REQUIRED_PAIR_ARGS - set(kwargs) 441 if missing_args: 442 raise ValueError("Argument pair must have explicit %s" 443 % (', '.join(missing_args),)) 444 445 if isinstance(yes_flags, (str, unicode)): 446 yes_flags = [yes_flags] 447 if isinstance(no_flags, (str, unicode)): 448 no_flags = [no_flags] 449 450 self.add_argument(*yes_flags, action='store_true', **kwargs) 451 self.add_argument(*no_flags, action='store_false', **kwargs) 452 453 454 def _make_common_parser(command_name): 455 """Create argument parser for common arguments. 456 457 @param command_name The command name. 458 @return ArgumentParser instance. 459 """ 460 parser = _ArgumentParser( 461 prog=command_name, 462 description='Install a test image on newly deployed DUTs') 463 # frontend.AFE(server=None) will use the default web server, 464 # so default for --web is `None`. 465 parser.add_argument('-w', '--web', metavar='SERVER', default=None, 466 help='specify web server') 467 parser.add_argument('-d', '--dir', dest='logdir', 468 help='directory for logs') 469 parser.add_argument('-i', '--build', 470 help='select stable test build version') 471 parser.add_argument('-n', '--noinstall', action='store_true', 472 help='skip install (for script testing)') 473 parser.add_argument('-s', '--nostage', action='store_true', 474 help='skip staging test image (for script testing)') 475 parser.add_argument('-t', '--nostable', action='store_true', 476 help='skip changing stable test image ' 477 '(for script testing)') 478 parser.add_argument('-f', '--hostname_file', 479 help='CSV file that contains a list of hostnames and ' 480 'their details to install with.') 481 parser.add_argument('board', nargs='?', metavar='BOARD', 482 help='board for DUTs to be installed') 483 parser.add_argument('hostnames', nargs='*', metavar='HOSTNAME', 484 help='host names of DUTs to be installed') 485 return parser 486 487 488 def _add_upload_argument_pair(parser, default): 489 """Add option pair for uploading logs. 490 491 @param parser _ArgumentParser instance. 492 @param default Default option value. 493 """ 494 parser.add_argument_pair('--upload', '--noupload', dest='upload', 495 default=default, 496 help='upload logs to GS bucket',) 497 498 499 def _parse_hostname_file_line(hostname_file_row): 500 """ 501 Parse a line from the hostname_file and return a dict of the info. 502 503 @param hostname_file_row: List of strings from each line in the hostname 504 file. 505 506 @returns a NamedTuple of (hostname, host_attr_dict). host_attr_dict is a 507 dict of host attributes for the host. 508 """ 509 if len(hostname_file_row) != _EXPECTED_NUMBER_OF_HOST_INFO: 510 raise Exception('hostname_file line has unexpected number of items ' 511 '%d (expect %d): %s' % 512 (len(hostname_file_row), _EXPECTED_NUMBER_OF_HOST_INFO, 513 hostname_file_row)) 514 # The file will have the info in the following order: 515 # 0: board 516 # 1: dut hostname 517 # 2: dut/v4 mac address 518 # 3: dut ip 519 # 4: labstation hostname 520 # 5: servo serial 521 # 6: servo mac address 522 # 7: servo ip 523 return HostInfo( 524 hostname=hostname_file_row[1], 525 host_attr_dict={servo_host.SERVO_HOST_ATTR: hostname_file_row[4], 526 servo_host.SERVO_SERIAL_ATTR: hostname_file_row[5]}) 527 528 529 def parse_hostname_file(hostname_file): 530 """ 531 Parse the hostname_file and return a list of dicts for each line. 532 533 @param hostname_file: CSV file that contains all the goodies. 534 535 @returns a list of dicts where each line is broken down into a dict. 536 """ 537 host_info_list = [] 538 # First line will be the header, no need to parse that. 539 first_line_skipped = False 540 with open(hostname_file) as f: 541 hostname_file_reader = csv.reader(f) 542 for row in hostname_file_reader: 543 if not first_line_skipped: 544 first_line_skipped = True 545 continue 546 host_info_list.append(_parse_hostname_file_line(row)) 547 548 return host_info_list 549 550 def parse_command(argv, full_deploy): 551 """Get arguments for install from `argv` or the user. 552 553 Create an argument parser for this command's syntax, parse the 554 command line, and return the result of the ArgumentParser 555 parse_args() method. 556 557 If mandatory arguments are missing, execute a dialog with the 558 user to read the arguments from `sys.stdin`. Fill in the 559 return value with the values read prior to returning. 560 561 @param argv Standard command line argument vector; 562 argv[0] is assumed to be the command name. 563 @param full_deploy Whether this is for full deployment or 564 repair. 565 566 @return Result, as returned by ArgumentParser.parse_args(). 567 """ 568 command_name = os.path.basename(argv[0]) 569 parser = _make_common_parser(command_name) 570 _add_upload_argument_pair(parser, default=full_deploy) 571 572 arguments = parser.parse_args(argv[1:]) 573 arguments.full_deploy = full_deploy 574 if arguments.board is None: 575 _read_arguments(sys.stdin, arguments) 576 elif not _validate_arguments(arguments): 577 return None 578 579 arguments.start_time = datetime.datetime.now(dateutil.tz.tzlocal()) 580 if not arguments.logdir: 581 basename = get_default_logdir_name(arguments) 582 arguments.logdir = os.path.join(os.environ['HOME'], 583 'Documents', basename) 584 os.makedirs(arguments.logdir) 585 elif not os.path.isdir(arguments.logdir): 586 os.mkdir(arguments.logdir) 587 588 if arguments.hostname_file: 589 # Populate arguments.hostnames with the hostnames from the file. 590 hostname_file_info_list = parse_hostname_file(arguments.hostname_file) 591 arguments.hostnames = [host_info.hostname 592 for host_info in hostname_file_info_list] 593 arguments.host_info_list = hostname_file_info_list 594 else: 595 arguments.host_info_list = [] 596 return arguments 597