• 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 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