Update ansible-test sanity command. (#31958)

* Use correct pip version in ansible-test.
* Add git fallback for validate-modules.
* Run sanity tests in a docker container.
* Use correct python version for sanity tests.
* Pin docker completion images and add default.
* Split pylint execution into multiple contexts.
* Only test .py files in use-argspec-type-path test.
* Accept identical python interpeter name or binary.
* Switch cloud tests to default container.
* Remove unused extras from pip install.
* Filter out empty pip commands.
* Don't force running of pip list.
* Support delegation for windows and network tests.
* Fix ansible-test python version usage.
* Fix ansible-test python version skipping.
* Use absolute path for log in ansible-test.
* Run vyos_command test on python 3.
* Fix windows/network instance persistence.
* Add `test/cache` dir to classification.
* Enable more python versions for network tests.
* Fix cs_router test.
This commit is contained in:
Matt Clay 2017-10-26 00:21:46 -07:00 committed by GitHub
parent 602a618e60
commit cf1337ca9a
37 changed files with 788 additions and 456 deletions

View file

@ -11,12 +11,7 @@ import tempfile
import time
import textwrap
import functools
import shutil
import stat
import pipes
import random
import string
import atexit
import hashlib
import lib.pytar
@ -45,11 +40,12 @@ from lib.util import (
SubprocessError,
display,
run_command,
common_environment,
intercept_command,
remove_tree,
make_dirs,
is_shippable,
is_binary_file,
find_pip,
find_executable,
raw_command,
)
@ -110,8 +106,6 @@ SUPPORTED_PYTHON_VERSIONS = (
COMPILE_PYTHON_VERSIONS = SUPPORTED_PYTHON_VERSIONS
coverage_path = '' # pylint: disable=locally-disabled, invalid-name
def check_startup():
"""Checks to perform at startup before running commands."""
@ -163,23 +157,27 @@ def install_command_requirements(args):
if args.junit:
packages.append('junit-xml')
commands = [generate_pip_install(args.command, packages=packages)]
pip = find_pip(version=args.python_version)
commands = [generate_pip_install(pip, args.command, packages=packages)]
if isinstance(args, IntegrationConfig):
for cloud_platform in get_cloud_platforms(args):
commands.append(generate_pip_install('%s.cloud.%s' % (args.command, cloud_platform)))
commands.append(generate_pip_install(pip, '%s.cloud.%s' % (args.command, cloud_platform)))
commands = [cmd for cmd in commands if cmd]
# only look for changes when more than one requirements file is needed
detect_pip_changes = len(commands) > 1
# first pass to install requirements, changes expected unless environment is already set up
changes = run_pip_commands(args, commands, detect_pip_changes)
changes = run_pip_commands(args, pip, commands, detect_pip_changes)
if not changes:
return # no changes means we can stop early
# second pass to check for conflicts in requirements, changes are not expected here
changes = run_pip_commands(args, commands, detect_pip_changes)
changes = run_pip_commands(args, pip, commands, detect_pip_changes)
if not changes:
return # no changes means no conflicts
@ -188,16 +186,17 @@ def install_command_requirements(args):
'\n'.join((' '.join(pipes.quote(c) for c in cmd) for cmd in changes)))
def run_pip_commands(args, commands, detect_pip_changes=False):
def run_pip_commands(args, pip, commands, detect_pip_changes=False):
"""
:type args: EnvironmentConfig
:type pip: str
:type commands: list[list[str]]
:type detect_pip_changes: bool
:rtype: list[list[str]]
"""
changes = []
after_list = pip_list(args) if detect_pip_changes else None
after_list = pip_list(args, pip) if detect_pip_changes else None
for cmd in commands:
if not cmd:
@ -217,10 +216,10 @@ def run_pip_commands(args, commands, detect_pip_changes=False):
# AttributeError: 'Requirement' object has no attribute 'project_name'
# See: https://bugs.launchpad.net/ubuntu/xenial/+source/python-pip/+bug/1626258
# Upgrading pip works around the issue.
run_command(args, ['pip', 'install', '--upgrade', 'pip'])
run_command(args, [pip, 'install', '--upgrade', 'pip'])
run_command(args, cmd)
after_list = pip_list(args) if detect_pip_changes else None
after_list = pip_list(args, pip) if detect_pip_changes else None
if before_list != after_list:
changes.append(cmd)
@ -228,12 +227,13 @@ def run_pip_commands(args, commands, detect_pip_changes=False):
return changes
def pip_list(args):
def pip_list(args, pip):
"""
:type args: EnvironmentConfig
:type pip: str
:rtype: str
"""
stdout, _ = run_command(args, ['pip', 'list'], capture=True, always=True)
stdout, _ = run_command(args, [pip, 'list'], capture=True)
return stdout
@ -244,14 +244,14 @@ def generate_egg_info(args):
if os.path.isdir('lib/ansible.egg-info'):
return
run_command(args, ['python', 'setup.py', 'egg_info'], capture=args.verbosity < 3)
run_command(args, ['python%s' % args.python_version, 'setup.py', 'egg_info'], capture=args.verbosity < 3)
def generate_pip_install(command, packages=None, extras=None):
def generate_pip_install(pip, command, packages=None):
"""
:type pip: str
:type command: str
:type packages: list[str] | None
:type extras: list[str] | None
:rtype: list[str] | None
"""
constraints = 'test/runner/requirements/constraints.txt'
@ -259,15 +259,8 @@ def generate_pip_install(command, packages=None, extras=None):
options = []
requirements_list = [requirements]
if extras:
for extra in extras:
requirements_list.append('test/runner/requirements/%s.%s.txt' % (command, extra))
for requirements in requirements_list:
if os.path.exists(requirements) and os.path.getsize(requirements):
options += ['-r', requirements]
if os.path.exists(requirements) and os.path.getsize(requirements):
options += ['-r', requirements]
if packages:
options += packages
@ -275,7 +268,7 @@ def generate_pip_install(command, packages=None, extras=None):
if not options:
return None
return ['pip', 'install', '--disable-pip-version-check', '-c', constraints] + options
return [pip, 'install', '--disable-pip-version-check', '-c', constraints] + options
def command_shell(args):
@ -323,31 +316,24 @@ def command_network_integration(args):
)
all_targets = tuple(walk_network_integration_targets(include_hidden=True))
internal_targets = command_integration_filter(args, all_targets)
platform_targets = set(a for t in internal_targets for a in t.aliases if a.startswith('network/'))
internal_targets = command_integration_filter(args, all_targets, init_callback=network_init)
if args.platform:
configs = dict((config['platform_version'], config) for config in args.metadata.instance_config)
instances = [] # type: list [lib.thread.WrappedThread]
# generate an ssh key (if needed) up front once, instead of for each instance
SshKey(args)
for platform_version in args.platform:
platform, version = platform_version.split('/', 1)
platform_target = 'network/%s/' % platform
config = configs.get(platform_version)
if platform_target not in platform_targets and 'network/basics/' not in platform_targets:
display.warning('Skipping "%s" because selected tests do not target the "%s" platform.' % (
platform_version, platform))
if not config:
continue
instance = lib.thread.WrappedThread(functools.partial(network_run, args, platform, version))
instance = lib.thread.WrappedThread(functools.partial(network_run, args, platform, version, config))
instance.daemon = True
instance.start()
instances.append(instance)
install_command_requirements(args)
while any(instance.is_alive() for instance in instances):
time.sleep(1)
@ -359,22 +345,71 @@ def command_network_integration(args):
if not args.explain:
with open(filename, 'w') as inventory_fd:
inventory_fd.write(inventory)
else:
install_command_requirements(args)
command_integration_filtered(args, internal_targets, all_targets)
def network_run(args, platform, version):
def network_init(args, internal_targets):
"""
:type args: NetworkIntegrationConfig
:type internal_targets: tuple[IntegrationTarget]
"""
if not args.platform:
return
if args.metadata.instance_config is not None:
return
platform_targets = set(a for t in internal_targets for a in t.aliases if a.startswith('network/'))
instances = [] # type: list [lib.thread.WrappedThread]
# generate an ssh key (if needed) up front once, instead of for each instance
SshKey(args)
for platform_version in args.platform:
platform, version = platform_version.split('/', 1)
platform_target = 'network/%s/' % platform
if platform_target not in platform_targets and 'network/basics/' not in platform_targets:
display.warning('Skipping "%s" because selected tests do not target the "%s" platform.' % (
platform_version, platform))
continue
instance = lib.thread.WrappedThread(functools.partial(network_start, args, platform, version))
instance.daemon = True
instance.start()
instances.append(instance)
while any(instance.is_alive() for instance in instances):
time.sleep(1)
args.metadata.instance_config = [instance.wait_for_result() for instance in instances]
def network_start(args, platform, version):
"""
:type args: NetworkIntegrationConfig
:type platform: str
:type version: str
:rtype: AnsibleCoreCI
"""
core_ci = AnsibleCoreCI(args, platform, version, stage=args.remote_stage)
core_ci.start()
return core_ci.save()
def network_run(args, platform, version, config):
"""
:type args: NetworkIntegrationConfig
:type platform: str
:type version: str
:type config: dict[str, str]
:rtype: AnsibleCoreCI
"""
core_ci = AnsibleCoreCI(args, platform, version, stage=args.remote_stage, load=False)
core_ci.load(config)
core_ci.wait()
manage = ManageNetworkCI(core_ci)
@ -431,19 +466,20 @@ def command_windows_integration(args):
raise ApplicationError('Use the --windows option or provide an inventory file (see %s.template).' % filename)
all_targets = tuple(walk_windows_integration_targets(include_hidden=True))
internal_targets = command_integration_filter(args, all_targets)
internal_targets = command_integration_filter(args, all_targets, init_callback=windows_init)
if args.windows:
configs = dict((config['platform_version'], config) for config in args.metadata.instance_config)
instances = [] # type: list [lib.thread.WrappedThread]
for version in args.windows:
instance = lib.thread.WrappedThread(functools.partial(windows_run, args, version))
config = configs['windows/%s' % version]
instance = lib.thread.WrappedThread(functools.partial(windows_run, args, version, config))
instance.daemon = True
instance.start()
instances.append(instance)
install_command_requirements(args)
while any(instance.is_alive() for instance in instances):
time.sleep(1)
@ -455,16 +491,36 @@ def command_windows_integration(args):
if not args.explain:
with open(filename, 'w') as inventory_fd:
inventory_fd.write(inventory)
else:
install_command_requirements(args)
try:
command_integration_filtered(args, internal_targets, all_targets)
finally:
pass
command_integration_filtered(args, internal_targets, all_targets)
def windows_run(args, version):
def windows_init(args, internal_targets): # pylint: disable=locally-disabled, unused-argument
"""
:type args: WindowsIntegrationConfig
:type internal_targets: tuple[IntegrationTarget]
"""
if not args.windows:
return
if args.metadata.instance_config is not None:
return
instances = [] # type: list [lib.thread.WrappedThread]
for version in args.windows:
instance = lib.thread.WrappedThread(functools.partial(windows_start, args, version))
instance.daemon = True
instance.start()
instances.append(instance)
while any(instance.is_alive() for instance in instances):
time.sleep(1)
args.metadata.instance_config = [instance.wait_for_result() for instance in instances]
def windows_start(args, version):
"""
:type args: WindowsIntegrationConfig
:type version: str
@ -472,6 +528,19 @@ def windows_run(args, version):
"""
core_ci = AnsibleCoreCI(args, 'windows', version, stage=args.remote_stage)
core_ci.start()
return core_ci.save()
def windows_run(args, version, config):
"""
:type args: WindowsIntegrationConfig
:type version: str
:type config: dict[str, str]
:rtype: AnsibleCoreCI
"""
core_ci = AnsibleCoreCI(args, 'windows', version, stage=args.remote_stage, load=False)
core_ci.load(config)
core_ci.wait()
manage = ManageWindowsCI(core_ci)
@ -525,10 +594,11 @@ def windows_inventory(remotes):
return inventory
def command_integration_filter(args, targets):
def command_integration_filter(args, targets, init_callback=None):
"""
:type args: IntegrationConfig
:type targets: collections.Iterable[IntegrationTarget]
:type init_callback: (IntegrationConfig, tuple[IntegrationTarget]) -> None
:rtype: tuple[IntegrationTarget]
"""
targets = tuple(target for target in targets if 'hidden/' not in target.aliases)
@ -551,6 +621,9 @@ def command_integration_filter(args, targets):
if args.start_at and not any(t.name == args.start_at for t in internal_targets):
raise ApplicationError('Start at target matches nothing: %s' % args.start_at)
if init_callback:
init_callback(args, internal_targets)
cloud_init(args, internal_targets)
if args.delegate:
@ -880,7 +953,7 @@ def command_units(args):
for version in SUPPORTED_PYTHON_VERSIONS:
# run all versions unless version given, in which case run only that version
if args.python and version != args.python:
if args.python and version != args.python_version:
continue
env = ansible_environment(args)
@ -940,7 +1013,7 @@ def command_compile(args):
for version in COMPILE_PYTHON_VERSIONS:
# run all versions unless version given, in which case run only that version
if args.python and version != args.python:
if args.python and version != args.python_version:
continue
display.info('Compile with Python %s' % version)
@ -1027,104 +1100,6 @@ def compile_version(args, python_version, include, exclude):
return TestSuccess(command, test, python_version=python_version)
def intercept_command(args, cmd, target_name, capture=False, env=None, data=None, cwd=None, python_version=None, path=None):
"""
:type args: TestConfig
:type cmd: collections.Iterable[str]
:type target_name: 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
:rtype: str | None, str | None
"""
if not env:
env = common_environment()
cmd = list(cmd)
inject_path = get_coverage_path(args)
config_path = os.path.join(inject_path, 'injector.json')
version = python_version or args.python_version
interpreter = find_executable('python%s' % version, path=path)
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)))
env['PATH'] = inject_path + os.pathsep + env['PATH']
env['ANSIBLE_TEST_PYTHON_VERSION'] = version
env['ANSIBLE_TEST_PYTHON_INTERPRETER'] = interpreter
config = dict(
python_interpreter=interpreter,
coverage_file=coverage_file if args.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)
return run_command(args, cmd, capture=capture, env=env, data=data, cwd=cwd)
def get_coverage_path(args):
"""
:type args: TestConfig
:rtype: str
"""
global coverage_path # pylint: disable=locally-disabled, global-statement, invalid-name
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)
atexit.register(cleanup_coverage_dir)
return os.path.join(coverage_path, 'coverage')
def cleanup_coverage_dir():
"""Copy over coverage data from temporary directory and purge temporary directory."""
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 get_changes_filter(args):
"""
:type args: TestConfig
@ -1306,12 +1281,16 @@ def get_integration_local_filter(args, targets):
% (skip.rstrip('/'), ', '.join(skipped)))
if args.python_version.startswith('3'):
skip = 'skip/python3/'
skipped = [target.name for target in targets if skip in target.aliases]
if skipped:
exclude.append(skip)
display.warning('Excluding tests marked "%s" which are not yet supported on python 3: %s'
% (skip.rstrip('/'), ', '.join(skipped)))
python_version = 3
else:
python_version = 2
skip = 'skip/python%d/' % python_version
skipped = [target.name for target in targets if skip in target.aliases]
if skipped:
exclude.append(skip)
display.warning('Excluding tests marked "%s" which are not supported on python %d: %s'
% (skip.rstrip('/'), python_version, ', '.join(skipped)))
return exclude
@ -1332,13 +1311,26 @@ def get_integration_docker_filter(args, targets):
display.warning('Excluding tests marked "%s" which require --docker-privileged to run under docker: %s'
% (skip.rstrip('/'), ', '.join(skipped)))
python_version = 2 # images are expected to default to python 2 unless otherwise specified
if args.docker.endswith('py3'):
skip = 'skip/python3/'
skipped = [target.name for target in targets if skip in target.aliases]
if skipped:
exclude.append(skip)
display.warning('Excluding tests marked "%s" which are not yet supported on python 3: %s'
% (skip.rstrip('/'), ', '.join(skipped)))
python_version = 3 # docker images ending in 'py3' are expected to default to python 3
if args.docker.endswith(':default'):
python_version = 3 # docker images tagged 'default' are expected to default to python 3
if args.python: # specifying a numeric --python option overrides the default python
if args.python.startswith('3'):
python_version = 3
elif args.python.startswith('2'):
python_version = 2
skip = 'skip/python%d/' % python_version
skipped = [target.name for target in targets if skip in target.aliases]
if skipped:
exclude.append(skip)
display.warning('Excluding tests marked "%s" which are not supported on python %d: %s'
% (skip.rstrip('/'), python_version, ', '.join(skipped)))
return exclude
@ -1359,9 +1351,18 @@ def get_integration_remote_filter(args, targets):
skipped = [target.name for target in targets if skip in target.aliases]
if skipped:
exclude.append(skip)
display.warning('Excluding tests marked "%s" which are not yet supported on %s: %s'
display.warning('Excluding tests marked "%s" which are not supported on %s: %s'
% (skip.rstrip('/'), platform, ', '.join(skipped)))
python_version = 2 # remotes are expected to default to python 2
skip = 'skip/python%d/' % python_version
skipped = [target.name for target in targets if skip in target.aliases]
if skipped:
exclude.append(skip)
display.warning('Excluding tests marked "%s" which are not supported on python %d: %s'
% (skip.rstrip('/'), python_version, ', '.join(skipped)))
return exclude