xref: /illumos-gate/usr/src/tools/scripts/check_test_runfiles.py (revision 438aef901290936eef49f9b9d65327411a83c4f0)
1#!@TOOLS_PYTHON@ -Es
2#
3# This file and its contents are supplied under the terms of the
4# Common Development and Distribution License ("CDDL"), version 1.0.
5# You may only use this file in accordance with the terms of version
6# 1.0 of the CDDL.
7#
8# A full copy of the text of the CDDL should have accompanied this
9# source.  A copy of the CDDL is also available via the Internet at
10# http://www.illumos.org/license/CDDL.
11#
12
13#
14# Copyright 2026 Gordon W. Ross
15#
16
17#
18# Check test runfiles against installed test artifacts in proto.
19#
20# For each section in a runfile, verify that the test group path,
21# the tests (if listed), and any pre/post auxiliary scripts exist
22# in the proto area and are executable.  Auxiliary scripts are
23# allowed to be outside the test group's own directory.
24#
25# Note that some tests might not run on the target system for which
26# this workspace is building.  Some runfiles have test groups that
27# specify an architecture like "arch=i86pc" and run only there.
28# A runfile test group runs on the target if the "arch=" value is
29# absent, or if the "arch" value matches the target architecture.
30#
31# For the build-time checks this runs, assume the build has all
32# architectures for a given MACH (eg. i386/amd64 builds for all of
33# i86pc i86xpv intel) and check for existence of test programs
34# when the runfile arch matches any of the architectures that
35# should exist in this build.
36
37import argparse
38import ast
39import configparser
40import io
41import os
42import posixpath
43import re
44import sys
45import tokenize
46
47SECTION_RE = re.compile(r'^\s*\[(.+?)\]\s*$')
48
49# Maps MACH (build ISA) to the set of architecture names that may appear
50# in runfile "arch=" properties for that build.  Derived from the
51# *_ARCHITECTURES variables in usr/src/uts/Makefile.
52MACH_ARCHITECTURES = {
53    'i386':  {'i86pc', 'i86xpv', 'intel'},
54    'sparc': {'sun4v', 'sun4u', 'sparc'},
55}
56
57
58def parse_args():
59    parser = argparse.ArgumentParser(
60        description='Validate test runfiles against proto/root_i386 installs')
61    parser.add_argument(
62        '-R', dest='root', default=os.environ.get('ROOT'),
63        help='proto root path (for example, .../proto/root_i386); '
64             'default: $ROOT')
65    parser.add_argument(
66        '-T', dest='testroot', required=True,
67        help='test root relative to proto root (for example, opt/os-tests)')
68    parser.add_argument(
69        '-m', '--mach', default=None,
70        help='target CPU type (for example, i386 or sparc); used to determine '
71             'which arch-constrained sections to check')
72    parser.add_argument(
73        'runfiles', nargs='+',
74        help='runfile paths to check')
75
76    args = parser.parse_args()
77    if args.root is None:
78        parser.error('missing proto root: set -R <root> or the ROOT '
79                     'environment variable')
80    return args
81
82
83def find_runfiles(args):
84    return list(args.runfiles)
85
86
87def normalize_testroot(testroot):
88    root = testroot.strip()
89    if not root:
90        return None
91    if root.startswith('/'):
92        return None
93    root = posixpath.normpath(root)
94    if root in ('.', '..') or root.startswith('../'):
95        return None
96    return root
97
98
99def section_path(section, testroot):
100    raw = section.split(':', 1)[0].strip()
101    if raw.startswith('/'):
102        return posixpath.normpath(raw)
103    return posixpath.normpath(posixpath.join('/', testroot, raw))
104
105
106def section_raw_path(section):
107    return section.split(':', 1)[0].strip()
108
109
110def is_test_file(path):
111    return (os.path.isfile(path) and not os.path.islink(path) and
112            os.access(path, os.X_OK))
113
114
115def has_adjacent_string_literals(expr):
116    try:
117        toks = tokenize.generate_tokens(io.StringIO(expr).readline)
118    except tokenize.TokenError:
119        return False
120
121    prev = None
122    ignored = set([tokenize.NL, tokenize.NEWLINE, tokenize.INDENT,
123                   tokenize.DEDENT, tokenize.COMMENT, tokenize.ENDMARKER])
124    for tok in toks:
125        if tok.type in ignored:
126            continue
127        if tok.type == tokenize.STRING and prev == tokenize.STRING:
128            return True
129        prev = tok.type
130    return False
131
132
133def section_line_map(content):
134    lines = {}
135    for lineno, line in enumerate(content.splitlines(), 1):
136        match = SECTION_RE.match(line)
137        if match is None:
138            continue
139        section = match.group(1).strip()
140        if section not in lines:
141            lines[section] = lineno
142    return lines
143
144
145def format_error(runfile, lineno, test_group, detail):
146    loc = '%s:%s' % (runfile, lineno)
147    return 'In test group %s, %s: %s' % (test_group, loc, detail)
148
149
150def emit_error(issues, runfile, lineno, test_group, detail):
151    issues.append(format_error(runfile, lineno, test_group, detail))
152
153
154def check_runfile(runfile, testroot, protoroot, archlist):
155    issues = []
156    config = configparser.RawConfigParser()
157    content = None
158
159    try:
160        with open(runfile, encoding='utf-8') as f:
161            content = f.read()
162    except OSError as err:
163        return ['%s: failed to read runfile: %s' % (runfile, err)]
164
165    section_lines = section_line_map(content)
166
167    try:
168        config.read_string(content, source=runfile)
169    except configparser.Error as err:
170        return ['%s: parse error: %s' % (os.path.basename(runfile), err)]
171
172    for sec in config.sections():
173        lineno = section_lines.get(sec, '?')
174        if section_raw_path(sec).upper() == 'DEFAULT':
175            continue
176        if config.has_option(sec, 'arch'):
177            sec_arch = config.get(sec, 'arch').strip()
178            if sec_arch not in archlist:
179                continue
180        # else arch not specified so do checks.
181
182        secpath = section_path(sec, testroot)
183        proto_path = os.path.join(protoroot, secpath.lstrip('/'))
184        has_tests = config.has_option(sec, 'tests')
185        has_autotests = config.has_option(sec, 'autotests')
186
187        if has_tests or has_autotests:
188            path_exists = os.path.exists(proto_path)
189            path_is_dir = os.path.isdir(proto_path)
190            path_is_file = os.path.isfile(proto_path)
191
192            if not path_exists:
193                emit_error(issues, runfile, lineno, secpath,
194                           'test group path not found in proto area.')
195            elif path_is_file:
196                if has_tests:
197                    emit_error(
198                        issues, runfile, lineno, secpath,
199                        'test group path is a file and should not have a '
200                        'tests property.')
201                else:
202                    emit_error(
203                        issues, runfile, lineno, secpath,
204                        'test group path is a file and should not have an '
205                        'autotests property.')
206
207            if has_tests:
208                tests_raw = config.get(sec, 'tests')
209                if has_adjacent_string_literals(tests_raw):
210                    emit_error(
211                        issues, runfile, lineno, secpath,
212                        'tests list contains adjacent string literals '
213                        '(possible missing comma).')
214
215                try:
216                    tests = ast.literal_eval(tests_raw)
217                except (SyntaxError, ValueError) as err:
218                    emit_error(
219                        issues, runfile, lineno, secpath,
220                        'tests is not a valid Python list: %s' % err)
221                    continue
222
223                if not isinstance(tests, list):
224                    emit_error(issues, runfile, lineno, secpath,
225                               'tests must evaluate to a list.')
226                    continue
227
228                bad = [repr(x) for x in tests if not isinstance(x, str)]
229                if bad:
230                    emit_error(issues, runfile, lineno, secpath,
231                               'tests must contain only strings: %s' %
232                               ', '.join(bad))
233                    continue
234
235                if not path_is_dir:
236                    continue
237
238                for test in tests:
239                    tpath = os.path.join(proto_path, test)
240                    if not is_test_file(tpath):
241                        emit_error(issues, runfile, lineno, secpath,
242                                   'test %s not found in proto area.' % test)
243        else:
244            if not is_test_file(proto_path):
245                emit_error(issues, runfile, lineno, secpath,
246                           'single test path not found in proto area.')
247
248        # Check pre/post auxiliary scripts. They are allowed to live in any
249        # directory, so we only verify they exist in the proto area.
250        aux_dir = secpath if (has_tests or has_autotests) \
251            else posixpath.dirname(secpath)
252        for prop in ('pre', 'post'):
253            if not config.has_option(sec, prop):
254                continue
255            val = config.get(sec, prop).strip()
256            if not val:
257                continue
258            if posixpath.isabs(val):
259                apath = os.path.join(protoroot, val.lstrip('/'))
260            else:
261                apath = os.path.join(protoroot, aux_dir.lstrip('/'), val)
262            if not is_test_file(apath):
263                emit_error(issues, runfile, lineno, secpath,
264                           '%s script not found in proto area: %s' %
265                           (prop, val))
266
267    return issues
268
269
270def main():
271    args = parse_args()
272    protoroot = os.path.abspath(os.path.expanduser(args.root))
273    testroot = normalize_testroot(args.testroot)
274
275    if not os.path.isdir(protoroot):
276        sys.stderr.write('error: proto root not found: %s\n' % protoroot)
277        return 2
278
279    if not os.path.isdir(os.path.join(protoroot, 'opt')):
280        sys.stderr.write('error: invalid proto root (missing opt/): %s\n' %
281                         protoroot)
282        return 2
283
284    if testroot is None:
285        sys.stderr.write('error: invalid -T value (must be a relative path '
286                         'under proto root): %s\n' % args.testroot)
287        return 2
288
289    testroot_path = os.path.join(protoroot, testroot)
290    if not os.path.isdir(testroot_path):
291        sys.stderr.write('error: test root not found under proto root: %s\n' %
292                         testroot_path)
293        return 2
294
295    runfiles = find_runfiles(args)
296    archlist = MACH_ARCHITECTURES.get(args.mach, set())
297    issues = []
298    for runfile in runfiles:
299        issues.extend(check_runfile(runfile, testroot, protoroot, archlist))
300
301    for issue in issues:
302        sys.stderr.write('%s\n' % issue)
303
304    return 1 if issues else 0
305
306
307if __name__ == '__main__':
308    sys.exit(main())
309