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