xref: /aosp_15_r20/external/autotest/site_utils/deployment/cmdvalidate.py (revision 9c5db1993ded3edbeafc8092d69fe5de2ee02df7)
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