summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMatt Clay <matt@mystile.com>2018-12-20 22:08:57 -0800
committerMatt Clay <matt@mystile.com>2018-12-22 00:10:24 -0800
commit6548b7a558d74b6b833bf40464e6412c98db8c37 (patch)
treee35094a5375862f640710142a3664607dd8081ca
parentc56a23416bfb5e3915bd52bf238fb8dc7a1d9dab (diff)
downloadansible-6548b7a558d74b6b833bf40464e6412c98db8c37.tar.gz
[stable-2.5] 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. (cherry picked from commit 01833b6fb167d3211ff2a649f4c0a22fd3658473) Co-authored-by: Matt Clay <matt@mystile.com>
-rw-r--r--test/env/ansible.cfg0
-rw-r--r--test/runner/lib/classification.py3
-rw-r--r--test/runner/lib/cli.py20
-rw-r--r--test/runner/lib/config.py5
-rw-r--r--test/runner/lib/docker_util.py40
-rw-r--r--test/runner/lib/env.py230
-rw-r--r--test/runner/lib/util.py5
-rwxr-xr-xtest/utils/shippable/shippable.sh2
8 files changed, 298 insertions, 7 deletions
diff --git a/test/env/ansible.cfg b/test/env/ansible.cfg
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/test/env/ansible.cfg
diff --git a/test/runner/lib/classification.py b/test/runner/lib/classification.py
index 404e561b34..6995fd93c9 100644
--- a/test/runner/lib/classification.py
+++ b/test/runner/lib/classification.py
@@ -492,6 +492,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 fa960600f0..0a474edee6 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<parents>[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 f04bcc5212..3ca4e04536 100644
--- a/test/runner/lib/util.py
+++ b/test/runner/lib/util.py
@@ -732,10 +732,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 bf31da0643..42f2e57663 100755
--- a/test/utils/shippable/shippable.sh
+++ b/test/utils/shippable/shippable.sh
@@ -116,4 +116,6 @@ function cleanup
trap cleanup EXIT
+ansible-test env --dump --show --color -v
+
"test/utils/shippable/${script}.sh" "${test}"