summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMatt Clay <matt@mystile.com>2016-11-29 21:21:53 -0800
committerGitHub <noreply@github.com>2016-11-29 21:21:53 -0800
commit6bbd92e422fcba608cd00ddd38a4c1f03dca301a (patch)
tree6cd5b7cacb78ddadb41ffe45b5f4ea9c7a1cac79
parentd95eac16ebf2ab8293d91adf39763eead4bf0a2e (diff)
downloadansible-6bbd92e422fcba608cd00ddd38a4c1f03dca301a.tar.gz
Initial ansible-test implementation. (#18556)
-rw-r--r--.coveragerc24
-rw-r--r--.gitignore9
-rw-r--r--shippable.yml79
-rw-r--r--test/compile/python2.4-skip.txt51
-rw-r--r--test/compile/python2.6-skip.txt2
-rw-r--r--test/compile/python3.5-skip.txt2
-rw-r--r--test/integration/targets/add_host/aliases1
-rw-r--r--test/integration/targets/ansible/aliases1
-rw-r--r--test/integration/targets/apache2_module/aliases2
-rw-r--r--test/integration/targets/apt/aliases2
-rw-r--r--test/integration/targets/apt_repository/aliases3
-rw-r--r--test/integration/targets/args/aliases1
-rw-r--r--test/integration/targets/assemble/aliases1
-rw-r--r--test/integration/targets/async/aliases3
-rw-r--r--test/integration/targets/async_extra_data/aliases4
-rw-r--r--test/integration/targets/authorized_key/aliases2
-rw-r--r--test/integration/targets/become/aliases2
-rw-r--r--test/integration/targets/binary/aliases1
-rw-r--r--test/integration/targets/binary_modules_posix/aliases1
-rw-r--r--test/integration/targets/binary_modules_winrm/aliases1
-rw-r--r--test/integration/targets/blocks/aliases1
-rw-r--r--test/integration/targets/changed_when/aliases1
-rw-r--r--test/integration/targets/check_mode/aliases1
-rw-r--r--test/integration/targets/command_shell/aliases3
-rw-r--r--test/integration/targets/conditionals/aliases1
-rw-r--r--test/integration/targets/connection_chroot/aliases2
-rw-r--r--test/integration/targets/connection_docker/aliases0
-rw-r--r--test/integration/targets/connection_jail/aliases0
-rw-r--r--test/integration/targets/connection_libvirt_lxc/aliases0
-rw-r--r--test/integration/targets/connection_local/aliases1
-rw-r--r--test/integration/targets/connection_lxc/aliases0
-rw-r--r--test/integration/targets/connection_lxd/aliases0
-rw-r--r--test/integration/targets/connection_paramiko_ssh/aliases2
-rw-r--r--test/integration/targets/connection_ssh/aliases2
-rw-r--r--test/integration/targets/connection_winrm/aliases1
-rw-r--r--test/integration/targets/copy/aliases2
-rw-r--r--test/integration/targets/delegate_to/aliases1
-rw-r--r--test/integration/targets/dnf/aliases2
-rw-r--r--test/integration/targets/docker/aliases2
-rw-r--r--test/integration/targets/embedded_module/aliases1
-rw-r--r--test/integration/targets/environment/aliases1
-rw-r--r--test/integration/targets/facts_d/aliases1
-rw-r--r--test/integration/targets/failed_when/aliases1
-rw-r--r--test/integration/targets/fetch/aliases1
-rw-r--r--test/integration/targets/file/aliases1
-rw-r--r--test/integration/targets/filters/aliases1
-rw-r--r--test/integration/targets/gathering_facts/aliases2
-rw-r--r--test/integration/targets/gem/aliases2
-rw-r--r--test/integration/targets/get_url/aliases4
-rw-r--r--test/integration/targets/git/aliases3
-rw-r--r--test/integration/targets/group_by/aliases1
-rw-r--r--test/integration/targets/handlers/aliases1
-rw-r--r--test/integration/targets/hash/aliases1
-rw-r--r--test/integration/targets/hg/aliases2
-rw-r--r--test/integration/targets/hosts_field/aliases1
-rw-r--r--test/integration/targets/ignore_errors/aliases1
-rw-r--r--test/integration/targets/include_vars/aliases1
-rw-r--r--test/integration/targets/includes/aliases1
-rw-r--r--test/integration/targets/iterators/aliases2
-rw-r--r--test/integration/targets/lineinfile/aliases1
-rw-r--r--test/integration/targets/lookup_paths/aliases1
-rw-r--r--test/integration/targets/lookup_properties/aliases1
-rw-r--r--test/integration/targets/lookups/aliases1
-rw-r--r--test/integration/targets/loops/aliases1
-rw-r--r--test/integration/targets/mount/aliases3
-rw-r--r--test/integration/targets/mysql_db/aliases4
-rw-r--r--test/integration/targets/mysql_user/aliases4
-rw-r--r--test/integration/targets/mysql_variables/aliases4
-rw-r--r--test/integration/targets/no_log/aliases1
-rw-r--r--test/integration/targets/parsing/aliases1
-rw-r--r--test/integration/targets/ping/aliases1
-rw-r--r--test/integration/targets/pip/aliases2
-rw-r--r--test/integration/targets/postgresql/aliases8
-rw-r--r--test/integration/targets/pull_limit_inventory/aliases1
-rw-r--r--test/integration/targets/pull_no_127/aliases1
-rw-r--r--test/integration/targets/pull_run/aliases1
-rw-r--r--test/integration/targets/script/aliases1
-rw-r--r--test/integration/targets/service/aliases6
-rw-r--r--test/integration/targets/special_vars/aliases1
-rw-r--r--test/integration/targets/stat/aliases1
-rw-r--r--test/integration/targets/subversion/aliases1
-rw-r--r--test/integration/targets/synchronize/aliases1
-rw-r--r--test/integration/targets/tags/aliases1
-rw-r--r--test/integration/targets/task_ordering/aliases1
-rw-r--r--test/integration/targets/template/aliases2
-rw-r--r--test/integration/targets/templating_settings/aliases1
-rw-r--r--test/integration/targets/test_infra/aliases1
-rw-r--r--test/integration/targets/unarchive/aliases2
-rw-r--r--test/integration/targets/unicode/aliases1
-rw-r--r--test/integration/targets/until/aliases1
-rw-r--r--test/integration/targets/uri/aliases5
-rw-r--r--test/integration/targets/var_blending/aliases1
-rw-r--r--test/integration/targets/var_precedence/aliases1
-rw-r--r--test/integration/targets/vault/aliases1
-rw-r--r--test/integration/targets/win_async_wrapper/aliases2
-rw-r--r--test/integration/targets/win_command/aliases1
-rw-r--r--test/integration/targets/win_copy/aliases1
-rw-r--r--test/integration/targets/win_feature/aliases1
-rw-r--r--test/integration/targets/win_fetch/aliases1
-rw-r--r--test/integration/targets/win_fetch/tasks/main.yml4
-rw-r--r--test/integration/targets/win_file/aliases1
-rw-r--r--test/integration/targets/win_get_url/aliases1
-rw-r--r--test/integration/targets/win_group/aliases1
-rw-r--r--test/integration/targets/win_lineinfile/aliases1
-rw-r--r--test/integration/targets/win_msi/aliases1
-rw-r--r--test/integration/targets/win_package/aliases1
-rw-r--r--test/integration/targets/win_ping/aliases1
-rw-r--r--test/integration/targets/win_raw/aliases1
-rw-r--r--test/integration/targets/win_regedit/aliases1
-rw-r--r--test/integration/targets/win_regmerge/aliases1
-rw-r--r--test/integration/targets/win_script/aliases1
-rw-r--r--test/integration/targets/win_service/aliases1
-rw-r--r--test/integration/targets/win_setup/aliases1
-rw-r--r--test/integration/targets/win_shell/aliases1
-rw-r--r--test/integration/targets/win_slurp/aliases1
-rw-r--r--test/integration/targets/win_slurp/tasks/main.yml1
-rw-r--r--test/integration/targets/win_stat/aliases1
-rw-r--r--test/integration/targets/win_template/aliases1
-rw-r--r--test/integration/targets/win_user/aliases1
-rw-r--r--test/integration/targets/yum/aliases2
-rw-r--r--test/integration/targets/zypper/aliases2
-rw-r--r--test/integration/targets/zypper_repository/aliases2
-rw-r--r--test/results/coverage/.keep0
-rw-r--r--test/results/junit/.keep0
-rw-r--r--test/results/logs/.keep0
-rw-r--r--test/results/reports/.keep0
l---------test/runner/ansible-test1
l---------test/runner/injector/ansible1
l---------test/runner/injector/ansible-console1
l---------test/runner/injector/ansible-doc1
l---------test/runner/injector/ansible-galaxy1
l---------test/runner/injector/ansible-playbook1
l---------test/runner/injector/ansible-pull1
l---------test/runner/injector/ansible-vault1
l---------test/runner/injector/cover1
l---------test/runner/injector/cover21
l---------test/runner/injector/cover2.41
l---------test/runner/injector/cover2.61
l---------test/runner/injector/cover2.71
l---------test/runner/injector/cover31
l---------test/runner/injector/cover3.51
-rwxr-xr-xtest/runner/injector/injector.py184
l---------test/runner/injector/pytest1
l---------test/runner/injector/runner1
l---------test/runner/injector/runner21
l---------test/runner/injector/runner2.41
l---------test/runner/injector/runner2.61
l---------test/runner/injector/runner2.71
l---------test/runner/injector/runner31
l---------test/runner/injector/runner3.51
-rw-r--r--test/runner/lib/__init__.py0
-rw-r--r--test/runner/lib/ansible_util.py34
-rw-r--r--test/runner/lib/changes.py165
-rw-r--r--test/runner/lib/classification.py326
-rw-r--r--test/runner/lib/core_ci.py340
-rw-r--r--test/runner/lib/cover.py148
-rw-r--r--test/runner/lib/delegation.py331
-rw-r--r--test/runner/lib/executor.py1253
-rw-r--r--test/runner/lib/git.py76
-rw-r--r--test/runner/lib/http.py122
-rw-r--r--test/runner/lib/manage_ci.py142
-rw-r--r--test/runner/lib/pytar.py69
-rw-r--r--test/runner/lib/target.py530
-rw-r--r--test/runner/lib/thread.py48
-rw-r--r--test/runner/lib/util.py415
-rwxr-xr-xtest/runner/reorganize-tests.sh235
-rw-r--r--test/runner/requirements/ansible-test.txt1
-rw-r--r--test/runner/requirements/constraints.txt2
-rw-r--r--test/runner/requirements/coverage.txt1
-rw-r--r--test/runner/requirements/integration.txt8
-rw-r--r--test/runner/requirements/sanity.txt5
-rw-r--r--test/runner/requirements/units.txt11
-rw-r--r--test/runner/requirements/windows-integration.txt4
-rw-r--r--test/runner/setup/docker.sh18
-rw-r--r--test/runner/setup/remote.sh69
-rwxr-xr-xtest/runner/test.py446
-rw-r--r--test/runner/tox.ini9
-rw-r--r--test/sanity/ansible-doc/skip.txt5
-rwxr-xr-xtest/sanity/code-smell/no-basestring.sh1
-rwxr-xr-xtest/sanity/code-smell/pylint-ansible-test.sh18
-rw-r--r--test/sanity/code-smell/skip.txt1
-rw-r--r--test/sanity/shellcheck/skip.txt1
-rw-r--r--test/sanity/validate-modules/skip.txt7
-rwxr-xr-xtest/utils/shippable/freebsd.sh12
-rwxr-xr-xtest/utils/shippable/linux.sh11
-rwxr-xr-xtest/utils/shippable/osx.sh12
-rwxr-xr-xtest/utils/shippable/other.sh15
-rwxr-xr-xtest/utils/shippable/shippable.sh40
-rwxr-xr-xtest/utils/shippable/timing.sh5
-rwxr-xr-xtest/utils/shippable/units.sh7
-rwxr-xr-xtest/utils/shippable/windows.sh38
191 files changed, 5483 insertions, 48 deletions
diff --git a/.coveragerc b/.coveragerc
index 812fc3b139..848e804c42 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -1,4 +1,22 @@
-[report]
+# This configuration file is used for manual execution of coverage
+# as well as for tests run through ansible-test.
+
+[run]
+branch = True
+
+# Enable concurrency. This also enables parallel mode, which results in
+# multiple coverage files being created. Concurrency allows us to collect
+# results from multiple tests simultaneously, as well as supporting multiple
+# test runs, such as from integration tests.
+concurrency = multiprocessing
+
+# When running tests through ansible-test, this option is overridden by
+# the COVERAGE_FILE environment variable. This option is present for
+# convenience when running coverage manually from this directory.
+data_file = test/results/coverage/coverage
+
+# Don't collect or report code coverage from files matching these patterns.
omit =
- */python?.?/*
- */site-packages/nose/*
+ */python*/dist-packages/*
+ */python*/site-packages/*
+ */pytest
diff --git a/.gitignore b/.gitignore
index 4d01e0160b..d258abe030 100644
--- a/.gitignore
+++ b/.gitignore
@@ -52,7 +52,6 @@ credentials.yml
# test output
*.retry
*.out
-.coverage
.tox
.cache
results.xml
@@ -67,3 +66,11 @@ ansible.egg-info/
/shippable/
# Release directory
packaging/release/ansible_release
+/.cache/
+/test/results/coverage/coverage*
+/test/results/reports/coverage.xml
+/test/results/reports/coverage/
+/test/results/junit/*.xml
+/test/results/logs/*.log
+/test/integration/inventory.remote
+/test/integration/inventory.winrm
diff --git a/shippable.yml b/shippable.yml
index 31fc99e08f..f14f454177 100644
--- a/shippable.yml
+++ b/shippable.yml
@@ -8,60 +8,51 @@ matrix:
exclude:
- env: TEST=none
include:
- - env: TEST=remote TARGET=all PLATFORM=osx VERSION=10.11
+ - env: TEST=other
+ - env: TEST=units
- - env: TEST=remote TARGET=all PLATFORM=freebsd VERSION=10.3-STABLE PRIVILEGED=true
+ - env: TEST=osx/10.11
- - env: TEST=remote TARGET=ci_win1 PLATFORM=windows VERSION=2012-R2_RTM
- - env: TEST=remote TARGET=ci_win2 PLATFORM=windows VERSION=2012-R2_RTM
- - env: TEST=remote TARGET=ci_win3 PLATFORM=windows VERSION=2012-R2_RTM
+ - env: TEST=freebsd/10.3-STABLE
- - env: TEST=integration TARGET=destructive IMAGE=ansible/ansible:centos6
- - env: TEST=integration TARGET=destructive IMAGE=ansible/ansible:centos7
- - env: TEST=integration TARGET=destructive IMAGE=ansible/ansible:fedora-rawhide
- - env: TEST=integration TARGET=destructive IMAGE=ansible/ansible:fedora23
- - env: TEST=integration TARGET=destructive IMAGE=ansible/ansible:opensuseleap
- - env: TEST=integration TARGET=destructive IMAGE=ansible/ansible:ubuntu1204 PRIVILEGED=true
- - env: TEST=integration TARGET=destructive IMAGE=ansible/ansible:ubuntu1404 PRIVILEGED=true
- - env: TEST=integration TARGET=destructive IMAGE=ansible/ansible:ubuntu1604
- - env: TEST=integration TARGET=destructive IMAGE=ansible/ansible:ubuntu1604py3 PYTHON3=1
+ - env: TEST=windows/1
+ - env: TEST=windows/2
+ - env: TEST=windows/3
- - env: TEST=integration TARGET=non_destructive IMAGE=ansible/ansible:centos6
- - env: TEST=integration TARGET=non_destructive IMAGE=ansible/ansible:centos7 PRIVILEGED=true
- - env: TEST=integration TARGET=non_destructive IMAGE=ansible/ansible:fedora-rawhide PRIVILEGED=true
- - env: TEST=integration TARGET=non_destructive IMAGE=ansible/ansible:fedora23 PRIVILEGED=true
- - env: TEST=integration TARGET=non_destructive IMAGE=ansible/ansible:opensuseleap PRIVILEGED=true
- - env: TEST=integration TARGET=non_destructive IMAGE=ansible/ansible:ubuntu1204 PRIVILEGED=true
- - env: TEST=integration TARGET=non_destructive IMAGE=ansible/ansible:ubuntu1404 PRIVILEGED=true
- - env: TEST=integration TARGET=non_destructive IMAGE=ansible/ansible:ubuntu1604 PRIVILEGED=true
- - env: TEST=integration TARGET=non_destructive IMAGE=ansible/ansible:ubuntu1604py3 PYTHON3=1 PRIVILEGED=true
+ - env: TEST=linux/centos6/1
+ - env: TEST=linux/centos7/1
+ - env: TEST=linux/fedora-rawhide/1
+ - env: TEST=linux/fedora23/1
+ - env: TEST=linux/opensuseleap/1
+ - env: TEST=linux/ubuntu1204/1
+ - env: TEST=linux/ubuntu1404/1
+ - env: TEST=linux/ubuntu1604/1
+ - env: TEST=linux/ubuntu1604py3/1
- - env: TEST=integration TARGET=other IMAGE=ansible/ansible:centos6
- - env: TEST=integration TARGET=other IMAGE=ansible/ansible:centos7
- - env: TEST=integration TARGET=other IMAGE=ansible/ansible:fedora-rawhide
- - env: TEST=integration TARGET=other IMAGE=ansible/ansible:fedora23
- - env: TEST=integration TARGET=other IMAGE=ansible/ansible:opensuseleap
- - env: TEST=integration TARGET=other IMAGE=ansible/ansible:ubuntu1204
- - env: TEST=integration TARGET=other IMAGE=ansible/ansible:ubuntu1404
- - env: TEST=integration TARGET=other IMAGE=ansible/ansible:ubuntu1604
- - env: TEST=integration TARGET=other IMAGE=ansible/ansible:ubuntu1604py3 PYTHON3=1
+ - env: TEST=linux/centos6/2
+ - env: TEST=linux/centos7/2
+ - env: TEST=linux/fedora-rawhide/2
+ - env: TEST=linux/fedora23/2
+ - env: TEST=linux/opensuseleap/2
+ - env: TEST=linux/ubuntu1204/2
+ - env: TEST=linux/ubuntu1404/2
+ - env: TEST=linux/ubuntu1604/2
+ - env: TEST=linux/ubuntu1604py3/2
- - env: TEST=sanity INSTALL_DEPS=1 TOXENV=py24
- python: 2.7
- - env: TEST=sanity INSTALL_DEPS=1 TOXENV=py26
- python: 2.6
- - env: TEST=sanity INSTALL_DEPS=1 TOXENV=py27
- python: 2.7
- - env: TEST=sanity INSTALL_DEPS=1 TOXENV=py35
- python: 3.5
-
- - env: TEST=code-smell INSTALL_DEPS=1
- python: 2.7
+ - env: TEST=linux/centos6/3
+ - env: TEST=linux/centos7/3
+ - env: TEST=linux/fedora-rawhide/3
+ - env: TEST=linux/fedora23/3
+ - env: TEST=linux/opensuseleap/3
+ - env: TEST=linux/ubuntu1204/3
+ - env: TEST=linux/ubuntu1404/3
+ - env: TEST=linux/ubuntu1604/3
+ - env: TEST=linux/ubuntu1604py3/3
build:
pre_ci_boot:
options: "--privileged=false --net=bridge"
ci:
- - test/utils/shippable/ci.sh
+ - test/utils/shippable/timing.sh test/utils/shippable/shippable.sh
integrations:
notifications:
diff --git a/test/compile/python2.4-skip.txt b/test/compile/python2.4-skip.txt
new file mode 100644
index 0000000000..156f8d3a2c
--- /dev/null
+++ b/test/compile/python2.4-skip.txt
@@ -0,0 +1,51 @@
+/contrib/
+/docsite/
+/hacking/
+/lib/ansible/cli/
+/lib/ansible/compat/
+/lib/ansible/config/
+/lib/ansible/constants.py
+/lib/ansible/errors/
+/lib/ansible/executor/
+/lib/ansible/galaxy/
+/lib/ansible/__init__.py
+/lib/ansible/inventory/
+/lib/ansible/modules/core/cloud/[^/]+/(?!(ec2_facts.py|_ec2_ami_search.py))
+/lib/ansible/modules/extras/cloud/
+/lib/ansible/modules/extras/clustering/consul.*.py
+/lib/ansible/modules/extras/clustering/znode.py
+/lib/ansible/modules/extras/database/influxdb/
+/lib/ansible/modules/extras/database/mssql/
+/lib/ansible/modules/extras/infrastructure/foreman/
+/lib/ansible/modules/extras/monitoring/zabbix.*.py
+/lib/ansible/modules/extras/network/f5/
+/lib/ansible/modules/extras/network/nmcli.py
+/lib/ansible/modules/extras/notification/pushbullet.py
+/lib/ansible/modules/extras/packaging/language/maven_artifact.py
+/lib/ansible/modules/extras/packaging/os/dnf.py
+/lib/ansible/modules/extras/packaging/os/layman.py
+/lib/ansible/modules/extras/remote_management/ipmi/
+/lib/ansible/modules/extras/univention/
+/lib/ansible/modules/extras/web_infrastructure/letsencrypt.py
+/lib/ansible/modules/__init__.py
+/lib/ansible/module_utils/a10.py
+/lib/ansible/module_utils/azure_rm_common.py
+/lib/ansible/module_utils/cloud.py
+/lib/ansible/module_utils/docker_common.py
+/lib/ansible/module_utils/ec2.py
+/lib/ansible/module_utils/gcdns.py
+/lib/ansible/module_utils/gce.py
+/lib/ansible/module_utils/gcp.py
+/lib/ansible/module_utils/lxd.py
+/lib/ansible/module_utils/openstack.py
+/lib/ansible/module_utils/rax.py
+/lib/ansible/module_utils/vca.py
+/lib/ansible/module_utils/vmware.py
+/lib/ansible/parsing/
+/lib/ansible/playbook/
+/lib/ansible/plugins/
+/lib/ansible/release.py
+/lib/ansible/template/
+/lib/ansible/utils/
+/lib/ansible/vars/
+/test/
diff --git a/test/compile/python2.6-skip.txt b/test/compile/python2.6-skip.txt
new file mode 100644
index 0000000000..4637820e9d
--- /dev/null
+++ b/test/compile/python2.6-skip.txt
@@ -0,0 +1,2 @@
+/contrib/inventory/vagrant.py
+/hacking/dump_playbook_attributes.py
diff --git a/test/compile/python3.5-skip.txt b/test/compile/python3.5-skip.txt
new file mode 100644
index 0000000000..5b126fb85e
--- /dev/null
+++ b/test/compile/python3.5-skip.txt
@@ -0,0 +1,2 @@
+/examples/scripts/uptime.py
+/test/samples/multi.py
diff --git a/test/integration/targets/add_host/aliases b/test/integration/targets/add_host/aliases
new file mode 100644
index 0000000000..7af8b7f05b
--- /dev/null
+++ b/test/integration/targets/add_host/aliases
@@ -0,0 +1 @@
+posix/ci/group2
diff --git a/test/integration/targets/ansible/aliases b/test/integration/targets/ansible/aliases
new file mode 100644
index 0000000000..79d8b9285e
--- /dev/null
+++ b/test/integration/targets/ansible/aliases
@@ -0,0 +1 @@
+posix/ci/group3
diff --git a/test/integration/targets/apache2_module/aliases b/test/integration/targets/apache2_module/aliases
new file mode 100644
index 0000000000..8e7d715f9c
--- /dev/null
+++ b/test/integration/targets/apache2_module/aliases
@@ -0,0 +1,2 @@
+destructive
+posix/ci/group1
diff --git a/test/integration/targets/apt/aliases b/test/integration/targets/apt/aliases
new file mode 100644
index 0000000000..8e7d715f9c
--- /dev/null
+++ b/test/integration/targets/apt/aliases
@@ -0,0 +1,2 @@
+destructive
+posix/ci/group1
diff --git a/test/integration/targets/apt_repository/aliases b/test/integration/targets/apt_repository/aliases
new file mode 100644
index 0000000000..cfaec2bded
--- /dev/null
+++ b/test/integration/targets/apt_repository/aliases
@@ -0,0 +1,3 @@
+apt_key
+destructive
+posix/ci/group1
diff --git a/test/integration/targets/args/aliases b/test/integration/targets/args/aliases
new file mode 100644
index 0000000000..79d8b9285e
--- /dev/null
+++ b/test/integration/targets/args/aliases
@@ -0,0 +1 @@
+posix/ci/group3
diff --git a/test/integration/targets/assemble/aliases b/test/integration/targets/assemble/aliases
new file mode 100644
index 0000000000..7af8b7f05b
--- /dev/null
+++ b/test/integration/targets/assemble/aliases
@@ -0,0 +1 @@
+posix/ci/group2
diff --git a/test/integration/targets/async/aliases b/test/integration/targets/async/aliases
new file mode 100644
index 0000000000..8fb1c443d3
--- /dev/null
+++ b/test/integration/targets/async/aliases
@@ -0,0 +1,3 @@
+async_status
+async_wrapper
+posix/ci/group2
diff --git a/test/integration/targets/async_extra_data/aliases b/test/integration/targets/async_extra_data/aliases
new file mode 100644
index 0000000000..4cfb31df36
--- /dev/null
+++ b/test/integration/targets/async_extra_data/aliases
@@ -0,0 +1,4 @@
+needs/ssh
+posix/ci/group3
+skip/freebsd
+skip/osx
diff --git a/test/integration/targets/authorized_key/aliases b/test/integration/targets/authorized_key/aliases
new file mode 100644
index 0000000000..53b32510a0
--- /dev/null
+++ b/test/integration/targets/authorized_key/aliases
@@ -0,0 +1,2 @@
+needs/root
+posix/ci/group2
diff --git a/test/integration/targets/become/aliases b/test/integration/targets/become/aliases
new file mode 100644
index 0000000000..8e7d715f9c
--- /dev/null
+++ b/test/integration/targets/become/aliases
@@ -0,0 +1,2 @@
+destructive
+posix/ci/group1
diff --git a/test/integration/targets/binary/aliases b/test/integration/targets/binary/aliases
new file mode 100644
index 0000000000..7af8b7f05b
--- /dev/null
+++ b/test/integration/targets/binary/aliases
@@ -0,0 +1 @@
+posix/ci/group2
diff --git a/test/integration/targets/binary_modules_posix/aliases b/test/integration/targets/binary_modules_posix/aliases
new file mode 100644
index 0000000000..79d8b9285e
--- /dev/null
+++ b/test/integration/targets/binary_modules_posix/aliases
@@ -0,0 +1 @@
+posix/ci/group3
diff --git a/test/integration/targets/binary_modules_winrm/aliases b/test/integration/targets/binary_modules_winrm/aliases
new file mode 100644
index 0000000000..ee0ed5974e
--- /dev/null
+++ b/test/integration/targets/binary_modules_winrm/aliases
@@ -0,0 +1 @@
+windows/ci/group2
diff --git a/test/integration/targets/blocks/aliases b/test/integration/targets/blocks/aliases
new file mode 100644
index 0000000000..79d8b9285e
--- /dev/null
+++ b/test/integration/targets/blocks/aliases
@@ -0,0 +1 @@
+posix/ci/group3
diff --git a/test/integration/targets/changed_when/aliases b/test/integration/targets/changed_when/aliases
new file mode 100644
index 0000000000..7af8b7f05b
--- /dev/null
+++ b/test/integration/targets/changed_when/aliases
@@ -0,0 +1 @@
+posix/ci/group2
diff --git a/test/integration/targets/check_mode/aliases b/test/integration/targets/check_mode/aliases
new file mode 100644
index 0000000000..79d8b9285e
--- /dev/null
+++ b/test/integration/targets/check_mode/aliases
@@ -0,0 +1 @@
+posix/ci/group3
diff --git a/test/integration/targets/command_shell/aliases b/test/integration/targets/command_shell/aliases
new file mode 100644
index 0000000000..dc4f3c4502
--- /dev/null
+++ b/test/integration/targets/command_shell/aliases
@@ -0,0 +1,3 @@
+command
+posix/ci/group2
+shell
diff --git a/test/integration/targets/conditionals/aliases b/test/integration/targets/conditionals/aliases
new file mode 100644
index 0000000000..7af8b7f05b
--- /dev/null
+++ b/test/integration/targets/conditionals/aliases
@@ -0,0 +1 @@
+posix/ci/group2
diff --git a/test/integration/targets/connection_chroot/aliases b/test/integration/targets/connection_chroot/aliases
new file mode 100644
index 0000000000..e33b2794c9
--- /dev/null
+++ b/test/integration/targets/connection_chroot/aliases
@@ -0,0 +1,2 @@
+needs/root
+posix/ci/group3
diff --git a/test/integration/targets/connection_docker/aliases b/test/integration/targets/connection_docker/aliases
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/test/integration/targets/connection_docker/aliases
diff --git a/test/integration/targets/connection_jail/aliases b/test/integration/targets/connection_jail/aliases
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/test/integration/targets/connection_jail/aliases
diff --git a/test/integration/targets/connection_libvirt_lxc/aliases b/test/integration/targets/connection_libvirt_lxc/aliases
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/test/integration/targets/connection_libvirt_lxc/aliases
diff --git a/test/integration/targets/connection_local/aliases b/test/integration/targets/connection_local/aliases
new file mode 100644
index 0000000000..79d8b9285e
--- /dev/null
+++ b/test/integration/targets/connection_local/aliases
@@ -0,0 +1 @@
+posix/ci/group3
diff --git a/test/integration/targets/connection_lxc/aliases b/test/integration/targets/connection_lxc/aliases
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/test/integration/targets/connection_lxc/aliases
diff --git a/test/integration/targets/connection_lxd/aliases b/test/integration/targets/connection_lxd/aliases
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/test/integration/targets/connection_lxd/aliases
diff --git a/test/integration/targets/connection_paramiko_ssh/aliases b/test/integration/targets/connection_paramiko_ssh/aliases
new file mode 100644
index 0000000000..c9362781f6
--- /dev/null
+++ b/test/integration/targets/connection_paramiko_ssh/aliases
@@ -0,0 +1,2 @@
+needs/ssh
+posix/ci/group3
diff --git a/test/integration/targets/connection_ssh/aliases b/test/integration/targets/connection_ssh/aliases
new file mode 100644
index 0000000000..c9362781f6
--- /dev/null
+++ b/test/integration/targets/connection_ssh/aliases
@@ -0,0 +1,2 @@
+needs/ssh
+posix/ci/group3
diff --git a/test/integration/targets/connection_winrm/aliases b/test/integration/targets/connection_winrm/aliases
new file mode 100644
index 0000000000..c6d6198167
--- /dev/null
+++ b/test/integration/targets/connection_winrm/aliases
@@ -0,0 +1 @@
+windows/ci/group3
diff --git a/test/integration/targets/copy/aliases b/test/integration/targets/copy/aliases
new file mode 100644
index 0000000000..53b32510a0
--- /dev/null
+++ b/test/integration/targets/copy/aliases
@@ -0,0 +1,2 @@
+needs/root
+posix/ci/group2
diff --git a/test/integration/targets/delegate_to/aliases b/test/integration/targets/delegate_to/aliases
new file mode 100644
index 0000000000..79d8b9285e
--- /dev/null
+++ b/test/integration/targets/delegate_to/aliases
@@ -0,0 +1 @@
+posix/ci/group3
diff --git a/test/integration/targets/dnf/aliases b/test/integration/targets/dnf/aliases
new file mode 100644
index 0000000000..8e7d715f9c
--- /dev/null
+++ b/test/integration/targets/dnf/aliases
@@ -0,0 +1,2 @@
+destructive
+posix/ci/group1
diff --git a/test/integration/targets/docker/aliases b/test/integration/targets/docker/aliases
new file mode 100644
index 0000000000..8e7d715f9c
--- /dev/null
+++ b/test/integration/targets/docker/aliases
@@ -0,0 +1,2 @@
+destructive
+posix/ci/group1
diff --git a/test/integration/targets/embedded_module/aliases b/test/integration/targets/embedded_module/aliases
new file mode 100644
index 0000000000..7af8b7f05b
--- /dev/null
+++ b/test/integration/targets/embedded_module/aliases
@@ -0,0 +1 @@
+posix/ci/group2
diff --git a/test/integration/targets/environment/aliases b/test/integration/targets/environment/aliases
new file mode 100644
index 0000000000..79d8b9285e
--- /dev/null
+++ b/test/integration/targets/environment/aliases
@@ -0,0 +1 @@
+posix/ci/group3
diff --git a/test/integration/targets/facts_d/aliases b/test/integration/targets/facts_d/aliases
new file mode 100644
index 0000000000..7af8b7f05b
--- /dev/null
+++ b/test/integration/targets/facts_d/aliases
@@ -0,0 +1 @@
+posix/ci/group2
diff --git a/test/integration/targets/failed_when/aliases b/test/integration/targets/failed_when/aliases
new file mode 100644
index 0000000000..7af8b7f05b
--- /dev/null
+++ b/test/integration/targets/failed_when/aliases
@@ -0,0 +1 @@
+posix/ci/group2
diff --git a/test/integration/targets/fetch/aliases b/test/integration/targets/fetch/aliases
new file mode 100644
index 0000000000..7af8b7f05b
--- /dev/null
+++ b/test/integration/targets/fetch/aliases
@@ -0,0 +1 @@
+posix/ci/group2
diff --git a/test/integration/targets/file/aliases b/test/integration/targets/file/aliases
new file mode 100644
index 0000000000..7af8b7f05b
--- /dev/null
+++ b/test/integration/targets/file/aliases
@@ -0,0 +1 @@
+posix/ci/group2
diff --git a/test/integration/targets/filters/aliases b/test/integration/targets/filters/aliases
new file mode 100644
index 0000000000..7af8b7f05b
--- /dev/null
+++ b/test/integration/targets/filters/aliases
@@ -0,0 +1 @@
+posix/ci/group2
diff --git a/test/integration/targets/gathering_facts/aliases b/test/integration/targets/gathering_facts/aliases
new file mode 100644
index 0000000000..be46696124
--- /dev/null
+++ b/test/integration/targets/gathering_facts/aliases
@@ -0,0 +1,2 @@
+posix/ci/group3
+skip/osx
diff --git a/test/integration/targets/gem/aliases b/test/integration/targets/gem/aliases
new file mode 100644
index 0000000000..8e7d715f9c
--- /dev/null
+++ b/test/integration/targets/gem/aliases
@@ -0,0 +1,2 @@
+destructive
+posix/ci/group1
diff --git a/test/integration/targets/get_url/aliases b/test/integration/targets/get_url/aliases
new file mode 100644
index 0000000000..5d97a9be5e
--- /dev/null
+++ b/test/integration/targets/get_url/aliases
@@ -0,0 +1,4 @@
+destructive
+posix/ci/group1
+skip/freebsd
+skip/osx
diff --git a/test/integration/targets/git/aliases b/test/integration/targets/git/aliases
new file mode 100644
index 0000000000..796f3e41ed
--- /dev/null
+++ b/test/integration/targets/git/aliases
@@ -0,0 +1,3 @@
+destructive
+posix/ci/group1
+skip/osx
diff --git a/test/integration/targets/group_by/aliases b/test/integration/targets/group_by/aliases
new file mode 100644
index 0000000000..79d8b9285e
--- /dev/null
+++ b/test/integration/targets/group_by/aliases
@@ -0,0 +1 @@
+posix/ci/group3
diff --git a/test/integration/targets/handlers/aliases b/test/integration/targets/handlers/aliases
new file mode 100644
index 0000000000..79d8b9285e
--- /dev/null
+++ b/test/integration/targets/handlers/aliases
@@ -0,0 +1 @@
+posix/ci/group3
diff --git a/test/integration/targets/hash/aliases b/test/integration/targets/hash/aliases
new file mode 100644
index 0000000000..79d8b9285e
--- /dev/null
+++ b/test/integration/targets/hash/aliases
@@ -0,0 +1 @@
+posix/ci/group3
diff --git a/test/integration/targets/hg/aliases b/test/integration/targets/hg/aliases
new file mode 100644
index 0000000000..fbbde3a161
--- /dev/null
+++ b/test/integration/targets/hg/aliases
@@ -0,0 +1,2 @@
+posix/ci/group2
+skip/python3
diff --git a/test/integration/targets/hosts_field/aliases b/test/integration/targets/hosts_field/aliases
new file mode 100644
index 0000000000..79d8b9285e
--- /dev/null
+++ b/test/integration/targets/hosts_field/aliases
@@ -0,0 +1 @@
+posix/ci/group3
diff --git a/test/integration/targets/ignore_errors/aliases b/test/integration/targets/ignore_errors/aliases
new file mode 100644
index 0000000000..7af8b7f05b
--- /dev/null
+++ b/test/integration/targets/ignore_errors/aliases
@@ -0,0 +1 @@
+posix/ci/group2
diff --git a/test/integration/targets/include_vars/aliases b/test/integration/targets/include_vars/aliases
new file mode 100644
index 0000000000..7af8b7f05b
--- /dev/null
+++ b/test/integration/targets/include_vars/aliases
@@ -0,0 +1 @@
+posix/ci/group2
diff --git a/test/integration/targets/includes/aliases b/test/integration/targets/includes/aliases
new file mode 100644
index 0000000000..79d8b9285e
--- /dev/null
+++ b/test/integration/targets/includes/aliases
@@ -0,0 +1 @@
+posix/ci/group3
diff --git a/test/integration/targets/iterators/aliases b/test/integration/targets/iterators/aliases
new file mode 100644
index 0000000000..c364b48f38
--- /dev/null
+++ b/test/integration/targets/iterators/aliases
@@ -0,0 +1,2 @@
+posix/ci/group2
+skip/osx
diff --git a/test/integration/targets/lineinfile/aliases b/test/integration/targets/lineinfile/aliases
new file mode 100644
index 0000000000..7af8b7f05b
--- /dev/null
+++ b/test/integration/targets/lineinfile/aliases
@@ -0,0 +1 @@
+posix/ci/group2
diff --git a/test/integration/targets/lookup_paths/aliases b/test/integration/targets/lookup_paths/aliases
new file mode 100644
index 0000000000..79d8b9285e
--- /dev/null
+++ b/test/integration/targets/lookup_paths/aliases
@@ -0,0 +1 @@
+posix/ci/group3
diff --git a/test/integration/targets/lookup_properties/aliases b/test/integration/targets/lookup_properties/aliases
new file mode 100644
index 0000000000..79d8b9285e
--- /dev/null
+++ b/test/integration/targets/lookup_properties/aliases
@@ -0,0 +1 @@
+posix/ci/group3
diff --git a/test/integration/targets/lookups/aliases b/test/integration/targets/lookups/aliases
new file mode 100644
index 0000000000..7af8b7f05b
--- /dev/null
+++ b/test/integration/targets/lookups/aliases
@@ -0,0 +1 @@
+posix/ci/group2
diff --git a/test/integration/targets/loops/aliases b/test/integration/targets/loops/aliases
new file mode 100644
index 0000000000..7af8b7f05b
--- /dev/null
+++ b/test/integration/targets/loops/aliases
@@ -0,0 +1 @@
+posix/ci/group2
diff --git a/test/integration/targets/mount/aliases b/test/integration/targets/mount/aliases
new file mode 100644
index 0000000000..83710ef50c
--- /dev/null
+++ b/test/integration/targets/mount/aliases
@@ -0,0 +1,3 @@
+needs/privileged
+needs/root
+posix/ci/group2
diff --git a/test/integration/targets/mysql_db/aliases b/test/integration/targets/mysql_db/aliases
new file mode 100644
index 0000000000..5d97a9be5e
--- /dev/null
+++ b/test/integration/targets/mysql_db/aliases
@@ -0,0 +1,4 @@
+destructive
+posix/ci/group1
+skip/freebsd
+skip/osx
diff --git a/test/integration/targets/mysql_user/aliases b/test/integration/targets/mysql_user/aliases
new file mode 100644
index 0000000000..5d97a9be5e
--- /dev/null
+++ b/test/integration/targets/mysql_user/aliases
@@ -0,0 +1,4 @@
+destructive
+posix/ci/group1
+skip/freebsd
+skip/osx
diff --git a/test/integration/targets/mysql_variables/aliases b/test/integration/targets/mysql_variables/aliases
new file mode 100644
index 0000000000..5d97a9be5e
--- /dev/null
+++ b/test/integration/targets/mysql_variables/aliases
@@ -0,0 +1,4 @@
+destructive
+posix/ci/group1
+skip/freebsd
+skip/osx
diff --git a/test/integration/targets/no_log/aliases b/test/integration/targets/no_log/aliases
new file mode 100644
index 0000000000..79d8b9285e
--- /dev/null
+++ b/test/integration/targets/no_log/aliases
@@ -0,0 +1 @@
+posix/ci/group3
diff --git a/test/integration/targets/parsing/aliases b/test/integration/targets/parsing/aliases
new file mode 100644
index 0000000000..79d8b9285e
--- /dev/null
+++ b/test/integration/targets/parsing/aliases
@@ -0,0 +1 @@
+posix/ci/group3
diff --git a/test/integration/targets/ping/aliases b/test/integration/targets/ping/aliases
new file mode 100644
index 0000000000..7af8b7f05b
--- /dev/null
+++ b/test/integration/targets/ping/aliases
@@ -0,0 +1 @@
+posix/ci/group2
diff --git a/test/integration/targets/pip/aliases b/test/integration/targets/pip/aliases
new file mode 100644
index 0000000000..8e7d715f9c
--- /dev/null
+++ b/test/integration/targets/pip/aliases
@@ -0,0 +1,2 @@
+destructive
+posix/ci/group1
diff --git a/test/integration/targets/postgresql/aliases b/test/integration/targets/postgresql/aliases
new file mode 100644
index 0000000000..57a7cd8e89
--- /dev/null
+++ b/test/integration/targets/postgresql/aliases
@@ -0,0 +1,8 @@
+destructive
+needs/privileged
+posix/ci/group1
+postgresql_db
+postgresql_privs
+postgresql_user
+skip/freebsd
+skip/osx
diff --git a/test/integration/targets/pull_limit_inventory/aliases b/test/integration/targets/pull_limit_inventory/aliases
new file mode 100644
index 0000000000..79d8b9285e
--- /dev/null
+++ b/test/integration/targets/pull_limit_inventory/aliases
@@ -0,0 +1 @@
+posix/ci/group3
diff --git a/test/integration/targets/pull_no_127/aliases b/test/integration/targets/pull_no_127/aliases
new file mode 100644
index 0000000000..79d8b9285e
--- /dev/null
+++ b/test/integration/targets/pull_no_127/aliases
@@ -0,0 +1 @@
+posix/ci/group3
diff --git a/test/integration/targets/pull_run/aliases b/test/integration/targets/pull_run/aliases
new file mode 100644
index 0000000000..79d8b9285e
--- /dev/null
+++ b/test/integration/targets/pull_run/aliases
@@ -0,0 +1 @@
+posix/ci/group3
diff --git a/test/integration/targets/script/aliases b/test/integration/targets/script/aliases
new file mode 100644
index 0000000000..7af8b7f05b
--- /dev/null
+++ b/test/integration/targets/script/aliases
@@ -0,0 +1 @@
+posix/ci/group2
diff --git a/test/integration/targets/service/aliases b/test/integration/targets/service/aliases
new file mode 100644
index 0000000000..b46d4eb449
--- /dev/null
+++ b/test/integration/targets/service/aliases
@@ -0,0 +1,6 @@
+destructive
+needs/privileged
+posix/ci/group1
+skip/freebsd
+skip/osx
+skip/python3
diff --git a/test/integration/targets/special_vars/aliases b/test/integration/targets/special_vars/aliases
new file mode 100644
index 0000000000..7af8b7f05b
--- /dev/null
+++ b/test/integration/targets/special_vars/aliases
@@ -0,0 +1 @@
+posix/ci/group2
diff --git a/test/integration/targets/stat/aliases b/test/integration/targets/stat/aliases
new file mode 100644
index 0000000000..7af8b7f05b
--- /dev/null
+++ b/test/integration/targets/stat/aliases
@@ -0,0 +1 @@
+posix/ci/group2
diff --git a/test/integration/targets/subversion/aliases b/test/integration/targets/subversion/aliases
new file mode 100644
index 0000000000..7af8b7f05b
--- /dev/null
+++ b/test/integration/targets/subversion/aliases
@@ -0,0 +1 @@
+posix/ci/group2
diff --git a/test/integration/targets/synchronize/aliases b/test/integration/targets/synchronize/aliases
new file mode 100644
index 0000000000..7af8b7f05b
--- /dev/null
+++ b/test/integration/targets/synchronize/aliases
@@ -0,0 +1 @@
+posix/ci/group2
diff --git a/test/integration/targets/tags/aliases b/test/integration/targets/tags/aliases
new file mode 100644
index 0000000000..79d8b9285e
--- /dev/null
+++ b/test/integration/targets/tags/aliases
@@ -0,0 +1 @@
+posix/ci/group3
diff --git a/test/integration/targets/task_ordering/aliases b/test/integration/targets/task_ordering/aliases
new file mode 100644
index 0000000000..7af8b7f05b
--- /dev/null
+++ b/test/integration/targets/task_ordering/aliases
@@ -0,0 +1 @@
+posix/ci/group2
diff --git a/test/integration/targets/template/aliases b/test/integration/targets/template/aliases
new file mode 100644
index 0000000000..53b32510a0
--- /dev/null
+++ b/test/integration/targets/template/aliases
@@ -0,0 +1,2 @@
+needs/root
+posix/ci/group2
diff --git a/test/integration/targets/templating_settings/aliases b/test/integration/targets/templating_settings/aliases
new file mode 100644
index 0000000000..79d8b9285e
--- /dev/null
+++ b/test/integration/targets/templating_settings/aliases
@@ -0,0 +1 @@
+posix/ci/group3
diff --git a/test/integration/targets/test_infra/aliases b/test/integration/targets/test_infra/aliases
new file mode 100644
index 0000000000..79d8b9285e
--- /dev/null
+++ b/test/integration/targets/test_infra/aliases
@@ -0,0 +1 @@
+posix/ci/group3
diff --git a/test/integration/targets/unarchive/aliases b/test/integration/targets/unarchive/aliases
new file mode 100644
index 0000000000..53b32510a0
--- /dev/null
+++ b/test/integration/targets/unarchive/aliases
@@ -0,0 +1,2 @@
+needs/root
+posix/ci/group2
diff --git a/test/integration/targets/unicode/aliases b/test/integration/targets/unicode/aliases
new file mode 100644
index 0000000000..79d8b9285e
--- /dev/null
+++ b/test/integration/targets/unicode/aliases
@@ -0,0 +1 @@
+posix/ci/group3
diff --git a/test/integration/targets/until/aliases b/test/integration/targets/until/aliases
new file mode 100644
index 0000000000..7af8b7f05b
--- /dev/null
+++ b/test/integration/targets/until/aliases
@@ -0,0 +1 @@
+posix/ci/group2
diff --git a/test/integration/targets/uri/aliases b/test/integration/targets/uri/aliases
new file mode 100644
index 0000000000..eccb0e7659
--- /dev/null
+++ b/test/integration/targets/uri/aliases
@@ -0,0 +1,5 @@
+destructive
+posix/ci/group1
+skip/freebsd
+skip/osx
+skip/python3
diff --git a/test/integration/targets/var_blending/aliases b/test/integration/targets/var_blending/aliases
new file mode 100644
index 0000000000..79d8b9285e
--- /dev/null
+++ b/test/integration/targets/var_blending/aliases
@@ -0,0 +1 @@
+posix/ci/group3
diff --git a/test/integration/targets/var_precedence/aliases b/test/integration/targets/var_precedence/aliases
new file mode 100644
index 0000000000..79d8b9285e
--- /dev/null
+++ b/test/integration/targets/var_precedence/aliases
@@ -0,0 +1 @@
+posix/ci/group3
diff --git a/test/integration/targets/vault/aliases b/test/integration/targets/vault/aliases
new file mode 100644
index 0000000000..79d8b9285e
--- /dev/null
+++ b/test/integration/targets/vault/aliases
@@ -0,0 +1 @@
+posix/ci/group3
diff --git a/test/integration/targets/win_async_wrapper/aliases b/test/integration/targets/win_async_wrapper/aliases
new file mode 100644
index 0000000000..22fa0ae0cc
--- /dev/null
+++ b/test/integration/targets/win_async_wrapper/aliases
@@ -0,0 +1,2 @@
+async_status
+windows/ci/group3
diff --git a/test/integration/targets/win_command/aliases b/test/integration/targets/win_command/aliases
new file mode 100644
index 0000000000..c6d6198167
--- /dev/null
+++ b/test/integration/targets/win_command/aliases
@@ -0,0 +1 @@
+windows/ci/group3
diff --git a/test/integration/targets/win_copy/aliases b/test/integration/targets/win_copy/aliases
new file mode 100644
index 0000000000..ee0ed5974e
--- /dev/null
+++ b/test/integration/targets/win_copy/aliases
@@ -0,0 +1 @@
+windows/ci/group2
diff --git a/test/integration/targets/win_feature/aliases b/test/integration/targets/win_feature/aliases
new file mode 100644
index 0000000000..c6d6198167
--- /dev/null
+++ b/test/integration/targets/win_feature/aliases
@@ -0,0 +1 @@
+windows/ci/group3
diff --git a/test/integration/targets/win_fetch/aliases b/test/integration/targets/win_fetch/aliases
new file mode 100644
index 0000000000..10e03fc2bf
--- /dev/null
+++ b/test/integration/targets/win_fetch/aliases
@@ -0,0 +1 @@
+windows/ci/group1
diff --git a/test/integration/targets/win_fetch/tasks/main.yml b/test/integration/targets/win_fetch/tasks/main.yml
index f8b1865744..b91412f4c0 100644
--- a/test/integration/targets/win_fetch/tasks/main.yml
+++ b/test/integration/targets/win_fetch/tasks/main.yml
@@ -16,6 +16,10 @@
# You should have received a copy of the GNU General Public License
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
+- name: define host-specific output_dir
+ set_fact:
+ output_dir: "{{ output_dir }}/{{ inventory_hostname }}"
+
- name: clean out the test directory
local_action: file name={{ output_dir|mandatory }} state=absent
run_once: true
diff --git a/test/integration/targets/win_file/aliases b/test/integration/targets/win_file/aliases
new file mode 100644
index 0000000000..ee0ed5974e
--- /dev/null
+++ b/test/integration/targets/win_file/aliases
@@ -0,0 +1 @@
+windows/ci/group2
diff --git a/test/integration/targets/win_get_url/aliases b/test/integration/targets/win_get_url/aliases
new file mode 100644
index 0000000000..ee0ed5974e
--- /dev/null
+++ b/test/integration/targets/win_get_url/aliases
@@ -0,0 +1 @@
+windows/ci/group2
diff --git a/test/integration/targets/win_group/aliases b/test/integration/targets/win_group/aliases
new file mode 100644
index 0000000000..ee0ed5974e
--- /dev/null
+++ b/test/integration/targets/win_group/aliases
@@ -0,0 +1 @@
+windows/ci/group2
diff --git a/test/integration/targets/win_lineinfile/aliases b/test/integration/targets/win_lineinfile/aliases
new file mode 100644
index 0000000000..ee0ed5974e
--- /dev/null
+++ b/test/integration/targets/win_lineinfile/aliases
@@ -0,0 +1 @@
+windows/ci/group2
diff --git a/test/integration/targets/win_msi/aliases b/test/integration/targets/win_msi/aliases
new file mode 100644
index 0000000000..ee0ed5974e
--- /dev/null
+++ b/test/integration/targets/win_msi/aliases
@@ -0,0 +1 @@
+windows/ci/group2
diff --git a/test/integration/targets/win_package/aliases b/test/integration/targets/win_package/aliases
new file mode 100644
index 0000000000..ee0ed5974e
--- /dev/null
+++ b/test/integration/targets/win_package/aliases
@@ -0,0 +1 @@
+windows/ci/group2
diff --git a/test/integration/targets/win_ping/aliases b/test/integration/targets/win_ping/aliases
new file mode 100644
index 0000000000..10e03fc2bf
--- /dev/null
+++ b/test/integration/targets/win_ping/aliases
@@ -0,0 +1 @@
+windows/ci/group1
diff --git a/test/integration/targets/win_raw/aliases b/test/integration/targets/win_raw/aliases
new file mode 100644
index 0000000000..10e03fc2bf
--- /dev/null
+++ b/test/integration/targets/win_raw/aliases
@@ -0,0 +1 @@
+windows/ci/group1
diff --git a/test/integration/targets/win_regedit/aliases b/test/integration/targets/win_regedit/aliases
new file mode 100644
index 0000000000..10e03fc2bf
--- /dev/null
+++ b/test/integration/targets/win_regedit/aliases
@@ -0,0 +1 @@
+windows/ci/group1
diff --git a/test/integration/targets/win_regmerge/aliases b/test/integration/targets/win_regmerge/aliases
new file mode 100644
index 0000000000..10e03fc2bf
--- /dev/null
+++ b/test/integration/targets/win_regmerge/aliases
@@ -0,0 +1 @@
+windows/ci/group1
diff --git a/test/integration/targets/win_script/aliases b/test/integration/targets/win_script/aliases
new file mode 100644
index 0000000000..10e03fc2bf
--- /dev/null
+++ b/test/integration/targets/win_script/aliases
@@ -0,0 +1 @@
+windows/ci/group1
diff --git a/test/integration/targets/win_service/aliases b/test/integration/targets/win_service/aliases
new file mode 100644
index 0000000000..c6d6198167
--- /dev/null
+++ b/test/integration/targets/win_service/aliases
@@ -0,0 +1 @@
+windows/ci/group3
diff --git a/test/integration/targets/win_setup/aliases b/test/integration/targets/win_setup/aliases
new file mode 100644
index 0000000000..10e03fc2bf
--- /dev/null
+++ b/test/integration/targets/win_setup/aliases
@@ -0,0 +1 @@
+windows/ci/group1
diff --git a/test/integration/targets/win_shell/aliases b/test/integration/targets/win_shell/aliases
new file mode 100644
index 0000000000..c6d6198167
--- /dev/null
+++ b/test/integration/targets/win_shell/aliases
@@ -0,0 +1 @@
+windows/ci/group3
diff --git a/test/integration/targets/win_slurp/aliases b/test/integration/targets/win_slurp/aliases
new file mode 100644
index 0000000000..10e03fc2bf
--- /dev/null
+++ b/test/integration/targets/win_slurp/aliases
@@ -0,0 +1 @@
+windows/ci/group1
diff --git a/test/integration/targets/win_slurp/tasks/main.yml b/test/integration/targets/win_slurp/tasks/main.yml
index b72b74238b..ac18fd0a19 100644
--- a/test/integration/targets/win_slurp/tasks/main.yml
+++ b/test/integration/targets/win_slurp/tasks/main.yml
@@ -31,6 +31,7 @@
- name: test slurping a large binary file with path param and backslashes
slurp: path="C:\Windows\explorer.exe"
register: slurp_path_backslashes
+ no_log: true
- name: check slurp result with path param and backslashes
assert:
diff --git a/test/integration/targets/win_stat/aliases b/test/integration/targets/win_stat/aliases
new file mode 100644
index 0000000000..ee0ed5974e
--- /dev/null
+++ b/test/integration/targets/win_stat/aliases
@@ -0,0 +1 @@
+windows/ci/group2
diff --git a/test/integration/targets/win_template/aliases b/test/integration/targets/win_template/aliases
new file mode 100644
index 0000000000..ee0ed5974e
--- /dev/null
+++ b/test/integration/targets/win_template/aliases
@@ -0,0 +1 @@
+windows/ci/group2
diff --git a/test/integration/targets/win_user/aliases b/test/integration/targets/win_user/aliases
new file mode 100644
index 0000000000..c6d6198167
--- /dev/null
+++ b/test/integration/targets/win_user/aliases
@@ -0,0 +1 @@
+windows/ci/group3
diff --git a/test/integration/targets/yum/aliases b/test/integration/targets/yum/aliases
new file mode 100644
index 0000000000..8e7d715f9c
--- /dev/null
+++ b/test/integration/targets/yum/aliases
@@ -0,0 +1,2 @@
+destructive
+posix/ci/group1
diff --git a/test/integration/targets/zypper/aliases b/test/integration/targets/zypper/aliases
new file mode 100644
index 0000000000..8e7d715f9c
--- /dev/null
+++ b/test/integration/targets/zypper/aliases
@@ -0,0 +1,2 @@
+destructive
+posix/ci/group1
diff --git a/test/integration/targets/zypper_repository/aliases b/test/integration/targets/zypper_repository/aliases
new file mode 100644
index 0000000000..8e7d715f9c
--- /dev/null
+++ b/test/integration/targets/zypper_repository/aliases
@@ -0,0 +1,2 @@
+destructive
+posix/ci/group1
diff --git a/test/results/coverage/.keep b/test/results/coverage/.keep
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/test/results/coverage/.keep
diff --git a/test/results/junit/.keep b/test/results/junit/.keep
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/test/results/junit/.keep
diff --git a/test/results/logs/.keep b/test/results/logs/.keep
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/test/results/logs/.keep
diff --git a/test/results/reports/.keep b/test/results/reports/.keep
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/test/results/reports/.keep
diff --git a/test/runner/ansible-test b/test/runner/ansible-test
new file mode 120000
index 0000000000..9465664311
--- /dev/null
+++ b/test/runner/ansible-test
@@ -0,0 +1 @@
+test.py \ No newline at end of file
diff --git a/test/runner/injector/ansible b/test/runner/injector/ansible
new file mode 120000
index 0000000000..1f9d09cbf2
--- /dev/null
+++ b/test/runner/injector/ansible
@@ -0,0 +1 @@
+injector.py \ No newline at end of file
diff --git a/test/runner/injector/ansible-console b/test/runner/injector/ansible-console
new file mode 120000
index 0000000000..1f9d09cbf2
--- /dev/null
+++ b/test/runner/injector/ansible-console
@@ -0,0 +1 @@
+injector.py \ No newline at end of file
diff --git a/test/runner/injector/ansible-doc b/test/runner/injector/ansible-doc
new file mode 120000
index 0000000000..1f9d09cbf2
--- /dev/null
+++ b/test/runner/injector/ansible-doc
@@ -0,0 +1 @@
+injector.py \ No newline at end of file
diff --git a/test/runner/injector/ansible-galaxy b/test/runner/injector/ansible-galaxy
new file mode 120000
index 0000000000..1f9d09cbf2
--- /dev/null
+++ b/test/runner/injector/ansible-galaxy
@@ -0,0 +1 @@
+injector.py \ No newline at end of file
diff --git a/test/runner/injector/ansible-playbook b/test/runner/injector/ansible-playbook
new file mode 120000
index 0000000000..1f9d09cbf2
--- /dev/null
+++ b/test/runner/injector/ansible-playbook
@@ -0,0 +1 @@
+injector.py \ No newline at end of file
diff --git a/test/runner/injector/ansible-pull b/test/runner/injector/ansible-pull
new file mode 120000
index 0000000000..1f9d09cbf2
--- /dev/null
+++ b/test/runner/injector/ansible-pull
@@ -0,0 +1 @@
+injector.py \ No newline at end of file
diff --git a/test/runner/injector/ansible-vault b/test/runner/injector/ansible-vault
new file mode 120000
index 0000000000..1f9d09cbf2
--- /dev/null
+++ b/test/runner/injector/ansible-vault
@@ -0,0 +1 @@
+injector.py \ No newline at end of file
diff --git a/test/runner/injector/cover b/test/runner/injector/cover
new file mode 120000
index 0000000000..1f9d09cbf2
--- /dev/null
+++ b/test/runner/injector/cover
@@ -0,0 +1 @@
+injector.py \ No newline at end of file
diff --git a/test/runner/injector/cover2 b/test/runner/injector/cover2
new file mode 120000
index 0000000000..1f9d09cbf2
--- /dev/null
+++ b/test/runner/injector/cover2
@@ -0,0 +1 @@
+injector.py \ No newline at end of file
diff --git a/test/runner/injector/cover2.4 b/test/runner/injector/cover2.4
new file mode 120000
index 0000000000..1f9d09cbf2
--- /dev/null
+++ b/test/runner/injector/cover2.4
@@ -0,0 +1 @@
+injector.py \ No newline at end of file
diff --git a/test/runner/injector/cover2.6 b/test/runner/injector/cover2.6
new file mode 120000
index 0000000000..1f9d09cbf2
--- /dev/null
+++ b/test/runner/injector/cover2.6
@@ -0,0 +1 @@
+injector.py \ No newline at end of file
diff --git a/test/runner/injector/cover2.7 b/test/runner/injector/cover2.7
new file mode 120000
index 0000000000..1f9d09cbf2
--- /dev/null
+++ b/test/runner/injector/cover2.7
@@ -0,0 +1 @@
+injector.py \ No newline at end of file
diff --git a/test/runner/injector/cover3 b/test/runner/injector/cover3
new file mode 120000
index 0000000000..1f9d09cbf2
--- /dev/null
+++ b/test/runner/injector/cover3
@@ -0,0 +1 @@
+injector.py \ No newline at end of file
diff --git a/test/runner/injector/cover3.5 b/test/runner/injector/cover3.5
new file mode 120000
index 0000000000..1f9d09cbf2
--- /dev/null
+++ b/test/runner/injector/cover3.5
@@ -0,0 +1 @@
+injector.py \ No newline at end of file
diff --git a/test/runner/injector/injector.py b/test/runner/injector/injector.py
new file mode 100755
index 0000000000..a0c068e148
--- /dev/null
+++ b/test/runner/injector/injector.py
@@ -0,0 +1,184 @@
+#!/usr/bin/env python
+"""Code coverage wrapper."""
+
+from __future__ import absolute_import, print_function
+
+import errno
+import os
+import sys
+import pipes
+import logging
+import getpass
+
+logger = logging.getLogger('injector') # pylint: disable=locally-disabled, invalid-name
+
+
+def main():
+ """Main entry point."""
+ formatter = logging.Formatter('%(asctime)s ' + str(os.getpid()) + ' %(levelname)s %(message)s')
+ log_name = 'ansible-test-coverage.%s.log' % getpass.getuser()
+ self_dir = os.path.dirname(os.path.abspath(__file__))
+
+ handler = logging.FileHandler(os.path.join('/tmp', log_name))
+ handler.setFormatter(formatter)
+ logger.addHandler(handler)
+
+ handler = logging.FileHandler(os.path.abspath(os.path.join(self_dir, '..', 'logs', log_name)))
+ handler.setFormatter(formatter)
+ logger.addHandler(handler)
+
+ logger.setLevel(logging.DEBUG)
+
+ try:
+ logger.debug('Self: %s', __file__)
+ logger.debug('Arguments: %s', ' '.join(pipes.quote(c) for c in sys.argv))
+
+ if os.path.basename(__file__).startswith('runner'):
+ args, env = runner()
+ elif os.path.basename(__file__).startswith('cover'):
+ args, env = cover()
+ else:
+ args, env = injector()
+
+ logger.debug('Run command: %s', ' '.join(pipes.quote(c) for c in args))
+
+ try:
+ cwd = os.getcwd()
+ except OSError as ex:
+ if ex.errno != errno.EACCES:
+ raise
+ cwd = None
+
+ logger.debug('Working directory: %s', cwd or '?')
+
+ for key in sorted(env.keys()):
+ logger.debug('%s=%s', key, env[key])
+
+ os.execvpe(args[0], args, env)
+ except Exception as ex:
+ logger.fatal(ex)
+ raise
+
+
+def injector():
+ """
+ :rtype: list[str], dict[str, str]
+ """
+ self_dir = os.path.dirname(os.path.abspath(__file__))
+ command = os.path.basename(__file__)
+ mode = os.environ.get('ANSIBLE_TEST_COVERAGE')
+ version = os.environ.get('ANSIBLE_TEST_PYTHON_VERSION', '')
+ executable = find_executable(command)
+
+ if mode in ('coverage', 'version'):
+ if mode == 'coverage':
+ args, env = coverage_command(self_dir, version)
+ args += [executable]
+ tool = 'cover'
+ else:
+ interpreter = find_executable('python' + version)
+ args, env = [interpreter, executable], os.environ.copy()
+ tool = 'runner'
+
+ if command in ('ansible', 'ansible-playbook', 'ansible-pull'):
+ interpreter = find_executable(tool + version)
+ args += ['--extra-vars', 'ansible_python_interpreter=' + interpreter]
+ else:
+ args, env = [executable], os.environ.copy()
+
+ args += sys.argv[1:]
+
+ return args, env
+
+
+def runner():
+ """
+ :rtype: list[str], dict[str, str]
+ """
+ command = os.path.basename(__file__)
+ version = command.replace('runner', '')
+
+ interpreter = find_executable('python' + version)
+ args, env = [interpreter], os.environ.copy()
+
+ args += sys.argv[1:]
+
+ return args, env
+
+
+def cover():
+ """
+ :rtype: list[str], dict[str, str]
+ """
+ self_dir = os.path.dirname(os.path.abspath(__file__))
+ command = os.path.basename(__file__)
+ version = command.replace('cover', '')
+
+ if len(sys.argv) > 1:
+ executable = sys.argv[1]
+ else:
+ executable = ''
+
+ if os.path.basename(executable).startswith('ansible_module_'):
+ args, env = coverage_command(self_dir, version)
+ else:
+ interpreter = find_executable('python' + version)
+ args, env = [interpreter], os.environ.copy()
+
+ args += sys.argv[1:]
+
+ return args, env
+
+
+def coverage_command(self_dir, version):
+ """
+ :type self_dir: str
+ :type version: str
+ :rtype: list[str], dict[str, str]
+ """
+ executable = 'coverage'
+
+ if version:
+ executable += '-%s' % version
+
+ args = [
+ find_executable(executable),
+ 'run',
+ '--append',
+ '--rcfile',
+ os.path.join(self_dir, '.coveragerc'),
+ ]
+
+ env = os.environ.copy()
+ env['COVERAGE_FILE'] = os.path.abspath(os.path.join(self_dir, '..', 'output', 'coverage'))
+
+ return args, env
+
+
+def find_executable(executable):
+ """
+ :type executable: str
+ :rtype: str
+ """
+ self = os.path.abspath(__file__)
+ path = os.environ.get('PATH', os.defpath)
+ seen_dirs = set()
+
+ for path_dir in path.split(os.pathsep):
+ if path_dir in seen_dirs:
+ continue
+
+ seen_dirs.add(path_dir)
+ candidate = os.path.abspath(os.path.join(path_dir, executable))
+
+ if candidate == self:
+ continue
+
+ if os.path.exists(candidate) and os.access(candidate, os.F_OK | os.X_OK):
+ return candidate
+
+ raise Exception('Executable "%s" not found in path: %s' % (executable, path))
+
+
+if __name__ == '__main__':
+ main()
diff --git a/test/runner/injector/pytest b/test/runner/injector/pytest
new file mode 120000
index 0000000000..1f9d09cbf2
--- /dev/null
+++ b/test/runner/injector/pytest
@@ -0,0 +1 @@
+injector.py \ No newline at end of file
diff --git a/test/runner/injector/runner b/test/runner/injector/runner
new file mode 120000
index 0000000000..1f9d09cbf2
--- /dev/null
+++ b/test/runner/injector/runner
@@ -0,0 +1 @@
+injector.py \ No newline at end of file
diff --git a/test/runner/injector/runner2 b/test/runner/injector/runner2
new file mode 120000
index 0000000000..1f9d09cbf2
--- /dev/null
+++ b/test/runner/injector/runner2
@@ -0,0 +1 @@
+injector.py \ No newline at end of file
diff --git a/test/runner/injector/runner2.4 b/test/runner/injector/runner2.4
new file mode 120000
index 0000000000..1f9d09cbf2
--- /dev/null
+++ b/test/runner/injector/runner2.4
@@ -0,0 +1 @@
+injector.py \ No newline at end of file
diff --git a/test/runner/injector/runner2.6 b/test/runner/injector/runner2.6
new file mode 120000
index 0000000000..1f9d09cbf2
--- /dev/null
+++ b/test/runner/injector/runner2.6
@@ -0,0 +1 @@
+injector.py \ No newline at end of file
diff --git a/test/runner/injector/runner2.7 b/test/runner/injector/runner2.7
new file mode 120000
index 0000000000..1f9d09cbf2
--- /dev/null
+++ b/test/runner/injector/runner2.7
@@ -0,0 +1 @@
+injector.py \ No newline at end of file
diff --git a/test/runner/injector/runner3 b/test/runner/injector/runner3
new file mode 120000
index 0000000000..1f9d09cbf2
--- /dev/null
+++ b/test/runner/injector/runner3
@@ -0,0 +1 @@
+injector.py \ No newline at end of file
diff --git a/test/runner/injector/runner3.5 b/test/runner/injector/runner3.5
new file mode 120000
index 0000000000..1f9d09cbf2
--- /dev/null
+++ b/test/runner/injector/runner3.5
@@ -0,0 +1 @@
+injector.py \ No newline at end of file
diff --git a/test/runner/lib/__init__.py b/test/runner/lib/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/test/runner/lib/__init__.py
diff --git a/test/runner/lib/ansible_util.py b/test/runner/lib/ansible_util.py
new file mode 100644
index 0000000000..ae74db7408
--- /dev/null
+++ b/test/runner/lib/ansible_util.py
@@ -0,0 +1,34 @@
+"""Miscellaneous utility functions and classes specific to ansible cli tools."""
+
+from __future__ import absolute_import, print_function
+
+import os
+
+from lib.util import common_environment
+
+
+def ansible_environment(args):
+ """
+ :type args: CommonConfig
+ :rtype: dict[str, str]
+ """
+ env = common_environment()
+ path = env['PATH']
+
+ ansible_path = os.path.join(os.getcwd(), 'bin')
+
+ if not path.startswith(ansible_path + os.pathsep):
+ path = ansible_path + os.pathsep + path
+
+ ansible = dict(
+ ANSIBLE_FORCE_COLOR='%s' % 'true' if args.color else 'false',
+ ANSIBLE_DEPRECATION_WARNINGS='false',
+ ANSIBLE_CONFIG='/dev/null',
+ PYTHONPATH=os.path.abspath('lib'),
+ PAGER='/bin/cat',
+ PATH=path,
+ )
+
+ env.update(ansible)
+
+ return env
diff --git a/test/runner/lib/changes.py b/test/runner/lib/changes.py
new file mode 100644
index 0000000000..019b51e0f7
--- /dev/null
+++ b/test/runner/lib/changes.py
@@ -0,0 +1,165 @@
+"""Detect changes in Ansible code."""
+
+from __future__ import absolute_import, print_function
+
+import re
+import os
+
+from lib.util import (
+ ApplicationError,
+ SubprocessError,
+ MissingEnvironmentVariable,
+ CommonConfig,
+)
+
+from lib.http import (
+ HttpClient,
+ urlencode,
+)
+
+from lib.git import (
+ Git,
+)
+
+
+class InvalidBranch(ApplicationError):
+ """Exception for invalid branch specification."""
+ def __init__(self, branch, reason):
+ """
+ :type branch: str
+ :type reason: str
+ """
+ message = 'Invalid branch: %s\n%s' % (branch, reason)
+
+ super(InvalidBranch, self).__init__(message)
+
+ self.branch = branch
+
+
+class ChangeDetectionNotSupported(ApplicationError):
+ """Exception for cases where change detection is not supported."""
+ def __init__(self, message):
+ """
+ :type message: str
+ """
+ super(ChangeDetectionNotSupported, self).__init__(message)
+
+
+class ShippableChanges(object):
+ """Change information for Shippable build."""
+ def __init__(self, args, git):
+ """
+ :type args: CommonConfig
+ :type git: Git
+ """
+ self.args = args
+
+ try:
+ self.branch = os.environ['BRANCH']
+ self.is_pr = os.environ['IS_PULL_REQUEST'] == 'true'
+ self.is_tag = os.environ['IS_GIT_TAG'] == 'true'
+ self.commit = os.environ['COMMIT']
+ self.project_id = os.environ['PROJECT_ID']
+ except KeyError as ex:
+ raise MissingEnvironmentVariable(name=ex.args[0])
+
+ if self.is_tag:
+ raise ChangeDetectionNotSupported('Change detection is not supported for tags.')
+
+ if self.is_pr:
+ self.paths = sorted(git.get_diff_names([self.branch]))
+ else:
+ merge_runs = self.get_merge_runs(self.project_id, self.branch)
+ last_successful_commit = self.get_last_successful_commit(merge_runs)
+ self.paths = sorted(git.get_diff_names([last_successful_commit, self.commit]))
+
+ def get_merge_runs(self, project_id, branch):
+ """
+ :type project_id: str
+ :type branch: str
+ :rtype: list[dict]
+ """
+ params = dict(
+ isPullRequest='false',
+ projectIds=project_id,
+ branch=branch,
+ )
+
+ client = HttpClient(self.args, always=True)
+ response = client.get('https://api.shippable.com/runs?%s' % urlencode(params))
+ return response.json()
+
+ @staticmethod
+ def get_last_successful_commit(merge_runs):
+ """
+ :type merge_runs: list[dict]
+ :rtype: str
+ """
+ merge_runs = sorted(merge_runs, key=lambda r: r['createdAt'])
+ known_commits = set()
+ last_successful_commit = None
+
+ for merge_run in merge_runs:
+ commit_sha = merge_run['commitSha']
+ if commit_sha not in known_commits:
+ known_commits.add(commit_sha)
+ if merge_run['statusCode'] == 30:
+ last_successful_commit = commit_sha
+
+ return last_successful_commit
+
+
+class LocalChanges(object):
+ """Change information for local work."""
+ def __init__(self, args, git):
+ """
+ :type args: CommonConfig
+ :type git: Git
+ """
+ self.args = args
+ self.current_branch = git.get_branch()
+
+ if self.is_official_branch(self.current_branch):
+ raise InvalidBranch(branch=self.current_branch,
+ reason='Current branch is not a feature branch.')
+
+ self.fork_branch = None
+ self.fork_point = None
+
+ self.local_branches = sorted(git.get_branches())
+ self.official_branches = sorted([b for b in self.local_branches if self.is_official_branch(b)])
+
+ for self.fork_branch in self.official_branches:
+ try:
+ self.fork_point = git.get_branch_fork_point(self.fork_branch)
+ break
+ except SubprocessError:
+ pass
+
+ if self.fork_point is None:
+ raise ApplicationError('Unable to auto-detect fork branch and fork point.')
+
+ # tracked files (including unchanged)
+ self.tracked = sorted(git.get_file_names(['--cached']))
+ # untracked files (except ignored)
+ self.untracked = sorted(git.get_file_names(['--others', '--exclude-standard']))
+ # tracked changes (including deletions) committed since the branch was forked
+ self.committed = sorted(git.get_diff_names([self.fork_point, 'HEAD']))
+ # tracked changes (including deletions) which are staged
+ self.staged = sorted(git.get_diff_names(['--cached']))
+ # tracked changes (including deletions) which are not staged
+ self.unstaged = sorted(git.get_diff_names([]))
+
+ @staticmethod
+ def is_official_branch(name):
+ """
+ :type name: str
+ :rtype: bool
+ """
+ if name == 'devel':
+ return True
+
+ if re.match(r'^stable-[0-9]+\.[0-9]+$', name):
+ return True
+
+ return False
diff --git a/test/runner/lib/classification.py b/test/runner/lib/classification.py
new file mode 100644
index 0000000000..d89737d1e6
--- /dev/null
+++ b/test/runner/lib/classification.py
@@ -0,0 +1,326 @@
+"""Classify changes in Ansible code."""
+
+from __future__ import absolute_import, print_function
+
+import os
+
+from lib.target import (
+ walk_module_targets,
+ walk_integration_targets,
+ walk_units_targets,
+ walk_compile_targets,
+)
+
+from lib.util import (
+ display,
+)
+
+
+def categorize_changes(paths, verbose_command=None):
+ """
+ :type paths: list[str]
+ :type verbose_command: str
+ :rtype paths: dict[str, list[str]]
+ """
+ mapper = PathMapper()
+
+ commands = {
+ 'sanity': set(),
+ 'compile': set(),
+ 'units': set(),
+ 'integration': set(),
+ 'windows-integration': set(),
+ 'network-integration': set(),
+ }
+
+ display.info('Mapping %d changed file(s) to tests.' % len(paths))
+
+ for path in paths:
+ tests = mapper.classify(path)
+
+ if tests is None:
+ display.info('%s -> all' % path, verbosity=1)
+ tests = all_tests() # not categorized, run all tests
+ display.warning('Path not categorized: %s' % path)
+ else:
+ tests = dict((key, value) for key, value in tests.items() if value)
+
+ if verbose_command:
+ result = '%s: %s' % (verbose_command, tests.get(verbose_command) or 'none')
+
+ # identify targeted integration tests (those which only target a single integration command)
+ if 'integration' in verbose_command and tests.get(verbose_command):
+ if not any('integration' in command for command in tests.keys() if command != verbose_command):
+ result += ' (targeted)'
+ else:
+ result = '%s' % tests
+
+ display.info('%s -> %s' % (path, result), verbosity=1)
+
+ for command, target in tests.items():
+ commands[command].add(target)
+
+ for command in commands:
+ if any(t == 'all' for t in commands[command]):
+ commands[command] = set(['all'])
+
+ commands = dict((c, sorted(commands[c])) for c in commands.keys() if commands[c])
+
+ return commands
+
+
+class PathMapper(object):
+ """Map file paths to test commands and targets."""
+ def __init__(self):
+ self.integration_targets = list(walk_integration_targets())
+ self.module_targets = list(walk_module_targets())
+ self.compile_targets = list(walk_compile_targets())
+ self.units_targets = list(walk_units_targets())
+
+ self.compile_paths = set(t.path for t in self.compile_targets)
+ self.units_modules = set(t.module for t in self.units_targets if t.module)
+ self.units_paths = set(t.path for t in self.units_targets)
+
+ self.module_names_by_path = dict((t.path, t.module) for t in self.module_targets)
+ self.integration_targets_by_name = dict((t.name, t) for t in self.integration_targets)
+
+ self.posix_integration_by_module = dict((m, t.name) for t in self.integration_targets
+ if 'posix/' in t.aliases for m in t.modules)
+ self.windows_integration_by_module = dict((m, t.name) for t in self.integration_targets
+ if 'windows/' in t.aliases for m in t.modules)
+ self.network_integration_by_module = dict((m, t.name) for t in self.integration_targets
+ if 'network/' in t.aliases for m in t.modules)
+
+ def classify(self, path):
+ """
+ :type path: str
+ :rtype: dict[str, str] | None
+ """
+ result = self._classify(path)
+
+ # run all tests when no result given
+ if result is None:
+ return None
+
+ # compile path if eligible
+ if path in self.compile_paths:
+ result['compile'] = path
+
+ # run sanity on path unless result specified otherwise
+ if 'sanity' not in result:
+ result['sanity'] = path
+
+ return result
+
+ def _classify(self, path):
+ """
+ :type path: str
+ :rtype: dict[str, str] | None
+ """
+ filename = os.path.basename(path)
+ name, ext = os.path.splitext(filename)
+
+ minimal = {}
+
+ if path.startswith('.github/'):
+ return minimal
+
+ if path.startswith('bin/'):
+ return minimal
+
+ if path.startswith('contrib/'):
+ return {
+ 'units': 'test/units/contrib/'
+ }
+
+ if path.startswith('docs/'):
+ return minimal
+
+ if path.startswith('docs-api/'):
+ return minimal
+
+ if path.startswith('docsite/'):
+ return minimal
+
+ if path.startswith('examples/'):
+ return minimal
+
+ if path.startswith('hacking/'):
+ return minimal
+
+ if path.startswith('lib/ansible/modules/'):
+ module = self.module_names_by_path.get(path)
+
+ if module:
+ return {
+ 'units': module if module in self.units_modules else None,
+ 'integration': self.posix_integration_by_module.get(module) if ext == '.py' else None,
+ 'windows-integration': self.windows_integration_by_module.get(module) if ext == '.ps1' else None,
+ 'network-integration': self.network_integration_by_module.get(module),
+ }
+
+ return minimal
+
+ if path.startswith('lib/ansible/module_utils/'):
+ if ext == '.ps1':
+ return {
+ 'windows-integration': 'all',
+ }
+
+ if ext == '.py':
+ return {
+ 'integration': 'all',
+ 'network-integration': 'all',
+ 'units': 'all',
+ }
+
+ if path.startswith('lib/ansible/plugins/connection/'):
+ if name == '__init__':
+ return {
+ 'integration': 'all',
+ 'windows-integration': 'all',
+ 'network-integration': 'all',
+ 'units': 'test/units/plugins/connection/',
+ }
+
+ if name == 'winrm':
+ return {
+ 'windows-integration': 'all',
+ 'units': 'test/units/plugins/connection/',
+ }
+
+ if name == 'local':
+ return {
+ 'integration': 'all',
+ 'network-integration': 'all',
+ 'units': 'test/units/plugins/connections/',
+ }
+
+ if 'connection_%s' % name in self.integration_targets_by_name:
+ return {
+ 'integration': 'connection_%s' % name,
+ }
+
+ return minimal
+
+ if path.startswith('lib/ansible/utils/module_docs_fragments/'):
+ return {
+ 'sanity': 'all',
+ }
+
+ if path.startswith('lib/ansible/'):
+ return all_tests() # broad impact, run all tests
+
+ if path.startswith('packaging/'):
+ return minimal
+
+ if path.startswith('test/compile/'):
+ return {
+ 'compile': 'all',
+ }
+
+ if path.startswith('test/results/'):
+ return minimal
+
+ if path.startswith('test/integration/roles/'):
+ return minimal
+
+ if path.startswith('test/integration/targets/'):
+ target = self.integration_targets_by_name[path.split('/')[3]]
+
+ if 'hidden/' in target.aliases:
+ return {
+ 'integration': 'all',
+ 'windows-integration': 'all',
+ 'network-integration': 'all',
+ }
+
+ return {
+ 'integration': target.name if 'posix/' in target.aliases else None,
+ 'windows-integration': target.name if 'windows/' in target.aliases else None,
+ 'network-integration': target.name if 'network/' in target.aliases else None,
+ }
+
+ if path.startswith('test/integration/'):
+ return {
+ 'integration': 'all',
+ 'windows-integration': 'all',
+ 'network-integration': 'all',
+ }
+
+ if path.startswith('test/samples/'):
+ return minimal
+
+ if path.startswith('test/sanity/'):
+ return {
+ 'sanity': 'all', # test infrastructure, run all sanity checks
+ }
+
+ if path.startswith('test/units/'):
+ if path in self.units_paths:
+ return {
+ 'units': path,
+ }
+
+ return {
+ 'units': os.path.dirname(path),
+ }
+
+ if path.startswith('test/runner/'):
+ return all_tests() # test infrastructure, run all tests
+
+ if path.startswith('test/utils/shippable/'):
+ return all_tests() # test infrastructure, run all tests
+
+ if path.startswith('test/utils/'):
+ return minimal
+
+ if path == 'test/README.md':
+ return minimal
+
+ if path.startswith('ticket_stubs/'):
+ return minimal
+
+ if '/' not in path:
+ if path in (
+ '.gitattributes',
+ '.gitignore',
+ '.gitmodules',
+ '.mailmap',
+ 'tox.ini', # obsolete
+ 'COPYING',
+ 'VERSION',
+ 'Makefile',
+ 'setup.py',
+ ):
+ return minimal
+
+ if path in (
+ 'shippable.yml',
+ '.coveragerc',
+ ):
+ return all_tests() # test infrastructure, run all tests
+
+ if path == '.yamllint':
+ return {
+ 'sanity': 'all',
+ }
+
+ if ext in ('.md', '.rst', '.txt', '.xml', '.in'):
+ return minimal
+
+ return None # unknown, will result in fall-back to run all tests
+
+
+def all_tests():
+ """
+ :rtype: dict[str, str]
+ """
+ return {
+ 'sanity': 'all',
+ 'compile': 'all',
+ 'units': 'all',
+ 'integration': 'all',
+ 'windows-integration': 'all',
+ 'network-integration': 'all',
+ }
diff --git a/test/runner/lib/core_ci.py b/test/runner/lib/core_ci.py
new file mode 100644
index 0000000000..6aaae057f0
--- /dev/null
+++ b/test/runner/lib/core_ci.py
@@ -0,0 +1,340 @@
+"""Access Ansible Core CI remote services."""
+
+from __future__ import absolute_import, print_function
+
+import json
+import os
+import traceback
+import uuid
+import errno
+import time
+
+from lib.http import (
+ HttpClient,
+ HttpResponse,
+ HttpError,
+)
+
+from lib.util import (
+ ApplicationError,
+ run_command,
+ make_dirs,
+ CommonConfig,
+ display,
+ is_shippable,
+)
+
+
+class AnsibleCoreCI(object):
+ """Client for Ansible Core CI services."""
+ def __init__(self, args, platform, version, stage='prod', persist=True, name=None):
+ """
+ :type args: CommonConfig
+ :type platform: str
+ :type version: str
+ :type stage: str
+ :type persist: bool
+ :type name: str
+ """
+ self.args = args
+ self.platform = platform
+ self.version = version
+ self.stage = stage
+ self.client = HttpClient(args)
+ self.connection = None
+ self.instance_id = None
+ self.name = name if name else '%s-%s' % (self.platform, self.version)
+
+ if self.platform == 'windows':
+ self.ssh_key = None
+ self.endpoint = 'https://14blg63h2i.execute-api.us-east-1.amazonaws.com'
+ self.port = 5986
+ elif self.platform == 'freebsd':
+ self.ssh_key = SshKey(args)
+ self.endpoint = 'https://14blg63h2i.execute-api.us-east-1.amazonaws.com'
+ self.port = 22
+ elif self.platform == 'osx':
+ self.ssh_key = SshKey(args)
+ self.endpoint = 'https://osx.testing.ansible.com'
+ self.port = None
+ else:
+ raise ApplicationError('Unsupported platform: %s' % platform)
+
+ self.path = os.path.expanduser('~/.ansible/test/instances/%s-%s' % (self.name, self.stage))
+
+ if persist and self._load():
+ try:
+ display.info('Checking existing %s/%s instance %s.' % (self.platform, self.version, self.instance_id),
+ verbosity=1)
+
+ self.connection = self.get()
+
+ display.info('Loaded existing %s/%s instance %s.' % (self.platform, self.version, self.instance_id),
+ verbosity=1)
+ except HttpError as ex:
+ if ex.status != 404:
+ raise
+
+ self._clear()
+
+ display.info('Cleared stale %s/%s instance %s.' % (self.platform, self.version, self.instance_id),
+ verbosity=1)
+
+ self.instance_id = None
+ else:
+ self.instance_id = None
+ self._clear()
+
+ if self.instance_id:
+ self.started = True
+ else:
+ self.started = False
+ self.instance_id = str(uuid.uuid4())
+
+ display.info('Initializing new %s/%s instance %s.' % (self.platform, self.version, self.instance_id),
+ verbosity=1)
+
+ def start(self):
+ """Start instance."""
+ if is_shippable():
+ self.start_shippable()
+ else:
+ self.start_remote()
+
+ def start_remote(self):
+ """Start instance for remote development/testing."""
+ with open(os.path.expanduser('~/.ansible-core-ci.key'), 'r') as key_fd:
+ auth_key = key_fd.read().strip()
+
+ self._start(dict(
+ remote=dict(
+ key=auth_key,
+ nonce=None,
+ ),
+ ))
+
+ def start_shippable(self):
+ """Start instance on Shippable."""
+ self._start(dict(
+ shippable=dict(
+ run_id=os.environ['SHIPPABLE_BUILD_ID'],
+ job_number=int(os.environ['SHIPPABLE_JOB_NUMBER']),
+ ),
+ ))
+
+ def stop(self):
+ """Stop instance."""
+ if not self.started:
+ display.info('Skipping invalid %s/%s instance %s.' % (self.platform, self.version, self.instance_id),
+ verbosity=1)
+ return
+
+ response = self.client.delete(self._uri)
+
+ if response.status_code == 404:
+ self._clear()
+ display.info('Cleared invalid %s/%s instance %s.' % (self.platform, self.version, self.instance_id),
+ verbosity=1)
+ return
+
+ if response.status_code == 200:
+ self._clear()
+ display.info('Stopped running %s/%s instance %s.' % (self.platform, self.version, self.instance_id),
+ verbosity=1)
+ return
+
+ raise self._create_http_error(response)
+
+ def get(self):
+ """
+ Get instance connection information.
+ :rtype: InstanceConnection
+ """
+ if not self.started:
+ display.info('Skipping invalid %s/%s instance %s.' % (self.platform, self.version, self.instance_id),
+ verbosity=1)
+ return None
+
+ if self.connection and self.connection.running:
+ return self.connection
+
+ response = self.client.get(self._uri)
+
+ if response.status_code != 200:
+ raise self._create_http_error(response)
+
+ if self.args.explain:
+ self.connection = InstanceConnection(
+ running=True,
+ hostname='cloud.example.com',
+ port=self.port or 12345,
+ username='username',
+ password='password' if self.platform == 'windows' else None,
+ )
+ else:
+ response_json = response.json()
+
+ status = response_json['status']
+ con = response_json['connection']
+
+ self.connection = InstanceConnection(
+ running=status == 'running',
+ hostname=con['hostname'],
+ port=int(con.get('port', self.port)),
+ username=con['username'],
+ password=con.get('password'),
+ )
+
+ status = 'running' if self.connection.running else 'starting'
+
+ display.info('Retrieved %s %s/%s instance %s.' % (status, self.platform, self.version, self.instance_id),
+ verbosity=1)
+
+ return self.connection
+
+ def wait(self):
+ """Wait for the instance to become ready."""
+ for _ in range(1, 90):
+ if self.get().running:
+ return
+ time.sleep(10)
+
+ raise ApplicationError('Timeout waiting for %s/%s instance %s.' %
+ (self.platform, self.version, self.instance_id))
+
+ @property
+ def _uri(self):
+ return '%s/%s/jobs/%s' % (self.endpoint, self.stage, self.instance_id)
+
+ def _start(self, auth):
+ """Start instance."""
+ if self.started:
+ display.info('Skipping started %s/%s instance %s.' % (self.platform, self.version, self.instance_id),
+ verbosity=1)
+ return
+
+ data = dict(
+ config=dict(
+ platform=self.platform,
+ version=self.version,
+ public_key=self.ssh_key.pub_contents if self.ssh_key else None,
+ query=False,
+ )
+ )
+
+ data.update(dict(auth=auth))
+
+ headers = {
+ 'Content-Type': 'application/json',
+ }
+
+ response = self.client.put(self._uri, data=json.dumps(data), headers=headers)
+
+ if response.status_code != 200:
+ raise self._create_http_error(response)
+
+ self.started = True
+ self._save()
+
+ display.info('Started %s/%s instance %s.' % (self.platform, self.version, self.instance_id),
+ verbosity=1)
+
+ def _clear(self):
+ """Clear instance information."""
+ try:
+ self.connection = None
+ os.remove(self.path)
+ except OSError as ex:
+ if ex.errno != errno.ENOENT:
+ raise
+
+ def _load(self):
+ """Load instance information."""
+ try:
+ with open(self.path, 'r') as instance_fd:
+ self.instance_id = instance_fd.read()
+ self.started = True
+ except IOError as ex:
+ if ex.errno != errno.ENOENT:
+ raise
+ self.instance_id = None
+
+ return self.instance_id
+
+ def _save(self):
+ """Save instance information."""
+ if self.args.explain:
+ return
+
+ make_dirs(os.path.dirname(self.path))
+
+ with open(self.path, 'w') as instance_fd:
+ instance_fd.write(self.instance_id)
+
+ @staticmethod
+ def _create_http_error(response):
+ """
+ :type response: HttpResponse
+ :rtype: ApplicationError
+ """
+ response_json = response.json()
+ stack_trace = ''
+
+ if 'message' in response_json:
+ message = response_json['message']
+ elif 'errorMessage' in response_json:
+ message = response_json['errorMessage'].strip()
+ if 'stackTrace' in response_json:
+ trace = '\n'.join([x.rstrip() for x in traceback.format_list(response_json['stackTrace'])])
+ stack_trace = ('\nTraceback (from remote server):\n%s' % trace)
+ else:
+ message = str(response_json)
+
+ return HttpError(response.status_code, '%s%s' % (message, stack_trace))
+
+
+class SshKey(object):
+ """Container for SSH key used to connect to remote instances."""
+ def __init__(self, args):
+ """
+ :type args: CommonConfig
+ """
+ tmp = os.path.expanduser('~/.ansible/test/')
+
+ self.key = os.path.join(tmp, 'id_rsa')
+ self.pub = os.path.join(tmp, 'id_rsa.pub')
+
+ if not os.path.isfile(self.pub):
+ if not args.explain:
+ make_dirs(tmp)
+
+ run_command(args, ['ssh-keygen', '-q', '-t', 'rsa', '-N', '', '-f', self.key])
+
+ if args.explain:
+ self.pub_contents = None
+ else:
+ with open(self.pub, 'r') as pub_fd:
+ self.pub_contents = pub_fd.read().strip()
+
+
+class InstanceConnection(object):
+ """Container for remote instance status and connection details."""
+ def __init__(self, running, hostname, port, username, password):
+ """
+ :type running: bool
+ :type hostname: str
+ :type port: int
+ :type username: str
+ :type password: str | None
+ """
+ self.running = running
+ self.hostname = hostname
+ self.port = port
+ self.username = username
+ self.password = password
+
+ def __str__(self):
+ if self.password:
+ return '%s:%s [%s:%s]' % (self.hostname, self.port, self.username, self.password)
+
+ return '%s:%s [%s]' % (self.hostname, self.port, self.username)
diff --git a/test/runner/lib/cover.py b/test/runner/lib/cover.py
new file mode 100644
index 0000000000..493ff72b93
--- /dev/null
+++ b/test/runner/lib/cover.py
@@ -0,0 +1,148 @@
+"""Code coverage utilities."""
+
+from __future__ import absolute_import, print_function
+
+import os
+import re
+
+from lib.target import walk_module_targets
+from lib.util import display, ApplicationError, run_command
+from lib.executor import EnvironmentConfig, Delegate, install_command_requirements
+
+COVERAGE_DIR = 'test/results/coverage'
+COVERAGE_FILE = os.path.join(COVERAGE_DIR, 'coverage')
+
+
+def command_coverage_combine(args):
+ """Patch paths in coverage files and merge into a single file.
+ :type args: CoverageConfig
+ """
+ coverage = initialize_coverage(args)
+
+ modules = dict((t.module, t.path) for t in list(walk_module_targets()))
+
+ coverage_files = [os.path.join(COVERAGE_DIR, f) for f in os.listdir(COVERAGE_DIR)
+ if f.startswith('coverage') and f != 'coverage']
+
+ arc_data = {}
+
+ ansible_path = os.path.abspath('lib/ansible/') + '/'
+ root_path = os.getcwd() + '/'
+
+ for coverage_file in coverage_files:
+ original = coverage.CoverageData()
+
+ if os.path.getsize(coverage_file) == 0:
+ display.warning('Empty coverage file: %s' % coverage_file)
+ continue
+
+ try:
+ original.read_file(coverage_file)
+ except Exception as ex: # pylint: disable=locally-disabled, broad-except
+ display.error(str(ex))
+ continue
+
+ for filename in original.measured_files():
+ arcs = original.arcs(filename)
+
+ if '/ansible_modlib.zip/ansible/' in filename:
+ new_name = re.sub('^.*/ansible_modlib.zip/ansible/', ansible_path, filename)
+ display.info('%s -> %s' % (filename, new_name), verbosity=3)
+ filename = new_name
+ elif '/ansible_module_' in filename:
+ module = re.sub('^.*/ansible_module_(?P<module>.*).py$', '\\g<module>', filename)
+ new_name = os.path.abspath(modules[module])
+ display.info('%s -> %s' % (filename, new_name), verbosity=3)
+ filename = new_name
+ elif filename.startswith('/root/ansible/'):
+ new_name = re.sub('^/.*?/ansible/', root_path, filename)
+ display.info('%s -> %s' % (filename, new_name), verbosity=3)
+ filename = new_name
+
+ if filename not in arc_data:
+ arc_data[filename] = []
+
+ arc_data[filename] += arcs
+
+ updated = coverage.CoverageData()
+
+ for filename in arc_data:
+ if not os.path.isfile(filename):
+ display.warning('Invalid coverage path: %s' % filename)
+ continue
+
+ updated.add_arcs({filename: arc_data[filename]})
+
+ if not args.explain:
+ updated.write_file(COVERAGE_FILE)
+
+
+def command_coverage_report(args):
+ """
+ :type args: CoverageConfig
+ """
+ command_coverage_combine(args)
+ run_command(args, ['coverage', 'report'])
+
+
+def command_coverage_html(args):
+ """
+ :type args: CoverageConfig
+ """
+ command_coverage_combine(args)
+ run_command(args, ['coverage', 'html', '-d', 'test/results/reports/coverage'])
+
+
+def command_coverage_xml(args):
+ """
+ :type args: CoverageConfig
+ """
+ command_coverage_combine(args)
+ run_command(args, ['coverage', 'xml', '-o', 'test/results/reports/coverage.xml'])
+
+
+def command_coverage_erase(args):
+ """
+ :type args: CoverageConfig
+ """
+ initialize_coverage(args)
+
+ for name in os.listdir(COVERAGE_DIR):
+ if not name.startswith('coverage'):
+ continue
+
+ path = os.path.join(COVERAGE_DIR, name)
+
+ if not args.explain:
+ os.remove(path)
+
+
+def initialize_coverage(args):
+ """
+ :type args: CoverageConfig
+ :rtype: coverage
+ """
+ if args.delegate:
+ raise Delegate()
+
+ if args.requirements:
+ install_command_requirements(args)
+
+ try:
+ import coverage
+ except ImportError:
+ coverage = None
+
+ if not coverage:
+ raise ApplicationError('You must install the "coverage" python module to use this command.')
+
+ return coverage
+
+
+class CoverageConfig(EnvironmentConfig):
+ """Configuration for the coverage command."""
+ def __init__(self, args):
+ """
+ :type args: any
+ """
+ super(CoverageConfig, self).__init__(args, 'coverage')
diff --git a/test/runner/lib/delegation.py b/test/runner/lib/delegation.py
new file mode 100644
index 0000000000..88dcf9b7fe
--- /dev/null
+++ b/test/runner/lib/delegation.py
@@ -0,0 +1,331 @@
+"""Delegate test execution to another environment."""
+
+from __future__ import absolute_import, print_function
+
+import os
+import sys
+
+import lib.pytar
+import lib.thread
+
+from lib.executor import (
+ SUPPORTED_PYTHON_VERSIONS,
+ EnvironmentConfig,
+ IntegrationConfig,
+ ShellConfig,
+ TestConfig,
+ create_shell_command,
+)
+
+from lib.core_ci import (
+ AnsibleCoreCI,
+)
+
+from lib.manage_ci import (
+ ManagePosixCI,
+)
+
+from lib.util import (
+ ApplicationError,
+ run_command,
+)
+
+BUFFER_SIZE = 256 * 256
+
+
+def delegate(args, exclude, require):
+ """
+ :type args: EnvironmentConfig
+ :type exclude: list[str]
+ :type require: list[str]
+ """
+ if args.tox:
+ delegate_tox(args, exclude, require)
+ return True
+
+ if args.docker:
+ delegate_docker(args, exclude, require)
+ return True
+
+ if args.remote:
+ delegate_remote(args, exclude, require)
+ return True
+
+ return False
+
+
+def delegate_tox(args, exclude, require):
+ """
+ :type args: EnvironmentConfig
+ :type exclude: list[str]
+ :type require: list[str]
+ """
+ if args.python:
+ versions = args.python,
+
+ if args.python not in SUPPORTED_PYTHON_VERSIONS:
+ raise ApplicationError('tox does not support Python version %s' % args.python)
+ else:
+ versions = SUPPORTED_PYTHON_VERSIONS
+
+ options = {
+ '--tox': args.tox_args,
+ }
+
+ for version in versions:
+ tox = ['tox', '-c', 'test/runner/tox.ini', '-e', 'py' + version.replace('.', ''), '--']
+ cmd = generate_command(args, os.path.abspath('test/runner/test.py'), options, exclude, require)
+
+ if not args.python:
+ cmd += ['--python', version]
+
+ run_command(args, tox + cmd)
+
+
+def delegate_docker(args, exclude, require):
+ """
+ :type args: EnvironmentConfig
+ :type exclude: list[str]
+ :type require: list[str]
+ """
+ util_image = args.docker_util
+ test_image = args.docker
+ privileged = args.docker_privileged
+
+ util_id = None
+ test_id = None
+
+ options = {
+ '--docker': 1,
+ '--docker-privileged': 0,
+ '--docker-util': 1,
+ }
+
+ cmd = generate_command(args, '/root/ansible/test/runner/test.py', options, exclude, require)
+
+ if isinstance(args, IntegrationConfig):
+ if not args.allow_destructive:
+ cmd.append('--allow-destructive')
+
+ if not args.explain:
+ lib.pytar.create_tarfile('/tmp/ansible.tgz', '.', lib.pytar.ignore)
+
+ try:
+ if util_image:
+ util_id, _ = run_command(args, [
+ 'docker', 'run', '--detach',
+ util_image,
+ ], capture=True)
+
+ if args.explain:
+ util_id = 'util_id'
+ else:
+ util_id = util_id.strip()
+ else:
+ util_id = None
+
+ test_cmd = [
+ 'docker', 'run', '--detach',
+ '--volume', '/sys/fs/cgroup:/sys/fs/cgroup:ro',
+ '--privileged=%s' % str(privileged).lower(),
+ ]
+
+ if util_id:
+ test_cmd += [
+ '--link', '%s:ansible.http.tests' % util_id,
+ '--link', '%s:sni1.ansible.http.tests' % util_id,
+ '--link', '%s:sni2.ansible.http.tests' % util_id,
+ '--link', '%s:fail.ansible.http.tests' % util_id,
+ '--env', 'HTTPTESTER=1',
+ ]
+
+ test_id, _ = run_command(args, test_cmd + [test_image], capture=True)
+
+ 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, 'test/runner/setup/docker.sh', '/root/docker.sh')
+
+ run_command(args,
+ ['docker', 'exec', test_id, '/bin/bash', '/root/docker.sh'])
+
+ docker_put(args, test_id, '/tmp/ansible.tgz', '/root/ansible.tgz')
+
+ run_command(args,
+ ['docker', 'exec', test_id, 'mkdir', '/root/ansible'])
+
+ run_command(args,
+ ['docker', 'exec', test_id, 'tar', 'oxzf', '/root/ansible.tgz', '--directory', '/root/ansible'])
+
+ try:
+ command = ['docker', 'exec']
+
+ if isinstance(args, ShellConfig):
+ command.append('-it')
+
+ run_command(args, command + [test_id] + cmd)
+ finally:
+ run_command(args,
+ ['docker', 'exec', test_id,
+ 'tar', 'czf', '/root/results.tgz', '--directory', '/root/ansible/test', 'results'])
+
+ docker_get(args, test_id, '/root/results.tgz', '/tmp/results.tgz')
+
+ run_command(args,
+ ['tar', 'oxzf', '/tmp/results.tgz', '-C', 'test'])
+ finally:
+ if util_id:
+ run_command(args,
+ ['docker', 'rm', '-f', util_id],
+ capture=True)
+
+ if test_id:
+ run_command(args,
+ ['docker', 'rm', '-f', test_id],
+ capture=True)
+
+
+def docker_put(args, container_id, src, dst):
+ """
+ :type args: EnvironmentConfig
+ :type container_id: str
+ :type src: str
+ :type dst: str
+ """
+ # avoid 'docker cp' due to a bug which causes 'docker rm' to fail
+ cmd = ['docker', 'exec', '-i', container_id, 'dd', 'of=%s' % dst, 'bs=%s' % BUFFER_SIZE]
+
+ with open(src, 'rb') as src_fd:
+ run_command(args, cmd, stdin=src_fd, capture=True)
+
+
+def docker_get(args, container_id, src, dst):
+ """
+ :type args: EnvironmentConfig
+ :type container_id: str
+ :type src: str
+ :type dst: str
+ """
+ # avoid 'docker cp' due to a bug which causes 'docker rm' to fail
+ cmd = ['docker', 'exec', '-i', container_id, 'dd', 'if=%s' % src, 'bs=%s' % BUFFER_SIZE]
+
+ with open(dst, 'wb') as dst_fd:
+ run_command(args, cmd, stdout=dst_fd, capture=True)
+
+
+def delegate_remote(args, exclude, require):
+ """
+ :type args: EnvironmentConfig
+ :type exclude: list[str]
+ :type require: list[str]
+ """
+ parts = args.remote.split('/', 1)
+
+ platform = parts[0]
+ version = parts[1]
+
+ core_ci = AnsibleCoreCI(args, platform, version, stage=args.remote_stage)
+
+ try:
+ core_ci.start()
+ core_ci.wait()
+
+ options = {
+ '--remote': 1,
+ }
+
+ cmd = generate_command(args, 'ansible/test/runner/test.py', options, exclude, require)
+
+ if isinstance(args, IntegrationConfig):
+ if not args.allow_destructive:
+ cmd.append('--allow-destructive')
+
+ manage = ManagePosixCI(core_ci)
+ manage.setup()
+
+ try:
+ manage.ssh(cmd)
+ finally:
+ manage.ssh('rm -rf /tmp/results && cp -a ansible/test/results /tmp/results')
+ manage.download('/tmp/results', 'test')
+ finally:
+ pass
+
+
+def generate_command(args, path, options, exclude, require):
+ """
+ :type args: EnvironmentConfig
+ :type path: str
+ :type options: dict[str, int]
+ :type exclude: list[str]
+ :type require: list[str]
+ :return: list[str]
+ """
+ options['--color'] = 1
+
+ cmd = [path]
+ 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)
+
+ 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
+
+ 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,
+ })
+
+ 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 target in exclude:
+ yield '--exclude'
+ yield target
+
+ for target in require:
+ yield '--require'
+ yield target
diff --git a/test/runner/lib/executor.py b/test/runner/lib/executor.py
new file mode 100644
index 0000000000..2140704b69
--- /dev/null
+++ b/test/runner/lib/executor.py
@@ -0,0 +1,1253 @@
+"""Execute Ansible tests."""
+
+from __future__ import absolute_import, print_function
+
+import glob
+import os
+import tempfile
+import sys
+import time
+import textwrap
+import functools
+import shutil
+import stat
+import random
+import pipes
+import string
+import atexit
+
+import lib.pytar
+import lib.thread
+
+from lib.core_ci import (
+ AnsibleCoreCI,
+)
+
+from lib.manage_ci import (
+ ManageWindowsCI,
+)
+
+from lib.util import (
+ CommonConfig,
+ ApplicationWarning,
+ ApplicationError,
+ SubprocessError,
+ display,
+ run_command,
+ deepest_path,
+ common_environment,
+ remove_tree,
+ make_dirs,
+ is_shippable,
+)
+
+from lib.ansible_util import (
+ ansible_environment,
+)
+
+from lib.target import (
+ IntegrationTarget,
+ walk_external_targets,
+ walk_internal_targets,
+ walk_posix_integration_targets,
+ walk_network_integration_targets,
+ walk_windows_integration_targets,
+ walk_units_targets,
+ walk_compile_targets,
+ walk_sanity_targets,
+)
+
+from lib.changes import (
+ ShippableChanges,
+ LocalChanges,
+)
+
+from lib.git import (
+ Git,
+)
+
+from lib.classification import (
+ categorize_changes,
+)
+
+SUPPORTED_PYTHON_VERSIONS = (
+ '2.6',
+ '2.7',
+ '3.5',
+)
+
+COMPILE_PYTHON_VERSIONS = tuple(sorted(SUPPORTED_PYTHON_VERSIONS + ('2.4',)))
+
+coverage_path = '' # pylint: disable=locally-disabled, invalid-name
+
+
+def create_shell_command(command):
+ """
+ :type command: list[str]
+ :rtype: list[str]
+ """
+ optional_vars = (
+ 'TERM',
+ )
+
+ cmd = ['/usr/bin/env']
+ cmd += ['%s=%s' % (var, os.environ[var]) for var in optional_vars if var in os.environ]
+ cmd += command
+
+ return cmd
+
+
+def install_command_requirements(args):
+ """
+ :type args: EnvironmentConfig
+ """
+ generate_egg_info(args)
+
+ if not args.requirements:
+ return
+
+ cmd = generate_pip_install(args.command)
+
+ if not cmd:
+ return
+
+ if isinstance(args, TestConfig):
+ if args.coverage:
+ cmd += ['coverage']
+
+ try:
+ run_command(args, cmd)
+ except SubprocessError as ex:
+ if ex.status != 2:
+ raise
+
+ # If pip is too old it won't understand the arguments we passed in, so we'll need to upgrade it.
+
+ # Installing "coverage" on ubuntu 16.04 fails with the error:
+ # 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, cmd)
+
+
+def generate_egg_info(args):
+ """
+ :type args: EnvironmentConfig
+ """
+ if os.path.isdir('lib/ansible.egg-info'):
+ return
+
+ run_command(args, ['python', 'setup.py', 'egg_info'], capture=args.verbosity < 3)
+
+
+def generate_pip_install(command):
+ """
+ :type command: str
+ :return: list[str] | None
+ """
+ constraints = 'test/runner/requirements/constraints.txt'
+ requirements = 'test/runner/requirements/%s.txt' % command
+
+ if not os.path.exists(requirements) or not os.path.getsize(requirements):
+ return None
+
+ return ['pip', 'install', '--disable-pip-version-check', '-r', requirements, '-c', constraints]
+
+
+def command_shell(args):
+ """
+ :type args: ShellConfig
+ """
+ if args.delegate:
+ raise Delegate()
+
+ install_command_requirements(args)
+
+ cmd = create_shell_command(['bash', '-i'])
+ run_command(args, cmd)
+
+
+def command_posix_integration(args):
+ """
+ :type args: PosixIntegrationConfig
+ """
+ internal_targets = command_integration_filter(args, walk_posix_integration_targets())
+ command_integration_filtered(args, internal_targets)
+
+
+def command_network_integration(args):
+ """
+ :type args: NetworkIntegrationConfig
+ """
+ internal_targets = command_integration_filter(args, walk_network_integration_targets())
+ command_integration_filtered(args, internal_targets)
+
+
+def command_windows_integration(args):
+ """
+ :type args: WindowsIntegrationConfig
+ """
+ internal_targets = command_integration_filter(args, walk_windows_integration_targets())
+
+ if args.windows:
+ instances = [] # type: list [lib.thread.WrappedThread]
+
+ for version in args.windows:
+ instance = lib.thread.WrappedThread(functools.partial(windows_run, args, version))
+ instance.daemon = True
+ instance.start()
+ instances.append(instance)
+
+ install_command_requirements(args)
+
+ while any(instance.is_alive() for instance in instances):
+ time.sleep(1)
+
+ remotes = [instance.wait_for_result() for instance in instances]
+ inventory = windows_inventory(remotes)
+
+ if not args.explain:
+ with open('test/integration/inventory.winrm', 'w') as inventory_fd:
+ inventory_fd.write(inventory)
+ else:
+ install_command_requirements(args)
+
+ try:
+ command_integration_filtered(args, internal_targets)
+ finally:
+ pass
+
+
+def windows_run(args, version):
+ """
+ :type args: WindowsIntegrationConfig
+ :type version: str
+ :rtype: AnsibleCoreCI
+ """
+ core_ci = AnsibleCoreCI(args, 'windows', version, stage=args.remote_stage)
+ core_ci.start()
+ core_ci.wait()
+
+ manage = ManageWindowsCI(core_ci)
+ manage.wait()
+
+ return core_ci
+
+
+def windows_inventory(remotes):
+ """
+ :type remotes: list[AnsibleCoreCI]
+ :rtype: str
+ """
+ hosts = ['%s ansible_host=%s ansible_user=%s ansible_password="%s" ansible_port=%s' %
+ (
+ remote.name.replace('/', '_'),
+ remote.connection.hostname,
+ remote.connection.username,
+ remote.connection.password,
+ remote.connection.port,
+ )
+ for remote in remotes]
+
+ template = """
+ [windows]
+ %s
+
+ [windows:vars]
+ ansible_connection=winrm
+ ansible_winrm_server_cert_validation=ignore
+
+ # support winrm connection tests (temporary solution, does not support testing enable/disable of pipelining)
+ [winrm:children]
+ windows
+
+ # support winrm binary module tests (temporary solution)
+ [testhost_binary_modules:children]
+ windows
+ """
+
+ template = textwrap.dedent(template)
+ inventory = template % ('\n'.join(hosts))
+
+ return inventory
+
+
+def command_integration_filter(args, targets):
+ """
+ :type args: IntegrationConfig
+ :type targets: collections.Iterable[IntegrationTarget]
+ :rtype: tuple[IntegrationTarget]
+ """
+ targets = tuple(targets)
+ changes = get_changes_filter(args)
+ require = (args.require or []) + changes
+ exclude = (args.exclude or [])
+
+ internal_targets = walk_internal_targets(targets, args.include, exclude, require)
+ environment_exclude = get_integration_filter(args, internal_targets)
+
+ if environment_exclude:
+ exclude += environment_exclude
+ internal_targets = walk_internal_targets(targets, args.include, exclude, require)
+
+ if not internal_targets:
+ raise AllTargetsSkipped()
+
+ 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 args.delegate:
+ raise Delegate(require=changes, exclude=exclude)
+
+ install_command_requirements(args)
+
+ return internal_targets
+
+
+def command_integration_filtered(args, targets):
+ """
+ :type args: IntegrationConfig
+ :type targets: tuple[IntegrationTarget]
+ """
+ found = False
+
+ targets_iter = iter(targets)
+
+ test_dir = os.path.expanduser('~/ansible_testing')
+
+ if not args.explain:
+ remove_tree(test_dir)
+ make_dirs(test_dir)
+
+ if any('needs/ssh/' in target.aliases for target in targets):
+ max_tries = 20
+ display.info('SSH service required for tests. Checking to make sure we can connect.')
+ for i in range(1, max_tries + 1):
+ try:
+ run_command(args, ['ssh', '-o', 'BatchMode=yes', 'localhost', 'id'], capture=True)
+ display.info('SSH service responded.')
+ break
+ except SubprocessError as ex:
+ if i == max_tries:
+ raise ex
+ seconds = 3
+ display.warning('SSH service not responding. Waiting %d second(s) before checking again.' % seconds)
+ time.sleep(seconds)
+
+ start_at_task = args.start_at_task
+
+ for target in targets_iter:
+ if args.start_at and not found:
+ found = target.name == args.start_at
+
+ if not found:
+ continue
+
+ tries = 2 if args.retry_on_error else 1
+ verbosity = args.verbosity
+
+ try:
+ while tries:
+ tries -= 1
+
+ try:
+ if target.script_path:
+ command_integration_script(args, target)
+ else:
+ command_integration_role(args, target, start_at_task)
+ start_at_task = None
+ break
+ except SubprocessError:
+ if not tries:
+ raise
+
+ display.warning('Retrying test target "%s" with maximum verbosity.' % target.name)
+ display.verbosity = args.verbosity = 6
+ except:
+ display.notice('To resume at this test target, use the option: --start-at %s' % target.name)
+
+ next_target = next(targets_iter, None)
+
+ if next_target:
+ display.notice('To resume after this test target, use the option: --start-at %s' % next_target.name)
+
+ raise
+ finally:
+ display.verbosity = args.verbosity = verbosity
+
+
+def integration_environment(args):
+ """
+ :type args: IntegrationConfig
+ :rtype: dict[str, str]
+ """
+ env = ansible_environment(args)
+
+ integration = dict(
+ JUNIT_OUTPUT_DIR=os.path.abspath('test/results/junit'),
+ ANSIBLE_CALLBACK_WHITELIST='junit',
+ )
+
+ env.update(integration)
+
+ return env
+
+
+def command_integration_script(args, target):
+ """
+ :type args: IntegrationConfig
+ :type target: IntegrationTarget
+ """
+ display.info('Running %s integration test script' % target.name)
+
+ cmd = ['./%s' % os.path.basename(target.script_path)]
+
+ if args.verbosity:
+ cmd.append('-' + ('v' * args.verbosity))
+
+ env = integration_environment(args)
+ cwd = target.path
+
+ intercept_command(args, cmd, env=env, cwd=cwd)
+
+
+def command_integration_role(args, target, start_at_task):
+ """
+ :type args: IntegrationConfig
+ :type target: IntegrationTarget
+ :type start_at_task: str
+ """
+ display.info('Running %s integration test role' % target.name)
+
+ vars_file = 'integration_config.yml'
+
+ if 'windows/' in target.aliases:
+ inventory = 'inventory.winrm'
+ hosts = 'windows'
+ gather_facts = False
+ elif 'network/' in target.aliases:
+ inventory = 'inventory.network'
+ hosts = target.name[:target.name.find('_')]
+ gather_facts = False
+ else:
+ inventory = 'inventory'
+ hosts = 'testhost'
+ gather_facts = True
+
+ playbook = '''
+- hosts: %s
+ gather_facts: %s
+ roles:
+ - { role: %s }
+ ''' % (hosts, gather_facts, target.name)
+
+ with tempfile.NamedTemporaryFile(dir='test/integration', prefix='%s-' % target.name, suffix='.yml') as pb_fd:
+ pb_fd.write(playbook.encode('utf-8'))
+ pb_fd.flush()
+
+ filename = os.path.basename(pb_fd.name)
+
+ display.info('>>> Playbook: %s\n%s' % (filename, playbook.strip()), verbosity=3)
+
+ cmd = ['ansible-playbook', filename, '-i', inventory, '-e', '@%s' % vars_file]
+
+ if start_at_task:
+ cmd += ['--start-at-task', start_at_task]
+
+ if args.verbosity:
+ cmd.append('-' + ('v' * args.verbosity))
+
+ env = integration_environment(args)
+ cwd = 'test/integration'
+
+ env['ANSIBLE_ROLES_PATH'] = os.path.abspath('test/integration/targets')
+
+ intercept_command(args, cmd, env=env, cwd=cwd)
+
+
+def command_units(args):
+ """
+ :type args: UnitsConfig
+ """
+ changes = get_changes_filter(args)
+ require = (args.require or []) + changes
+ include, exclude = walk_external_targets(walk_units_targets(), args.include, args.exclude, require)
+
+ if not include:
+ raise AllTargetsSkipped()
+
+ if args.delegate:
+ raise Delegate(require=changes)
+
+ install_command_requirements(args)
+
+ version_commands = []
+
+ 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:
+ continue
+
+ env = ansible_environment(args)
+
+ cmd = [
+ 'pytest',
+ '-r', 'a',
+ '--color',
+ 'yes' if args.color else 'no',
+ '--junit-xml',
+ 'test/results/junit/python%s-units.xml' % version,
+ ]
+
+ if args.collect_only:
+ cmd.append('--collect-only')
+
+ if args.verbosity:
+ cmd.append('-' + ('v' * args.verbosity))
+
+ if exclude:
+ cmd += ['--ignore=%s' % target.path for target in exclude]
+
+ cmd += [target.path for target in include]
+
+ version_commands.append((version, cmd, env))
+
+ for version, command, env in version_commands:
+ display.info('Unit test with Python %s' % version)
+ intercept_command(args, command, env=env, python_version=version)
+
+
+def command_compile(args):
+ """
+ :type args: CompileConfig
+ """
+ changes = get_changes_filter(args)
+ require = (args.require or []) + changes
+ include, exclude = walk_external_targets(walk_compile_targets(), args.include, args.exclude, require)
+
+ if not include:
+ raise AllTargetsSkipped()
+
+ if args.delegate:
+ raise Delegate(require=changes)
+
+ install_command_requirements(args)
+
+ version_commands = []
+
+ 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:
+ continue
+
+ # optional list of regex patterns to exclude from tests
+ skip_file = 'test/compile/python%s-skip.txt' % version
+
+ if os.path.exists(skip_file):
+ with open(skip_file, 'r') as skip_fd:
+ skip_paths = skip_fd.read().splitlines()
+ else:
+ skip_paths = []
+
+ # augment file exclusions
+ skip_paths += [e.path for e in exclude]
+ skip_paths.append('/.tox/')
+
+ skip_paths = sorted(skip_paths)
+
+ python = 'python%s' % version
+ cmd = [python, '-m', 'compileall', '-fq']
+
+ if skip_paths:
+ cmd += ['-x', '|'.join(skip_paths)]
+
+ cmd += [target.path for target in include]
+
+ version_commands.append((version, cmd))
+
+ for version, command in version_commands:
+ display.info('Compile with Python %s' % version)
+ run_command(args, command)
+
+
+def command_sanity(args):
+ """
+ :type args: SanityConfig
+ """
+ changes = get_changes_filter(args)
+ require = (args.require or []) + changes
+ targets = SanityTargets(args.include, args.exclude, require)
+
+ if not targets.include:
+ raise AllTargetsSkipped()
+
+ if args.delegate:
+ raise Delegate(require=changes)
+
+ install_command_requirements(args)
+
+ tests = SANITY_TESTS
+
+ if args.test:
+ tests = [t for t in tests if t.name in args.test]
+
+ if args.skip_test:
+ tests = [t for t in tests if t.name not in args.skip_test]
+
+ for test in tests:
+ if args.list_tests:
+ display.info(test.name)
+ continue
+
+ if test.intercept:
+ versions = SUPPORTED_PYTHON_VERSIONS
+ else:
+ versions = None,
+
+ for version in versions:
+ if args.python and version and version != args.python:
+ continue
+
+ display.info('Sanity check using %s%s' % (test.name, ' with Python %s' % version if version else ''))
+
+ if test.intercept:
+ test.func(args, targets, python_version=version)
+ else:
+ test.func(args, targets)
+
+
+def command_sanity_code_smell(args, _):
+ """
+ :type args: SanityConfig
+ :type _: SanityTargets
+ """
+ with open('test/sanity/code-smell/skip.txt', 'r') as skip_fd:
+ skip_tests = skip_fd.read().splitlines()
+
+ tests = glob.glob('test/sanity/code-smell/*')
+ tests = sorted(p for p in tests if os.access(p, os.X_OK) and os.path.basename(p) not in skip_tests)
+
+ for test in tests:
+ display.info('Code smell check using %s' % os.path.basename(test))
+ run_command(args, [test])
+
+
+def command_sanity_validate_modules(args, targets):
+ """
+ :type args: SanityConfig
+ :type targets: SanityTargets
+ """
+ env = ansible_environment(args)
+
+ paths = [deepest_path(i.path, 'lib/ansible/modules/') for i in targets.include_external]
+ paths = sorted(set(p for p in paths if p))
+
+ if not paths:
+ display.info('No tests applicable.', verbosity=1)
+ return
+
+ cmd = ['test/sanity/validate-modules/validate-modules'] + paths
+
+ with open('test/sanity/validate-modules/skip.txt', 'r') as skip_fd:
+ skip_paths = skip_fd.read().splitlines()
+
+ skip_paths += [e.path for e in targets.exclude_external]
+
+ if skip_paths:
+ cmd += ['--exclude', '^(%s)' % '|'.join(skip_paths)]
+
+ run_command(args, cmd, env=env)
+
+
+def command_sanity_shellcheck(args, targets):
+ """
+ :type args: SanityConfig
+ :type targets: SanityTargets
+ """
+ with open('test/sanity/shellcheck/skip.txt', 'r') as skip_fd:
+ skip_paths = set(skip_fd.read().splitlines())
+
+ paths = sorted(i.path for i in targets.include if os.path.splitext(i.path)[1] == '.sh' and i.path not in skip_paths)
+
+ if not paths:
+ display.info('No tests applicable.', verbosity=1)
+ return
+
+ run_command(args, ['shellcheck'] + paths)
+
+
+def command_sanity_yamllint(args, targets):
+ """
+ :type args: SanityConfig
+ :type targets: SanityTargets
+ """
+ paths = sorted(i.path for i in targets.include if os.path.splitext(i.path)[1] in ('.yml', '.yaml'))
+
+ if not paths:
+ display.info('No tests applicable.', verbosity=1)
+ return
+
+ run_command(args, ['yamllint'] + paths)
+
+
+def command_sanity_ansible_doc(args, targets, python_version):
+ """
+ :type args: SanityConfig
+ :type targets: SanityTargets
+ :type python_version: str
+ """
+ with open('test/sanity/ansible-doc/skip.txt', 'r') as skip_fd:
+ skip_modules = set(skip_fd.read().splitlines())
+
+ modules = sorted(set(m for i in targets.include_external for m in i.modules) -
+ set(m for i in targets.exclude_external for m in i.modules) -
+ skip_modules)
+
+ if not modules:
+ display.info('No tests applicable.', verbosity=1)
+ return
+
+ env = ansible_environment(args)
+ cmd = ['ansible-doc'] + modules
+
+ stdout, stderr = intercept_command(args, cmd, env=env, capture=True, python_version=python_version)
+
+ if stderr:
+ # consider any output on stderr an error, even though the return code is zero
+ raise SubprocessError(cmd, stderr=stderr)
+
+ if stdout:
+ display.info(stdout.strip(), verbosity=3)
+
+
+def intercept_command(args, cmd, capture=False, env=None, data=None, cwd=None, python_version=None):
+ """
+ :type args: TestConfig
+ :type cmd: collections.Iterable[str]
+ :type capture: bool
+ :type env: dict[str, str] | None
+ :type data: str | None
+ :type cwd: str | None
+ :type python_version: str | None
+ :rtype: str | None, str | None
+ """
+ if not env:
+ env = common_environment()
+
+ cmd = list(cmd)
+ escaped_cmd = ' '.join(pipes.quote(c) for c in cmd)
+ inject_path = get_coverage_path(args)
+
+ env['PATH'] = inject_path + os.pathsep + env['PATH']
+ env['ANSIBLE_TEST_COVERAGE'] = 'coverage' if args.coverage else 'version'
+ env['ANSIBLE_TEST_PYTHON_VERSION'] = python_version or args.python_version
+ env['ANSIBLE_TEST_CMD'] = escaped_cmd
+
+ 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 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
+ :rtype: list[str]
+ """
+ paths = detect_changes(args)
+
+ if paths is None:
+ return [] # change detection not enabled, do not filter targets
+
+ if not paths:
+ raise NoChangesDetected()
+
+ commands = categorize_changes(paths, args.command)
+
+ targets = commands.get(args.command)
+
+ if targets is None:
+ raise NoTestsForChanges()
+
+ if targets == ['all']:
+ return [] # changes require testing all targets, do not filter targets
+
+ return targets
+
+
+def detect_changes(args):
+ """
+ :type args: TestConfig
+ :rtype: list[str] | None
+ """
+ if is_shippable():
+ display.info('Shippable detected, collecting parameters from environment.')
+ paths = detect_changes_shippable(args)
+ elif args.changed_from or args.changed_path:
+ paths = args.changed_path or []
+ if args.changed_from:
+ with open(args.changed_from, 'r') as changes_fd:
+ paths += changes_fd.read().splitlines()
+ elif args.changed:
+ paths = detect_changes_local(args)
+ else:
+ return None # change detection not enabled
+
+ display.info('Detected changes in %d file(s).' % len(paths))
+
+ for path in paths:
+ display.info(path, verbosity=1)
+
+ return paths
+
+
+def detect_changes_shippable(args):
+ """Initialize change detection on Shippable.
+ :type args: CommonConfig
+ :rtype: list[str]
+ """
+ git = Git(args)
+ result = ShippableChanges(args, git)
+
+ if result.is_pr:
+ job_type = 'pull request'
+ elif result.is_tag:
+ job_type = 'tag'
+ else:
+ job_type = 'merge commit'
+
+ display.info('Processing %s for branch %s commit %s' % (job_type, result.branch, result.commit))
+
+ return result.paths
+
+
+def detect_changes_local(args):
+ """
+ :type args: TestConfig
+ :rtype: list[str]
+ """
+ git = Git(args)
+ result = LocalChanges(args, git)
+
+ display.info('Detected branch %s forked from %s at commit %s' % (
+ result.current_branch, result.fork_branch, result.fork_point))
+
+ if result.untracked and not args.untracked:
+ display.warning('Ignored %s untracked file(s). Use --untracked to include them.' %
+ len(result.untracked))
+
+ if result.committed and not args.committed:
+ display.warning('Ignored %s committed change(s). Omit --ignore-committed to include them.' %
+ len(result.committed))
+
+ if result.staged and not args.staged:
+ display.warning('Ignored %s staged change(s). Omit --ignore-staged to include them.' %
+ len(result.staged))
+
+ if result.unstaged and not args.unstaged:
+ display.warning('Ignored %s unstaged change(s). Omit --ignore-unstaged to include them.' %
+ len(result.unstaged))
+
+ names = set()
+
+ if args.tracked:
+ names |= set(result.tracked)
+ if args.untracked:
+ names |= set(result.untracked)
+ if args.committed:
+ names |= set(result.committed)
+ if args.staged:
+ names |= set(result.staged)
+ if args.unstaged:
+ names |= set(result.unstaged)
+
+ return sorted(names)
+
+
+def docker_qualify_image(name):
+ """
+ :type name: str | None
+ :rtype: str | None
+ """
+ if not name or any((c in name) for c in ('/', ':')):
+ return name
+
+ return 'ansible/ansible:%s' % name
+
+
+def get_integration_filter(args, targets):
+ """
+ :type args: IntegrationConfig
+ :type targets: tuple[IntegrationTarget]
+ :rtype: list[str]
+ """
+ if args.tox:
+ # tox has the same exclusions as the local environment
+ return get_integration_local_filter(args, targets)
+
+ if args.docker:
+ return get_integration_docker_filter(args, targets)
+
+ if args.remote:
+ return get_integration_remote_filter(args, targets)
+
+ return get_integration_local_filter(args, targets)
+
+
+def get_integration_local_filter(args, targets):
+ """
+ :type args: IntegrationConfig
+ :type targets: tuple[IntegrationTarget]
+ :rtype: list[str]
+ """
+ exclude = []
+
+ if os.getuid() != 0:
+ skip = 'needs/root/'
+ skipped = [target.name for target in targets if skip in target.aliases]
+ if skipped:
+ exclude.append(skip)
+ display.warning('Excluding tests marked "%s" which require running as root: %s'
+ % (skip.rstrip('/'), ', '.join(skipped)))
+
+ # consider explicit testing of destructive as though --allow-destructive was given
+ include_destructive = any(target.startswith('destructive/') for target in args.include)
+
+ if not args.allow_destructive and not include_destructive:
+ skip = 'destructive/'
+ skipped = [target.name for target in targets if skip in target.aliases]
+ if skipped:
+ exclude.append(skip)
+ display.warning('Excluding tests marked "%s" which require --allow-destructive to run locally: %s'
+ % (skip.rstrip('/'), ', '.join(skipped)))
+
+ return exclude
+
+
+def get_integration_docker_filter(args, targets):
+ """
+ :type args: IntegrationConfig
+ :type targets: tuple[IntegrationTarget]
+ :rtype: list[str]
+ """
+ exclude = []
+
+ if not args.docker_privileged:
+ skip = 'needs/privileged/'
+ skipped = [target.name for target in targets if skip in target.aliases]
+ if skipped:
+ exclude.append(skip)
+ display.warning('Excluding tests marked "%s" which require --docker-privileged to run under docker: %s'
+ % (skip.rstrip('/'), ', '.join(skipped)))
+
+ 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)))
+
+ return exclude
+
+
+def get_integration_remote_filter(args, targets):
+ """
+ :type args: IntegrationConfig
+ :type targets: tuple[IntegrationTarget]
+ :rtype: list[str]
+ """
+ parts = args.remote.split('/', 1)
+
+ platform = parts[0]
+
+ exclude = []
+
+ skip = 'skip/%s/' % platform
+ 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'
+ % (skip.rstrip('/'), platform, ', '.join(skipped)))
+
+ return exclude
+
+
+class NoChangesDetected(ApplicationWarning):
+ """Exception when change detection was performed, but no changes were found."""
+ def __init__(self):
+ super(NoChangesDetected, self).__init__('No changes detected.')
+
+
+class NoTestsForChanges(ApplicationWarning):
+ """Exception when changes detected, but no tests trigger as a result."""
+ def __init__(self):
+ super(NoTestsForChanges, self).__init__('No tests found for detected changes.')
+
+
+class SanityTargets(object):
+ """Sanity test target information."""
+ def __init__(self, include, exclude, require):
+ """
+ :type include: list[str]
+ :type exclude: list[str]
+ :type require: list[str]
+ """
+ self.all = not include
+ self.targets = tuple(sorted(walk_sanity_targets()))
+ self.include = walk_internal_targets(self.targets, include, exclude, require)
+ self.include_external, self.exclude_external = walk_external_targets(self.targets, include, exclude, require)
+
+
+class SanityTest(object):
+ """Sanity test base class."""
+ def __init__(self, name):
+ self.name = name
+
+
+class SanityFunc(SanityTest):
+ """Sanity test function information."""
+ def __init__(self, name, func, intercept=True):
+ """
+ :type name: str
+ :type func: (SanityConfig, SanityTargets) -> None
+ :type intercept: bool
+ """
+ super(SanityFunc, self).__init__(name)
+
+ self.func = func
+ self.intercept = intercept
+
+
+class EnvironmentConfig(CommonConfig):
+ """Configuration common to all commands which execute in an environment."""
+ def __init__(self, args, command):
+ """
+ :type args: any
+ """
+ super(EnvironmentConfig, self).__init__(args)
+
+ self.command = command
+
+ self.local = args.local is True
+
+ if args.tox is True or args.tox is False or args.tox is None:
+ self.tox = args.tox is True
+ self.tox_args = 0
+ self.python = args.python if 'python' in args else None # type: str
+ else:
+ self.tox = True
+ self.tox_args = 1
+ self.python = args.tox # type: str
+
+ self.docker = docker_qualify_image(args.docker) # type: str
+ self.remote = args.remote # type: str
+
+ self.docker_privileged = args.docker_privileged if 'docker_privileged' in args else False # type: bool
+ self.docker_util = docker_qualify_image(args.docker_util if 'docker_util' in args else None) # type: str | None
+
+ self.remote_stage = args.remote_stage # type: str
+
+ self.requirements = args.requirements # type: bool
+
+ self.python_version = self.python or '.'.join(str(i) for i in sys.version_info[:2])
+
+ self.delegate = self.tox or self.docker or self.remote
+
+ if self.delegate:
+ self.requirements = True
+
+
+class TestConfig(EnvironmentConfig):
+ """Configuration common to all test commands."""
+ def __init__(self, args, command):
+ """
+ :type args: any
+ :type command: str
+ """
+ super(TestConfig, self).__init__(args, command)
+
+ self.coverage = args.coverage # type: bool
+ self.include = args.include # type: list [str]
+ self.exclude = args.exclude # type: list [str]
+ self.require = args.require # type: list [str]
+
+ self.changed = args.changed # type: bool
+ self.tracked = args.tracked # type: bool
+ self.untracked = args.untracked # type: bool
+ self.committed = args.committed # type: bool
+ self.staged = args.staged # type: bool
+ self.unstaged = args.unstaged # type: bool
+ self.changed_from = args.changed_from # type: str
+ self.changed_path = args.changed_path # type: list [str]
+
+
+class ShellConfig(EnvironmentConfig):
+ """Configuration for the shell command."""
+ def __init__(self, args):
+ """
+ :type args: any
+ """
+ super(ShellConfig, self).__init__(args, 'shell')
+
+
+class SanityConfig(TestConfig):
+ """Configuration for the sanity command."""
+ def __init__(self, args):
+ """
+ :type args: any
+ """
+ super(SanityConfig, self).__init__(args, 'sanity')
+
+ self.test = args.test # type: list [str]
+ self.skip_test = args.skip_test # type: list [str]
+ self.list_tests = args.list_tests # type: bool
+
+
+class IntegrationConfig(TestConfig):
+ """Configuration for the integration command."""
+ def __init__(self, args, command):
+ """
+ :type args: any
+ :type command: str
+ """
+ super(IntegrationConfig, self).__init__(args, command)
+
+ self.start_at = args.start_at # type: str
+ self.start_at_task = args.start_at_task # type: str
+ self.allow_destructive = args.allow_destructive if 'allow_destructive' in args else False # type: bool
+ self.retry_on_error = args.retry_on_error # type: bool
+
+
+class PosixIntegrationConfig(IntegrationConfig):
+ """Configuration for the posix integration command."""
+
+ def __init__(self, args):
+ """
+ :type args: any
+ """
+ super(PosixIntegrationConfig, self).__init__(args, 'integration')
+
+
+class WindowsIntegrationConfig(IntegrationConfig):
+ """Configuration for the windows integration command."""
+
+ def __init__(self, args):
+ """
+ :type args: any
+ """
+ super(WindowsIntegrationConfig, self).__init__(args, 'windows-integration')
+
+ self.windows = args.windows # type: list [str]
+
+
+class NetworkIntegrationConfig(IntegrationConfig):
+ """Configuration for the network integration command."""
+
+ def __init__(self, args):
+ """
+ :type args: any
+ """
+ super(NetworkIntegrationConfig, self).__init__(args, 'network-integration')
+
+
+class UnitsConfig(TestConfig):
+ """Configuration for the units command."""
+ def __init__(self, args):
+ """
+ :type args: any
+ """
+ super(UnitsConfig, self).__init__(args, 'units')
+
+ self.collect_only = args.collect_only # type: bool
+
+
+class CompileConfig(TestConfig):
+ """Configuration for the compile command."""
+ def __init__(self, args):
+ """
+ :type args: any
+ """
+ super(CompileConfig, self).__init__(args, 'compile')
+
+
+class Delegate(Exception):
+ """Trigger command delegation."""
+ def __init__(self, exclude=None, require=None):
+ """
+ :type exclude: list[str] | None
+ :type require: list[str] | None
+ """
+ super(Delegate, self).__init__()
+
+ self.exclude = exclude or []
+ self.require = require or []
+
+
+class AllTargetsSkipped(ApplicationWarning):
+ """All targets skipped."""
+ def __init__(self):
+ super(AllTargetsSkipped, self).__init__('All targets skipped.')
+
+
+SANITY_TESTS = (
+ # tests which ignore include/exclude (they're so fast it doesn't matter)
+ SanityFunc('code-smell', command_sanity_code_smell, intercept=False),
+ # tests which honor include/exclude
+ SanityFunc('shellcheck', command_sanity_shellcheck, intercept=False),
+ SanityFunc('yamllint', command_sanity_yamllint, intercept=False),
+ SanityFunc('validate-modules', command_sanity_validate_modules, intercept=False),
+ SanityFunc('ansible-doc', command_sanity_ansible_doc),
+)
diff --git a/test/runner/lib/git.py b/test/runner/lib/git.py
new file mode 100644
index 0000000000..b732a5dbe1
--- /dev/null
+++ b/test/runner/lib/git.py
@@ -0,0 +1,76 @@
+"""Wrapper around git command-line tools."""
+
+from __future__ import absolute_import, print_function
+
+from lib.util import (
+ CommonConfig,
+ run_command,
+)
+
+
+class Git(object):
+ """Wrapper around git command-line tools."""
+ def __init__(self, args):
+ """
+ :type args: CommonConfig
+ """
+ self.args = args
+ self.git = 'git'
+
+ def get_diff_names(self, args):
+ """
+ :type args: list[str]
+ :rtype: list[str]
+ """
+ cmd = ['diff', '--name-only', '--no-renames', '-z'] + args
+ return self.run_git_split(cmd, '\0')
+
+ def get_file_names(self, args):
+ """
+ :type args: list[str]
+ :rtype: list[str]
+ """
+ cmd = ['ls-files', '-z'] + args
+ return self.run_git_split(cmd, '\0')
+
+ def get_branches(self):
+ """
+ :rtype: list[str]
+ """
+ cmd = ['for-each-ref', 'refs/heads/', '--format', '%(refname:strip=2)']
+ return self.run_git_split(cmd)
+
+ def get_branch(self):
+ """
+ :rtype: str
+ """
+ cmd = ['symbolic-ref', '--short', 'HEAD']
+ return self.run_git(cmd).strip()
+
+ def get_branch_fork_point(self, branch):
+ """
+ :type branch: str
+ :rtype: str
+ """
+ cmd = ['merge-base', '--fork-point', branch]
+ return self.run_git(cmd).strip()
+
+ def run_git_split(self, cmd, separator=None):
+ """
+ :type cmd: list[str]
+ :param separator: str | None
+ :rtype: list[str]
+ """
+ output = self.run_git(cmd).strip(separator)
+
+ if len(output) == 0:
+ return []
+
+ return output.split(separator)
+
+ def run_git(self, cmd):
+ """
+ :type cmd: list[str]
+ :rtype: str
+ """
+ return run_command(self.args, [self.git] + cmd, capture=True, always=True)[0]
diff --git a/test/runner/lib/http.py b/test/runner/lib/http.py
new file mode 100644
index 0000000000..df82166efc
--- /dev/null
+++ b/test/runner/lib/http.py
@@ -0,0 +1,122 @@
+"""
+Primitive replacement for requests to avoid extra dependency.
+Avoids use of urllib2 due to lack of SNI support.
+"""
+
+from __future__ import absolute_import, print_function
+
+import json
+
+try:
+ from urllib import urlencode
+except ImportError:
+ # noinspection PyCompatibility,PyUnresolvedReferences,PyUnresolvedReferences
+ from urllib.parse import urlencode # pylint: disable=locally-disabled, import-error, no-name-in-module
+
+from lib.util import (
+ CommonConfig,
+ ApplicationError,
+ run_command,
+)
+
+
+class HttpClient(object):
+ """Make HTTP requests via curl."""
+ def __init__(self, args, always=False):
+ """
+ :type args: CommonConfig
+ :type always: bool
+ """
+ self.args = args
+ self.always = always
+
+ def get(self, url):
+ """
+ :type url: str
+ :rtype: HttpResponse
+ """
+ return self.request('GET', url)
+
+ def delete(self, url):
+ """
+ :type url: str
+ :rtype: HttpResponse
+ """
+ return self.request('DELETE', url)
+
+ def put(self, url, data=None, headers=None):
+ """
+ :type url: str
+ :type data: str | None
+ :type headers: dict[str, str] | None
+ :rtype: HttpResponse
+ """
+ return self.request('PUT', url, data, headers)
+
+ def request(self, method, url, data=None, headers=None):
+ """
+ :type method: str
+ :type url: str
+ :type data: str | None
+ :type headers: dict[str, str] | None
+ :rtype: HttpResponse
+ """
+ cmd = ['curl', '-s', '-S', '-i', '-X', method]
+
+ if headers is None:
+ headers = {}
+
+ headers['Expect'] = '' # don't send expect continue header
+
+ for header in headers.keys():
+ cmd += ['-H', '%s: %s' % (header, headers[header])]
+
+ if data is not None:
+ cmd += ['-d', data]
+
+ cmd += [url]
+
+ stdout, _ = run_command(self.args, cmd, capture=True, always=self.always)
+
+ if self.args.explain and not self.always:
+ return HttpResponse(200, '')
+
+ header, body = stdout.split('\r\n\r\n', 1)
+
+ response_headers = header.split('\r\n')
+ first_line = response_headers[0]
+ http_response = first_line.split(' ')
+ status_code = int(http_response[1])
+
+ return HttpResponse(status_code, body)
+
+
+class HttpResponse(object):
+ """HTTP response from curl."""
+ def __init__(self, status_code, response):
+ """
+ :type status_code: int
+ :type response: str
+ """
+ self.status_code = status_code
+ self.response = response
+
+ def json(self):
+ """
+ :rtype: any
+ """
+ try:
+ return json.loads(self.response)
+ except ValueError:
+ raise HttpError(self.status_code, 'Cannot parse response as JSON:\n%s' % self.response)
+
+
+class HttpError(ApplicationError):
+ """HTTP response as an error."""
+ def __init__(self, status, message):
+ """
+ :type status: int
+ :type message: str
+ """
+ super(HttpError, self).__init__('%s: %s' % (status, message))
+ self.status = status
diff --git a/test/runner/lib/manage_ci.py b/test/runner/lib/manage_ci.py
new file mode 100644
index 0000000000..85839cd644
--- /dev/null
+++ b/test/runner/lib/manage_ci.py
@@ -0,0 +1,142 @@
+"""Access Ansible Core CI remote services."""
+
+from __future__ import absolute_import, print_function
+
+import pipes
+
+from time import sleep
+
+import lib.pytar
+
+from lib.util import (
+ SubprocessError,
+ ApplicationError,
+ run_command,
+)
+
+from lib.core_ci import (
+ AnsibleCoreCI,
+)
+
+from lib.ansible_util import (
+ ansible_environment,
+)
+
+
+class ManageWindowsCI(object):
+ """Manage access to a Windows instance provided by Ansible Core CI."""
+ def __init__(self, core_ci):
+ """
+ :type core_ci: AnsibleCoreCI
+ """
+ self.core_ci = core_ci
+
+ def wait(self):
+ """Wait for instance to respond to ansible ping."""
+ extra_vars = [
+ 'ansible_connection=winrm',
+ 'ansible_host=%s' % self.core_ci.connection.hostname,
+ 'ansible_user=%s' % self.core_ci.connection.username,
+ 'ansible_password=%s' % self.core_ci.connection.password,
+ 'ansible_port=%s' % self.core_ci.connection.port,
+ 'ansible_winrm_server_cert_validation=ignore',
+ ]
+
+ name = 'windows_%s' % self.core_ci.version
+
+ env = ansible_environment(self.core_ci.args)
+ cmd = ['ansible', '-m', 'win_ping', '-i', '%s,' % name, name, '-e', ' '.join(extra_vars)]
+
+ for _ in range(1, 90):
+ try:
+ run_command(self.core_ci.args, cmd, env=env)
+ return
+ except SubprocessError:
+ sleep(10)
+ continue
+
+ raise ApplicationError('Timeout waiting for %s/%s instance %s.' %
+ (self.core_ci.platform, self.core_ci.version, self.core_ci.instance_id))
+
+
+class ManagePosixCI(object):
+ """Manage access to a POSIX instance provided by Ansible Core CI."""
+ def __init__(self, core_ci):
+ """
+ :type core_ci: AnsibleCoreCI
+ """
+ self.core_ci = core_ci
+ self.ssh_args = ['-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=no', '-i', self.core_ci.ssh_key.key]
+
+ if self.core_ci.platform == 'freebsd':
+ self.become = ['su', '-l', 'root', '-c']
+ elif self.core_ci.platform == 'osx':
+ self.become = ['sudo', '-in', 'PATH=/usr/local/bin:$PATH']
+
+ def setup(self):
+ """Start instance and wait for it to become ready and respond to an ansible ping."""
+ self.wait()
+ self.configure()
+ self.upload_source()
+
+ def wait(self):
+ """Wait for instance to respond to SSH."""
+ for _ in range(1, 90):
+ try:
+ self.ssh('id')
+ return
+ except SubprocessError:
+ sleep(10)
+ continue
+
+ raise ApplicationError('Timeout waiting for %s/%s instance %s.' %
+ (self.core_ci.platform, self.core_ci.version, self.core_ci.instance_id))
+
+ def configure(self):
+ """Configure remote host for testing."""
+ self.upload('test/runner/setup/remote.sh', '/tmp')
+ self.ssh('chmod +x /tmp/remote.sh && /tmp/remote.sh %s' % self.core_ci.platform)
+
+ def upload_source(self):
+ """Upload and extract source."""
+ if not self.core_ci.args.explain:
+ lib.pytar.create_tarfile('/tmp/ansible.tgz', '.', lib.pytar.ignore)
+
+ self.upload('/tmp/ansible.tgz', '/tmp')
+ self.ssh('rm -rf ~/ansible && mkdir ~/ansible && cd ~/ansible && tar oxzf /tmp/ansible.tgz')
+
+ def download(self, remote, local):
+ """
+ :type remote: str
+ :type local: str
+ """
+ self.scp('%s@%s:%s' % (self.core_ci.connection.username, self.core_ci.connection.hostname, remote), local)
+
+ def upload(self, local, remote):
+ """
+ :type local: str
+ :type remote: str
+ """
+ self.scp(local, '%s@%s:%s' % (self.core_ci.connection.username, self.core_ci.connection.hostname, remote))
+
+ def ssh(self, command):
+ """
+ :type command: str | list[str]
+ """
+ if isinstance(command, list):
+ command = ' '.join(pipes.quote(c) for c in command)
+
+ run_command(self.core_ci.args,
+ ['ssh', '-tt', '-q'] + self.ssh_args +
+ ['-p', str(self.core_ci.connection.port),
+ '%s@%s' % (self.core_ci.connection.username, self.core_ci.connection.hostname)] +
+ self.become + [pipes.quote(command)])
+
+ def scp(self, src, dst):
+ """
+ :type src: str
+ :type dst: str
+ """
+ run_command(self.core_ci.args,
+ ['scp'] + self.ssh_args +
+ ['-P', str(self.core_ci.connection.port), '-q', '-r', src, dst])
diff --git a/test/runner/lib/pytar.py b/test/runner/lib/pytar.py
new file mode 100644
index 0000000000..2f10b18133
--- /dev/null
+++ b/test/runner/lib/pytar.py
@@ -0,0 +1,69 @@
+"""Python native TGZ creation."""
+
+from __future__ import absolute_import, print_function
+
+import tarfile
+import os
+
+# improve performance by disabling uid/gid lookups
+tarfile.pwd = None
+tarfile.grp = None
+
+# To reduce archive time and size, ignore non-versioned files which are large or numerous.
+# Also ignore miscellaneous git related files since the .git directory is ignored.
+
+IGNORE_DIRS = (
+ '.tox',
+ '.git',
+ '.idea',
+ '__pycache__',
+ 'ansible.egg-info',
+)
+
+IGNORE_FILES = (
+ '.gitignore',
+ '.gitdir',
+)
+
+IGNORE_EXTENSIONS = (
+ '.pyc',
+ '.retry',
+)
+
+
+def ignore(item):
+ """
+ :type item: tarfile.TarInfo
+ :rtype: tarfile.TarInfo | None
+ """
+ filename = os.path.basename(item.path)
+ name, ext = os.path.splitext(filename)
+ dirs = os.path.split(item.path)
+
+ if not item.isdir():
+ if item.path.startswith('./test/results/'):
+ return None
+
+ if item.path.startswith('./docsite/') and filename.endswith('_module.rst'):
+ return None
+
+ if name in IGNORE_FILES:
+ return None
+
+ if ext in IGNORE_EXTENSIONS:
+ return None
+
+ if any(d in IGNORE_DIRS for d in dirs):
+ return None
+
+ return item
+
+
+def create_tarfile(dst_path, src_path, tar_filter):
+ """
+ :type dst_path: str
+ :type src_path: str
+ :type tar_filter: (tarfile.TarInfo) -> tarfile.TarInfo | None
+ """
+ with tarfile.TarFile.gzopen(dst_path, mode='w', compresslevel=4) as tar:
+ tar.add(src_path, filter=tar_filter)
diff --git a/test/runner/lib/target.py b/test/runner/lib/target.py
new file mode 100644
index 0000000000..1ea925e5cc
--- /dev/null
+++ b/test/runner/lib/target.py
@@ -0,0 +1,530 @@
+"""Test target identification, iteration and inclusion/exclusion."""
+
+from __future__ import absolute_import, print_function
+
+import os
+import re
+import errno
+import itertools
+import abc
+
+from lib.util import ApplicationError
+
+MODULE_EXTENSIONS = '.py', '.ps1'
+
+
+def find_target_completion(target_func, prefix):
+ """
+ :type target_func: () -> collections.Iterable[CompletionTarget]
+ :type prefix: unicode
+ :rtype: list[str]
+ """
+ try:
+ targets = target_func()
+ prefix = prefix.encode()
+ short = os.environ.get('COMP_TYPE') == '63' # double tab completion from bash
+ matches = walk_completion_targets(targets, prefix, short)
+ return matches
+ except Exception as ex: # pylint: disable=locally-disabled, broad-except
+ return [str(ex)]
+
+
+def walk_completion_targets(targets, prefix, short=False):
+ """
+ :type targets: collections.Iterable[CompletionTarget]
+ :type prefix: str
+ :type short: bool
+ :rtype: tuple[str]
+ """
+ aliases = set(alias for target in targets for alias in target.aliases)
+
+ if prefix.endswith('/') and prefix in aliases:
+ aliases.remove(prefix)
+
+ matches = [alias for alias in aliases if alias.startswith(prefix) and '/' not in alias[len(prefix):-1]]
+
+ if short:
+ offset = len(os.path.dirname(prefix))
+ if offset:
+ offset += 1
+ relative_matches = [match[offset:] for match in matches if len(match) > offset]
+ if len(relative_matches) > 1:
+ matches = relative_matches
+
+ return tuple(sorted(matches))
+
+
+def walk_internal_targets(targets, includes=None, excludes=None, requires=None):
+ """
+ :type targets: collections.Iterable[T <= CompletionTarget]
+ :type includes: list[str]
+ :type excludes: list[str]
+ :type requires: list[str]
+ :rtype: tuple[T <= CompletionTarget]
+ """
+ targets = tuple(targets)
+
+ include_targets = sorted(filter_targets(targets, includes, errors=True, directories=False), key=lambda t: t.name)
+
+ if requires:
+ require_targets = set(filter_targets(targets, requires, errors=True, directories=False))
+ include_targets = [target for target in include_targets if target in require_targets]
+
+ if excludes:
+ list(filter_targets(targets, excludes, errors=True, include=False, directories=False))
+
+ internal_targets = set(filter_targets(include_targets, excludes, errors=False, include=False, directories=False))
+ return tuple(sorted(internal_targets, key=lambda t: t.name))
+
+
+def walk_external_targets(targets, includes=None, excludes=None, requires=None):
+ """
+ :type targets: collections.Iterable[CompletionTarget]
+ :type includes: list[str]
+ :type excludes: list[str]
+ :type requires: list[str]
+ :rtype: tuple[CompletionTarget], tuple[CompletionTarget]
+ """
+ targets = tuple(targets)
+
+ if requires:
+ include_targets = list(filter_targets(targets, includes, errors=True, directories=False))
+ require_targets = set(filter_targets(targets, requires, errors=True, directories=False))
+ includes = [target.name for target in include_targets if target in require_targets]
+
+ if includes:
+ include_targets = sorted(filter_targets(targets, includes, errors=True), key=lambda t: t.name)
+ else:
+ include_targets = []
+ else:
+ include_targets = sorted(filter_targets(targets, includes, errors=True), key=lambda t: t.name)
+
+ if excludes:
+ exclude_targets = sorted(filter_targets(targets, excludes, errors=True), key=lambda t: t.name)
+ else:
+ exclude_targets = []
+
+ previous = None
+ include = []
+ for target in include_targets:
+ if isinstance(previous, DirectoryTarget) and isinstance(target, DirectoryTarget) \
+ and previous.name == target.name:
+ previous.modules = tuple(set(previous.modules) | set(target.modules))
+ else:
+ include.append(target)
+ previous = target
+
+ previous = None
+ exclude = []
+ for target in exclude_targets:
+ if isinstance(previous, DirectoryTarget) and isinstance(target, DirectoryTarget) \
+ and previous.name == target.name:
+ previous.modules = tuple(set(previous.modules) | set(target.modules))
+ else:
+ exclude.append(target)
+ previous = target
+
+ return tuple(include), tuple(exclude)
+
+
+def filter_targets(targets, patterns, include=True, directories=True, errors=True):
+ """
+ :type targets: collections.Iterable[CompletionTarget]
+ :type patterns: list[str]
+ :type include: bool
+ :type directories: bool
+ :type errors: bool
+ :rtype: collections.Iterable[CompletionTarget]
+ """
+ unmatched = set(patterns or ())
+
+ for target in targets:
+ matched_directories = set()
+ match = False
+
+ if patterns:
+ for alias in target.aliases:
+ for pattern in patterns:
+ if re.match('^%s$' % pattern, alias):
+ match = True
+
+ try:
+ unmatched.remove(pattern)
+ except KeyError:
+ pass
+
+ if alias.endswith('/'):
+ if target.base_path and len(target.base_path) > len(alias):
+ matched_directories.add(target.base_path)
+ else:
+ matched_directories.add(alias)
+ elif include:
+ match = True
+ if not target.base_path:
+ matched_directories.add('.')
+ for alias in target.aliases:
+ if alias.endswith('/'):
+ if target.base_path and len(target.base_path) > len(alias):
+ matched_directories.add(target.base_path)
+ else:
+ matched_directories.add(alias)
+
+ if match != include:
+ continue
+
+ if directories and matched_directories:
+ yield DirectoryTarget(sorted(matched_directories, key=len)[0], target.modules)
+ else:
+ yield target
+
+ if errors:
+ if unmatched:
+ raise TargetPatternsNotMatched(unmatched)
+
+
+def walk_module_targets():
+ """
+ :rtype: collections.Iterable[TestTarget]
+ """
+ path = 'lib/ansible/modules'
+
+ for target in walk_test_targets(path, path + '/', extensions=MODULE_EXTENSIONS):
+ if not target.module:
+ continue
+
+ yield target
+
+
+def walk_units_targets():
+ """
+ :rtype: collections.Iterable[TestTarget]
+ """
+ return walk_test_targets(path='test/units', module_path='test/units/modules/', extensions=('.py',), prefix='test_')
+
+
+def walk_compile_targets():
+ """
+ :rtype: collections.Iterable[TestTarget]
+ """
+ return walk_test_targets(module_path='lib/ansible/modules/', extensions=('.py',))
+
+
+def walk_sanity_targets():
+ """
+ :rtype: collections.Iterable[TestTarget]
+ """
+ return walk_test_targets(module_path='lib/ansible/modules/')
+
+
+def walk_posix_integration_targets():
+ """
+ :rtype: collections.Iterable[IntegrationTarget]
+ """
+ for target in walk_integration_targets():
+ if 'posix/' in target.aliases:
+ yield target
+
+
+def walk_network_integration_targets():
+ """
+ :rtype: collections.Iterable[IntegrationTarget]
+ """
+ for target in walk_integration_targets():
+ if 'network/' in target.aliases:
+ yield target
+
+
+def walk_windows_integration_targets():
+ """
+ :rtype: collections.Iterable[IntegrationTarget]
+ """
+ for target in walk_integration_targets():
+ if 'windows/' in target.aliases:
+ yield target
+
+
+def walk_integration_targets():
+ """
+ :rtype: collections.Iterable[IntegrationTarget]
+ """
+ path = 'test/integration/targets'
+ modules = frozenset(t.module for t in walk_module_targets())
+ paths = sorted(os.path.join(path, p) for p in os.listdir(path))
+ prefixes = load_integration_prefixes()
+
+ for path in paths:
+ yield IntegrationTarget(path, modules, prefixes)
+
+
+def load_integration_prefixes():
+ """
+ :rtype: dict[str, str]
+ """
+ path = 'test/integration'
+ names = sorted(f for f in os.listdir(path) if os.path.splitext(f)[0] == 'target-prefixes')
+ prefixes = {}
+
+ for name in names:
+ prefix = os.path.splitext(name)[1][1:]
+ with open(os.path.join(path, name), 'r') as prefix_fd:
+ prefixes.update(dict((k, prefix) for k in prefix_fd.read().splitlines()))
+
+ return prefixes
+
+
+def walk_test_targets(path=None, module_path=None, extensions=None, prefix=None):
+ """
+ :type path: str | None
+ :type module_path: str | None
+ :type extensions: tuple[str] | None
+ :type prefix: str | None
+ :rtype: collections.Iterable[TestTarget]
+ """
+ for root, _, file_names in os.walk(path or '.', topdown=False):
+ if root.endswith('/__pycache__'):
+ continue
+
+ if path is None:
+ root = root[2:]
+
+ if root.startswith('.'):
+ continue
+
+ for file_name in file_names:
+ name, ext = os.path.splitext(os.path.basename(file_name))
+
+ if name.startswith('.'):
+ continue
+
+ if extensions and ext not in extensions:
+ continue
+
+ if prefix and not name.startswith(prefix):
+ continue
+
+ yield TestTarget(os.path.join(root, file_name), module_path, prefix, path)
+
+
+class CompletionTarget(object):
+ """Command-line argument completion target base class."""
+ __metaclass__ = abc.ABCMeta
+
+ def __init__(self):
+ self.name = None
+ self.path = None
+ self.base_path = None
+ self.modules = tuple()
+ self.aliases = tuple()
+
+ def __eq__(self, other):
+ if isinstance(other, CompletionTarget):
+ return self.__repr__() == other.__repr__()
+ else:
+ return False
+
+ def __ne__(self, other):
+ return not self.__eq__(other)
+
+ def __lt__(self, other):
+ return self.name.__lt__(other.name)
+
+ def __gt__(self, other):
+ return self.name.__gt__(other.name)
+
+ def __hash__(self):
+ return hash(self.__repr__())
+
+ def __repr__(self):
+ if self.modules:
+ return '%s (%s)' % (self.name, ', '.join(self.modules))
+
+ return self.name
+
+
+class DirectoryTarget(CompletionTarget):
+ """Directory target."""
+ def __init__(self, path, modules):
+ """
+ :type path: str
+ :type modules: tuple[str]
+ """
+ super(DirectoryTarget, self).__init__()
+
+ self.name = path
+ self.path = path
+ self.modules = modules
+
+
+class TestTarget(CompletionTarget):
+ """Generic test target."""
+ def __init__(self, path, module_path, module_prefix, base_path):
+ """
+ :type path: str
+ :type module_path: str | None
+ :type module_prefix: str | None
+ :type base_path: str
+ """
+ super(TestTarget, self).__init__()
+
+ self.name = path
+ self.path = path
+ self.base_path = base_path + '/' if base_path else None
+
+ name, ext = os.path.splitext(os.path.basename(self.path))
+
+ if module_path and path.startswith(module_path) and name != '__init__' and ext in MODULE_EXTENSIONS:
+ self.module = name[len(module_prefix or ''):].lstrip('_')
+ self.modules = self.module,
+ else:
+ self.module = None
+ self.modules = tuple()
+
+ aliases = [self.path, self.module]
+ parts = self.path.split('/')
+
+ for i in range(1, len(parts)):
+ alias = '%s/' % '/'.join(parts[:i])
+ aliases.append(alias)
+
+ aliases = [a for a in aliases if a]
+
+ self.aliases = tuple(sorted(aliases))
+
+
+class IntegrationTarget(CompletionTarget):
+ """Integration test target."""
+ non_posix = frozenset((
+ 'network',
+ 'windows',
+ ))
+
+ categories = frozenset(non_posix | frozenset((
+ 'posix',
+ 'module',
+ 'needs',
+ 'skip',
+ )))
+
+ def __init__(self, path, modules, prefixes):
+ """
+ :type path: str
+ :type modules: frozenset[str]
+ :type prefixes: dict[str, str]
+ """
+ super(IntegrationTarget, self).__init__()
+
+ self.name = os.path.basename(path)
+ self.path = path
+
+ # script_path and type
+
+ contents = sorted(os.listdir(path))
+
+ runme_files = tuple(c for c in contents if os.path.splitext(c)[0] == 'runme')
+ test_files = tuple(c for c in contents if os.path.splitext(c)[0] == 'test')
+
+ self.script_path = None
+
+ if runme_files:
+ self.type = 'script'
+ self.script_path = os.path.join(path, runme_files[0])
+ elif test_files:
+ self.type = 'special'
+ elif os.path.isdir(os.path.join(path, 'tasks')):
+ self.type = 'role'
+ else:
+ self.type = 'unknown'
+
+ # static_aliases
+
+ try:
+ with open(os.path.join(path, 'aliases'), 'r') as aliases_file:
+ static_aliases = tuple(aliases_file.read().splitlines())
+ except IOError as ex:
+ if ex.errno != errno.ENOENT:
+ raise
+ static_aliases = tuple()
+
+ # modules
+
+ if self.name in modules:
+ module = self.name
+ elif self.name.startswith('win_') and self.name[4:] in modules:
+ module = self.name[4:]
+ else:
+ module = None
+
+ self.modules = tuple(sorted(a for a in static_aliases + tuple([module]) if a in modules))
+
+ # groups
+
+ groups = [self.type]
+ groups += [a for a in static_aliases if a not in modules]
+ groups += ['module/%s' % m for m in self.modules]
+
+ if not self.modules:
+ groups.append('non_module')
+
+ if 'destructive' not in groups:
+ groups.append('non_destructive')
+
+ if '_' in self.name:
+ prefix = self.name[:self.name.find('_')]
+ else:
+ prefix = None
+
+ if prefix in prefixes:
+ group = prefixes[prefix]
+
+ if group != prefix:
+ group = '%s/%s' % (group, prefix)
+
+ groups.append(group)
+
+ if self.name.startswith('win_'):
+ groups.append('windows')
+
+ if self.name.startswith('connection_'):
+ groups.append('connection')
+
+ if self.name.startswith('setup_') or self.name.startswith('prepare_'):
+ groups.append('hidden')
+
+ if self.type not in ('script', 'role'):
+ groups.append('hidden')
+
+ for group in itertools.islice(groups, 0, len(groups)):
+ if '/' in group:
+ parts = group.split('/')
+ for i in range(1, len(parts)):
+ groups.append('/'.join(parts[:i]))
+
+ if not any(g in self.non_posix for g in groups):
+ groups.append('posix')
+
+ # aliases
+
+ aliases = [self.name] + \
+ ['%s/' % g for g in groups] + \
+ ['%s/%s' % (g, self.name) for g in groups if g not in self.categories]
+
+ if 'hidden/' in aliases:
+ aliases = ['hidden/'] + ['hidden/%s' % a for a in aliases if not a.startswith('hidden/')]
+
+ self.aliases = tuple(sorted(set(aliases)))
+
+
+class TargetPatternsNotMatched(ApplicationError):
+ """One or more targets were not matched when a match was required."""
+ def __init__(self, patterns):
+ """
+ :type patterns: set[str]
+ """
+ self.patterns = sorted(patterns)
+
+ if len(patterns) > 1:
+ message = 'Target patterns not matched:\n%s' % '\n'.join(self.patterns)
+ else:
+ message = 'Target pattern not matched: %s' % self.patterns[0]
+
+ super(TargetPatternsNotMatched, self).__init__(message)
diff --git a/test/runner/lib/thread.py b/test/runner/lib/thread.py
new file mode 100644
index 0000000000..cf7d6a36c4
--- /dev/null
+++ b/test/runner/lib/thread.py
@@ -0,0 +1,48 @@
+"""Python threading tools."""
+
+from __future__ import absolute_import, print_function
+
+import threading
+import sys
+
+try:
+ # noinspection PyPep8Naming
+ import Queue as queue
+except ImportError:
+ # noinspection PyUnresolvedReferences
+ import queue # pylint: disable=locally-disabled, import-error
+
+
+class WrappedThread(threading.Thread):
+ """Wrapper around Thread which captures results and exceptions."""
+ def __init__(self, action):
+ """
+ :type action: () -> any
+ """
+ super(WrappedThread, self).__init__()
+ self._result = queue.Queue()
+ self.action = action
+
+ def run(self):
+ """
+ Run action and capture results or exception.
+ Do not override. Do not call directly. Executed by the start() method.
+ """
+ # noinspection PyBroadException
+ try:
+ self._result.put((self.action(), None))
+ except: # pylint: disable=locally-disabled, bare-except
+ self._result.put((None, sys.exc_info()))
+
+ def wait_for_result(self):
+ """
+ Wait for thread to exit and return the result or raise an exception.
+ :rtype: any
+ """
+ result, exception = self._result.get()
+ if exception:
+ if sys.version_info[0] > 2:
+ raise exception[0](exception[1]).with_traceback(exception[2])
+ # noinspection PyRedundantParentheses
+ exec('raise exception[0], exception[1], exception[2]') # pylint: disable=locally-disabled, exec-used
+ return result
diff --git a/test/runner/lib/util.py b/test/runner/lib/util.py
new file mode 100644
index 0000000000..f7544c4b19
--- /dev/null
+++ b/test/runner/lib/util.py
@@ -0,0 +1,415 @@
+"""Miscellaneous utility functions and classes."""
+
+from __future__ import absolute_import, print_function
+
+import errno
+import os
+import pipes
+import shutil
+import subprocess
+import sys
+import time
+
+
+def is_shippable():
+ """
+ :rtype: bool
+ """
+ return os.environ.get('SHIPPABLE') == 'true'
+
+
+def remove_file(path):
+ """
+ :type path: str
+ """
+ if os.path.isfile(path):
+ os.remove(path)
+
+
+def find_executable(executable, cwd=None, path=None, required=True):
+ """
+ :type executable: str
+ :type cwd: str
+ :type path: str
+ :type required: bool | str
+ :rtype: str | None
+ """
+ match = None
+ real_cwd = os.getcwd()
+
+ if not cwd:
+ cwd = real_cwd
+
+ if os.path.dirname(executable):
+ target = os.path.join(cwd, executable)
+ if os.path.exists(target) and os.access(target, os.F_OK | os.X_OK):
+ match = executable
+ else:
+ if path is None:
+ path = os.environ.get('PATH', os.defpath)
+
+ if path:
+ path_dirs = path.split(os.pathsep)
+ seen_dirs = set()
+
+ for path_dir in path_dirs:
+ if path_dir in seen_dirs:
+ continue
+
+ seen_dirs.add(path_dir)
+
+ if os.path.abspath(path_dir) == real_cwd:
+ path_dir = cwd
+
+ candidate = os.path.join(path_dir, executable)
+
+ if os.path.exists(candidate) and os.access(candidate, os.F_OK | os.X_OK):
+ match = candidate
+ break
+
+ if not match and required:
+ message = 'Required program "%s" not found.' % executable
+
+ if required != 'warning':
+ raise ApplicationError(message)
+
+ display.warning(message)
+
+ return match
+
+
+def run_command(args, cmd, capture=False, env=None, data=None, cwd=None, always=False, stdin=None, stdout=None):
+ """
+ :type args: CommonConfig
+ :type cmd: collections.Iterable[str]
+ :type capture: bool
+ :type env: dict[str, str] | None
+ :type data: str | None
+ :type cwd: str | None
+ :type always: bool
+ :type stdin: file | None
+ :type stdout: file | None
+ :rtype: str | None, str | None
+ """
+ explain = args.explain and not always
+ return raw_command(cmd, capture=capture, env=env, data=data, cwd=cwd, explain=explain, stdin=stdin, stdout=stdout)
+
+
+def raw_command(cmd, capture=False, env=None, data=None, cwd=None, explain=False, stdin=None, stdout=None):
+ """
+ :type cmd: collections.Iterable[str]
+ :type capture: bool
+ :type env: dict[str, str] | None
+ :type data: str | None
+ :type cwd: str | None
+ :type explain: bool
+ :type stdin: file | None
+ :type stdout: file | None
+ :rtype: str | None, str | None
+ """
+ if not cwd:
+ cwd = os.getcwd()
+
+ if not env:
+ env = common_environment()
+
+ cmd = list(cmd)
+
+ escaped_cmd = ' '.join(pipes.quote(c) for c in cmd)
+
+ display.info('Run command: %s' % escaped_cmd, verbosity=1)
+ display.info('Working directory: %s' % cwd, verbosity=2)
+
+ program = find_executable(cmd[0], cwd=cwd, path=env['PATH'], required='warning')
+
+ if program:
+ display.info('Program found: %s' % program, verbosity=2)
+
+ for key in sorted(env.keys()):
+ display.info('%s=%s' % (key, env[key]), verbosity=2)
+
+ if explain:
+ return None, None
+
+ communicate = False
+
+ if stdin is not None:
+ data = None
+ communicate = True
+ elif data is not None:
+ stdin = subprocess.PIPE
+ communicate = True
+
+ if stdout:
+ communicate = True
+
+ if capture:
+ stdout = stdout or subprocess.PIPE
+ stderr = subprocess.PIPE
+ communicate = True
+ else:
+ stderr = None
+
+ start = time.time()
+
+ try:
+ process = subprocess.Popen(cmd, env=env, stdin=stdin, stdout=stdout, stderr=stderr, cwd=cwd)
+ except OSError as ex:
+ if ex.errno == errno.ENOENT:
+ raise ApplicationError('Required program "%s" not found.' % cmd[0])
+ raise
+
+ if communicate:
+ stdout, stderr = process.communicate(data)
+ else:
+ process.wait()
+ stdout, stderr = None, None
+
+ status = process.returncode
+ runtime = time.time() - start
+
+ display.info('Command exited with status %s after %s seconds.' % (status, runtime), verbosity=4)
+
+ if status == 0:
+ return stdout, stderr
+
+ raise SubprocessError(cmd, status, stdout, stderr, runtime)
+
+
+def common_environment():
+ """Common environment used for executing all programs."""
+ env = dict(
+ LC_ALL='en_US.UTF-8',
+ PATH=os.environ.get('PATH', os.defpath),
+ )
+
+ required = (
+ 'HOME',
+ )
+
+ optional = (
+ 'HTTPTESTER',
+ )
+
+ env.update(pass_vars(required=required, optional=optional))
+
+ return env
+
+
+def pass_vars(required=None, optional=None):
+ """
+ :type required: collections.Iterable[str]
+ :type optional: collections.Iterable[str]
+ :rtype: dict[str, str]
+ """
+ env = {}
+
+ for name in required:
+ if name not in os.environ:
+ raise MissingEnvironmentVariable(name)
+ env[name] = os.environ[name]
+
+ for name in optional:
+ if name not in os.environ:
+ continue
+ env[name] = os.environ[name]
+
+ return env
+
+
+def deepest_path(path_a, path_b):
+ """Return the deepest of two paths, or None if the paths are unrelated.
+ :type path_a: str
+ :type path_b: str
+ :return: str | None
+ """
+ if path_a == '.':
+ path_a = ''
+
+ if path_b == '.':
+ path_b = ''
+
+ if path_a.startswith(path_b):
+ return path_a or '.'
+
+ if path_b.startswith(path_a):
+ return path_b or '.'
+
+ return None
+
+
+def remove_tree(path):
+ """
+ :type path: str
+ """
+ try:
+ shutil.rmtree(path)
+ except OSError as ex:
+ if ex.errno != errno.ENOENT:
+ raise
+
+
+def make_dirs(path):
+ """
+ :type path: str
+ """
+ try:
+ os.makedirs(path)
+ except OSError as ex:
+ if ex.errno != errno.EEXIST:
+ raise
+
+
+class Display(object):
+ """Manages color console output."""
+ clear = '\033[0m'
+ red = '\033[31m'
+ green = '\033[32m'
+ yellow = '\033[33m'
+ blue = '\033[34m'
+ purple = '\033[35m'
+ cyan = '\033[36m'
+
+ verbosity_colors = {
+ 0: None,
+ 1: green,
+ 2: blue,
+ 3: cyan,
+ }
+
+ def __init__(self):
+ self.verbosity = 0
+ self.color = True
+ self.warnings = []
+
+ def __warning(self, message):
+ """
+ :type message: str
+ """
+ self.print_message('WARNING: %s' % message, color=self.purple, fd=sys.stderr)
+
+ def review_warnings(self):
+ """Review all warnings which previously occurred."""
+ if not self.warnings:
+ return
+
+ self.__warning('Reviewing previous %d warning(s):' % len(self.warnings))
+
+ for warning in self.warnings:
+ self.__warning(warning)
+
+ def warning(self, message):
+ """
+ :type message: str
+ """
+ self.__warning(message)
+ self.warnings.append(message)
+
+ def notice(self, message):
+ """
+ :type message: str
+ """
+ self.print_message('NOTICE: %s' % message, color=self.purple, fd=sys.stderr)
+
+ def error(self, message):
+ """
+ :type message: str
+ """
+ self.print_message('ERROR: %s' % message, color=self.red, fd=sys.stderr)
+
+ def info(self, message, verbosity=0):
+ """
+ :type message: str
+ :type verbosity: int
+ """
+ if self.verbosity >= verbosity:
+ color = self.verbosity_colors.get(verbosity, self.yellow)
+ self.print_message(message, color=color)
+
+ def print_message(self, message, color=None, fd=sys.stdout): # pylint: disable=locally-disabled, invalid-name
+ """
+ :type message: str
+ :type color: str | None
+ :type fd: file
+ """
+ if color and self.color:
+ # convert color resets in message to desired color
+ message = message.replace(self.clear, color)
+ message = '%s%s%s' % (color, message, self.clear)
+
+ print(message, file=fd)
+ fd.flush()
+
+
+class ApplicationError(Exception):
+ """General application error."""
+ def __init__(self, message=None):
+ """
+ :type message: str | None
+ """
+ super(ApplicationError, self).__init__(message)
+
+
+class ApplicationWarning(Exception):
+ """General application warning which interrupts normal program flow."""
+ def __init__(self, message=None):
+ """
+ :type message: str | None
+ """
+ super(ApplicationWarning, self).__init__(message)
+
+
+class SubprocessError(ApplicationError):
+ """Error resulting from failed subprocess execution."""
+ def __init__(self, cmd, status=0, stdout=None, stderr=None, runtime=None):
+ """
+ :type cmd: list[str]
+ :type status: int
+ :type stdout: str | None
+ :type stderr: str | None
+ :type runtime: float | None
+ """
+ message = 'Command "%s" returned exit status %s.\n' % (' '.join(pipes.quote(c) for c in cmd), status)
+
+ if stderr:
+ message += '>>> Standard Error\n'
+ message += '%s%s\n' % (stderr.strip(), Display.clear)
+
+ if stdout:
+ message += '>>> Standard Output\n'
+ message += '%s%s\n' % (stdout.strip(), Display.clear)
+
+ message = message.strip()
+
+ super(SubprocessError, self).__init__(message)
+
+ self.cmd = cmd
+ self.status = status
+ self.stdout = stdout
+ self.stderr = stderr
+ self.runtime = runtime
+
+
+class MissingEnvironmentVariable(ApplicationError):
+ """Error caused by missing environment variable."""
+ def __init__(self, name):
+ """
+ :type name: str
+ """
+ super(MissingEnvironmentVariable, self).__init__('Missing environment variable: %s' % name)
+
+ self.name = name
+
+
+class CommonConfig(object):
+ """Configuration common to all commands."""
+ def __init__(self, args):
+ """
+ :type args: any
+ """
+ self.color = args.color # type: bool
+ self.explain = args.explain # type: bool
+ self.verbosity = args.verbosity # type: int
+
+
+display = Display() # pylint: disable=locally-disabled, invalid-name
diff --git a/test/runner/reorganize-tests.sh b/test/runner/reorganize-tests.sh
new file mode 100755
index 0000000000..1d6daf8986
--- /dev/null
+++ b/test/runner/reorganize-tests.sh
@@ -0,0 +1,235 @@
+#!/usr/bin/env bash
+
+set -eu
+
+source_root=$(python -c "from os import path; print(path.abspath(path.join(path.dirname('$0'), '..', '..')))")
+
+cd "${source_root}"
+
+# Convert existing compile skip files to match the unified repository layout.
+
+mkdir -p test/compile
+
+rm -f test/compile/*.txt
+
+for type in core extras; do
+ sed "s|^|/lib/ansible/modules/${type}|" \
+ < "lib/ansible/modules/${type}/test/utils/shippable/sanity-skip-python24.txt" \
+ >> "test/compile/python2.4-skip.txt"
+done
+
+# Existing skip files are only for modules.
+# Add missing skip entries for core code.
+
+cat << EOF >> test/compile/python2.4-skip.txt
+/lib/ansible/modules/__init__.py
+/lib/ansible/module_utils/a10.py
+/lib/ansible/module_utils/rax.py
+/lib/ansible/module_utils/openstack.py
+/lib/ansible/module_utils/cloud.py
+/lib/ansible/module_utils/ec2.py
+/lib/ansible/module_utils/gce.py
+/lib/ansible/module_utils/lxd.py
+/lib/ansible/module_utils/docker_common.py
+/lib/ansible/module_utils/azure_rm_common.py
+/lib/ansible/module_utils/vca.py
+/lib/ansible/module_utils/vmware.py
+/lib/ansible/module_utils/gcp.py
+/lib/ansible/module_utils/gcdns.py
+/lib/ansible/vars/
+/lib/ansible/utils/
+/lib/ansible/template/
+/lib/ansible/plugins/
+/lib/ansible/playbook/
+/lib/ansible/parsing/
+/lib/ansible/inventory/
+/lib/ansible/galaxy/
+/lib/ansible/executor/
+/lib/ansible/errors/
+/lib/ansible/compat/
+/lib/ansible/config/
+/lib/ansible/cli/
+/lib/ansible/constants.py
+/lib/ansible/release.py
+/lib/ansible/__init__.py
+/hacking/
+/contrib/
+/docsite/
+/test/
+EOF
+
+cat << EOF >> test/compile/python2.6-skip.txt
+/contrib/inventory/vagrant.py
+/hacking/dump_playbook_attributes.py
+EOF
+
+cat << EOF >> test/compile/python3.5-skip.txt
+/test/samples/multi.py
+/examples/scripts/uptime.py
+EOF
+
+for path in test/compile/*.txt; do
+ sort -o "${path}" "${path}"
+done
+
+# Not all scripts pass shellcheck yet.
+
+mkdir -p test/sanity/shellcheck
+
+cat << EOF > test/sanity/shellcheck/skip.txt
+test/sanity/code-smell/boilerplate.sh
+EOF
+
+sort -o test/sanity/shellcheck/skip.txt test/sanity/shellcheck/skip.txt
+
+# Add skip list for code-smell scripts.
+# These scripts don't pass, so we can't run them in CI.
+
+cat << EOF > test/sanity/code-smell/skip.txt
+inappropriately-private.sh
+EOF
+
+# Add skip list for validate-modules.
+# Some of these exclusions are temporary, others belong in validate-modules.
+cat << EOF > test/sanity/validate-modules/skip.txt
+lib/ansible/modules/core/utilities/logic/async_status.py
+lib/ansible/modules/core/utilities/helper/_fireball.py
+lib/ansible/modules/core/utilities/helper/_accelerate.py
+lib/ansible/modules/core/test
+lib/ansible/modules/core/.github
+lib/ansible/modules/extras/test
+lib/ansible/modules/extras/.github
+EOF
+
+# Remove existing aliases from previous script runs.
+rm -f test/integration/targets/*/aliases
+
+# Map destructive/ targets to integration tests.
+targets=$(grep 'role:' "test/integration/destructive.yml" \
+ | sed 's/^.* role: //; s/[ ,].*$//;')
+
+for target in ${targets}; do
+ alias='destructive'
+ echo "target: ${target}, alias: ${alias}"
+ echo "${alias}" >> "test/integration/targets/${target}/aliases"
+done
+
+# Map destructive/non_destructive targets to posix groups for integration tests.
+# This will allow re-balancing of posix tests on Shippable independently of destructive/non_destructive targets.
+for type in destructive non_destructive; do
+ targets=$(grep 'role:' "test/integration/${type}.yml" \
+ | sed 's/^.* role: //; s/[ ,].*$//;')
+
+ if [ "${type}" = "destructive" ]; then
+ group="posix/ci/group1"
+ else
+ group="posix/ci/group2"
+ fi
+
+ for target in ${targets}; do
+ echo "target: ${target}, group: ${group}"
+ echo "${group}" >> "test/integration/targets/${target}/aliases"
+ done
+done
+
+# Add aliases to integration tests.
+targets=$(grep 'role:' test/integration/{destructive,non_destructive}.yml \
+ | sed 's/^.* role: //; s/[ ,].*$//;')
+
+for target in ${targets}; do
+ aliases=$(grep -h "role: *${target}[ ,]" test/integration/{destructive,non_destructive}.yml \
+ | sed 's/when:[^,]*//;' \
+ | sed 's/^.*tags:[ []*//g; s/[]}].*$//g; s/ //g; s/,/ /g; s/test_//g;')
+
+ for alias in ${aliases}; do
+ if [ "${target}" != "${alias}" ]; then
+ # convert needs_ prefixed aliases to groups
+ alias="${alias//needs_/needs\/}"
+
+ echo "target: ${target}, alias: ${alias}"
+ echo "${alias}" >> "test/integration/targets/${target}/aliases"
+ fi
+ done
+done
+
+# Map test_win_group* targets to windows groups for integration tests.
+for type in test_win_group1 test_win_group2 test_win_group3; do
+ targets=$(grep 'role:' "test/integration/${type}.yml" \
+ | sed 's/^.* role: //; s/[ ,].*$//;')
+
+ group=$(echo "${type}" | sed 's/^test_win_/windows_/; s/_/\/ci\//;')
+
+ for target in ${targets}; do
+ echo "target: ${target}, group: ${group}"
+ echo "${group}" >> "test/integration/targets/${target}/aliases"
+ done
+done
+
+# Add additional windows tests to appropriate groups.
+echo 'windows/ci/group2' >> test/integration/targets/binary_modules_winrm/aliases
+echo 'windows/ci/group3' >> test/integration/targets/connection_winrm/aliases
+
+# Add posix/ci/group3 for posix tests which are not already grouped for ci.
+group="posix/ci/group3"
+for target in test/integration/targets/*; do
+ target=$(basename "${target}")
+ if [[ "${target}" =~ (setup|prepare)_ ]]; then
+ continue
+ fi
+ if [ -f "test/integration/targets/${target}/test.sh" ]; then
+ continue
+ fi
+ if [ -f "test/integration/targets/${target}/aliases" ]; then
+ if grep -q -P "^(windows|posix)/" "test/integration/targets/${target}/aliases"; then
+ continue
+ fi
+ fi
+ if [[ "${target}" =~ _ ]]; then
+ prefix="${target//_*/}"
+ if grep -q --line-regex "${prefix}" test/integration/target-prefixes.*; then
+ continue
+ fi
+ fi
+ echo "target: ${target}, group: ${group}"
+ echo "${group}" >> "test/integration/targets/${target}/aliases"
+done
+
+# Add skip aliases for python3.
+sed 's/^test_//' test/utils/shippable/python3-test-tag-blacklist.txt | while IFS= read -r target; do
+ echo "skip/python3" >> "test/integration/targets/${target}/aliases"
+done
+
+# Add skip aliases for tests which don't pass yet on osx/freebsd.
+for target in service postgresql mysql_db mysql_user mysql_variables uri get_url async_extra_data; do
+ echo "skip/osx" >> "test/integration/targets/${target}/aliases"
+ echo "skip/freebsd" >> "test/integration/targets/${target}/aliases"
+done
+
+# Add skip aliases for tests which don't pass yet on osx.
+for target in gathering_facts iterators git; do
+ echo "skip/osx" >> "test/integration/targets/${target}/aliases"
+done
+
+# Add needs/root entries as required.
+for target in connection_chroot authorized_key copy template unarchive; do
+ echo "needs/root" >> "test/integration/targets/${target}/aliases"
+done
+
+# Add needs/ssh entries as required.
+for target in async_extra_data connection_ssh connection_paramiko_ssh; do
+ echo "needs/ssh" >> "test/integration/targets/${target}/aliases"
+done
+
+# Add missing alias for windows async_status.
+echo "async_status" >> test/integration/targets/win_async_wrapper/aliases
+
+# Remove connection tests from CI groups which aren't supported yet.
+for connection in docker jail libvirt_lxc lxc lxd; do
+ target="connection_${connection}"
+ sed -i '/^posix\/ci\/.*$/d' "test/integration/targets/${target}/aliases"
+done
+
+# Sort aliases.
+for file in test/integration/targets/*/aliases; do
+ sort -o "${file}" "${file}"
+done
diff --git a/test/runner/requirements/ansible-test.txt b/test/runner/requirements/ansible-test.txt
new file mode 100644
index 0000000000..d08d921a32
--- /dev/null
+++ b/test/runner/requirements/ansible-test.txt
@@ -0,0 +1 @@
+argparse ; python_version < '2.7'
diff --git a/test/runner/requirements/constraints.txt b/test/runner/requirements/constraints.txt
new file mode 100644
index 0000000000..ec8e8a069b
--- /dev/null
+++ b/test/runner/requirements/constraints.txt
@@ -0,0 +1,2 @@
+coverage >= 4.2
+pywinrm >= 0.2.1 # 0.1.1 required, but 0.2.1 provides better performance
diff --git a/test/runner/requirements/coverage.txt b/test/runner/requirements/coverage.txt
new file mode 100644
index 0000000000..4ebc8aea50
--- /dev/null
+++ b/test/runner/requirements/coverage.txt
@@ -0,0 +1 @@
+coverage
diff --git a/test/runner/requirements/integration.txt b/test/runner/requirements/integration.txt
new file mode 100644
index 0000000000..84ca1ec94a
--- /dev/null
+++ b/test/runner/requirements/integration.txt
@@ -0,0 +1,8 @@
+jinja2
+jmespath
+junit-xml
+ordereddict ; python_version < '2.7'
+paramiko
+passlib
+pycrypto
+pyyaml
diff --git a/test/runner/requirements/sanity.txt b/test/runner/requirements/sanity.txt
new file mode 100644
index 0000000000..1a86edd464
--- /dev/null
+++ b/test/runner/requirements/sanity.txt
@@ -0,0 +1,5 @@
+jinja2
+mock
+pylint
+voluptuous
+yamllint
diff --git a/test/runner/requirements/units.txt b/test/runner/requirements/units.txt
new file mode 100644
index 0000000000..885050a7b6
--- /dev/null
+++ b/test/runner/requirements/units.txt
@@ -0,0 +1,11 @@
+boto3
+jinja2
+mock
+nose
+passlib
+pycrypto
+pytest
+python-memcached
+pyyaml
+redis
+unittest2 ; python_version < '2.7'
diff --git a/test/runner/requirements/windows-integration.txt b/test/runner/requirements/windows-integration.txt
new file mode 100644
index 0000000000..d6fcc566fc
--- /dev/null
+++ b/test/runner/requirements/windows-integration.txt
@@ -0,0 +1,4 @@
+jinja2
+junit-xml
+pywinrm
+pyyaml
diff --git a/test/runner/setup/docker.sh b/test/runner/setup/docker.sh
new file mode 100644
index 0000000000..fa5788e8e7
--- /dev/null
+++ b/test/runner/setup/docker.sh
@@ -0,0 +1,18 @@
+#!/bin/sh
+
+set -eu
+
+# Support images with only python3 installed.
+if [ ! -f /usr/bin/python ] && [ -f /usr/bin/python3 ]; then
+ ln -s /usr/bin/python3 /usr/bin/python
+fi
+if [ ! -f /usr/bin/pip ] && [ -f /usr/bin/pip3 ]; then
+ ln -s /usr/bin/pip3 /usr/bin/pip
+fi
+
+# Improve prompts on remote host for interactive use.
+cat << EOF > ~/.bashrc
+alias ls='ls --color=auto'
+export PS1='\[\e]0;\u@\h: \w\a\]\[\033[01;32m\]\u@\h\[\033[00m\]:\[\033[01;34m\]\w\[\033[00m\]\$ '
+cd ~/ansible/
+EOF
diff --git a/test/runner/setup/remote.sh b/test/runner/setup/remote.sh
new file mode 100644
index 0000000000..13eb1cbc74
--- /dev/null
+++ b/test/runner/setup/remote.sh
@@ -0,0 +1,69 @@
+#!/bin/sh
+
+set -eu
+
+platform="$1"
+
+env
+
+cd ~/
+
+if [ "${platform}" = "freebsd" ]; then
+ pkg install -y curl
+
+ if [ ! -f bootstrap.sh ]; then
+ curl "https://raw.githubusercontent.com/mattclay/ansible-hacking/master/bootstrap.sh" -o bootstrap.sh -#
+ fi
+
+ chmod +x bootstrap.sh
+ ./bootstrap.sh pip -y -q
+
+ pkg install -y \
+ bash \
+ devel/ruby-gems \
+ gtar \
+ mercurial \
+ rsync \
+ ruby \
+ subversion \
+ sudo \
+ zip
+fi
+
+pip install virtualenv
+
+# Tests assume loopback addresses other than 127.0.0.1 will work.
+# Add aliases for loopback addresses used by tests.
+
+for i in 3 4 254; do
+ ifconfig lo0 alias "127.0.0.${i}" up
+done
+
+ifconfig lo0
+
+# Since tests run as root, we also need to be able to ssh to localhost as root.
+sed -i '' 's/^# *PermitRootLogin.*$/PermitRootLogin yes/;' /etc/ssh/sshd_config
+
+if [ "${platform}" = "freebsd" ]; then
+ # Restart sshd for configuration changes and loopback aliases to work.
+ service sshd restart
+fi
+
+# Generate our ssh key and add it to our authorized_keys file.
+# We also need to add localhost's server keys to known_hosts.
+
+if [ ! -f "${HOME}/.ssh/id_rsa.pub" ]; then
+ ssh-keygen -q -t rsa -N '' -f "${HOME}/.ssh/id_rsa"
+ cp "${HOME}/.ssh/id_rsa.pub" "${HOME}/.ssh/authorized_keys"
+ for key in /etc/ssh/ssh_host_*_key.pub; do
+ pk=$(cat "${key}")
+ echo "localhost ${pk}" >> "${HOME}/.ssh/known_hosts"
+ done
+fi
+
+# Improve prompts on remote host for interactive use.
+cat << EOF > ~/.bashrc
+alias ls='ls -G'
+export PS1='\[\e]0;\u@\h: \w\a\]\[\033[01;32m\]\u@\h\[\033[00m\]:\[\033[01;34m\]\w\[\033[00m\]\$ '
+cd ~/ansible/
+EOF
diff --git a/test/runner/test.py b/test/runner/test.py
new file mode 100755
index 0000000000..b9589b6c35
--- /dev/null
+++ b/test/runner/test.py
@@ -0,0 +1,446 @@
+#!/usr/bin/env python
+# PYTHON_ARGCOMPLETE_OK
+"""Test runner for all Ansible tests."""
+
+from __future__ import absolute_import, print_function
+
+import errno
+import os
+import sys
+
+from lib.util import (
+ ApplicationError,
+ display,
+ raw_command,
+)
+
+from lib.delegation import (
+ delegate,
+)
+
+from lib.executor import (
+ command_posix_integration,
+ command_network_integration,
+ command_windows_integration,
+ command_units,
+ command_compile,
+ command_sanity,
+ command_shell,
+ SANITY_TESTS,
+ SUPPORTED_PYTHON_VERSIONS,
+ COMPILE_PYTHON_VERSIONS,
+ PosixIntegrationConfig,
+ WindowsIntegrationConfig,
+ NetworkIntegrationConfig,
+ SanityConfig,
+ UnitsConfig,
+ CompileConfig,
+ ShellConfig,
+ ApplicationWarning,
+ Delegate,
+ generate_pip_install,
+)
+
+from lib.target import (
+ find_target_completion,
+ walk_posix_integration_targets,
+ walk_network_integration_targets,
+ walk_windows_integration_targets,
+ walk_units_targets,
+ walk_compile_targets,
+ walk_sanity_targets,
+)
+
+import lib.cover
+
+
+def main():
+ """Main program function."""
+ try:
+ git_root = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', '..'))
+ os.chdir(git_root)
+ args = parse_args()
+ config = args.config(args)
+ display.verbosity = config.verbosity
+ display.color = config.color
+
+ try:
+ args.func(config)
+ except Delegate as ex:
+ delegate(config, ex.exclude, ex.require)
+
+ display.review_warnings()
+ except ApplicationWarning as ex:
+ display.warning(str(ex))
+ exit(0)
+ except ApplicationError as ex:
+ display.error(str(ex))
+ exit(1)
+ except KeyboardInterrupt:
+ exit(2)
+ except IOError as ex:
+ if ex.errno == errno.EPIPE:
+ exit(3)
+ raise
+
+
+def parse_args():
+ """Parse command line arguments."""
+ try:
+ import argparse
+ except ImportError:
+ if '--requirements' not in sys.argv:
+ raise
+ raw_command(generate_pip_install('ansible-test'))
+ import argparse
+
+ try:
+ import argcomplete
+ except ImportError:
+ argcomplete = None
+
+ if argcomplete:
+ epilog = 'Tab completion available using the "argcomplete" python package.'
+ else:
+ epilog = 'Install the "argcomplete" python package to enable tab completion.'
+
+ parser = argparse.ArgumentParser(epilog=epilog)
+
+ common = argparse.ArgumentParser(add_help=False)
+
+ common.add_argument('-e', '--explain',
+ action='store_true',
+ help='explain commands that would be executed')
+
+ common.add_argument('-v', '--verbose',
+ dest='verbosity',
+ action='count',
+ default=0,
+ help='display more output')
+
+ common.add_argument('--color',
+ metavar='COLOR',
+ nargs='?',
+ help='generate color output: %(choices)s',
+ choices=('yes', 'no', 'auto'),
+ const='yes',
+ default='auto')
+
+ test = argparse.ArgumentParser(add_help=False, parents=[common])
+
+ test.add_argument('include',
+ metavar='TARGET',
+ nargs='*',
+ help='test the specified target').completer = complete_target
+
+ test.add_argument('--exclude',
+ metavar='TARGET',
+ action='append',
+ help='exclude the specified target').completer = complete_target
+
+ test.add_argument('--require',
+ metavar='TARGET',
+ action='append',
+ help='require the specified target').completer = complete_target
+
+ test.add_argument('--coverage',
+ action='store_true',
+ help='analyze code coverage when running tests')
+
+ add_changes(test, argparse)
+ add_environments(test)
+
+ integration = argparse.ArgumentParser(add_help=False, parents=[test])
+
+ integration.add_argument('--python',
+ metavar='VERSION',
+ choices=SUPPORTED_PYTHON_VERSIONS,
+ help='python version: %s' % ', '.join(SUPPORTED_PYTHON_VERSIONS))
+
+ integration.add_argument('--start-at',
+ metavar='TARGET',
+ help='start at the specified target').completer = complete_target
+
+ integration.add_argument('--start-at-task',
+ metavar='TASK',
+ help='start at the specified task')
+
+ integration.add_argument('--allow-destructive',
+ action='store_true',
+ help='allow destructive tests (--local and --tox only)')
+
+ integration.add_argument('--retry-on-error',
+ action='store_true',
+ help='retry failed test with increased verbosity')
+
+ subparsers = parser.add_subparsers(metavar='COMMAND')
+ subparsers.required = True # work-around for python 3 bug which makes subparsers optional
+
+ posix_integration = subparsers.add_parser('integration',
+ parents=[integration],
+ help='posix integration tests')
+
+ posix_integration.set_defaults(func=command_posix_integration,
+ targets=walk_posix_integration_targets,
+ config=PosixIntegrationConfig)
+
+ add_extra_docker_options(posix_integration)
+
+ network_integration = subparsers.add_parser('network-integration',
+ parents=[integration],
+ help='network integration tests')
+
+ network_integration.set_defaults(func=command_network_integration,
+ targets=walk_network_integration_targets,
+ config=NetworkIntegrationConfig)
+
+ windows_integration = subparsers.add_parser('windows-integration',
+ parents=[integration],
+ help='windows integration tests')
+
+ windows_integration.set_defaults(func=command_windows_integration,
+ targets=walk_windows_integration_targets,
+ config=WindowsIntegrationConfig)
+
+ windows_integration.add_argument('--windows',
+ metavar='VERSION',
+ action='append',
+ help='windows version')
+
+ units = subparsers.add_parser('units',
+ parents=[test],
+ help='unit tests')
+
+ units.set_defaults(func=command_units,
+ targets=walk_units_targets,
+ config=UnitsConfig)
+
+ units.add_argument('--python',
+ metavar='VERSION',
+ choices=SUPPORTED_PYTHON_VERSIONS,
+ help='python version: %s' % ', '.join(SUPPORTED_PYTHON_VERSIONS))
+
+ units.add_argument('--collect-only',
+ action='store_true',
+ help='collect tests but do not execute them')
+
+ compiler = subparsers.add_parser('compile',
+ parents=[test],
+ help='compile tests')
+
+ compiler.set_defaults(func=command_compile,
+ targets=walk_compile_targets,
+ config=CompileConfig)
+
+ compiler.add_argument('--python',
+ metavar='VERSION',
+ choices=COMPILE_PYTHON_VERSIONS,
+ help='python version: %s' % ', '.join(COMPILE_PYTHON_VERSIONS))
+
+ sanity = subparsers.add_parser('sanity',
+ parents=[test],
+ help='sanity tests')
+
+ sanity.set_defaults(func=command_sanity,
+ targets=walk_sanity_targets,
+ config=SanityConfig)
+
+ sanity.add_argument('--test',
+ metavar='TEST',
+ action='append',
+ choices=[t.name for t in SANITY_TESTS],
+ help='tests to run')
+
+ sanity.add_argument('--skip-test',
+ metavar='TEST',
+ action='append',
+ choices=[t.name for t in SANITY_TESTS],
+ help='tests to skip')
+
+ sanity.add_argument('--list-tests',
+ action='store_true',
+ help='list available tests')
+
+ sanity.add_argument('--python',
+ metavar='VERSION',
+ choices=SUPPORTED_PYTHON_VERSIONS,
+ help='python version: %s' % ', '.join(SUPPORTED_PYTHON_VERSIONS))
+
+ shell = subparsers.add_parser('shell',
+ parents=[common],
+ help='open an interactive shell')
+
+ shell.set_defaults(func=command_shell,
+ config=ShellConfig)
+
+ add_environments(shell, tox_version=True)
+ add_extra_docker_options(shell)
+
+ coverage_common = argparse.ArgumentParser(add_help=False, parents=[common])
+
+ add_environments(coverage_common, tox_version=True, tox_only=True)
+
+ coverage = subparsers.add_parser('coverage',
+ help='code coverage management and reporting')
+
+ coverage_subparsers = coverage.add_subparsers(metavar='COMMAND')
+ coverage_subparsers.required = True # work-around for python 3 bug which makes subparsers optional
+
+ coverage_combine = coverage_subparsers.add_parser('combine',
+ parents=[coverage_common],
+ help='combine coverage data and rewrite remote paths')
+
+ coverage_combine.set_defaults(func=lib.cover.command_coverage_combine,
+ config=lib.cover.CoverageConfig)
+
+ coverage_erase = coverage_subparsers.add_parser('erase',
+ parents=[coverage_common],
+ help='erase coverage data files')
+
+ coverage_erase.set_defaults(func=lib.cover.command_coverage_erase,
+ config=lib.cover.CoverageConfig)
+
+ coverage_report = coverage_subparsers.add_parser('report',
+ parents=[coverage_common],
+ help='generate console coverage report')
+
+ coverage_report.set_defaults(func=lib.cover.command_coverage_report,
+ config=lib.cover.CoverageConfig)
+
+ coverage_html = coverage_subparsers.add_parser('html',
+ parents=[coverage_common],
+ help='generate html coverage report')
+
+ coverage_html.set_defaults(func=lib.cover.command_coverage_html,
+ config=lib.cover.CoverageConfig)
+
+ coverage_xml = coverage_subparsers.add_parser('xml',
+ parents=[coverage_common],
+ help='generate xml coverage report')
+
+ coverage_xml.set_defaults(func=lib.cover.command_coverage_xml,
+ config=lib.cover.CoverageConfig)
+
+ if argcomplete:
+ argcomplete.autocomplete(parser, always_complete_options=False, validator=lambda i, k: True)
+
+ args = parser.parse_args()
+
+ if args.explain and not args.verbosity:
+ args.verbosity = 1
+
+ if args.color == 'yes':
+ args.color = True
+ elif args.color == 'no':
+ args.color = False
+ else:
+ args.color = sys.stdout.isatty()
+
+ return args
+
+
+def add_changes(parser, argparse):
+ """
+ :type parser: argparse.ArgumentParser
+ :type argparse: argparse
+ """
+ parser.add_argument('--changed', action='store_true', help='limit targets based on changes')
+
+ changes = parser.add_argument_group(title='change detection arguments')
+
+ changes.add_argument('--tracked', action='store_true', help=argparse.SUPPRESS)
+ changes.add_argument('--untracked', action='store_true', help='include untracked files')
+ changes.add_argument('--ignore-committed', dest='committed', action='store_false', help='exclude committed files')
+ changes.add_argument('--ignore-staged', dest='staged', action='store_false', help='exclude staged files')
+ changes.add_argument('--ignore-unstaged', dest='unstaged', action='store_false', help='exclude unstaged files')
+
+ changes.add_argument('--changed-from', metavar='PATH', help=argparse.SUPPRESS)
+ changes.add_argument('--changed-path', metavar='PATH', action='append', help=argparse.SUPPRESS)
+
+
+def add_environments(parser, tox_version=False, tox_only=False):
+ """
+ :type parser: argparse.ArgumentParser
+ :type tox_version: bool
+ :type tox_only: bool
+ """
+ parser.add_argument('--requirements',
+ action='store_true',
+ help='install command requirements')
+
+ environments = parser.add_mutually_exclusive_group()
+
+ environments.add_argument('--local',
+ action='store_true',
+ help='run from the local environment')
+
+ if tox_version:
+ environments.add_argument('--tox',
+ metavar='VERSION',
+ nargs='?',
+ default=None,
+ const='.'.join(str(i) for i in sys.version_info[:2]),
+ choices=SUPPORTED_PYTHON_VERSIONS,
+ help='run from a tox virtualenv: %s' % ', '.join(SUPPORTED_PYTHON_VERSIONS))
+ else:
+ environments.add_argument('--tox',
+ action='store_true',
+ help='run from a tox virtualenv')
+
+ if tox_only:
+ environments.set_defaults(
+ docker=None,
+ remote=None,
+ remote_stage=None,
+ )
+
+ return
+
+ environments.add_argument('--docker',
+ metavar='IMAGE',
+ nargs='?',
+ default=None,
+ const='ubuntu1604',
+ help='run from a docker container')
+
+ environments.add_argument('--remote',
+ metavar='PLATFORM',
+ default=None,
+ help='run from a remote instance')
+
+ remote = parser.add_argument_group(title='remote arguments')
+
+ remote.add_argument('--remote-stage',
+ metavar='STAGE',
+ help='remote stage to use: %(choices)s',
+ choices=['prod', 'dev'],
+ default='prod')
+
+
+def add_extra_docker_options(parser):
+ """
+ :type parser: argparse.ArgumentParser
+ """
+ docker = parser.add_argument_group(title='docker arguments')
+
+ docker.add_argument('--docker-util',
+ metavar='IMAGE',
+ default='httptester',
+ help='docker utility image to provide test services')
+
+ docker.add_argument('--docker-privileged',
+ action='store_true',
+ help='run docker container in privileged mode')
+
+
+def complete_target(prefix, parsed_args, **_):
+ """
+ :type prefix: unicode
+ :type parsed_args: any
+ :rtype: list[str]
+ """
+ return find_target_completion(parsed_args.targets, prefix)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/test/runner/tox.ini b/test/runner/tox.ini
new file mode 100644
index 0000000000..6399a524da
--- /dev/null
+++ b/test/runner/tox.ini
@@ -0,0 +1,9 @@
+[tox]
+skipsdist = True
+minversion = 2.5.0
+
+[testenv]
+changedir = {toxinidir}/../../
+commands = {posargs}
+passenv = HOME
+args_are_paths = False
diff --git a/test/sanity/ansible-doc/skip.txt b/test/sanity/ansible-doc/skip.txt
new file mode 100644
index 0000000000..b1b6b10392
--- /dev/null
+++ b/test/sanity/ansible-doc/skip.txt
@@ -0,0 +1,5 @@
+async_wrapper
+fireball
+win_dotnet_ngen
+xenserver_facts
+znode
diff --git a/test/sanity/code-smell/no-basestring.sh b/test/sanity/code-smell/no-basestring.sh
index 27757a9c44..d1068a989b 100755
--- a/test/sanity/code-smell/no-basestring.sh
+++ b/test/sanity/code-smell/no-basestring.sh
@@ -5,6 +5,7 @@ BASESTRING_USERS=$(grep -r basestring . \
--exclude-dir .tox \
| grep isinstance \
| grep -v \
+ -e test/results/ \
-e lib/ansible/compat/six/_six.py \
-e lib/ansible/module_utils/six.py \
-e lib/ansible/modules/core/ \
diff --git a/test/sanity/code-smell/pylint-ansible-test.sh b/test/sanity/code-smell/pylint-ansible-test.sh
new file mode 100755
index 0000000000..5cc56050a9
--- /dev/null
+++ b/test/sanity/code-smell/pylint-ansible-test.sh
@@ -0,0 +1,18 @@
+#!/bin/sh
+
+cd test/runner/
+
+pylint --max-line-length=120 --reports=n ./*.py ./*/*.py \
+ --jobs 2 \
+ --rcfile /dev/null \
+ --function-rgx '[a-z_][a-z0-9_]{2,40}$' \
+ -d unused-import \
+ -d too-few-public-methods \
+ -d too-many-arguments \
+ -d too-many-branches \
+ -d too-many-locals \
+ -d too-many-statements \
+ -d too-many-nested-blocks \
+ -d too-many-instance-attributes \
+ -d too-many-lines \
+ -d too-many-return-statements
diff --git a/test/sanity/code-smell/skip.txt b/test/sanity/code-smell/skip.txt
new file mode 100644
index 0000000000..bfb8b181a2
--- /dev/null
+++ b/test/sanity/code-smell/skip.txt
@@ -0,0 +1 @@
+inappropriately-private.sh
diff --git a/test/sanity/shellcheck/skip.txt b/test/sanity/shellcheck/skip.txt
new file mode 100644
index 0000000000..4252a97fbe
--- /dev/null
+++ b/test/sanity/shellcheck/skip.txt
@@ -0,0 +1 @@
+test/sanity/code-smell/boilerplate.sh
diff --git a/test/sanity/validate-modules/skip.txt b/test/sanity/validate-modules/skip.txt
new file mode 100644
index 0000000000..813b111d49
--- /dev/null
+++ b/test/sanity/validate-modules/skip.txt
@@ -0,0 +1,7 @@
+lib/ansible/modules/core/utilities/logic/async_status.py
+lib/ansible/modules/core/utilities/helper/_fireball.py
+lib/ansible/modules/core/utilities/helper/_accelerate.py
+lib/ansible/modules/core/test
+lib/ansible/modules/core/.github
+lib/ansible/modules/extras/test
+lib/ansible/modules/extras/.github
diff --git a/test/utils/shippable/freebsd.sh b/test/utils/shippable/freebsd.sh
new file mode 100755
index 0000000000..53b57b152b
--- /dev/null
+++ b/test/utils/shippable/freebsd.sh
@@ -0,0 +1,12 @@
+#!/bin/bash -eux
+
+set -o pipefail
+
+declare -a args
+IFS='/:' read -ra args <<< "${TEST}"
+
+platform="${args[0]}"
+version="${args[1]}"
+target="posix/ci/"
+
+ansible-test integration --color -v --retry-on-error "${target}" --remote "${platform}/${version}"
diff --git a/test/utils/shippable/linux.sh b/test/utils/shippable/linux.sh
new file mode 100755
index 0000000000..1d98735c7e
--- /dev/null
+++ b/test/utils/shippable/linux.sh
@@ -0,0 +1,11 @@
+#!/bin/bash -eux
+
+set -o pipefail
+
+declare -a args
+IFS='/:' read -ra args <<< "${TEST}"
+
+image="ansible/ansible:${args[1]}"
+target="posix/ci/group${args[2]}/"
+
+ansible-test integration --color -v --retry-on-error "${target}" --docker "${image}"
diff --git a/test/utils/shippable/osx.sh b/test/utils/shippable/osx.sh
new file mode 100755
index 0000000000..53b57b152b
--- /dev/null
+++ b/test/utils/shippable/osx.sh
@@ -0,0 +1,12 @@
+#!/bin/bash -eux
+
+set -o pipefail
+
+declare -a args
+IFS='/:' read -ra args <<< "${TEST}"
+
+platform="${args[0]}"
+version="${args[1]}"
+target="posix/ci/"
+
+ansible-test integration --color -v --retry-on-error "${target}" --remote "${platform}/${version}"
diff --git a/test/utils/shippable/other.sh b/test/utils/shippable/other.sh
new file mode 100755
index 0000000000..aa61e2d49f
--- /dev/null
+++ b/test/utils/shippable/other.sh
@@ -0,0 +1,15 @@
+#!/bin/bash -eux
+
+set -o pipefail
+
+add-apt-repository 'deb http://archive.ubuntu.com/ubuntu trusty-backports universe'
+add-apt-repository 'ppa:fkrull/deadsnakes'
+
+apt-get update -qq
+apt-get install python2.4 shellcheck -qq
+
+pip install tox --disable-pip-version-check
+
+ansible-test compile --color -v
+ansible-test sanity --color -v --tox --skip-test ansible-doc --python 2.7
+ansible-test sanity --color -v --tox --test ansible-doc --coverage
diff --git a/test/utils/shippable/shippable.sh b/test/utils/shippable/shippable.sh
new file mode 100755
index 0000000000..dd42eb7add
--- /dev/null
+++ b/test/utils/shippable/shippable.sh
@@ -0,0 +1,40 @@
+#!/bin/bash -eux
+
+set -o pipefail
+
+declare -a args
+IFS='/:' read -ra args <<< "${TEST}"
+
+script="${args[0]}"
+
+docker images ansible/ansible
+docker ps
+
+if [ -d /home/shippable/cache/ ]; then
+ ls -la /home/shippable/cache/
+fi
+
+which python
+python -V
+
+which pip
+pip --version
+pip list --disable-pip-version-check
+
+export PATH="test/runner:${PATH}"
+reorganize-tests.sh # temporary solution until repositories are merged
+
+function cleanup
+{
+ if [ "$(ls test/results/coverage/)" ]; then
+ ansible-test coverage xml --color -v --requirements
+ cp -av test/results/reports/coverage.xml shippable/codecoverage/coverage.xml
+ fi
+
+ rmdir shippable/testresults/
+ cp -av test/results/junit/ shippable/testresults/
+}
+
+trap cleanup EXIT
+
+"test/utils/shippable/${script}.sh"
diff --git a/test/utils/shippable/timing.sh b/test/utils/shippable/timing.sh
new file mode 100755
index 0000000000..bbae07d7b8
--- /dev/null
+++ b/test/utils/shippable/timing.sh
@@ -0,0 +1,5 @@
+#!/bin/bash -eux
+
+set -o pipefail
+
+"$1" 2>&1 | gawk '{ print strftime("%Y-%m-%d %H:%M:%S"), $0; fflush(); }'
diff --git a/test/utils/shippable/units.sh b/test/utils/shippable/units.sh
new file mode 100755
index 0000000000..55f4eeee57
--- /dev/null
+++ b/test/utils/shippable/units.sh
@@ -0,0 +1,7 @@
+#!/bin/bash -eux
+
+set -o pipefail
+
+pip install tox --disable-pip-version-check
+
+ansible-test units --color -v --tox --coverage
diff --git a/test/utils/shippable/windows.sh b/test/utils/shippable/windows.sh
new file mode 100755
index 0000000000..3ef4962a7c
--- /dev/null
+++ b/test/utils/shippable/windows.sh
@@ -0,0 +1,38 @@
+#!/bin/bash -eux
+
+set -o pipefail
+
+declare -a args
+IFS='/:' read -ra args <<< "${TEST}"
+
+job="${args[1]}"
+
+ansible-test windows-integration --explain 2>&1 | grep ' windows-integration: .* (targeted)$' > /tmp/windows.txt
+
+if [ -s /tmp/windows.txt ]; then
+ echo "Detected changes requiring integration tests specific to Windows:"
+ cat /tmp/windows.txt
+
+ if [ "${job}" != "1" ]; then
+ echo "Nothing to do, all Windows tests will run under TEST=windows/1 instead."
+ exit 0
+ fi
+
+ echo "Running Windows integration tests for multiple versions concurrently."
+
+ target="windows/ci/"
+
+ ansible-test windows-integration --color -v --retry-on-error "${target}" --requirements \
+ --windows 2008-SP2 \
+ --windows 2008-R2_SP1 \
+ --windows 2012-RTM \
+ --windows 2012-R2_RTM
+else
+ echo "No changes requiring integration tests specific to Windows were detected."
+ echo "Running Windows integration tests for a single version only."
+
+ target="windows/ci/group${job}/"
+
+ ansible-test windows-integration --color -v --retry-on-error "${target}" --requirements \
+ --windows 2012-R2_RTM
+fi