From 01833b6fb167d3211ff2a649f4c0a22fd3658473 Mon Sep 17 00:00:00 2001 From: Matt Clay Date: Thu, 20 Dec 2018 22:08:57 -0800 Subject: [PATCH] Add `env` command to ansible-test and run in CI. (#50176) * Add `env` command to ansible-test and run in CI. * Avoid unnecessary docker pull. --- test/env/ansible.cfg | 0 test/runner/lib/classification.py | 3 + test/runner/lib/cli.py | 20 +++ test/runner/lib/config.py | 5 +- test/runner/lib/docker_util.py | 40 +++++- test/runner/lib/env.py | 230 ++++++++++++++++++++++++++++++ test/runner/lib/util.py | 5 +- test/utils/shippable/shippable.sh | 2 + 8 files changed, 298 insertions(+), 7 deletions(-) create mode 100644 test/env/ansible.cfg create mode 100644 test/runner/lib/env.py diff --git a/test/env/ansible.cfg b/test/env/ansible.cfg new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/runner/lib/classification.py b/test/runner/lib/classification.py index 10fd83f3d6..2071bdf728 100644 --- a/test/runner/lib/classification.py +++ b/test/runner/lib/classification.py @@ -553,6 +553,9 @@ class PathMapper(object): if path.startswith('test/legacy/'): return minimal + if path.startswith('test/env/'): + return minimal + if path.startswith('test/integration/roles/'): return minimal diff --git a/test/runner/lib/cli.py b/test/runner/lib/cli.py index 5d6b96d4cd..cab215fe48 100644 --- a/test/runner/lib/cli.py +++ b/test/runner/lib/cli.py @@ -43,6 +43,11 @@ from lib.config import ( ShellConfig, ) +from lib.env import ( + EnvConfig, + command_env, +) + from lib.sanity import ( command_sanity, sanity_init, @@ -483,6 +488,21 @@ def parse_args(): add_extra_coverage_options(coverage_xml) + env = subparsers.add_parser('env', + parents=[common], + help='show information about the test environment') + + env.set_defaults(func=command_env, + config=EnvConfig) + + env.add_argument('--show', + action='store_true', + help='show environment on stdout') + + env.add_argument('--dump', + action='store_true', + help='dump environment to disk') + if argcomplete: argcomplete.autocomplete(parser, always_complete_options=False, validator=lambda i, k: True) diff --git a/test/runner/lib/config.py b/test/runner/lib/config.py index 13cf9b2113..208011189d 100644 --- a/test/runner/lib/config.py +++ b/test/runner/lib/config.py @@ -24,10 +24,9 @@ class EnvironmentConfig(CommonConfig): def __init__(self, args, command): """ :type args: any + :type command: str """ - super(EnvironmentConfig, self).__init__(args) - - self.command = command + super(EnvironmentConfig, self).__init__(args, command) self.local = args.local is True diff --git a/test/runner/lib/docker_util.py b/test/runner/lib/docker_util.py index 033f4d84e7..118a1929d2 100644 --- a/test/runner/lib/docker_util.py +++ b/test/runner/lib/docker_util.py @@ -83,6 +83,10 @@ def docker_pull(args, image): :type args: EnvironmentConfig :type image: str """ + if ('@' in image or ':' in image) and docker_images(args, image): + display.info('Skipping docker pull of existing image with tag or digest: %s' % image, verbosity=2) + return + if not args.docker_pull: display.warning('Skipping docker pull for "%s". Image may be out-of-date.' % image) return @@ -149,6 +153,17 @@ def docker_run(args, image, options, cmd=None): raise ApplicationError('Failed to run docker image "%s".' % image) +def docker_images(args, image): + """ + :param args: CommonConfig + :param image: str + :rtype: list[dict[str, any]] + """ + stdout, _dummy = docker_command(args, ['images', image, '--format', '{{json .}}'], capture=True, always=True) + results = [json.loads(line) for line in stdout.splitlines()] + return results + + def docker_rm(args, container_id): """ :type args: EnvironmentConfig @@ -221,17 +236,36 @@ def docker_exec(args, container_id, cmd, options=None, capture=False, stdin=None return docker_command(args, ['exec'] + options + [container_id] + cmd, capture=capture, stdin=stdin, stdout=stdout) -def docker_command(args, cmd, capture=False, stdin=None, stdout=None): +def docker_info(args): """ - :type args: EnvironmentConfig + :param args: CommonConfig + :rtype: dict[str, any] + """ + stdout, _dummy = docker_command(args, ['info', '--format', '{{json .}}'], capture=True, always=True) + return json.loads(stdout) + + +def docker_version(args): + """ + :param args: CommonConfig + :rtype: dict[str, any] + """ + stdout, _dummy = docker_command(args, ['version', '--format', '{{json .}}'], capture=True, always=True) + return json.loads(stdout) + + +def docker_command(args, cmd, capture=False, stdin=None, stdout=None, always=False): + """ + :type args: CommonConfig :type cmd: list[str] :type capture: bool :type stdin: file | None :type stdout: file | None + :type always: bool :rtype: str | None, str | None """ env = docker_environment() - return run_command(args, ['docker'] + cmd, env=env, capture=capture, stdin=stdin, stdout=stdout) + return run_command(args, ['docker'] + cmd, env=env, capture=capture, stdin=stdin, stdout=stdout, always=always) def docker_environment(): diff --git a/test/runner/lib/env.py b/test/runner/lib/env.py new file mode 100644 index 0000000000..908f75a3ac --- /dev/null +++ b/test/runner/lib/env.py @@ -0,0 +1,230 @@ +"""Show information about the test environment.""" + +from __future__ import absolute_import, print_function + +import datetime +import json +import os +import platform +import re +import sys + +from lib.config import ( + CommonConfig, +) + +from lib.util import ( + display, + find_executable, + raw_command, + SubprocessError, + ApplicationError, +) + +from lib.ansible_util import ( + ansible_environment, +) + +from lib.git import ( + Git, +) + +from lib.docker_util import ( + docker_info, + docker_version +) + + +class EnvConfig(CommonConfig): + """Configuration for the tools command.""" + def __init__(self, args): + """ + :type args: any + """ + super(EnvConfig, self).__init__(args, 'env') + + self.show = args.show or not args.dump + self.dump = args.dump + + +def command_env(args): + """ + :type args: EnvConfig + """ + data = dict( + ansible=dict( + version=get_ansible_version(args), + ), + docker=get_docker_details(args), + environ=os.environ.copy(), + git=get_git_details(args), + platform=dict( + datetime=datetime.datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ'), + platform=platform.platform(), + uname=platform.uname(), + ), + python=dict( + executable=sys.executable, + version=platform.python_version(), + ), + ) + + if args.show: + verbose = { + 'docker': 3, + 'docker.executable': 0, + 'environ': 2, + 'platform.uname': 1, + } + + show_dict(data, verbose) + + if args.dump and not args.explain: + with open('test/results/bot/data-environment.json', 'w') as results_fd: + results_fd.write(json.dumps(data, sort_keys=True)) + + +def show_dict(data, verbose, root_verbosity=0, path=None): + """ + :type data: dict[str, any] + :type verbose: dict[str, int] + :type root_verbosity: int + :type path: list[str] | None + """ + path = path if path else [] + + for key, value in sorted(data.items()): + indent = ' ' * len(path) + key_path = path + [key] + key_name = '.'.join(key_path) + verbosity = verbose.get(key_name, root_verbosity) + + if isinstance(value, (tuple, list)): + display.info(indent + '%s:' % key, verbosity=verbosity) + for item in value: + display.info(indent + ' - %s' % item, verbosity=verbosity) + elif isinstance(value, dict): + min_verbosity = min([verbosity] + [v for k, v in verbose.items() if k.startswith('%s.' % key)]) + display.info(indent + '%s:' % key, verbosity=min_verbosity) + show_dict(value, verbose, verbosity, key_path) + else: + display.info(indent + '%s: %s' % (key, value), verbosity=verbosity) + + +def get_ansible_version(args): + """ + :type args: CommonConfig + :rtype: str | None + """ + code = 'from __future__ import (print_function); from ansible.release import __version__; print(__version__)' + cmd = [sys.executable, '-c', code] + env = ansible_environment(args) + + try: + ansible_version, _dummy = raw_command(cmd, env=env, capture=True) + ansible_version = ansible_version.strip() + except SubprocessError as ex: + display.warning('Unable to get Ansible version:\n%s' % ex) + ansible_version = None + + return ansible_version + + +def get_docker_details(args): + """ + :type args: CommonConfig + :rtype: dict[str, any] + """ + docker = find_executable('docker', required=False) + info = None + version = None + + if docker: + try: + info = docker_info(args) + except SubprocessError as ex: + display.warning('Failed to collect docker info:\n%s' % ex) + + try: + version = docker_version(args) + except SubprocessError as ex: + display.warning('Failed to collect docker version:\n%s' % ex) + + docker_details = dict( + executable=docker, + info=info, + version=version, + ) + + return docker_details + + +def get_git_details(args): + """ + :type args: CommonConfig + :rtype: dict[str, any] + """ + commit = os.environ.get('COMMIT') + base_commit = os.environ.get('BASE_COMMIT') + + git_details = dict( + base_commit=base_commit, + commit=commit, + merged_commit=get_merged_commit(args, commit), + root=os.getcwd(), + ) + + return git_details + + +def get_merged_commit(args, commit): + """ + :type args: CommonConfig + :type commit: str + :rtype: str | None + """ + if not commit: + return None + + git = Git(args) + + try: + show_commit = git.run_git(['show', '--no-patch', '--no-abbrev', commit]) + except SubprocessError as ex: + # This should only fail for pull requests where the commit does not exist. + # Merge runs would fail much earlier when attempting to checkout the commit. + raise ApplicationError('Commit %s was not found:\n\n%s\n\n' + 'The commit was likely removed by a force push between job creation and execution.\n' + 'Find the latest run for the pull request and restart failed jobs as needed.' + % (commit, ex.stderr.strip())) + + head_commit = git.run_git(['show', '--no-patch', '--no-abbrev', 'HEAD']) + + if show_commit == head_commit: + # Commit is HEAD, so this is not a pull request or the base branch for the pull request is up-to-date. + return None + + match_merge = re.search(r'^Merge: (?P[0-9a-f]{40} [0-9a-f]{40})$', head_commit, flags=re.MULTILINE) + + if not match_merge: + # The most likely scenarios resulting in a failure here are: + # A new run should or does supersede this job, but it wasn't cancelled in time. + # A job was superseded and then later restarted. + raise ApplicationError('HEAD is not commit %s or a merge commit:\n\n%s\n\n' + 'This job has likely been superseded by another run due to additional commits being pushed.\n' + 'Find the latest run for the pull request and restart failed jobs as needed.' + % (commit, head_commit.strip())) + + parents = set(match_merge.group('parents').split(' ')) + + if len(parents) != 2: + raise ApplicationError('HEAD is a %d-way octopus merge.' % len(parents)) + + if commit not in parents: + raise ApplicationError('Commit %s is not a parent of HEAD.' % commit) + + parents.remove(commit) + + last_commit = parents.pop() + + return last_commit diff --git a/test/runner/lib/util.py b/test/runner/lib/util.py index eaa767b013..5e3411fdff 100644 --- a/test/runner/lib/util.py +++ b/test/runner/lib/util.py @@ -737,10 +737,13 @@ class MissingEnvironmentVariable(ApplicationError): class CommonConfig(object): """Configuration common to all commands.""" - def __init__(self, args): + def __init__(self, args, command): """ :type args: any + :type command: str """ + self.command = command + self.color = args.color # type: bool self.explain = args.explain # type: bool self.verbosity = args.verbosity # type: int diff --git a/test/utils/shippable/shippable.sh b/test/utils/shippable/shippable.sh index 9d5668efe0..f780af9686 100755 --- a/test/utils/shippable/shippable.sh +++ b/test/utils/shippable/shippable.sh @@ -117,4 +117,6 @@ function cleanup trap cleanup EXIT +ansible-test env --dump --show --color -v + "test/utils/shippable/${script}.sh" "${test}"