From a8e328f4741b6d3a9513c2b3f79d0f721c2596ce Mon Sep 17 00:00:00 2001 From: Matt Clay Date: Wed, 13 Mar 2019 07:14:12 -0700 Subject: [PATCH] Overhaul ansible-test code coverage and injector. (#53510) --- test/integration/inventory | 3 +- test/runner/injector/ansible | 2 +- test/runner/injector/ansible-config | 2 +- test/runner/injector/ansible-connection | 2 +- test/runner/injector/ansible-console | 2 +- test/runner/injector/ansible-doc | 2 +- test/runner/injector/ansible-galaxy | 2 +- test/runner/injector/ansible-inventory | 2 +- test/runner/injector/ansible-playbook | 2 +- test/runner/injector/ansible-pull | 2 +- test/runner/injector/ansible-vault | 2 +- test/runner/injector/importer.py | 2 +- test/runner/injector/injector.py | 245 ------------------------ test/runner/injector/pytest | 2 +- test/runner/injector/python.py | 64 ++++++- test/runner/lib/executor.py | 51 +++-- test/runner/lib/integration/__init__.py | 28 +++ test/runner/lib/sanity/import.py | 5 +- test/runner/lib/util.py | 203 +++++++++++--------- 19 files changed, 253 insertions(+), 370 deletions(-) delete mode 100755 test/runner/injector/injector.py mode change 120000 => 100755 test/runner/injector/python.py diff --git a/test/integration/inventory b/test/integration/inventory index 4c2e0d1e86c..1b77a7ea714 100644 --- a/test/integration/inventory +++ b/test/integration/inventory @@ -2,4 +2,5 @@ # For script based test targets (using runme.sh) put the inventory file in the test's directory instead. [testgroup] -testhost ansible_connection=local +# ansible_python_interpreter must be set to avoid interpreter discovery +testhost ansible_connection=local ansible_python_interpreter="{{ ansible_playbook_python }}" diff --git a/test/runner/injector/ansible b/test/runner/injector/ansible index 1f9d09cbf2a..6bbbfe4d919 120000 --- a/test/runner/injector/ansible +++ b/test/runner/injector/ansible @@ -1 +1 @@ -injector.py \ No newline at end of file +python.py \ No newline at end of file diff --git a/test/runner/injector/ansible-config b/test/runner/injector/ansible-config index 1f9d09cbf2a..6bbbfe4d919 120000 --- a/test/runner/injector/ansible-config +++ b/test/runner/injector/ansible-config @@ -1 +1 @@ -injector.py \ No newline at end of file +python.py \ No newline at end of file diff --git a/test/runner/injector/ansible-connection b/test/runner/injector/ansible-connection index 1f9d09cbf2a..6bbbfe4d919 120000 --- a/test/runner/injector/ansible-connection +++ b/test/runner/injector/ansible-connection @@ -1 +1 @@ -injector.py \ No newline at end of file +python.py \ No newline at end of file diff --git a/test/runner/injector/ansible-console b/test/runner/injector/ansible-console index 1f9d09cbf2a..6bbbfe4d919 120000 --- a/test/runner/injector/ansible-console +++ b/test/runner/injector/ansible-console @@ -1 +1 @@ -injector.py \ No newline at end of file +python.py \ No newline at end of file diff --git a/test/runner/injector/ansible-doc b/test/runner/injector/ansible-doc index 1f9d09cbf2a..6bbbfe4d919 120000 --- a/test/runner/injector/ansible-doc +++ b/test/runner/injector/ansible-doc @@ -1 +1 @@ -injector.py \ No newline at end of file +python.py \ No newline at end of file diff --git a/test/runner/injector/ansible-galaxy b/test/runner/injector/ansible-galaxy index 1f9d09cbf2a..6bbbfe4d919 120000 --- a/test/runner/injector/ansible-galaxy +++ b/test/runner/injector/ansible-galaxy @@ -1 +1 @@ -injector.py \ No newline at end of file +python.py \ No newline at end of file diff --git a/test/runner/injector/ansible-inventory b/test/runner/injector/ansible-inventory index 1f9d09cbf2a..6bbbfe4d919 120000 --- a/test/runner/injector/ansible-inventory +++ b/test/runner/injector/ansible-inventory @@ -1 +1 @@ -injector.py \ No newline at end of file +python.py \ No newline at end of file diff --git a/test/runner/injector/ansible-playbook b/test/runner/injector/ansible-playbook index 1f9d09cbf2a..6bbbfe4d919 120000 --- a/test/runner/injector/ansible-playbook +++ b/test/runner/injector/ansible-playbook @@ -1 +1 @@ -injector.py \ No newline at end of file +python.py \ No newline at end of file diff --git a/test/runner/injector/ansible-pull b/test/runner/injector/ansible-pull index 1f9d09cbf2a..6bbbfe4d919 120000 --- a/test/runner/injector/ansible-pull +++ b/test/runner/injector/ansible-pull @@ -1 +1 @@ -injector.py \ No newline at end of file +python.py \ No newline at end of file diff --git a/test/runner/injector/ansible-vault b/test/runner/injector/ansible-vault index 1f9d09cbf2a..6bbbfe4d919 120000 --- a/test/runner/injector/ansible-vault +++ b/test/runner/injector/ansible-vault @@ -1 +1 @@ -injector.py \ No newline at end of file +python.py \ No newline at end of file diff --git a/test/runner/injector/importer.py b/test/runner/injector/importer.py index 1f9d09cbf2a..6bbbfe4d919 120000 --- a/test/runner/injector/importer.py +++ b/test/runner/injector/importer.py @@ -1 +1 @@ -injector.py \ No newline at end of file +python.py \ No newline at end of file diff --git a/test/runner/injector/injector.py b/test/runner/injector/injector.py deleted file mode 100755 index d9a66cb91dc..00000000000 --- a/test/runner/injector/injector.py +++ /dev/null @@ -1,245 +0,0 @@ -#!/usr/bin/env python -"""Interpreter and code coverage injector for use with ansible-test. - -The injector serves two main purposes: - -1) Control the python interpreter used to run test tools and ansible code. -2) Provide optional code coverage analysis of ansible code. - -The injector is executed one of two ways: - -1) On the controller via a symbolic link such as ansible or pytest. - This is accomplished by prepending the injector directory to the PATH by ansible-test. - -2) As the python interpreter when running ansible modules. - This is only supported when connecting to the local host. - Otherwise set the ANSIBLE_TEST_REMOTE_INTERPRETER environment variable. - It can be empty to auto-detect the python interpreter on the remote host. - If not empty it will be used to set ansible_python_interpreter. - -NOTE: Running ansible-test with the --tox option or inside a virtual environment - may prevent the injector from working for tests which use connection - types other than local, or which use become, due to lack of permissions - to access the interpreter for the virtual environment. -""" - -from __future__ import absolute_import, print_function - -import json -import os -import sys -import pipes -import logging -import getpass -import resource - -logger = logging.getLogger('injector') # pylint: disable=locally-disabled, invalid-name -# pylint: disable=locally-disabled, invalid-name -config = None # type: InjectorConfig - - -class InjectorConfig(object): - """Mandatory configuration.""" - def __init__(self, config_path): - """Initialize config.""" - with open(config_path) as config_fd: - _config = json.load(config_fd) - - self.python_interpreter = _config['python_interpreter'] - self.coverage_file = _config['coverage_file'] - - # Read from the environment instead of config since it needs to be changed by integration test scripts. - # It also does not need to flow from the controller to the remote. It is only used on the controller. - self.remote_interpreter = os.environ.get('ANSIBLE_TEST_REMOTE_INTERPRETER', None) - - self.arguments = [to_text(c) for c in sys.argv] - - -def to_text(value): - """ - :type value: str | None - :rtype: str | None - """ - if value is None: - return None - - if isinstance(value, bytes): - return value.decode('utf-8') - - return u'%s' % value - - -def main(): - """Main entry point.""" - global config # pylint: disable=locally-disabled, global-statement - - formatter = logging.Formatter('%(asctime)s %(process)d %(levelname)s %(message)s') - log_name = 'ansible-test-coverage.%s.log' % getpass.getuser() - self_dir = os.path.dirname(os.path.abspath(__file__)) - - handler = logging.FileHandler(os.path.join('/tmp', log_name)) - handler.setFormatter(formatter) - logger.addHandler(handler) - - handler = logging.FileHandler(os.path.abspath(os.path.join(self_dir, '..', 'logs', log_name))) - handler.setFormatter(formatter) - logger.addHandler(handler) - - logger.setLevel(logging.DEBUG) - - try: - logger.debug('Self: %s', __file__) - - # to achieve a consistent nofile ulimit, set to 16k here, this can affect performance in subprocess.Popen when - # being called with close_fds=True on Python (8x the time on some environments) - nofile_limit = 16 * 1024 - current_limit = resource.getrlimit(resource.RLIMIT_NOFILE) - new_limit = (nofile_limit, nofile_limit) - if current_limit > new_limit: - logger.debug('RLIMIT_NOFILE: %s -> %s', current_limit, new_limit) - resource.setrlimit(resource.RLIMIT_NOFILE, (nofile_limit, nofile_limit)) - else: - logger.debug('RLIMIT_NOFILE: %s', current_limit) - - config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'injector.json') - - try: - config = InjectorConfig(config_path) - except IOError: - logger.exception('Error reading config: %s', config_path) - exit('No injector config found. Set ANSIBLE_TEST_REMOTE_INTERPRETER if the test is not connecting to the local host.') - - logger.debug('Arguments: %s', ' '.join(pipes.quote(c) for c in config.arguments)) - logger.debug('Python interpreter: %s', config.python_interpreter) - logger.debug('Remote interpreter: %s', config.remote_interpreter) - logger.debug('Coverage file: %s', config.coverage_file) - - if os.path.basename(__file__) == 'injector.py': - args, env = runner() # code coverage collection is baked into the AnsiballZ wrapper when needed - elif os.path.basename(__file__) == 'python.py': - args, env = python() # run arbitrary python commands using the correct python and with optional code coverage - else: - args, env = injector() - - logger.debug('Run command: %s', ' '.join(pipes.quote(c) for c in args)) - - for key in sorted(env.keys()): - logger.debug('%s=%s', key, env[key]) - - os.execvpe(args[0], args, env) - except Exception as ex: - logger.fatal(ex) - raise - - -def python(): - """ - :rtype: list[str], dict[str, str] - """ - if config.coverage_file: - args, env = coverage_command() - else: - args, env = [config.python_interpreter], os.environ.copy() - - args += config.arguments[1:] - - return args, env - - -def injector(): - """ - :rtype: list[str], dict[str, str] - """ - command = os.path.basename(__file__) - - run_as_python_module = ( - 'pytest', - ) - - if command in run_as_python_module: - executable_args = ['-m', command] - else: - executable_args = [find_executable(command)] - - if config.coverage_file: - args, env = coverage_command() - else: - args, env = [config.python_interpreter], os.environ.copy() - - args += executable_args - - if command in ('ansible', 'ansible-playbook', 'ansible-pull'): - if config.remote_interpreter is None: - interpreter = os.path.join(os.path.dirname(__file__), 'injector.py') - elif config.remote_interpreter == '': - interpreter = None - else: - interpreter = config.remote_interpreter - - if interpreter: - args += ['--extra-vars', 'ansible_python_interpreter=' + interpreter] - - args += config.arguments[1:] - - return args, env - - -def runner(): - """ - :rtype: list[str], dict[str, str] - """ - args, env = [config.python_interpreter], os.environ.copy() - - args += config.arguments[1:] - - return args, env - - -def coverage_command(): - """ - :rtype: list[str], dict[str, str] - """ - self_dir = os.path.dirname(os.path.abspath(__file__)) - - args = [ - config.python_interpreter, - '-m', - 'coverage.__main__', - 'run', - '--rcfile', - os.path.join(self_dir, '.coveragerc'), - ] - - env = os.environ.copy() - env['COVERAGE_FILE'] = config.coverage_file - - return args, env - - -def find_executable(executable): - """ - :type executable: str - :rtype: str - """ - self = os.path.abspath(__file__) - path = os.environ.get('PATH', os.path.defpath) - seen_dirs = set() - - for path_dir in path.split(os.path.pathsep): - if path_dir in seen_dirs: - continue - - seen_dirs.add(path_dir) - candidate = os.path.abspath(os.path.join(path_dir, executable)) - - if candidate == self: - continue - - if os.path.exists(candidate) and os.access(candidate, os.F_OK | os.X_OK): - return candidate - - raise Exception('Executable "%s" not found in path: %s' % (executable, path)) - - -if __name__ == '__main__': - main() diff --git a/test/runner/injector/pytest b/test/runner/injector/pytest index 1f9d09cbf2a..6bbbfe4d919 120000 --- a/test/runner/injector/pytest +++ b/test/runner/injector/pytest @@ -1 +1 @@ -injector.py \ No newline at end of file +python.py \ No newline at end of file diff --git a/test/runner/injector/python.py b/test/runner/injector/python.py deleted file mode 120000 index 1f9d09cbf2a..00000000000 --- a/test/runner/injector/python.py +++ /dev/null @@ -1 +0,0 @@ -injector.py \ No newline at end of file diff --git a/test/runner/injector/python.py b/test/runner/injector/python.py new file mode 100755 index 00000000000..38222e53254 --- /dev/null +++ b/test/runner/injector/python.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python +"""Provides an entry point for python scripts and python modules on the controller with the current python interpreter and optional code coverage collection.""" + +import imp +import os +import sys + + +def main(): + """Main entry point.""" + name = os.path.basename(__file__) + args = [sys.executable] + + coverage_config = os.environ.get('_ANSIBLE_COVERAGE_CONFIG') + coverage_output = os.environ.get('_ANSIBLE_COVERAGE_OUTPUT') + + if coverage_config: + if coverage_output: + args += ['-m', 'coverage.__main__', 'run', '--rcfile', coverage_config] + else: + try: + imp.find_module('coverage') + except ImportError: + exit('ERROR: Could not find `coverage` module. Did you use a virtualenv created without --system-site-packages or with the wrong interpreter?') + + if name == 'python.py': + if sys.argv[1] == '-c': + # prevent simple misuse of python.py with -c which does not work with coverage + sys.exit('ERROR: Use `python -c` instead of `python.py -c` to avoid errors when code coverage is collected.') + elif name == 'pytest': + args += ['-m', 'pytest'] + else: + args += [find_executable(name)] + + args += sys.argv[1:] + + os.execv(args[0], args) + + +def find_executable(name): + """ + :type name: str + :rtype: str + """ + path = os.environ.get('PATH', os.path.defpath) + seen = set([os.path.abspath(__file__)]) + + for base in path.split(os.path.pathsep): + candidate = os.path.abspath(os.path.join(base, name)) + + if candidate in seen: + continue + + seen.add(candidate) + + if os.path.exists(candidate) and os.access(candidate, os.F_OK | os.X_OK): + return candidate + + raise Exception('Executable "%s" not found in path: %s' % (name, path)) + + +if __name__ == '__main__': + main() diff --git a/test/runner/lib/executor.py b/test/runner/lib/executor.py index d37e9150a5a..8c4e083f2ef 100644 --- a/test/runner/lib/executor.py +++ b/test/runner/lib/executor.py @@ -15,6 +15,9 @@ import sys import hashlib import difflib import filecmp +import random +import string +import shutil import lib.pytar import lib.thread @@ -50,12 +53,13 @@ from lib.util import ( is_binary_file, find_executable, raw_command, - get_coverage_path, + get_python_path, get_available_port, generate_pip_command, find_python, get_docker_completion, named_temporary_file, + COVERAGE_OUTPUT_PATH, ) from lib.docker_util import ( @@ -112,6 +116,7 @@ from lib.metadata import ( from lib.integration import ( integration_test_environment, integration_test_config_file, + setup_common_temp_dir, ) SUPPORTED_PYTHON_VERSIONS = ( @@ -359,7 +364,7 @@ def command_network_integration(args): instances = [] # type: list [lib.thread.WrappedThread] if args.platform: - get_coverage_path(args, args.python_executable) # initialize before starting threads + get_python_path(args, args.python_executable) # initialize before starting threads configs = dict((config['platform_version'], config) for config in args.metadata.instance_config) @@ -527,7 +532,7 @@ def command_windows_integration(args): httptester_id = None if args.windows: - get_coverage_path(args, args.python_executable) # initialize before starting threads + get_python_path(args, args.python_executable) # initialize before starting threads configs = dict((config['platform_version'], config) for config in args.metadata.instance_config) @@ -833,6 +838,12 @@ def command_integration_filtered(args, targets, all_targets, inventory_path, pre current_environment = None # type: EnvironmentDescription | None + # common temporary directory path that will be valid on both the controller and the remote + # it must be common because it will be referenced in environment variables that are shared across multiple hosts + common_temp_path = '/tmp/ansible-test-%s' % ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(8)) + + setup_common_temp_dir(args, common_temp_path) + try: for target in targets_iter: if args.start_at and not found: @@ -863,11 +874,11 @@ def command_integration_filtered(args, targets, all_targets, inventory_path, pre if cloud_environment: cloud_environment.setup_once() - run_setup_targets(args, test_dir, target.setup_once, all_targets_dict, setup_targets_executed, inventory_path, False) + run_setup_targets(args, test_dir, target.setup_once, all_targets_dict, setup_targets_executed, inventory_path, common_temp_path, False) start_time = time.time() - run_setup_targets(args, test_dir, target.setup_always, all_targets_dict, setup_targets_executed, inventory_path, True) + run_setup_targets(args, test_dir, target.setup_always, all_targets_dict, setup_targets_executed, inventory_path, common_temp_path, True) if not args.explain: # create a fresh test directory for each test target @@ -879,9 +890,9 @@ def command_integration_filtered(args, targets, all_targets, inventory_path, pre try: if target.script_path: - command_integration_script(args, target, test_dir, inventory_path) + command_integration_script(args, target, test_dir, inventory_path, common_temp_path) else: - command_integration_role(args, target, start_at_task, test_dir, inventory_path) + command_integration_role(args, target, start_at_task, test_dir, inventory_path, common_temp_path) start_at_task = None finally: if post_target: @@ -945,6 +956,15 @@ def command_integration_filtered(args, targets, all_targets, inventory_path, pre finally: if not args.explain: + if args.coverage: + coverage_temp_path = os.path.join(common_temp_path, COVERAGE_OUTPUT_PATH) + coverage_save_path = 'test/results/coverage' + + for filename in os.listdir(coverage_temp_path): + shutil.copy(os.path.join(coverage_temp_path, filename), os.path.join(coverage_save_path, filename)) + + remove_tree(common_temp_path) + results_path = 'test/results/data/%s-%s.json' % (args.command, re.sub(r'[^0-9]', '-', str(datetime.datetime.utcnow().replace(microsecond=0)))) data = dict( @@ -1086,7 +1106,7 @@ rdr pass inet proto tcp from any to any port 443 -> 127.0.0.1 port 8443 raise ApplicationError('No supported port forwarding mechanism detected.') -def run_setup_targets(args, test_dir, target_names, targets_dict, targets_executed, inventory_path, always): +def run_setup_targets(args, test_dir, target_names, targets_dict, targets_executed, inventory_path, temp_path, always): """ :type args: IntegrationConfig :type test_dir: str @@ -1094,6 +1114,7 @@ def run_setup_targets(args, test_dir, target_names, targets_dict, targets_execut :type targets_dict: dict[str, IntegrationTarget] :type targets_executed: set[str] :type inventory_path: str + :type temp_path: str :type always: bool """ for target_name in target_names: @@ -1108,9 +1129,9 @@ def run_setup_targets(args, test_dir, target_names, targets_dict, targets_execut make_dirs(test_dir) if target.script_path: - command_integration_script(args, target, test_dir, inventory_path) + command_integration_script(args, target, test_dir, inventory_path, temp_path) else: - command_integration_role(args, target, None, test_dir, inventory_path) + command_integration_role(args, target, None, test_dir, inventory_path, temp_path) targets_executed.add(target_name) @@ -1156,12 +1177,13 @@ def integration_environment(args, target, test_dir, inventory_path, ansible_conf return env -def command_integration_script(args, target, test_dir, inventory_path): +def command_integration_script(args, target, test_dir, inventory_path, temp_path): """ :type args: IntegrationConfig :type target: IntegrationTarget :type test_dir: str :type inventory_path: str + :type temp_path: str """ display.info('Running %s integration test script' % target.name) @@ -1190,16 +1212,17 @@ def command_integration_script(args, target, test_dir, inventory_path): cmd += ['-e', '@%s' % config_path] coverage = args.coverage and 'non_local/' not in target.aliases - intercept_command(args, cmd, target_name=target.name, env=env, cwd=cwd, coverage=coverage) + intercept_command(args, cmd, target_name=target.name, env=env, cwd=cwd, temp_path=temp_path, coverage=coverage) -def command_integration_role(args, target, start_at_task, test_dir, inventory_path): +def command_integration_role(args, target, start_at_task, test_dir, inventory_path, temp_path): """ :type args: IntegrationConfig :type target: IntegrationTarget :type start_at_task: str | None :type test_dir: str :type inventory_path: str + :type temp_path: str """ display.info('Running %s integration test role' % target.name) @@ -1273,7 +1296,7 @@ def command_integration_role(args, target, start_at_task, test_dir, inventory_pa env['ANSIBLE_ROLES_PATH'] = os.path.abspath(os.path.join(test_env.integration_dir, 'targets')) coverage = args.coverage and 'non_local/' not in target.aliases - intercept_command(args, cmd, target_name=target.name, env=env, cwd=cwd, coverage=coverage) + intercept_command(args, cmd, target_name=target.name, env=env, cwd=cwd, temp_path=temp_path, coverage=coverage) def command_units(args): diff --git a/test/runner/lib/integration/__init__.py b/test/runner/lib/integration/__init__.py index df640681fd6..b386029d71a 100644 --- a/test/runner/lib/integration/__init__.py +++ b/test/runner/lib/integration/__init__.py @@ -6,6 +6,7 @@ import contextlib import json import os import shutil +import stat import tempfile from lib.target import ( @@ -24,6 +25,11 @@ from lib.util import ( display, make_dirs, named_temporary_file, + COVERAGE_CONFIG_PATH, + COVERAGE_OUTPUT_PATH, + MODE_DIRECTORY, + MODE_DIRECTORY_WRITE, + MODE_FILE, ) from lib.cache import ( @@ -35,6 +41,28 @@ from lib.cloud import ( ) +def setup_common_temp_dir(args, path): + """ + :type args: IntegrationConfig + :type path: str + """ + if args.explain: + return + + os.mkdir(path) + os.chmod(path, MODE_DIRECTORY) + + coverage_config_path = os.path.join(path, COVERAGE_CONFIG_PATH) + + shutil.copy(COVERAGE_CONFIG_PATH, coverage_config_path) + os.chmod(coverage_config_path, MODE_FILE) + + coverage_output_path = os.path.join(path, COVERAGE_OUTPUT_PATH) + + os.mkdir(coverage_output_path) + os.chmod(coverage_output_path, MODE_DIRECTORY_WRITE) + + def generate_dependency_map(integration_targets): """ :type integration_targets: list[IntegrationTarget] diff --git a/test/runner/lib/sanity/import.py b/test/runner/lib/sanity/import.py index 7ff5212eb97..943da2ee00c 100644 --- a/test/runner/lib/sanity/import.py +++ b/test/runner/lib/sanity/import.py @@ -116,9 +116,10 @@ class ImportTest(SanityMultipleVersion): results = [] + virtualenv_python = os.path.join(virtual_environment_bin, 'python') + try: - stdout, stderr = intercept_command(args, cmd, data=data, target_name=self.name, env=env, capture=True, python_version=python_version, - path=env['PATH']) + stdout, stderr = intercept_command(args, cmd, self.name, env, capture=True, data=data, python_version=python_version, virtualenv=virtualenv_python) if stdout or stderr: raise SubprocessError(cmd, stdout=stdout, stderr=stderr) diff --git a/test/runner/lib/util.py b/test/runner/lib/util.py index 5893a6fcde0..25b16058331 100644 --- a/test/runner/lib/util.py +++ b/test/runner/lib/util.py @@ -39,13 +39,30 @@ except ImportError: from configparser import ConfigParser DOCKER_COMPLETION = {} -COVERAGE_PATHS = {} # type: dict[str, str] +PYTHON_PATHS = {} # type: dict[str, str] try: MAXFD = subprocess.MAXFD except AttributeError: MAXFD = -1 +COVERAGE_CONFIG_PATH = '.coveragerc' +COVERAGE_OUTPUT_PATH = 'coverage' + +# Modes are set to allow all users the same level of access. +# This permits files to be used in tests that change users. +# The only exception is write access to directories for the user creating them. +# This avoids having to modify the directory permissions a second time. + +MODE_READ = stat.S_IRUSR | stat.S_IRGRP | stat.S_IROTH + +MODE_FILE = MODE_READ +MODE_FILE_EXECUTE = MODE_FILE | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH +MODE_FILE_WRITE = MODE_FILE | stat.S_IWUSR | stat.S_IWGRP | stat.S_IWOTH + +MODE_DIRECTORY = MODE_READ | stat.S_IWUSR | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH +MODE_DIRECTORY_WRITE = MODE_DIRECTORY | stat.S_IWGRP | stat.S_IWOTH + def get_docker_completion(): """ @@ -107,6 +124,83 @@ def read_lines_without_comments(path, remove_blank_lines=False): return lines +def get_python_path(args, interpreter): + """ + :type args: TestConfig + :type interpreter: str + :rtype: str + """ + python_path = PYTHON_PATHS.get(interpreter) + + if python_path: + return python_path + + prefix = 'python-' + suffix = '-ansible' + + root_temp_dir = '/tmp' + + if args.explain: + return os.path.join(root_temp_dir, ''.join((prefix, 'temp', suffix))) + + python_path = tempfile.mkdtemp(prefix=prefix, suffix=suffix, dir=root_temp_dir) + + os.chmod(python_path, MODE_DIRECTORY) + os.symlink(interpreter, os.path.join(python_path, 'python')) + + if not PYTHON_PATHS: + atexit.register(cleanup_python_paths) + + PYTHON_PATHS[interpreter] = python_path + + return python_path + + +def cleanup_python_paths(): + """Clean up all temporary python directories.""" + for path in sorted(PYTHON_PATHS.values()): + display.info('Cleaning up temporary python directory: %s' % path, verbosity=2) + shutil.rmtree(path) + + +def get_coverage_environment(args, target_name, version, temp_path): + """ + :type args: TestConfig + :type target_name: str + :type version: str + :type temp_path: str + :rtype: dict[str, str] + """ + if temp_path: + # integration tests (both localhost and the optional testhost) + # config and results are in a temporary directory + coverage_config_base_path = temp_path + coverage_output_base_path = temp_path + else: + # unit tests, sanity tests and other special cases (localhost only) + # config and results are in the source tree + coverage_config_base_path = os.getcwd() + coverage_output_base_path = os.path.abspath(os.path.join('test/results')) + + config_file = os.path.join(coverage_config_base_path, COVERAGE_CONFIG_PATH) + coverage_file = os.path.join(coverage_output_base_path, COVERAGE_OUTPUT_PATH, '%s=%s=%s=%s=coverage' % ( + args.command, target_name, args.coverage_label or 'local-%s' % version, 'python-%s' % version)) + + if args.coverage_check: + coverage_file = '' + + env = dict( + # both AnsiballZ and the ansible-test coverage injector rely on this + _ANSIBLE_COVERAGE_CONFIG=config_file, + # used during AnsiballZ wrapper creation to set COVERAGE_FILE for the module + _ANSIBLE_COVERAGE_OUTPUT=coverage_file, + # handle cases not covered by the AnsiballZ wrapper creation above + COVERAGE_FILE=coverage_file, + ) + + return env + + def find_executable(executable, cwd=None, path=None, required=True): """ :type executable: str @@ -183,18 +277,19 @@ def generate_pip_command(python): return [python, '-m', 'pip.__main__'] -def intercept_command(args, cmd, target_name, capture=False, env=None, data=None, cwd=None, python_version=None, path=None, coverage=None): +def intercept_command(args, cmd, target_name, env, capture=False, data=None, cwd=None, python_version=None, temp_path=None, coverage=None, virtualenv=None): """ :type args: TestConfig :type cmd: collections.Iterable[str] :type target_name: str + :type env: dict[str, str] :type capture: bool - :type env: dict[str, str] | None :type data: str | None :type cwd: str | None :type python_version: str | None - :type path: str | None + :type temp_path: str | None :type coverage: bool | None + :type virtualenv: str | None :rtype: str | None, str | None """ if not env: @@ -205,108 +300,26 @@ def intercept_command(args, cmd, target_name, capture=False, env=None, data=None cmd = list(cmd) version = python_version or args.python_version - interpreter = find_python(version, path) - inject_path = get_coverage_path(args, interpreter) - config_path = os.path.join(inject_path, 'injector.json') - coverage_file = os.path.abspath(os.path.join(inject_path, '..', 'output', '%s=%s=%s=%s=coverage' % ( - args.command, target_name, args.coverage_label or 'local-%s' % version, 'python-%s' % version))) + interpreter = virtualenv or find_python(version) + inject_path = os.path.abspath('test/runner/injector') - if args.coverage_check: - coverage_file = '' + if not virtualenv: + # injection of python into the path is required when not activating a virtualenv + # otherwise scripts may find the wrong interpreter or possibly no interpreter + python_path = get_python_path(args, interpreter) + inject_path = python_path + os.path.pathsep + inject_path env['PATH'] = inject_path + os.path.pathsep + env['PATH'] env['ANSIBLE_TEST_PYTHON_VERSION'] = version env['ANSIBLE_TEST_PYTHON_INTERPRETER'] = interpreter if coverage: - env['_ANSIBLE_COVERAGE_CONFIG'] = os.path.join(inject_path, '.coveragerc') - env['_ANSIBLE_COVERAGE_OUTPUT'] = coverage_file - - config = dict( - python_interpreter=interpreter, - coverage_file=coverage_file if coverage else None, - ) - - if not args.explain: - with open(config_path, 'w') as config_fd: - json.dump(config, config_fd, indent=4, sort_keys=True) + # add the necessary environment variables to enable code coverage collection + env.update(get_coverage_environment(args, target_name, version, temp_path)) return run_command(args, cmd, capture=capture, env=env, data=data, cwd=cwd) -def get_coverage_path(args, interpreter): - """ - :type args: TestConfig - :type interpreter: str - :rtype: str - """ - coverage_path = COVERAGE_PATHS.get(interpreter) - - if coverage_path: - return os.path.join(coverage_path, 'coverage') - - prefix = 'ansible-test-coverage-' - tmp_dir = '/tmp' - - if args.explain: - return os.path.join(tmp_dir, '%stmp' % prefix, 'coverage') - - src = os.path.abspath(os.path.join(os.getcwd(), 'test/runner/injector/')) - - coverage_path = tempfile.mkdtemp('', prefix, dir=tmp_dir) - os.chmod(coverage_path, stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH) - - shutil.copytree(src, os.path.join(coverage_path, 'coverage')) - shutil.copy('.coveragerc', os.path.join(coverage_path, 'coverage', '.coveragerc')) - - for root, dir_names, file_names in os.walk(coverage_path): - for name in dir_names + file_names: - os.chmod(os.path.join(root, name), stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH) - - for directory in 'output', 'logs': - os.mkdir(os.path.join(coverage_path, directory)) - os.chmod(os.path.join(coverage_path, directory), stat.S_IRWXU | stat.S_IRWXG | stat.S_IRWXO) - - os.symlink(interpreter, os.path.join(coverage_path, 'coverage', 'python')) - - if not COVERAGE_PATHS: - atexit.register(cleanup_coverage_dirs) - - COVERAGE_PATHS[interpreter] = coverage_path - - return os.path.join(coverage_path, 'coverage') - - -def cleanup_coverage_dirs(): - """Clean up all coverage directories.""" - for path in COVERAGE_PATHS.values(): - display.info('Cleaning up coverage directory: %s' % path, verbosity=2) - cleanup_coverage_dir(path) - - -def cleanup_coverage_dir(coverage_path): - """Copy over coverage data from temporary directory and purge temporary directory. - :type coverage_path: str - """ - output_dir = os.path.join(coverage_path, 'output') - - for filename in os.listdir(output_dir): - src = os.path.join(output_dir, filename) - dst = os.path.join(os.getcwd(), 'test', 'results', 'coverage') - shutil.copy(src, dst) - - logs_dir = os.path.join(coverage_path, 'logs') - - for filename in os.listdir(logs_dir): - random_suffix = ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(8)) - new_name = '%s.%s.log' % (os.path.splitext(os.path.basename(filename))[0], random_suffix) - src = os.path.join(logs_dir, filename) - dst = os.path.join(os.getcwd(), 'test', 'results', 'logs', new_name) - shutil.copy(src, dst) - - shutil.rmtree(coverage_path) - - def run_command(args, cmd, capture=False, env=None, data=None, cwd=None, always=False, stdin=None, stdout=None, cmd_verbosity=1, str_errors='strict'): """