summaryrefslogtreecommitdiffstats
path: root/test/lib/ansible_test/_internal/delegation.py
diff options
context:
space:
mode:
Diffstat (limited to 'test/lib/ansible_test/_internal/delegation.py')
-rw-r--r--test/lib/ansible_test/_internal/delegation.py667
1 files changed, 667 insertions, 0 deletions
diff --git a/test/lib/ansible_test/_internal/delegation.py b/test/lib/ansible_test/_internal/delegation.py
new file mode 100644
index 00000000..3262dd51
--- /dev/null
+++ b/test/lib/ansible_test/_internal/delegation.py
@@ -0,0 +1,667 @@
+"""Delegate test execution to another environment."""
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+import os
+import re
+import sys
+import tempfile
+
+from . import types as t
+
+from .io import (
+ make_dirs,
+)
+
+from .executor import (
+ SUPPORTED_PYTHON_VERSIONS,
+ HTTPTESTER_HOSTS,
+ create_shell_command,
+ run_httptester,
+ start_httptester,
+ get_python_interpreter,
+ get_python_version,
+)
+
+from .config import (
+ TestConfig,
+ EnvironmentConfig,
+ IntegrationConfig,
+ WindowsIntegrationConfig,
+ NetworkIntegrationConfig,
+ ShellConfig,
+ SanityConfig,
+ UnitsConfig,
+)
+
+from .core_ci import (
+ AnsibleCoreCI,
+)
+
+from .manage_ci import (
+ ManagePosixCI,
+ ManageWindowsCI,
+)
+
+from .util import (
+ ApplicationError,
+ common_environment,
+ display,
+ ANSIBLE_BIN_PATH,
+ ANSIBLE_TEST_DATA_ROOT,
+ ANSIBLE_LIB_ROOT,
+ ANSIBLE_TEST_ROOT,
+ tempdir,
+)
+
+from .util_common import (
+ run_command,
+ ResultType,
+ create_interpreter_wrapper,
+ get_docker_completion,
+ get_remote_completion,
+)
+
+from .docker_util import (
+ docker_exec,
+ docker_get,
+ docker_pull,
+ docker_put,
+ docker_rm,
+ docker_run,
+ docker_available,
+ docker_network_disconnect,
+ get_docker_networks,
+ get_docker_preferred_network_name,
+ get_docker_hostname,
+ is_docker_user_defined_network,
+)
+
+from .cloud import (
+ get_cloud_providers,
+)
+
+from .target import (
+ IntegrationTarget,
+)
+
+from .data import (
+ data_context,
+)
+
+from .payload import (
+ create_payload,
+)
+
+from .venv import (
+ create_virtual_environment,
+)
+
+from .ci import (
+ get_ci_provider,
+)
+
+
+def check_delegation_args(args):
+ """
+ :type args: CommonConfig
+ """
+ if not isinstance(args, EnvironmentConfig):
+ return
+
+ if args.docker:
+ get_python_version(args, get_docker_completion(), args.docker_raw)
+ elif args.remote:
+ get_python_version(args, get_remote_completion(), args.remote)
+
+
+def delegate(args, exclude, require, integration_targets):
+ """
+ :type args: EnvironmentConfig
+ :type exclude: list[str]
+ :type require: list[str]
+ :type integration_targets: tuple[IntegrationTarget]
+ :rtype: bool
+ """
+ if isinstance(args, TestConfig):
+ args.metadata.ci_provider = get_ci_provider().code
+
+ make_dirs(ResultType.TMP.path)
+
+ with tempfile.NamedTemporaryFile(prefix='metadata-', suffix='.json', dir=ResultType.TMP.path) as metadata_fd:
+ args.metadata_path = os.path.join(ResultType.TMP.relative_path, os.path.basename(metadata_fd.name))
+ args.metadata.to_file(args.metadata_path)
+
+ try:
+ return delegate_command(args, exclude, require, integration_targets)
+ finally:
+ args.metadata_path = None
+ else:
+ return delegate_command(args, exclude, require, integration_targets)
+
+
+def delegate_command(args, exclude, require, integration_targets):
+ """
+ :type args: EnvironmentConfig
+ :type exclude: list[str]
+ :type require: list[str]
+ :type integration_targets: tuple[IntegrationTarget]
+ :rtype: bool
+ """
+ if args.venv:
+ delegate_venv(args, exclude, require, integration_targets)
+ return True
+
+ if args.docker:
+ delegate_docker(args, exclude, require, integration_targets)
+ return True
+
+ if args.remote:
+ delegate_remote(args, exclude, require, integration_targets)
+ return True
+
+ return False
+
+
+def delegate_venv(args, # type: EnvironmentConfig
+ exclude, # type: t.List[str]
+ require, # type: t.List[str]
+ integration_targets, # type: t.Tuple[IntegrationTarget, ...]
+ ): # type: (...) -> None
+ """Delegate ansible-test execution to a virtual environment using venv or virtualenv."""
+ if args.python:
+ versions = (args.python_version,)
+ else:
+ versions = SUPPORTED_PYTHON_VERSIONS
+
+ if args.httptester:
+ needs_httptester = sorted(target.name for target in integration_targets if 'needs/httptester/' in target.aliases)
+
+ if needs_httptester:
+ display.warning('Use --docker or --remote to enable httptester for tests marked "needs/httptester": %s' % ', '.join(needs_httptester))
+
+ if args.venv_system_site_packages:
+ suffix = '-ssp'
+ else:
+ suffix = ''
+
+ venvs = dict((version, os.path.join(ResultType.TMP.path, 'delegation', 'python%s%s' % (version, suffix))) for version in versions)
+ venvs = dict((version, path) for version, path in venvs.items() if create_virtual_environment(args, version, path, args.venv_system_site_packages))
+
+ if not venvs:
+ raise ApplicationError('No usable virtual environment support found.')
+
+ options = {
+ '--venv': 0,
+ '--venv-system-site-packages': 0,
+ }
+
+ with tempdir() as inject_path:
+ for version, path in venvs.items():
+ create_interpreter_wrapper(os.path.join(path, 'bin', 'python'), os.path.join(inject_path, 'python%s' % version))
+
+ python_interpreter = os.path.join(inject_path, 'python%s' % args.python_version)
+
+ cmd = generate_command(args, python_interpreter, ANSIBLE_BIN_PATH, data_context().content.root, options, exclude, require)
+
+ if isinstance(args, TestConfig):
+ if args.coverage and not args.coverage_label:
+ cmd += ['--coverage-label', 'venv']
+
+ env = common_environment()
+
+ with tempdir() as library_path:
+ # expose ansible and ansible_test to the virtual environment (only required when running from an install)
+ os.symlink(ANSIBLE_LIB_ROOT, os.path.join(library_path, 'ansible'))
+ os.symlink(ANSIBLE_TEST_ROOT, os.path.join(library_path, 'ansible_test'))
+
+ env.update(
+ PATH=inject_path + os.path.pathsep + env['PATH'],
+ PYTHONPATH=library_path,
+ )
+
+ run_command(args, cmd, env=env)
+
+
+def delegate_docker(args, exclude, require, integration_targets):
+ """
+ :type args: EnvironmentConfig
+ :type exclude: list[str]
+ :type require: list[str]
+ :type integration_targets: tuple[IntegrationTarget]
+ """
+ test_image = args.docker
+ privileged = args.docker_privileged
+
+ if isinstance(args, ShellConfig):
+ use_httptester = args.httptester
+ else:
+ use_httptester = args.httptester and any('needs/httptester/' in target.aliases for target in integration_targets)
+
+ if use_httptester:
+ docker_pull(args, args.httptester)
+
+ docker_pull(args, test_image)
+
+ httptester_id = None
+ test_id = None
+ success = False
+
+ options = {
+ '--docker': 1,
+ '--docker-privileged': 0,
+ '--docker-util': 1,
+ }
+
+ python_interpreter = get_python_interpreter(args, get_docker_completion(), args.docker_raw)
+
+ pwd = '/root'
+ ansible_root = os.path.join(pwd, 'ansible')
+
+ if data_context().content.collection:
+ content_root = os.path.join(pwd, data_context().content.collection.directory)
+ else:
+ content_root = ansible_root
+
+ remote_results_root = os.path.join(content_root, data_context().content.results_path)
+
+ cmd = generate_command(args, python_interpreter, os.path.join(ansible_root, 'bin'), content_root, options, exclude, require)
+
+ if isinstance(args, TestConfig):
+ if args.coverage and not args.coverage_label:
+ image_label = args.docker_raw
+ image_label = re.sub('[^a-zA-Z0-9]+', '-', image_label)
+ cmd += ['--coverage-label', 'docker-%s' % image_label]
+
+ if isinstance(args, IntegrationConfig):
+ if not args.allow_destructive:
+ cmd.append('--allow-destructive')
+
+ cmd_options = []
+
+ if isinstance(args, ShellConfig) or (isinstance(args, IntegrationConfig) and args.debug_strategy):
+ cmd_options.append('-it')
+
+ with tempfile.NamedTemporaryFile(prefix='ansible-source-', suffix='.tgz') as local_source_fd:
+ try:
+ create_payload(args, local_source_fd.name)
+
+ if use_httptester:
+ httptester_id = run_httptester(args)
+ else:
+ httptester_id = None
+
+ test_options = [
+ '--detach',
+ '--volume', '/sys/fs/cgroup:/sys/fs/cgroup:ro',
+ '--privileged=%s' % str(privileged).lower(),
+ ]
+
+ if args.docker_memory:
+ test_options.extend([
+ '--memory=%d' % args.docker_memory,
+ '--memory-swap=%d' % args.docker_memory,
+ ])
+
+ docker_socket = '/var/run/docker.sock'
+
+ if args.docker_seccomp != 'default':
+ test_options += ['--security-opt', 'seccomp=%s' % args.docker_seccomp]
+
+ if get_docker_hostname() != 'localhost' or os.path.exists(docker_socket):
+ test_options += ['--volume', '%s:%s' % (docker_socket, docker_socket)]
+
+ if httptester_id:
+ test_options += ['--env', 'HTTPTESTER=1']
+
+ network = get_docker_preferred_network_name(args)
+
+ if not is_docker_user_defined_network(network):
+ # legacy links are required when using the default bridge network instead of user-defined networks
+ for host in HTTPTESTER_HOSTS:
+ test_options += ['--link', '%s:%s' % (httptester_id, host)]
+
+ if isinstance(args, IntegrationConfig):
+ cloud_platforms = get_cloud_providers(args)
+
+ for cloud_platform in cloud_platforms:
+ test_options += cloud_platform.get_docker_run_options()
+
+ test_id = docker_run(args, test_image, options=test_options)[0]
+
+ if args.explain:
+ test_id = 'test_id'
+ else:
+ test_id = test_id.strip()
+
+ # write temporary files to /root since /tmp isn't ready immediately on container start
+ docker_put(args, test_id, os.path.join(ANSIBLE_TEST_DATA_ROOT, 'setup', 'docker.sh'), '/root/docker.sh')
+ docker_exec(args, test_id, ['/bin/bash', '/root/docker.sh'])
+ docker_put(args, test_id, local_source_fd.name, '/root/test.tgz')
+ docker_exec(args, test_id, ['tar', 'oxzf', '/root/test.tgz', '-C', '/root'])
+
+ # docker images are only expected to have a single python version available
+ if isinstance(args, UnitsConfig) and not args.python:
+ cmd += ['--python', 'default']
+
+ # run unit tests unprivileged to prevent stray writes to the source tree
+ # also disconnect from the network once requirements have been installed
+ if isinstance(args, UnitsConfig):
+ writable_dirs = [
+ os.path.join(content_root, ResultType.JUNIT.relative_path),
+ os.path.join(content_root, ResultType.COVERAGE.relative_path),
+ ]
+
+ docker_exec(args, test_id, ['mkdir', '-p'] + writable_dirs)
+ docker_exec(args, test_id, ['chmod', '777'] + writable_dirs)
+ docker_exec(args, test_id, ['chmod', '755', '/root'])
+ docker_exec(args, test_id, ['chmod', '644', os.path.join(content_root, args.metadata_path)])
+
+ docker_exec(args, test_id, ['useradd', 'pytest', '--create-home'])
+
+ docker_exec(args, test_id, cmd + ['--requirements-mode', 'only'], options=cmd_options)
+
+ networks = get_docker_networks(args, test_id)
+
+ if networks is not None:
+ for network in networks:
+ docker_network_disconnect(args, test_id, network)
+ else:
+ display.warning('Network disconnection is not supported (this is normal under podman). '
+ 'Tests will not be isolated from the network. Network-related tests may misbehave.')
+
+ cmd += ['--requirements-mode', 'skip']
+
+ cmd_options += ['--user', 'pytest']
+
+ try:
+ docker_exec(args, test_id, cmd, options=cmd_options)
+ # docker_exec will throw SubprocessError if not successful
+ # If we make it here, all the prep work earlier and the docker_exec line above were all successful.
+ success = True
+ finally:
+ local_test_root = os.path.dirname(os.path.join(data_context().content.root, data_context().content.results_path))
+
+ remote_test_root = os.path.dirname(remote_results_root)
+ remote_results_name = os.path.basename(remote_results_root)
+ remote_temp_file = os.path.join('/root', remote_results_name + '.tgz')
+
+ make_dirs(local_test_root) # make sure directory exists for collections which have no tests
+
+ with tempfile.NamedTemporaryFile(prefix='ansible-result-', suffix='.tgz') as local_result_fd:
+ docker_exec(args, test_id, ['tar', 'czf', remote_temp_file, '--exclude', ResultType.TMP.name, '-C', remote_test_root, remote_results_name])
+ docker_get(args, test_id, remote_temp_file, local_result_fd.name)
+ run_command(args, ['tar', 'oxzf', local_result_fd.name, '-C', local_test_root])
+ finally:
+ if httptester_id:
+ docker_rm(args, httptester_id)
+
+ if test_id:
+ if args.docker_terminate == 'always' or (args.docker_terminate == 'success' and success):
+ docker_rm(args, test_id)
+
+
+def delegate_remote(args, exclude, require, integration_targets):
+ """
+ :type args: EnvironmentConfig
+ :type exclude: list[str]
+ :type require: list[str]
+ :type integration_targets: tuple[IntegrationTarget]
+ """
+ remote = args.parsed_remote
+
+ core_ci = AnsibleCoreCI(args, remote.platform, remote.version, stage=args.remote_stage, provider=args.remote_provider, arch=remote.arch)
+ success = False
+ raw = False
+
+ if isinstance(args, ShellConfig):
+ use_httptester = args.httptester
+ raw = args.raw
+ else:
+ use_httptester = args.httptester and any('needs/httptester/' in target.aliases for target in integration_targets)
+
+ if use_httptester and not docker_available():
+ display.warning('Assuming --disable-httptester since `docker` is not available.')
+ use_httptester = False
+
+ httptester_id = None
+ ssh_options = []
+ content_root = None
+
+ try:
+ core_ci.start()
+
+ if use_httptester:
+ httptester_id, ssh_options = start_httptester(args)
+
+ core_ci.wait()
+
+ python_version = get_python_version(args, get_remote_completion(), args.remote)
+
+ if remote.platform == 'windows':
+ # Windows doesn't need the ansible-test fluff, just run the SSH command
+ manage = ManageWindowsCI(core_ci)
+ manage.setup(python_version)
+
+ cmd = ['powershell.exe']
+ elif raw:
+ manage = ManagePosixCI(core_ci)
+ manage.setup(python_version)
+
+ cmd = create_shell_command(['bash'])
+ else:
+ manage = ManagePosixCI(core_ci)
+ pwd = manage.setup(python_version)
+
+ options = {
+ '--remote': 1,
+ }
+
+ python_interpreter = get_python_interpreter(args, get_remote_completion(), args.remote)
+
+ ansible_root = os.path.join(pwd, 'ansible')
+
+ if data_context().content.collection:
+ content_root = os.path.join(pwd, data_context().content.collection.directory)
+ else:
+ content_root = ansible_root
+
+ cmd = generate_command(args, python_interpreter, os.path.join(ansible_root, 'bin'), content_root, options, exclude, require)
+
+ if httptester_id:
+ cmd += ['--inject-httptester']
+
+ if isinstance(args, TestConfig):
+ if args.coverage and not args.coverage_label:
+ cmd += ['--coverage-label', 'remote-%s-%s' % (remote.platform, remote.version)]
+
+ if isinstance(args, IntegrationConfig):
+ if not args.allow_destructive:
+ cmd.append('--allow-destructive')
+
+ # remote instances are only expected to have a single python version available
+ if isinstance(args, UnitsConfig) and not args.python:
+ cmd += ['--python', 'default']
+
+ if isinstance(args, IntegrationConfig):
+ cloud_platforms = get_cloud_providers(args)
+
+ for cloud_platform in cloud_platforms:
+ ssh_options += cloud_platform.get_remote_ssh_options()
+
+ try:
+ manage.ssh(cmd, ssh_options)
+ success = True
+ finally:
+ download = False
+
+ if remote.platform != 'windows':
+ download = True
+
+ if isinstance(args, ShellConfig):
+ if args.raw:
+ download = False
+
+ if download and content_root:
+ local_test_root = os.path.dirname(os.path.join(data_context().content.root, data_context().content.results_path))
+
+ remote_results_root = os.path.join(content_root, data_context().content.results_path)
+ remote_results_name = os.path.basename(remote_results_root)
+ remote_temp_path = os.path.join('/tmp', remote_results_name)
+
+ # AIX cp and GNU cp provide different options, no way could be found to have a common
+ # pattern and achieve the same goal
+ cp_opts = '-hr' if remote.platform in ['aix', 'ibmi'] else '-a'
+
+ manage.ssh('rm -rf {0} && mkdir {0} && cp {1} {2}/* {0}/ && chmod -R a+r {0}'.format(remote_temp_path, cp_opts, remote_results_root))
+ manage.download(remote_temp_path, local_test_root)
+ finally:
+ if args.remote_terminate == 'always' or (args.remote_terminate == 'success' and success):
+ core_ci.stop()
+
+ if httptester_id:
+ docker_rm(args, httptester_id)
+
+
+def generate_command(args, python_interpreter, ansible_bin_path, content_root, options, exclude, require):
+ """
+ :type args: EnvironmentConfig
+ :type python_interpreter: str | None
+ :type ansible_bin_path: str
+ :type content_root: str
+ :type options: dict[str, int]
+ :type exclude: list[str]
+ :type require: list[str]
+ :rtype: list[str]
+ """
+ options['--color'] = 1
+
+ cmd = [os.path.join(ansible_bin_path, 'ansible-test')]
+
+ if python_interpreter:
+ cmd = [python_interpreter] + cmd
+
+ # Force the encoding used during delegation.
+ # This is only needed because ansible-test relies on Python's file system encoding.
+ # Environments that do not have the locale configured are thus unable to work with unicode file paths.
+ # Examples include FreeBSD and some Linux containers.
+ env_vars = dict(
+ LC_ALL='en_US.UTF-8',
+ ANSIBLE_TEST_CONTENT_ROOT=content_root,
+ )
+
+ env_args = ['%s=%s' % (key, env_vars[key]) for key in sorted(env_vars)]
+
+ cmd = ['/usr/bin/env'] + env_args + cmd
+
+ cmd += list(filter_options(args, sys.argv[1:], options, exclude, require))
+ cmd += ['--color', 'yes' if args.color else 'no']
+
+ if args.requirements:
+ cmd += ['--requirements']
+
+ if isinstance(args, ShellConfig):
+ cmd = create_shell_command(cmd)
+ elif isinstance(args, SanityConfig):
+ base_branch = args.base_branch or get_ci_provider().get_base_branch()
+
+ if base_branch:
+ cmd += ['--base-branch', base_branch]
+
+ return cmd
+
+
+def filter_options(args, argv, options, exclude, require):
+ """
+ :type args: EnvironmentConfig
+ :type argv: list[str]
+ :type options: dict[str, int]
+ :type exclude: list[str]
+ :type require: list[str]
+ :rtype: collections.Iterable[str]
+ """
+ options = options.copy()
+
+ options['--requirements'] = 0
+ options['--truncate'] = 1
+ options['--redact'] = 0
+ options['--no-redact'] = 0
+
+ if isinstance(args, TestConfig):
+ options.update({
+ '--changed': 0,
+ '--tracked': 0,
+ '--untracked': 0,
+ '--ignore-committed': 0,
+ '--ignore-staged': 0,
+ '--ignore-unstaged': 0,
+ '--changed-from': 1,
+ '--changed-path': 1,
+ '--metadata': 1,
+ '--exclude': 1,
+ '--require': 1,
+ })
+ elif isinstance(args, SanityConfig):
+ options.update({
+ '--base-branch': 1,
+ })
+
+ if isinstance(args, IntegrationConfig):
+ options.update({
+ '--no-temp-unicode': 0,
+ '--no-pip-check': 0,
+ })
+
+ if isinstance(args, (NetworkIntegrationConfig, WindowsIntegrationConfig)):
+ options.update({
+ '--inventory': 1,
+ })
+
+ remaining = 0
+
+ for arg in argv:
+ if not arg.startswith('-') and remaining:
+ remaining -= 1
+ continue
+
+ remaining = 0
+
+ parts = arg.split('=', 1)
+ key = parts[0]
+
+ if key in options:
+ remaining = options[key] - len(parts) + 1
+ continue
+
+ yield arg
+
+ for arg in args.delegate_args:
+ yield arg
+
+ for target in exclude:
+ yield '--exclude'
+ yield target
+
+ for target in require:
+ yield '--require'
+ yield target
+
+ if isinstance(args, TestConfig):
+ if args.metadata_path:
+ yield '--metadata'
+ yield args.metadata_path
+
+ yield '--truncate'
+ yield '%d' % args.truncate
+
+ if args.redact:
+ yield '--redact'
+ else:
+ yield '--no-redact'
+
+ if isinstance(args, IntegrationConfig):
+ if args.no_temp_unicode:
+ yield '--no-temp-unicode'
+
+ if not args.pip_check:
+ yield '--no-pip-check'