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-master" 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 # TODO b:169251326 terms below (and in the comment above) are set outside 182 # of this codebase and should be updated when possible. 183 # ("master" -> "main") 184 185 # Check Google storage; report failures on stderr. 186 if _build_path_exists(board, 'LATEST-master'): 187 return True 188 else: 189 sys.stderr.write('Board %s doesn\'t exist.\n' % board) 190 return False 191 192 193def _validate_build(board, build): 194 """Return whether a given build exists in Google storage. 195 196 N.B. For convenience, this function prints an error message 197 on stderr in certain failure cases. This is currently useful 198 for argument processing, but isn't really ideal if the callers 199 were to get more complicated. 200 201 @param board The board to be tested for a build 202 @param build The version of the build to be tested for. This 203 build may be in a user-specified (non-canonical) 204 form. 205 @return If the given board+build exists, return its canonical 206 (normalized) version string. If the build doesn't 207 exist, return a false value. 208 """ 209 canonical_build = _normalize_build_name(board, build) 210 if not canonical_build: 211 sys.stderr.write( 212 'Build %s is not a valid build version for %s.\n' % 213 (build, board)) 214 return canonical_build 215 216 217def _validate_hostname(hostname): 218 """Return whether a given hostname is valid for the test lab. 219 220 This is a sanity check meant to guarantee that host names follow 221 naming requirements for the test lab. 222 223 N.B. For convenience, this function prints an error message 224 on stderr in certain failure cases. This is currently useful 225 for argument processing, but isn't really ideal if the callers 226 were to get more complicated. 227 228 @param hostname The host name to be checked. 229 @return Return a true value iff the hostname is valid. 230 """ 231 for p in _VALID_HOSTNAME_PATTERNS: 232 if p.match(hostname): 233 return True 234 sys.stderr.write( 235 'Hostname %s doesn\'t match a valid location name.\n' % 236 hostname) 237 return False 238 239 240def _is_hostname_file_valid(hostname_file): 241 """Check that the hostname file is valid. 242 243 The hostname file is deemed valid if: 244 - the file exists. 245 - the file is non-empty. 246 247 @param hostname_file Filename of the hostname file to check. 248 249 @return `True` if the hostname file is valid, False otherse. 250 """ 251 return os.path.exists(hostname_file) and os.path.getsize(hostname_file) > 0 252 253 254def _validate_arguments(arguments): 255 """Check command line arguments, and account for defaults. 256 257 Check that all command-line argument constraints are satisfied. 258 If errors are found, they are reported on `sys.stderr`. 259 260 If there are any fields with defined defaults that couldn't be 261 calculated when we constructed the argument parser, calculate 262 them now. 263 264 @param arguments Parsed results from 265 `ArgumentParser.parse_args()`. 266 @return Return `True` if there are no errors to report, or 267 `False` if there are. 268 """ 269 # If both hostnames and hostname_file are specified, complain about that. 270 if arguments.hostnames and arguments.hostname_file: 271 sys.stderr.write( 272 'DUT hostnames and hostname file both specified, only ' 273 'specify one or the other.\n') 274 return False 275 if (arguments.hostname_file and 276 not _is_hostname_file_valid(arguments.hostname_file)): 277 sys.stderr.write( 278 'Specified hostname file must exist and be non-empty.\n') 279 return False 280 if (not arguments.hostnames and not arguments.hostname_file and 281 (arguments.board or arguments.build)): 282 sys.stderr.write( 283 'DUT hostnames are required with board or build.\n') 284 return False 285 if arguments.board is not None: 286 if not _validate_board(arguments.board): 287 return False 288 if (arguments.build is not None and 289 not _validate_build(arguments.board, arguments.build)): 290 return False 291 return True 292 293 294def _read_with_prompt(input, prompt): 295 """Print a prompt and then read a line of text. 296 297 @param input File-like object from which to read the line. 298 @param prompt String to print to stderr prior to reading. 299 @return Returns a string, stripped of whitespace. 300 """ 301 full_prompt = '%s> ' % prompt 302 sys.stderr.write(full_prompt) 303 return input.readline().strip() 304 305 306def _read_board(input, default_board): 307 """Read a valid board name from user input. 308 309 Prompt the user to supply a board name, and read one line. If 310 the line names a valid board, return the board name. If the 311 line is blank and `default_board` is a non-empty string, returns 312 `default_board`. Retry until a valid input is obtained. 313 314 `default_board` isn't checked; the caller is responsible for 315 ensuring its validity. 316 317 @param input File-like object from which to read the 318 board. 319 @param default_board Value to return if the user enters a 320 blank line. 321 @return Returns `default_board` or a validated board name. 322 """ 323 if default_board: 324 board_prompt = 'board name [%s]' % default_board 325 else: 326 board_prompt = 'board name' 327 new_board = None 328 while not _validate_board(new_board): 329 new_board = _read_with_prompt(input, board_prompt).lower() 330 if new_board: 331 sys.stderr.write('Checking for valid board.\n') 332 elif default_board: 333 return default_board 334 return new_board 335 336 337def _read_build(input, board): 338 """Read a valid build version from user input. 339 340 Prompt the user to supply a build version, and read one line. 341 If the line names an existing version for the given board, 342 return the canonical build version. If the line is blank, 343 return `None` (indicating the build shouldn't change). 344 345 @param input File-like object from which to read the build. 346 @param board Board for the build. 347 @return Returns canonical build version, or `None`. 348 """ 349 build = False 350 prompt = 'build version (optional)' 351 while not build: 352 build = _read_with_prompt(input, prompt) 353 if not build: 354 return None 355 sys.stderr.write('Checking for valid build.\n') 356 build = _validate_build(board, build) 357 return build 358 359 360def _read_model(input, default_model): 361 """Read a valid model name from user input. 362 363 Prompt the user to supply a model name, and read one line. If 364 the line names a valid model, return the model name. If the 365 line is blank and `default_model` is a non-empty string, returns 366 `default_model`. Retry until a valid input is obtained. 367 368 `default_model` isn't checked; the caller is responsible for 369 ensuring its validity. 370 371 @param input File-like object from which to read the 372 model. 373 @param default_model Value to return if the user enters a 374 blank line. 375 @return Returns `default_model` or a model name. 376 """ 377 model_prompt = 'model name' 378 if default_model: 379 model_prompt += ' [%s]' % default_model 380 new_model = None 381 # TODO(guocb): create a real model validator 382 _validate_model = lambda x: x 383 384 while not _validate_model(new_model): 385 new_model = _read_with_prompt(input, model_prompt).lower() 386 if new_model: 387 sys.stderr.write("It's your responsiblity to ensure validity of " 388 "model name.\n") 389 elif default_model: 390 return default_model 391 return new_model 392 393 394def _read_hostnames(input): 395 """Read a list of host names from user input. 396 397 Prompt the user to supply a list of host names. Any number of 398 lines are allowed; input is terminated at the first blank line. 399 Any number of hosts names are allowed on one line. Names are 400 separated by whitespace. 401 402 Only valid host names are accepted. Invalid host names are 403 ignored, and a warning is printed. 404 405 @param input File-like object from which to read the names. 406 @return Returns a list of validated host names. 407 """ 408 hostnames = [] 409 y_n = 'yes' 410 while not 'no'.startswith(y_n): 411 sys.stderr.write('enter hosts (blank line to end):\n') 412 while True: 413 new_hosts = input.readline().strip().split() 414 if not new_hosts: 415 break 416 for h in new_hosts: 417 if _validate_hostname(h): 418 hostnames.append(h) 419 if not hostnames: 420 sys.stderr.write('Must provide at least one hostname.\n') 421 continue 422 prompt = 'More hosts? [y/N]' 423 y_n = _read_with_prompt(input, prompt).lower() or 'no' 424 return hostnames 425 426 427def _read_arguments(input, arguments): 428 """Dialog to read all needed arguments from the user. 429 430 The user is prompted in turn for a board, a build, a model, and 431 hostnames. Responses are stored in `arguments`. The user is 432 given opportunity to accept or reject the responses before 433 continuing. 434 435 @param input File-like object from which to read user 436 responses. 437 @param arguments Namespace object returned from 438 `ArgumentParser.parse_args()`. Results are 439 stored here. 440 """ 441 y_n = 'no' 442 while not 'yes'.startswith(y_n): 443 arguments.board = _read_board(input, arguments.board) 444 arguments.build = _read_build(input, arguments.board) 445 arguments.model = _read_model(input, arguments.model) 446 prompt = '%s build %s? [Y/n]' % ( 447 arguments.board, arguments.build) 448 y_n = _read_with_prompt(input, prompt).lower() or 'yes' 449 arguments.hostnames = _read_hostnames(input) 450 451 452def _parse_hostname_file_line(hostname_file_row): 453 """ 454 Parse a line from the hostname_file and return a dict of the info. 455 456 @param hostname_file_row: List of strings from each line in the hostname 457 file. 458 459 @returns a NamedTuple of (hostname, host_attr_dict). host_attr_dict is a 460 dict of host attributes for the host. 461 """ 462 if len(hostname_file_row) != _EXPECTED_NUMBER_OF_HOST_INFO: 463 raise Exception('hostname_file line has unexpected number of items ' 464 '%d (expect %d): %s' % 465 (len(hostname_file_row), _EXPECTED_NUMBER_OF_HOST_INFO, 466 hostname_file_row)) 467 # The file will have the info in the following order: 468 # 0: board 469 # 1: dut hostname 470 # 2: dut/v4 mac address 471 # 3: dut ip 472 # 4: labstation hostname 473 # 5: servo serial 474 # 6: servo mac address 475 # 7: servo ip 476 return HostInfo( 477 hostname=hostname_file_row[1], 478 host_attr_dict={servo_constants.SERVO_HOST_ATTR: hostname_file_row[4], 479 servo_constants.SERVO_SERIAL_ATTR: hostname_file_row[5]}) 480 481 482def _get_upload_basename(arguments): 483 """Get base name for logs upload. 484 485 @param arguments Namespace object returned from argument parsing. 486 @return A filename as a string. 487 """ 488 time_format = '%Y-%m-%dT%H%M%S.%f%z' 489 timestamp = datetime.datetime.now(dateutil.tz.tzlocal()).strftime( 490 time_format) 491 return '{time}-{board}'.format(time=timestamp, board=arguments.board) 492 493 494def _parse_hostname_file(hostname_file): 495 """ 496 Parse the hostname_file and return a list of dicts for each line. 497 498 @param hostname_file: CSV file that contains all the goodies. 499 500 @returns a list of dicts where each line is broken down into a dict. 501 """ 502 host_info_list = [] 503 # First line will be the header, no need to parse that. 504 first_line_skipped = False 505 with open(hostname_file) as f: 506 hostname_file_reader = csv.reader(f) 507 for row in hostname_file_reader: 508 if not first_line_skipped: 509 first_line_skipped = True 510 continue 511 host_info_list.append(_parse_hostname_file_line(row)) 512 513 return host_info_list 514 515 516def validate_arguments(arguments): 517 """Validate parsed arguments for a repair or deployment command. 518 519 The `arguments` parameter represents a `Namespace` object returned 520 by `cmdparse.parse_command()`. Check this for mandatory arguments; 521 if they're missing, execute a dialog with the user to read them from 522 `sys.stdin`. 523 524 Once all arguments are known to be filled in, validate the values, 525 and fill in additional information that couldn't be processed at 526 parsing time. 527 528 @param arguments Standard `Namespace` object as returned by 529 `cmdparse.parse_command()`. 530 """ 531 if not arguments.board or not arguments.model: 532 _read_arguments(sys.stdin, arguments) 533 elif not _validate_arguments(arguments): 534 return None 535 536 arguments.upload_basename = _get_upload_basename(arguments) 537 if not arguments.logdir: 538 arguments.logdir = os.path.join(os.environ['HOME'], 539 'Documents', 540 arguments.upload_basename) 541 os.makedirs(arguments.logdir) 542 elif not os.path.isdir(arguments.logdir): 543 os.mkdir(arguments.logdir) 544 545 if arguments.hostname_file: 546 # Populate arguments.hostnames with the hostnames from the file. 547 hostname_file_info_list = _parse_hostname_file(arguments.hostname_file) 548 arguments.hostnames = [host_info.hostname 549 for host_info in hostname_file_info_list] 550 arguments.host_info_list = hostname_file_info_list 551 else: 552 arguments.host_info_list = [] 553 return arguments 554