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