1#!/usr/bin/env python3 2# Copyright 2019, The Android Open Source Project 3# 4# Licensed under the Apache License, Version 2.0 (the "License"); 5# you may not use this file except in compliance with the License. 6# You may obtain a copy of the License at 7# 8# http://www.apache.org/licenses/LICENSE-2.0 9# 10# Unless required by applicable law or agreed to in writing, software 11# distributed under the License is distributed on an "AS IS" BASIS, 12# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13# See the License for the specific language governing permissions and 14# limitations under the License. 15 16"""Atest tool functions.""" 17 18# pylint: disable=line-too-long 19 20from __future__ import print_function 21 22import json 23import logging 24import os 25import pickle 26import shutil 27import subprocess 28import sys 29import time 30 31import atest_utils as au 32import constants 33import module_info 34 35from metrics import metrics_utils 36 37MAC_UPDB_SRC = os.path.join(os.path.dirname(__file__), 'updatedb_darwin.sh') 38MAC_UPDB_DST = os.path.join(os.getenv(constants.ANDROID_HOST_OUT, ''), 'bin') 39UPDATEDB = 'updatedb' 40LOCATE = 'locate' 41ACLOUD_DURATION = 'duration' 42SEARCH_TOP = os.getenv(constants.ANDROID_BUILD_TOP, '') 43MACOSX = 'Darwin' 44OSNAME = os.uname()[0] 45# When adding new index, remember to append constants to below tuple. 46INDEXES = (constants.CC_CLASS_INDEX, 47 constants.CLASS_INDEX, 48 constants.LOCATE_CACHE, 49 constants.MODULE_INDEX, 50 constants.PACKAGE_INDEX, 51 constants.QCLASS_INDEX) 52 53# The list was generated by command: 54# find `gettop` -type d -wholename `gettop`/out -prune -o -type d -name '.*' 55# -print | awk -F/ '{{print $NF}}'| sort -u 56PRUNENAMES = ['.abc', '.appveyor', '.azure-pipelines', 57 '.bazelci', '.buildscript', 58 '.cache', '.ci', '.circleci', '.conan', '.config', 59 '.externalToolBuilders', 60 '.git', '.github', '.gitlab-ci', '.google', '.gradle', 61 '.idea', '.intermediates', 62 '.jenkins', 63 '.kokoro', 64 '.libs_cffi_backend', 65 '.mvn', 66 '.prebuilt_info', '.private', '__pycache__', 67 '.repo', 68 '.semaphore', '.settings', '.static', '.svn', 69 '.test', '.travis', '.travis_scripts', '.tx', 70 '.vscode'] 71 72def _mkdir_when_inexists(dirname): 73 if not os.path.isdir(dirname): 74 os.makedirs(dirname) 75 76def _install_updatedb(): 77 """Install a customized updatedb for MacOS and ensure it is executable.""" 78 _mkdir_when_inexists(MAC_UPDB_DST) 79 _mkdir_when_inexists(constants.INDEX_DIR) 80 if OSNAME == MACOSX: 81 shutil.copy2(MAC_UPDB_SRC, os.path.join(MAC_UPDB_DST, UPDATEDB)) 82 os.chmod(os.path.join(MAC_UPDB_DST, UPDATEDB), 0o0755) 83 84def _delete_indexes(): 85 """Delete all available index files.""" 86 for index in INDEXES: 87 if os.path.isfile(index): 88 os.remove(index) 89 90def get_report_file(results_dir, acloud_args): 91 """Get the acloud report file path. 92 93 This method can parse either string: 94 --acloud-create '--report-file=/tmp/acloud.json' 95 --acloud-create '--report-file /tmp/acloud.json' 96 and return '/tmp/acloud.json' as the report file. Otherwise returning the 97 default path(/tmp/atest_result/<hashed_dir>/acloud_status.json). 98 99 Args: 100 results_dir: string of directory to store atest results. 101 acloud_args: string of acloud create. 102 103 Returns: 104 A string path of acloud report file. 105 """ 106 match = constants.ACLOUD_REPORT_FILE_RE.match(acloud_args) 107 if match: 108 return match.group('report_file') 109 return os.path.join(results_dir, 'acloud_status.json') 110 111def has_command(cmd): 112 """Detect if the command is available in PATH. 113 114 Args: 115 cmd: A string of the tested command. 116 117 Returns: 118 True if found, False otherwise. 119 """ 120 return bool(shutil.which(cmd)) 121 122def run_updatedb(search_root=SEARCH_TOP, output_cache=constants.LOCATE_CACHE, 123 **kwargs): 124 """Run updatedb and generate cache in $ANDROID_HOST_OUT/indexes/mlocate.db 125 126 Args: 127 search_root: The path of the search root(-U). 128 output_cache: The filename of the updatedb cache(-o). 129 kwargs: (optional) 130 prunepaths: A list of paths unwanted to be searched(-e). 131 prunenames: A list of dirname that won't be cached(-n). 132 """ 133 prunenames = kwargs.pop('prunenames', ' '.join(PRUNENAMES)) 134 prunepaths = kwargs.pop('prunepaths', os.path.join(search_root, 'out')) 135 if kwargs: 136 raise TypeError('Unexpected **kwargs: %r' % kwargs) 137 updatedb_cmd = [UPDATEDB, '-l0'] 138 updatedb_cmd.append('-U%s' % search_root) 139 updatedb_cmd.append('-e%s' % prunepaths) 140 updatedb_cmd.append('-n%s' % prunenames) 141 updatedb_cmd.append('-o%s' % output_cache) 142 try: 143 _install_updatedb() 144 except IOError as e: 145 logging.error('Error installing updatedb: %s', e) 146 147 if not has_command(UPDATEDB): 148 return 149 logging.debug('Running updatedb... ') 150 try: 151 full_env_vars = os.environ.copy() 152 logging.debug('Executing: %s', updatedb_cmd) 153 if subprocess.check_call(updatedb_cmd, env=full_env_vars) == 0: 154 au.save_md5([constants.LOCATE_CACHE], constants.LOCATE_CACHE_MD5) 155 except (KeyboardInterrupt, SystemExit): 156 logging.error('Process interrupted or failure.') 157 158def _dump_index(dump_file, output, output_re, key, value): 159 """Dump indexed data with pickle. 160 161 Args: 162 dump_file: A string of absolute path of the index file. 163 output: A string generated by locate and grep. 164 output_re: An regex which is used for grouping patterns. 165 key: A string for dictionary key, e.g. classname, package, 166 cc_class, etc. 167 value: A set of path. 168 169 The data structure will be like: 170 { 171 'Foo': {'/path/to/Foo.java', '/path2/to/Foo.kt'}, 172 'Boo': {'/path3/to/Boo.java'} 173 } 174 """ 175 _dict = {} 176 with open(dump_file, 'wb') as cache_file: 177 if isinstance(output, bytes): 178 output = output.decode() 179 for entry in output.splitlines(): 180 match = output_re.match(entry) 181 if match: 182 _dict.setdefault(match.group(key), set()).add( 183 match.group(value)) 184 try: 185 pickle.dump(_dict, cache_file, protocol=2) 186 logging.debug('Done') 187 except IOError: 188 os.remove(dump_file) 189 logging.error('Failed in dumping %s', dump_file) 190 191def _get_cc_result(locatedb=None): 192 """Search all testable cc/cpp and grep TEST(), TEST_F() or TEST_P(). 193 194 Returns: 195 A string object generated by subprocess. 196 """ 197 if not locatedb: 198 locatedb = constants.LOCATE_CACHE 199 cc_grep_re = r'^\s*TEST(_P|_F)?\s*\(\w+,' 200 if OSNAME == MACOSX: 201 find_cmd = (r"locate -d {0} '*.cpp' '*.cc' | grep -i test " 202 "| xargs egrep -sH '{1}' || true") 203 else: 204 find_cmd = (r"locate -d {0} / | egrep -i '/*.test.*\.(cc|cpp)$' " 205 "| xargs egrep -sH '{1}' || true") 206 find_cc_cmd = find_cmd.format(locatedb, cc_grep_re) 207 logging.debug('Probing CC classes:\n %s', find_cc_cmd) 208 return subprocess.check_output(find_cc_cmd, shell=True) 209 210def _get_java_result(locatedb=None): 211 """Search all testable java/kt and grep package. 212 213 Returns: 214 A string object generated by subprocess. 215 """ 216 if not locatedb: 217 locatedb = constants.LOCATE_CACHE 218 package_grep_re = r'^\s*package\s+[a-z][[:alnum:]]+[^{]' 219 if OSNAME == MACOSX: 220 find_cmd = r"locate -d%s '*.java' '*.kt'|grep -i test" % locatedb 221 else: 222 find_cmd = r"locate -d%s / | egrep -i '/*.test.*\.(java|kt)$'" % locatedb 223 find_java_cmd = find_cmd + '| xargs egrep -sH \'%s\' || true' % package_grep_re 224 logging.debug('Probing Java classes:\n %s', find_java_cmd) 225 return subprocess.check_output(find_java_cmd, shell=True) 226 227def _index_testable_modules(index): 228 """Dump testable modules read by tab completion. 229 230 Args: 231 index: A string path of the index file. 232 """ 233 logging.debug('indexing testable modules.') 234 try: 235 # b/178559543 The module-info.json becomes invalid after a success build is 236 # unlikely to happen, wrap with a try-catch to prevent it from happening. 237 testable_modules = module_info.ModuleInfo().get_testable_modules() 238 except json.JSONDecodeError: 239 logging.error('Invalid module-info.json detected. Will not index modules.') 240 return 241 with open(index, 'wb') as cache: 242 try: 243 pickle.dump(testable_modules, cache, protocol=2) 244 logging.debug('Done') 245 except IOError: 246 os.remove(cache) 247 logging.error('Failed in dumping %s', cache) 248 249def _index_cc_classes(output, index): 250 """Index CC classes. 251 252 The data structure is like: 253 { 254 'FooTestCase': {'/path1/to/the/FooTestCase.cpp', 255 '/path2/to/the/FooTestCase.cc'} 256 } 257 258 Args: 259 output: A string object generated by _get_cc_result(). 260 index: A string path of the index file. 261 """ 262 logging.debug('indexing CC classes.') 263 _dump_index(dump_file=index, output=output, 264 output_re=constants.CC_OUTPUT_RE, 265 key='test_name', value='file_path') 266 267def _index_java_classes(output, index): 268 """Index Java classes. 269 The data structure is like: 270 { 271 'FooTestCase': {'/path1/to/the/FooTestCase.java', 272 '/path2/to/the/FooTestCase.kt'} 273 } 274 275 Args: 276 output: A string object generated by _get_java_result(). 277 index: A string path of the index file. 278 """ 279 logging.debug('indexing Java classes.') 280 _dump_index(dump_file=index, output=output, 281 output_re=constants.CLASS_OUTPUT_RE, 282 key='class', value='java_path') 283 284def _index_packages(output, index): 285 """Index Java packages. 286 The data structure is like: 287 { 288 'a.b.c.d': {'/path1/to/a/b/c/d/', 289 '/path2/to/a/b/c/d/' 290 } 291 292 Args: 293 output: A string object generated by _get_java_result(). 294 index: A string path of the index file. 295 """ 296 logging.debug('indexing packages.') 297 _dump_index(dump_file=index, 298 output=output, output_re=constants.PACKAGE_OUTPUT_RE, 299 key='package', value='java_dir') 300 301def _index_qualified_classes(output, index): 302 """Index Fully Qualified Java Classes(FQCN). 303 The data structure is like: 304 { 305 'a.b.c.d.FooTestCase': {'/path1/to/a/b/c/d/FooTestCase.java', 306 '/path2/to/a/b/c/d/FooTestCase.kt'} 307 } 308 309 Args: 310 output: A string object generated by _get_java_result(). 311 index: A string path of the index file. 312 """ 313 logging.debug('indexing qualified classes.') 314 _dict = {} 315 with open(index, 'wb') as cache_file: 316 if isinstance(output, bytes): 317 output = output.decode() 318 for entry in output.split('\n'): 319 match = constants.QCLASS_OUTPUT_RE.match(entry) 320 if match: 321 fqcn = match.group('package') + '.' + match.group('class') 322 _dict.setdefault(fqcn, set()).add(match.group('java_path')) 323 try: 324 pickle.dump(_dict, cache_file, protocol=2) 325 logging.debug('Done') 326 except (KeyboardInterrupt, SystemExit): 327 logging.error('Process interrupted or failure.') 328 os.remove(index) 329 except IOError: 330 logging.error('Failed in dumping %s', index) 331 332def index_targets(output_cache=constants.LOCATE_CACHE, **kwargs): 333 """The entrypoint of indexing targets. 334 335 Utilise mlocate database to index reference types of CLASS, CC_CLASS, 336 PACKAGE and QUALIFIED_CLASS. Testable module for tab completion is also 337 generated in this method. 338 339 Args: 340 output_cache: A file path of the updatedb cache 341 (e.g. /path/to/mlocate.db). 342 kwargs: (optional) 343 class_index: A path string of the Java class index. 344 qclass_index: A path string of the qualified class index. 345 package_index: A path string of the package index. 346 cc_class_index: A path string of the CC class index. 347 module_index: A path string of the testable module index. 348 integration_index: A path string of the integration index. 349 """ 350 class_index = kwargs.pop('class_index', constants.CLASS_INDEX) 351 qclass_index = kwargs.pop('qclass_index', constants.QCLASS_INDEX) 352 package_index = kwargs.pop('package_index', constants.PACKAGE_INDEX) 353 cc_class_index = kwargs.pop('cc_class_index', constants.CC_CLASS_INDEX) 354 module_index = kwargs.pop('module_index', constants.MODULE_INDEX) 355 # Uncomment below if we decide to support INTEGRATION. 356 #integration_index = kwargs.pop('integration_index', constants.INT_INDEX) 357 if kwargs: 358 raise TypeError('Unexpected **kwargs: %r' % kwargs) 359 360 try: 361 # Step 0: generate mlocate database prior to indexing targets. 362 run_updatedb(SEARCH_TOP, constants.LOCATE_CACHE) 363 if not has_command(LOCATE): 364 return 365 # Step 1: generate output string for indexing targets. 366 logging.debug('Indexing targets... ') 367 cc_result = _get_cc_result(output_cache) 368 java_result = _get_java_result(output_cache) 369 # Step 2: index Java and CC classes. 370 _index_cc_classes(cc_result, cc_class_index) 371 _index_java_classes(java_result, class_index) 372 _index_qualified_classes(java_result, qclass_index) 373 _index_packages(java_result, package_index) 374 # Step 3: index testable mods and TEST_MAPPING files. 375 _index_testable_modules(module_index) 376 377 # Delete indexes when mlocate.db is locked() or other CalledProcessError. 378 # (b/141588997) 379 except subprocess.CalledProcessError as err: 380 logging.error('Executing %s error.', UPDATEDB) 381 metrics_utils.handle_exc_and_send_exit_event( 382 constants.MLOCATEDB_LOCKED) 383 if err.output: 384 logging.error(err.output) 385 _delete_indexes() 386 387# pylint: disable=consider-using-with 388# TODO: b/187122993 refine subprocess with 'with-statement' in fixit week. 389def acloud_create(report_file, args="", no_metrics_notice=True): 390 """Method which runs acloud create with specified args in background. 391 392 Args: 393 report_file: A path string of acloud report file. 394 args: A string of arguments. 395 no_metrics_notice: Boolean whether sending data to metrics or not. 396 """ 397 notice = constants.NO_METRICS_ARG if no_metrics_notice else "" 398 match = constants.ACLOUD_REPORT_FILE_RE.match(args) 399 report_file_arg = '--report-file={}'.format(report_file) if not match else "" 400 # (b/161759557) Assume yes for acloud create to streamline atest flow. 401 acloud_cmd = ('acloud create -y {ACLOUD_ARGS} ' 402 '{REPORT_FILE_ARG} ' 403 '{METRICS_NOTICE} ' 404 ).format(ACLOUD_ARGS=args, 405 REPORT_FILE_ARG=report_file_arg, 406 METRICS_NOTICE=notice) 407 au.colorful_print("\nCreating AVD via acloud...", constants.CYAN) 408 logging.debug('Executing: %s', acloud_cmd) 409 start = time.time() 410 proc = subprocess.Popen(acloud_cmd, shell=True) 411 proc.communicate() 412 acloud_duration = time.time() - start 413 logging.info('"acloud create" process has completed.') 414 # Insert acloud create duration into the report file. 415 if au.is_valid_json_file(report_file): 416 try: 417 with open(report_file, 'r') as _rfile: 418 result = json.load(_rfile) 419 result[ACLOUD_DURATION] = acloud_duration 420 with open(report_file, 'w+') as _wfile: 421 _wfile.write(json.dumps(result)) 422 except OSError as e: 423 logging.error("Failed dumping duration to the report file: %s", str(e)) 424 425def probe_acloud_status(report_file): 426 """Method which probes the 'acloud create' result status. 427 428 If the report file exists and the status is 'SUCCESS', then the creation is 429 successful. 430 431 Args: 432 report_file: A path string of acloud report file. 433 434 Returns: 435 0: success. 436 8: acloud creation failure. 437 9: invalid acloud create arguments. 438 """ 439 # 1. Created but the status is not 'SUCCESS' 440 if os.path.exists(report_file): 441 if not au.is_valid_json_file(report_file): 442 return constants.EXIT_CODE_AVD_CREATE_FAILURE 443 with open(report_file, 'r') as rfile: 444 result = json.load(rfile) 445 446 if result.get('status') == 'SUCCESS': 447 logging.info('acloud create successfully!') 448 # Always fetch the adb of the first created AVD. 449 adb_port = result.get('data').get('devices')[0].get('adb_port') 450 os.environ[constants.ANDROID_SERIAL] = '127.0.0.1:{}'.format(adb_port) 451 return constants.EXIT_CODE_SUCCESS 452 au.colorful_print( 453 'acloud create failed. Please check\n{}\nfor detail'.format( 454 report_file), constants.RED) 455 return constants.EXIT_CODE_AVD_CREATE_FAILURE 456 457 # 2. Failed to create because of invalid acloud arguments. 458 logging.error('Invalid acloud arguments found!') 459 return constants.EXIT_CODE_AVD_INVALID_ARGS 460 461def get_acloud_duration(report_file): 462 """Method which gets the duration of 'acloud create' from a report file. 463 464 Args: 465 report_file: A path string of acloud report file. 466 467 Returns: 468 An float of seconds which acloud create takes. 469 """ 470 if not au.is_valid_json_file(report_file): 471 return 0 472 with open(report_file, 'r') as rfile: 473 return json.load(rfile).get(ACLOUD_DURATION, 0) 474 475 476if __name__ == '__main__': 477 if not os.getenv(constants.ANDROID_HOST_OUT, ''): 478 sys.exit() 479 index_targets() 480