• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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
7The argument processing is mostly a conventional client of
8`argparse`, except that if the command is invoked without required
9arguments, code here will start a line-oriented text dialog with the
10user to get the arguments.
11
12These 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
19The interactive dialog is invoked if the board and hostnames
20are omitted from the command line.
21
22"""
23
24import argparse
25import collections
26import csv
27import datetime
28import os
29import re
30import subprocess
31import sys
32
33import dateutil.tz
34
35import common
36from 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.
84HostInfo = collections.namedtuple('HostInfo', ['hostname', 'host_attr_dict'])
85
86
87def _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
116def _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
164def _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
190def _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
214def _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
237def _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
251def _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
291def _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
303def _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
334def _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
357def _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
390def _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
414def 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
425class _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
454def _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
488def _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
499def _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
529def 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
550def 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