mirror of
https://github.com/ansible-collections/community.general.git
synced 2024-09-14 20:13:21 +02:00
Overhaul ansible-test code coverage and injector. (#53510)
This commit is contained in:
parent
3bdbe24861
commit
a8e328f474
19 changed files with 253 additions and 370 deletions
|
@ -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 }}"
|
||||
|
|
|
@ -1 +1 @@
|
|||
injector.py
|
||||
python.py
|
|
@ -1 +1 @@
|
|||
injector.py
|
||||
python.py
|
|
@ -1 +1 @@
|
|||
injector.py
|
||||
python.py
|
|
@ -1 +1 @@
|
|||
injector.py
|
||||
python.py
|
|
@ -1 +1 @@
|
|||
injector.py
|
||||
python.py
|
|
@ -1 +1 @@
|
|||
injector.py
|
||||
python.py
|
|
@ -1 +1 @@
|
|||
injector.py
|
||||
python.py
|
|
@ -1 +1 @@
|
|||
injector.py
|
||||
python.py
|
|
@ -1 +1 @@
|
|||
injector.py
|
||||
python.py
|
|
@ -1 +1 @@
|
|||
injector.py
|
||||
python.py
|
|
@ -1 +1 @@
|
|||
injector.py
|
||||
python.py
|
|
@ -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()
|
|
@ -1 +1 @@
|
|||
injector.py
|
||||
python.py
|
|
@ -1 +0,0 @@
|
|||
injector.py
|
63
test/runner/injector/python.py
Executable file
63
test/runner/injector/python.py
Executable file
|
@ -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()
|
|
@ -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):
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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'):
|
||||
"""
|
||||
|
|
Loading…
Reference in a new issue