diff --git a/.coveragerc b/.coveragerc index 812fc3b1394..848e804c421 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 4d01e0160b7..d258abe030e 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 31fc99e08fc..f14f454177d 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 00000000000..156f8d3a2c1 --- /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 00000000000..4637820e9d4 --- /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 00000000000..5b126fb85e8 --- /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 00000000000..7af8b7f05bb --- /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 00000000000..79d8b9285eb --- /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 00000000000..8e7d715f9c0 --- /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 00000000000..8e7d715f9c0 --- /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 00000000000..cfaec2bded2 --- /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 00000000000..79d8b9285eb --- /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 00000000000..7af8b7f05bb --- /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 00000000000..8fb1c443d32 --- /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 00000000000..4cfb31df363 --- /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 00000000000..53b32510a0e --- /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 00000000000..8e7d715f9c0 --- /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 00000000000..7af8b7f05bb --- /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 00000000000..79d8b9285eb --- /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 00000000000..ee0ed5974e9 --- /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 00000000000..79d8b9285eb --- /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 00000000000..7af8b7f05bb --- /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 00000000000..79d8b9285eb --- /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 00000000000..dc4f3c45023 --- /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 00000000000..7af8b7f05bb --- /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 00000000000..e33b2794c91 --- /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 00000000000..e69de29bb2d diff --git a/test/integration/targets/connection_jail/aliases b/test/integration/targets/connection_jail/aliases new file mode 100644 index 00000000000..e69de29bb2d diff --git a/test/integration/targets/connection_libvirt_lxc/aliases b/test/integration/targets/connection_libvirt_lxc/aliases new file mode 100644 index 00000000000..e69de29bb2d diff --git a/test/integration/targets/connection_local/aliases b/test/integration/targets/connection_local/aliases new file mode 100644 index 00000000000..79d8b9285eb --- /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 00000000000..e69de29bb2d diff --git a/test/integration/targets/connection_lxd/aliases b/test/integration/targets/connection_lxd/aliases new file mode 100644 index 00000000000..e69de29bb2d diff --git a/test/integration/targets/connection_paramiko_ssh/aliases b/test/integration/targets/connection_paramiko_ssh/aliases new file mode 100644 index 00000000000..c9362781f61 --- /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 00000000000..c9362781f61 --- /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 00000000000..c6d61981670 --- /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 00000000000..53b32510a0e --- /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 00000000000..79d8b9285eb --- /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 00000000000..8e7d715f9c0 --- /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 00000000000..8e7d715f9c0 --- /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 00000000000..7af8b7f05bb --- /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 00000000000..79d8b9285eb --- /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 00000000000..7af8b7f05bb --- /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 00000000000..7af8b7f05bb --- /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 00000000000..7af8b7f05bb --- /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 00000000000..7af8b7f05bb --- /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 00000000000..7af8b7f05bb --- /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 00000000000..be46696124f --- /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 00000000000..8e7d715f9c0 --- /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 00000000000..5d97a9be5ee --- /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 00000000000..796f3e41ed3 --- /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 00000000000..79d8b9285eb --- /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 00000000000..79d8b9285eb --- /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 00000000000..79d8b9285eb --- /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 00000000000..fbbde3a1619 --- /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 00000000000..79d8b9285eb --- /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 00000000000..7af8b7f05bb --- /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 00000000000..7af8b7f05bb --- /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 00000000000..79d8b9285eb --- /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 00000000000..c364b48f38a --- /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 00000000000..7af8b7f05bb --- /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 00000000000..79d8b9285eb --- /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 00000000000..79d8b9285eb --- /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 00000000000..7af8b7f05bb --- /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 00000000000..7af8b7f05bb --- /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 00000000000..83710ef50c0 --- /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 00000000000..5d97a9be5ee --- /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 00000000000..5d97a9be5ee --- /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 00000000000..5d97a9be5ee --- /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 00000000000..79d8b9285eb --- /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 00000000000..79d8b9285eb --- /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 00000000000..7af8b7f05bb --- /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 00000000000..8e7d715f9c0 --- /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 00000000000..57a7cd8e893 --- /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 00000000000..79d8b9285eb --- /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 00000000000..79d8b9285eb --- /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 00000000000..79d8b9285eb --- /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 00000000000..7af8b7f05bb --- /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 00000000000..b46d4eb449e --- /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 00000000000..7af8b7f05bb --- /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 00000000000..7af8b7f05bb --- /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 00000000000..7af8b7f05bb --- /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 00000000000..7af8b7f05bb --- /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 00000000000..79d8b9285eb --- /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 00000000000..7af8b7f05bb --- /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 00000000000..53b32510a0e --- /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 00000000000..79d8b9285eb --- /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 00000000000..79d8b9285eb --- /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 00000000000..53b32510a0e --- /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 00000000000..79d8b9285eb --- /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 00000000000..7af8b7f05bb --- /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 00000000000..eccb0e7659f --- /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 00000000000..79d8b9285eb --- /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 00000000000..79d8b9285eb --- /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 00000000000..79d8b9285eb --- /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 00000000000..22fa0ae0cc7 --- /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 00000000000..c6d61981670 --- /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 00000000000..ee0ed5974e9 --- /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 00000000000..c6d61981670 --- /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 00000000000..10e03fc2bf7 --- /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 f8b18657448..b91412f4c0b 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 . +- 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 00000000000..ee0ed5974e9 --- /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 00000000000..ee0ed5974e9 --- /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 00000000000..ee0ed5974e9 --- /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 00000000000..ee0ed5974e9 --- /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 00000000000..ee0ed5974e9 --- /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 00000000000..ee0ed5974e9 --- /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 00000000000..10e03fc2bf7 --- /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 00000000000..10e03fc2bf7 --- /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 00000000000..10e03fc2bf7 --- /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 00000000000..10e03fc2bf7 --- /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 00000000000..10e03fc2bf7 --- /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 00000000000..c6d61981670 --- /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 00000000000..10e03fc2bf7 --- /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 00000000000..c6d61981670 --- /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 00000000000..10e03fc2bf7 --- /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 b72b74238b6..ac18fd0a196 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 00000000000..ee0ed5974e9 --- /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 00000000000..ee0ed5974e9 --- /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 00000000000..c6d61981670 --- /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 00000000000..8e7d715f9c0 --- /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 00000000000..8e7d715f9c0 --- /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 00000000000..8e7d715f9c0 --- /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 00000000000..e69de29bb2d diff --git a/test/results/junit/.keep b/test/results/junit/.keep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/test/results/logs/.keep b/test/results/logs/.keep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/test/results/reports/.keep b/test/results/reports/.keep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/test/runner/ansible-test b/test/runner/ansible-test new file mode 120000 index 00000000000..94656643117 --- /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 00000000000..1f9d09cbf2a --- /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 00000000000..1f9d09cbf2a --- /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 00000000000..1f9d09cbf2a --- /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 00000000000..1f9d09cbf2a --- /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 00000000000..1f9d09cbf2a --- /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 00000000000..1f9d09cbf2a --- /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 00000000000..1f9d09cbf2a --- /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 00000000000..1f9d09cbf2a --- /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 00000000000..1f9d09cbf2a --- /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 00000000000..1f9d09cbf2a --- /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 00000000000..1f9d09cbf2a --- /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 00000000000..1f9d09cbf2a --- /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 00000000000..1f9d09cbf2a --- /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 00000000000..1f9d09cbf2a --- /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 00000000000..a0c068e1484 --- /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 00000000000..1f9d09cbf2a --- /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 00000000000..1f9d09cbf2a --- /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 00000000000..1f9d09cbf2a --- /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 00000000000..1f9d09cbf2a --- /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 00000000000..1f9d09cbf2a --- /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 00000000000..1f9d09cbf2a --- /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 00000000000..1f9d09cbf2a --- /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 00000000000..1f9d09cbf2a --- /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 00000000000..e69de29bb2d diff --git a/test/runner/lib/ansible_util.py b/test/runner/lib/ansible_util.py new file mode 100644 index 00000000000..ae74db74089 --- /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 00000000000..019b51e0f77 --- /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 00000000000..d89737d1e6e --- /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 00000000000..6aaae057f03 --- /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 00000000000..493ff72b933 --- /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.*).py$', '\\g', 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 00000000000..88dcf9b7fe4 --- /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 00000000000..2140704b699 --- /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 00000000000..b732a5dbe18 --- /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 00000000000..df82166efc7 --- /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 00000000000..85839cd644f --- /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 00000000000..2f10b181336 --- /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 00000000000..1ea925e5cc9 --- /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 00000000000..cf7d6a36c40 --- /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 00000000000..f7544c4b19e --- /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 00000000000..1d6daf89863 --- /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 00000000000..d08d921a32a --- /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 00000000000..ec8e8a069ba --- /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 00000000000..4ebc8aea50e --- /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 00000000000..84ca1ec94a2 --- /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 00000000000..1a86edd4642 --- /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 00000000000..885050a7b6c --- /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 00000000000..d6fcc566fc5 --- /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 00000000000..fa5788e8e7f --- /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 00000000000..13eb1cbc74c --- /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 00000000000..b9589b6c359 --- /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 00000000000..6399a524da9 --- /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 00000000000..b1b6b10392a --- /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 27757a9c447..d1068a989b5 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 00000000000..5cc56050a9c --- /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 00000000000..bfb8b181a2b --- /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 00000000000..4252a97fbee --- /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 00000000000..813b111d498 --- /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 00000000000..53b57b152b1 --- /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 00000000000..1d98735c7e9 --- /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 00000000000..53b57b152b1 --- /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 00000000000..aa61e2d49f9 --- /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 00000000000..dd42eb7addb --- /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 00000000000..bbae07d7b80 --- /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 00000000000..55f4eeee576 --- /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 00000000000..3ef4962a7c6 --- /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