Home | History | Annotate | Download | only in deployment
      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