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.pull/4420/head
parent
4b300189fd
commit
01833b6fb1
|
@ -553,6 +553,9 @@ class PathMapper(object):
|
||||||
if path.startswith('test/legacy/'):
|
if path.startswith('test/legacy/'):
|
||||||
return minimal
|
return minimal
|
||||||
|
|
||||||
|
if path.startswith('test/env/'):
|
||||||
|
return minimal
|
||||||
|
|
||||||
if path.startswith('test/integration/roles/'):
|
if path.startswith('test/integration/roles/'):
|
||||||
return minimal
|
return minimal
|
||||||
|
|
||||||
|
|
|
@ -43,6 +43,11 @@ from lib.config import (
|
||||||
ShellConfig,
|
ShellConfig,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from lib.env import (
|
||||||
|
EnvConfig,
|
||||||
|
command_env,
|
||||||
|
)
|
||||||
|
|
||||||
from lib.sanity import (
|
from lib.sanity import (
|
||||||
command_sanity,
|
command_sanity,
|
||||||
sanity_init,
|
sanity_init,
|
||||||
|
@ -483,6 +488,21 @@ def parse_args():
|
||||||
|
|
||||||
add_extra_coverage_options(coverage_xml)
|
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:
|
if argcomplete:
|
||||||
argcomplete.autocomplete(parser, always_complete_options=False, validator=lambda i, k: True)
|
argcomplete.autocomplete(parser, always_complete_options=False, validator=lambda i, k: True)
|
||||||
|
|
||||||
|
|
|
@ -24,10 +24,9 @@ class EnvironmentConfig(CommonConfig):
|
||||||
def __init__(self, args, command):
|
def __init__(self, args, command):
|
||||||
"""
|
"""
|
||||||
:type args: any
|
:type args: any
|
||||||
|
:type command: str
|
||||||
"""
|
"""
|
||||||
super(EnvironmentConfig, self).__init__(args)
|
super(EnvironmentConfig, self).__init__(args, command)
|
||||||
|
|
||||||
self.command = command
|
|
||||||
|
|
||||||
self.local = args.local is True
|
self.local = args.local is True
|
||||||
|
|
||||||
|
|
|
@ -83,6 +83,10 @@ def docker_pull(args, image):
|
||||||
:type args: EnvironmentConfig
|
:type args: EnvironmentConfig
|
||||||
:type image: str
|
: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:
|
if not args.docker_pull:
|
||||||
display.warning('Skipping docker pull for "%s". Image may be out-of-date.' % image)
|
display.warning('Skipping docker pull for "%s". Image may be out-of-date.' % image)
|
||||||
return
|
return
|
||||||
|
@ -149,6 +153,17 @@ def docker_run(args, image, options, cmd=None):
|
||||||
raise ApplicationError('Failed to run docker image "%s".' % image)
|
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):
|
def docker_rm(args, container_id):
|
||||||
"""
|
"""
|
||||||
:type args: EnvironmentConfig
|
: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)
|
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 cmd: list[str]
|
||||||
:type capture: bool
|
:type capture: bool
|
||||||
:type stdin: file | None
|
:type stdin: file | None
|
||||||
:type stdout: file | None
|
:type stdout: file | None
|
||||||
|
:type always: bool
|
||||||
:rtype: str | None, str | None
|
:rtype: str | None, str | None
|
||||||
"""
|
"""
|
||||||
env = docker_environment()
|
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():
|
def docker_environment():
|
||||||
|
|
|
@ -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
|
|
@ -737,10 +737,13 @@ class MissingEnvironmentVariable(ApplicationError):
|
||||||
|
|
||||||
class CommonConfig(object):
|
class CommonConfig(object):
|
||||||
"""Configuration common to all commands."""
|
"""Configuration common to all commands."""
|
||||||
def __init__(self, args):
|
def __init__(self, args, command):
|
||||||
"""
|
"""
|
||||||
:type args: any
|
:type args: any
|
||||||
|
:type command: str
|
||||||
"""
|
"""
|
||||||
|
self.command = command
|
||||||
|
|
||||||
self.color = args.color # type: bool
|
self.color = args.color # type: bool
|
||||||
self.explain = args.explain # type: bool
|
self.explain = args.explain # type: bool
|
||||||
self.verbosity = args.verbosity # type: int
|
self.verbosity = args.verbosity # type: int
|
||||||
|
|
|
@ -117,4 +117,6 @@ function cleanup
|
||||||
|
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
ansible-test env --dump --show --color -v
|
||||||
|
|
||||||
"test/utils/shippable/${script}.sh" "${test}"
|
"test/utils/shippable/${script}.sh" "${test}"
|
||||||
|
|
Loading…
Reference in New Issue