• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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