pax_global_header00006660000000000000000000000064145633147740014530gustar00rootroot0000000000000052 comment=54f3b5c933ef597beb4cc96aada5015b64ced933 pytest-testinfra-10.1.0/000077500000000000000000000000001456331477400151345ustar00rootroot00000000000000pytest-testinfra-10.1.0/.editorconfig000066400000000000000000000005231456331477400176110ustar00rootroot00000000000000root = true [*] indent_style = space indent_size = 4 insert_final_newline = true trim_trailing_whitespace = true end_of_line = lf charset = utf-8 [*.py] max_line_length = 79 [*.yml] indent_size = 2 max_line_length = 79 [doc/**.rst] max_line_length = 79 [Dockerfile] indent_size = 2 [Makefile] indent_style = tab max_line_length = 79 pytest-testinfra-10.1.0/.flake8000066400000000000000000000004211456331477400163040ustar00rootroot00000000000000[flake8] extend-ignore = E203, E266, E501, H301, H306 # line length is intentionally set to 80 here because black uses Bugbear # See https://github.com/psf/black/blob/master/docs/the_black_code_style.md#line-length for more details max-line-length = 80 pytest-testinfra-10.1.0/.github/000077500000000000000000000000001456331477400164745ustar00rootroot00000000000000pytest-testinfra-10.1.0/.github/FUNDING.yml000066400000000000000000000000231456331477400203040ustar00rootroot00000000000000liberapay: philpep pytest-testinfra-10.1.0/.github/dependabot.yml000066400000000000000000000002631456331477400213250ustar00rootroot00000000000000--- version: 2 updates: - package-ecosystem: "github-actions" directory: "/" schedule: interval: daily labels: - "dependencies" - "skip-changelog" pytest-testinfra-10.1.0/.github/release-drafter.yml000066400000000000000000000001111456331477400222550ustar00rootroot00000000000000--- # see https://github.com/ansible/devtools _extends: ansible/devtools pytest-testinfra-10.1.0/.github/workflows/000077500000000000000000000000001456331477400205315ustar00rootroot00000000000000pytest-testinfra-10.1.0/.github/workflows/release.yml000066400000000000000000000015551456331477400227020ustar00rootroot00000000000000--- name: release on: release: types: [published] jobs: pypi: name: Publish to PyPI registry environment: release runs-on: ubuntu-22.04 env: FORCE_COLOR: 1 PY_COLORS: 1 steps: - uses: actions/checkout@v4 with: fetch-depth: 0 # needed by setuptools-scm - name: Switch to using Python 3.9 by default uses: actions/setup-python@v5 with: python-version: "3.9" - name: Install dependencies run: | python -m pip install --upgrade pip pip install tox - name: Build dists run: | tox -e packaging - name: Publish to pypi.org if: >- # "create" workflows run separately from "push" & "pull_request" github.event_name == 'release' uses: pypa/gh-action-pypi-publish@release/v1 with: password: ${{ secrets.pypi_password }} pytest-testinfra-10.1.0/.github/workflows/tox.yml000066400000000000000000000037061456331477400220740ustar00rootroot00000000000000name: tox on: create: # is used for publishing to TestPyPI tags: # any tag regardless of its name, no branches - "**" push: # only publishes pushes to the main branch to TestPyPI branches: # any integration branch but not tag - "main" pull_request: release: types: - published # It seems that you can publish directly without creating schedule: - cron: 1 0 15 * * # Run each month on day 15 at 0:01 UTC concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }} cancel-in-progress: true jobs: lint: runs-on: ubuntu-latest strategy: fail-fast: false matrix: python-version: ["3.9", "3.10", "3.11"] steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip pip install tox - name: Run tox -e lint run: | tox -e lint build: runs-on: ubuntu-latest strategy: fail-fast: false matrix: toxenv: [docs, packaging, py39] steps: - uses: actions/checkout@v4 - name: Set up Python 3.9 uses: actions/setup-python@v5 with: python-version: "3.9" - name: Install dependencies run: | python -m pip install --upgrade pip pip install tox - name: Run tox -e ${{ matrix.toxenv }} run: | tox -e ${{ matrix.toxenv }} check: # This job does nothing and is only used for the branch protection if: always() permissions: pull-requests: write # allow codenotify to comment on pull-request needs: - lint - build runs-on: ubuntu-latest steps: - name: Decide whether the needed jobs succeeded or failed uses: re-actors/alls-green@release/v1 with: jobs: ${{ toJSON(needs) }} pytest-testinfra-10.1.0/.gitignore000066400000000000000000000002521456331477400171230ustar00rootroot00000000000000*.py~ *.pyc *.egg-info *.egg .eggs *.swp .local .coverage* .tox .python-version build dist *.log AUTHORS ChangeLog coverage.xml flake8.report junit*.xml doc/build .cache pytest-testinfra-10.1.0/.pre-commit-config.yaml000066400000000000000000000032401456331477400214140ustar00rootroot00000000000000--- ci: # format compatible with commitlint autoupdate_commit_msg: "chore: pre-commit autoupdate" autoupdate_schedule: monthly autofix_commit_msg: | chore: auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci # exclude: > # (?x)^( # )$ repos: - repo: meta hooks: - id: check-useless-excludes - repo: https://github.com/pre-commit/pre-commit-hooks.git rev: v4.4.0 hooks: - id: end-of-file-fixer - id: trailing-whitespace - id: mixed-line-ending - id: fix-byte-order-marker - id: check-executables-have-shebangs - id: check-merge-conflict - id: debug-statements language_version: python3 - repo: https://github.com/PyCQA/isort rev: 5.12.0 hooks: - id: isort args: # https://github.com/pre-commit/mirrors-isort/issues/9#issuecomment-624404082 - --filter-files - repo: https://github.com/psf/black rev: 22.10.0 hooks: - id: black language_version: python3 - repo: https://github.com/pycqa/flake8.git rev: 6.0.0 hooks: - id: flake8 language_version: python3 additional_dependencies: - flake8-bugbear - flake8-comprehensions - flake8-debugger - flake8-logging-format - flake8-pep3101 - flake8-print - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.4.1 hooks: - id: mypy # empty args needed in order to match mypy cli behavior additional_dependencies: - types-paramiko - types-setuptools - setuptools-scm - alabaster - pytest pytest-testinfra-10.1.0/.readthedocs.yaml000066400000000000000000000001721456331477400203630ustar00rootroot00000000000000version: 2 build: os: ubuntu-22.04 tools: python: "3.9" python: install: - requirements: dev-requirements.txt pytest-testinfra-10.1.0/CHANGELOG.rst000066400000000000000000000304031456331477400171550ustar00rootroot00000000000000========= Changelog ========= 10.1.0 ====== * [NEW] Add Interface.link property * [FIX] Make file properties follow symlinks * [FIX] Require pytest>=6 and use future annotations for pytest<7 compatibility 10.0.0 ====== * [FIX] Ansible: Fix for missing group names in get_variables() * [FIX] testinfra/modules/blockdevice: Don't fail on stderr * [DOC] Extend and show the documentation of CommandResult * [FIX] Extend list of valid suffixes for systemd units * [DOC] Add missing Environment doc section * [MISC] Define types for plugin.py * [FIX] Missing RHEL distribution in package module * [NEW] Add brew support in package module * [NEW] Add Service.exists * [MISC] Make CommandResult a dataclass 9.0.0 ===== * [BREAKING] pytest-testinfra now require python >= 3.9 * [BREAKING] Drop deprecated module PipPackage * [NEW] Add support for the SSH ControlPath connection sharing option (#713) * [FIX] Retry SSH on ConnectionResetError (#708) * [FIX] List openSUSE Leap and Tumbleweed explicitly as rpm based distributions * [FIX] Make group name mandatory in group module 8.1.0 ===== * [NEW] Add Windows support for File and Service modules * [NEW] Add File.is_executable property 8.0.0 ===== * [NEW] Add Group.members attribute * [NEW] Add File.inode attribute * [NEW] Add Interface.routes() method * [NEW] Add Docker.is_restarting attribute * [FIX] Fix possible error in Interface.default() * [FIX] Fix busybox detection in Process module * [FIX] Fix possible KeyError in SysInfo module * [BREAKING] Drop support for python 3.7 7.0.1 ===== * [FIX] Fix `command -v` compatibility with dash shell 7.0.0 ===== * [NEW] Improved ssh config support in Paramiko backend * [NEW] Add chroot backend * [NEW] Add support for Manjaro-Linux * [NEW] Add support for Cloudlinux * [BREAKING] Drop support for python 3.6 which is EOL 6.8.0 ===== * [NEW] Add support for AlmaLinux and RockyLinux 6.7.0 ===== * [NEW] Handle ansible_connection type community.docker.docker * [NEW] add ssh_extra_args option 6.6.0 ===== * [NEW] Allow to test for user password expiration * [NEW] Handle ANSIBLE_SSH_COMMON_ARGS and ANSIBLE_SSH_EXTRA_ARGS environment variables for ansible connections * [FIX] Fix encoding issue in salt connections * [FIX] Fix AttributeError when "command" is not available and fallback to "which" 6.5.0 ===== * Fallback to which when "command -v" fails * Use realpath by default to resolve symlinks instead of "readlink -f" * ansible: Support environment variables * Force package module to resolve to RpmPackage on Fedora * Fix new versions of supervisor may exit with status != 0 * Eventually decode ansible output when it's not ascii * Either use python3 or python to get remote encoding 6.4.0 ===== * Implement Interface names and default (#615) * Implement Service.systemd_properties (#612) 6.3.0 ===== * Fix #451 for use with pytest -p no:terminal * Add client_version() and server_version() and version() to docker module. 6.2.0 ===== * Fix #590: Systeminfo doesn't resolve Windows correctly (#592) * First implementation of network namespaces in addr module (#596) * pip check support in PipPackage module (#605) * pip refactoring: implementation of installed and version (#606) * Allow to specify supervisorctl and supervisord.conf paths (#536) 6.1.0 ===== * Fix wrong package module on CentOS having dpkg tools installed #570 (#575) * Deduplicate hosts returned by get_backends() (#572) * Use /run/systemd/system/ to detect systemd (fixes #546) * Use ssh_args from ansible.cfg * Require python >= 3.6 * Fix ValueError with python 3.8+ when using --nagios option. 6.0.0 ===== * Breaking change: testinfra has moved to the https://github.com/pytest-dev/ organization. Project on PyPi is renamed as pytest-testinfra. A dummy testinfra will make the transition, but you should rename to pytest-testinfra in your requirements files. 5.3.1 ===== * Fix newly introduced is_masked property on systemd service https://github.com/philpep/testinfra/pull/569 5.3.0 ===== * Add is_masked property on systemd service 5.2.2 ===== * iptables: use -w option to wait for iptables lock when running in parallel with pytest-xdist. 5.2.1 ===== * Fix documentation build 5.2.0 ===== * Allow kubeconfig context to be supplied in kubernetes backend * Drop file.__ne__ implementation and require python >= 3.5 5.1.0 ===== * Use remote_user and remote_port in ansible.cfg * Add `arch` (architecture) attribute to system_info module 5.0.0 ===== * Breaking change: host.file().listdir() is now a method 4.1.0 ===== * Pass extra arguments to ansible CLI via host.ansible() * New method host.file.listdir() to list items in a directory. 4.0.0 ===== * Drop python2 support 3.4.0 ===== * Add podman backend and module * WARNING: this will be the latest testinfra version supporting python2, please upgrade to python3. 3.3.0 ===== * Add extras for backend dependencies (#454) * Various enhancements of kitchen integration documentation * ansible backend now support "password" field from ansible inventory * New backend "openshift" 3.2.1 ===== * Fix Process module when working with long strings (username, ...) #505 3.2.0 ===== * New module "environment" for getting remote environment variables * New module "block_device" exposing block device information * Add a global flag --force-ansible to the command line * Raise an error in case of missing ansible inventory file * Fix an escape issue with ansible ssh args set inventory or configuration file 3.1.0 ===== * ssh connections uses persistent connections by default. You can disable this by passing controlpersist=0 to the connections options. * ansible ssh connections now use ssh backend instead of paramiko. ansible_ssh_common_args and ansible_ssh_extra_args are now taking in account. * Add a new ansible connection options "force_ansible", when set to True, testinfra will always call ansible for all commands he need to run. * Handle all ansible connections types by setting force_ansible=True for connections which doesn't have a testinfra equivalent connection (for example "network_cli"). 3.0.6 ===== * Issue full command logging using DEBUG log level to avoid logging sensible data when log level is INFO. * Fix possible crash when parsing ansible inventories #470 * Support using alternative kubeconfig file in kubectl connections #460 * Support parsing ProxyCommand from ssh_config for paramiko connections 3.0.5 ===== * Set default timeout to 10s on ssh/paramiko connections * Add support for ansible inventory parameter ansible_private_key_file 3.0.4 ===== * Add support for ansible lxc and lxd connections 3.0.3 ===== * Fix paramiko parsing RequestTTY from ssh configs * Re-add "groups" key from ansible.get_variables() to be backward compatible with testinfra 2.X 3.0.2 ===== * Fix ansible with no inventory resolving to "localhost" * Fix support for ansible 2.8 with no inventory * Fix ansible/paramiko which wasn't reading hosts config from ~/.ssh/config * Allow to pass --ssh-config and --ssh-identity-file to ansible connection 3.0.1 ===== * Fix parsing of ipv6 addresses for paramiko, ssh and ansible backends. * Fix --connection=ansible invocation when no hosts are provided 3.0.0 ===== * New ansible backend fixing support for ansible 2.8 and license issue. See https://github.com/philpep/testinfra/issues/431 for details. This make ansible using testinfra native backends and only works for local, ssh or docker connections. I you have others connection types or issues, please open a bug on https://github.com/philpep/testinfra/issues/new * Windows support is improved. "package" module is handled with Chocolatey and there's support for the "user" module. 2.1.0 ====== * docker: new get_containers() classmethod * socket: fix parsing of ipv6 addresses with new versions of ss * service: systemd fallback to sysv when "systemctl is-active" is not working 2.0.0 ====== * Add addr module, used to test network connectivity * Drop deprecated "testinfra" command, you should use "py.test" instead * Drop deprecated top level fixtures, access them through the fixture "host" instead. * Drop support for ansible <= 2.4 1.19.0 ====== * Add docker module * Fix pytest 4 compatibility 1.18.0 ====== * Allow to urlencode character in host specification "user:pass@host" (#387) * Fix double logging from both pytest and testinfra * Drop support for python 2.6 * Allow to configure timeouts for winrm backend 1.17.0 ====== * Add support for ansible "become" user in ansible module * Add failed/succeeded property on run() output 1.16.0 ====== * packaging: Use setuptools_scm instead of pbr * iptables: add ip6tables support * sysctl: find sysctl outside of PATH (/sbin) 1.15.0 ====== * Fix finding ss and netstat command in "sbin" paths for Centos (359) * Add a workaround for https://github.com/pytest-dev/pytest/issues/3542 * Handle "starting" status for Service module on Alpine linux * Fix no_ssl and no_verify_ssl options for WinRM backend 1.14.1 ====== * Fix multi-host test ordering (#347), regression introduced in 1.13.1 * Fix Socket on OpenBSD hosts (#338) 1.14.0 ====== * Add a new lxc backend * Socket: fix is_listening for unix sockets * Add namespace and container support for kubernetes backend * Add a cache of parsed ansible inventories for ansible backend * Service: fix service detection on Centos 6 hosts * File: implement file comparison with string paths 1.13.1 ====== * package: fix is_installed and version behavior for uninstalled packages (#321 and #326) * ansible: Use predictibles test ordering when using pytest-xdist to fix random test collections errors (#316) 1.13.0 ====== * socket: fix detection of udp listening sockets (#311) * ssh backend: Add support for GSSAPI 1.12.0 ====== * ansible: fix compatibility with ansible 2.5 * pip: fix compatibility with pip 10 (#299) 1.11.1 ====== * Socket: fix error with old versions of ss without the --no-header option (#293) 1.11.0 ====== * Fix bad error reporting when using ansible module without ansible backend (#288) * Socket: add a new implementation using ss instead of netstat (#124) * Add service, process, and systeminfo support for Alpine (#283) 1.10.1 ====== * Fix get_variables() for ansible>=2.0,<2.4 (#274) * Paramiko: Use the RequireTTY setting if specified in a provided SSHConfig (#247) 1.10.0 ====== * New iptables module 1.9.1 ===== * Fix running testinfra within a suite using doctest (#268) * Service: add is_valid method for systemd * Fix file.linked_to() for Mac OS 1.9.0 ===== * Interface: allow to find 'ip' command ousite of PATH * Fix --nagios option with python 3 1.8.0 ===== * Deprecate testinfra command (will be dropped in 2.0), use py.test instead #135 * Handle --nagios option when using py.test command 1.7.1 ===== * Support for ansible 2.4 (#249) 1.7.0 ===== * Salt: allow specify config directory (#230) * Add a WinRM backend * Socket: ipv6 sockets can handle ipv4 clients (#234) * Service: Enhance upstart detection (#243) 1.6.5 ===== * Service: add is_enabled() support for OpenBSD * Add ssh identity file option for paramiko and ssh backends * Expand tilde (~) to user home directory for ssh-config, ssh-identity-file and ansible-inventory options 1.6.4 ===== * Service: Allow to find 'service' command outside of $PATH #211 * doc fixes 1.6.3 ===== * Fix unwanted deprecation warning when running tests with pytest 3.1 #204 1.6.2 ===== * Fix wheel package for 1.6.1 1.6.1 ===== * Support ansible 2.3 with python 3 (#197) 1.6.0 ===== * New 'host' fixture as a replacement for all other fixtures. See https://testinfra.readthedocs.io/en/latest/modules.html#host (Other fixtures are deprecated and will be removed in 2.0 release). 1.5.5 ===== * backends: Fix ansible backend with ansible >= 2.3 (#195) 1.5.4 ===== * backends: fallback to UTF-8 encoding when system encoding is ASCII. * Service: fix is_running() on systems using Upstart 1.5.3 ===== * Sudo: restore backend command in case of exceptions 1.5.2 ===== * Honnor become_user when using the ansible backend 1.5.1 ===== * Add dependency on importlib on python 2.6 1.5.0 ===== * New kubectl backend * Command: check_output strip carriage return and newlines (#164) * Package: rpm improve getting version() and release() * User: add gecos (comment) field (#155) 1.4.5 ===== * SystemInfo: detect codename from VERSION_CODENAME in /etc/os-release (fallback when lsb_release isn't installed). * Package: add release property for rpm based systems. pytest-testinfra-10.1.0/CONTRIBUTING.rst000066400000000000000000000017441456331477400176030ustar00rootroot00000000000000######################### Contributing to testinfra ######################### First, thanks for contributing to testinfra and make it even more awesome ! Pull requests ============= You're encouraged to setup a full test environment, to add tests and check if all the tests pass *before* submitting your pull request. To run the complete test suite you must install: - `Docker `_ - `tox `_ To run all tests run:: tox To run only some selected tests:: # Only tests matching 'ansible' on 4 processes with pytest-xdist tox -- -v -n 4 -k ansible test # Only modules tests on a specific Python 3, e.g., 3.8 and spawn a pdb on error tox -e py38 -- -v --pdb test/test_modules.py Code style ========== Your code must pass without errors under `flake8 `_ with the extension `hacking `_:: pip install hacking flake8 testinfra pytest-testinfra-10.1.0/LICENSE000066400000000000000000000236361456331477400161530ustar00rootroot00000000000000 Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. pytest-testinfra-10.1.0/MANIFEST.in000066400000000000000000000010131456331477400166650ustar00rootroot00000000000000recursive-include testinfra *.py recursive-include test *.py ssh_key recursive-include images Dockerfile recursive-include doc *.py *.rst *.svg include doc/source/_templates/piwik.html include doc/Makefile include tox.ini include .flake8 include mypy.ini include Makefile include *.yaml include README.rst CONTRIBUTING.rst CHANGELOG.rst include MANIFEST.in include ansible.cfg include dev-requirements.txt include test-requirements.txt include LICENSE exclude .editorconfig exclude .gitignore prune doc/build prune .github pytest-testinfra-10.1.0/Makefile000066400000000000000000000000661456331477400165760ustar00rootroot00000000000000all: doc doc: $(MAKE) -C doc html .PHONY: all doc pytest-testinfra-10.1.0/README.rst000066400000000000000000000043531456331477400166300ustar00rootroot00000000000000################################## Testinfra test your infrastructure ################################## Latest documentation: https://testinfra.readthedocs.io/en/latest About ===== With Testinfra you can write unit tests in Python to test *actual state* of your servers configured by management tools like Salt_, Ansible_, Puppet_, Chef_ and so on. Testinfra aims to be a Serverspec_ equivalent in python and is written as a plugin to the powerful Pytest_ test engine License ======= `Apache License 2.0 `_ The logo is licensed under the `Creative Commons NoDerivatives 4.0 License `_ If you have some other use in mind, contact us. Quick start =========== Install testinfra using pip:: $ pip install pytest-testinfra # or install the devel version $ pip install 'git+https://github.com/pytest-dev/pytest-testinfra@main#egg=pytest-testinfra' Write your first tests file to `test_myinfra.py`: .. code-block:: python def test_passwd_file(host): passwd = host.file("/etc/passwd") assert passwd.contains("root") assert passwd.user == "root" assert passwd.group == "root" assert passwd.mode == 0o644 def test_nginx_is_installed(host): nginx = host.package("nginx") assert nginx.is_installed assert nginx.version.startswith("1.2") def test_nginx_running_and_enabled(host): nginx = host.service("nginx") assert nginx.is_running assert nginx.is_enabled And run it:: $ py.test -v test_myinfra.py ====================== test session starts ====================== platform linux -- Python 2.7.3 -- py-1.4.26 -- pytest-2.6.4 plugins: testinfra collected 3 items test_myinfra.py::test_passwd_file[local] PASSED test_myinfra.py::test_nginx_is_installed[local] PASSED test_myinfra.py::test_nginx_running_and_enabled[local] PASSED =================== 3 passed in 0.66 seconds ==================== .. _Salt: https://saltstack.com/ .. _Ansible: https://www.ansible.com/ .. _Puppet: https://puppetlabs.com/ .. _Chef: https://www.chef.io/ .. _Serverspec: https://serverspec.org/ .. _Pytest: https://pytest.org/ pytest-testinfra-10.1.0/ansible.cfg000066400000000000000000000001231456331477400172260ustar00rootroot00000000000000[defaults] transport=ssh host_key_checking=False [ssh_connection] pipelining=True pytest-testinfra-10.1.0/dev-requirements.txt000066400000000000000000000000441456331477400211720ustar00rootroot00000000000000sphinx>=7.1,<7.2 alabaster>=0.7.2 . pytest-testinfra-10.1.0/doc/000077500000000000000000000000001456331477400157015ustar00rootroot00000000000000pytest-testinfra-10.1.0/doc/Makefile000066400000000000000000000151771456331477400173540ustar00rootroot00000000000000# Makefile for Sphinx documentation # # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = sphinx-build PAPER = BUILDDIR = build # User-friendly check for sphinx-build ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) endif # Internal variables. PAPEROPT_a4 = -D latex_paper_size=a4 PAPEROPT_letter = -D latex_paper_size=letter ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source # the i18n builder cannot share the environment and doctrees with the others I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext help: @echo "Please use \`make ' where is one of" @echo " html to make standalone HTML files" @echo " dirhtml to make HTML files named index.html in directories" @echo " singlehtml to make a single large HTML file" @echo " pickle to make pickle files" @echo " json to make JSON files" @echo " htmlhelp to make HTML files and a HTML help project" @echo " qthelp to make HTML files and a qthelp project" @echo " devhelp to make HTML files and a Devhelp project" @echo " epub to make an epub" @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" @echo " latexpdf to make LaTeX files and run them through pdflatex" @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" @echo " text to make text files" @echo " man to make manual pages" @echo " texinfo to make Texinfo files" @echo " info to make Texinfo files and run them through makeinfo" @echo " gettext to make PO message catalogs" @echo " changes to make an overview of all changed/added/deprecated items" @echo " xml to make Docutils-native XML files" @echo " pseudoxml to make pseudoxml-XML files for display purposes" @echo " linkcheck to check all external links for integrity" @echo " doctest to run all doctests embedded in the documentation (if enabled)" clean: rm -rf $(BUILDDIR)/* html: $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." dirhtml: $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." singlehtml: $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml @echo @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." pickle: $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle @echo @echo "Build finished; now you can process the pickle files." json: $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json @echo @echo "Build finished; now you can process the JSON files." htmlhelp: $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp @echo @echo "Build finished; now you can run HTML Help Workshop with the" \ ".hhp project file in $(BUILDDIR)/htmlhelp." qthelp: $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp @echo @echo "Build finished; now you can run "qcollectiongenerator" with the" \ ".qhcp project file in $(BUILDDIR)/qthelp, like this:" @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/testinfra.qhcp" @echo "To view the help file:" @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/testinfra.qhc" devhelp: $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp @echo @echo "Build finished." @echo "To view the help file:" @echo "# mkdir -p $$HOME/.local/share/devhelp/testinfra" @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/testinfra" @echo "# devhelp" epub: $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub @echo @echo "Build finished. The epub file is in $(BUILDDIR)/epub." latex: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." @echo "Run \`make' in that directory to run these through (pdf)latex" \ "(use \`make latexpdf' here to do that automatically)." latexpdf: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo "Running LaTeX files through pdflatex..." $(MAKE) -C $(BUILDDIR)/latex all-pdf @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." latexpdfja: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo "Running LaTeX files through platex and dvipdfmx..." $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." text: $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text @echo @echo "Build finished. The text files are in $(BUILDDIR)/text." man: $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man @echo @echo "Build finished. The manual pages are in $(BUILDDIR)/man." texinfo: $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo @echo @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." @echo "Run \`make' in that directory to run these through makeinfo" \ "(use \`make info' here to do that automatically)." info: $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo @echo "Running Texinfo files through makeinfo..." make -C $(BUILDDIR)/texinfo info @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." gettext: $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale @echo @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." changes: $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes @echo @echo "The overview file is in $(BUILDDIR)/changes." linkcheck: $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck @echo @echo "Link check complete; look for any errors in the above output " \ "or in $(BUILDDIR)/linkcheck/output.txt." doctest: $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest @echo "Testing of doctests in the sources finished, look at the " \ "results in $(BUILDDIR)/doctest/output.txt." xml: $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml @echo @echo "Build finished. The XML files are in $(BUILDDIR)/xml." pseudoxml: $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml @echo @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." pytest-testinfra-10.1.0/doc/source/000077500000000000000000000000001456331477400172015ustar00rootroot00000000000000pytest-testinfra-10.1.0/doc/source/_static/000077500000000000000000000000001456331477400206275ustar00rootroot00000000000000pytest-testinfra-10.1.0/doc/source/_static/logo.svg000066400000000000000000000232741456331477400223200ustar00rootroot00000000000000 image/svg+xml test infra pytest-testinfra-10.1.0/doc/source/_templates/000077500000000000000000000000001456331477400213365ustar00rootroot00000000000000pytest-testinfra-10.1.0/doc/source/_templates/piwik.html000066400000000000000000000011201456331477400233410ustar00rootroot00000000000000 pytest-testinfra-10.1.0/doc/source/api.rst000066400000000000000000000012301456331477400205000ustar00rootroot00000000000000API === .. _connection api: Connection API ~~~~~~~~~~~~~~ You can use testinfra outside of pytest. You can dynamically get a `host` instance and call functions or access members of the respective modules:: >>> import testinfra >>> host = testinfra.get_host("paramiko://root@server:2222", sudo=True) >>> host.file("/etc/shadow").mode == 0o640 True For instance you could make a test to compare two files on two different servers:: import testinfra def test_same_passwd(): a = testinfra.get_host("ssh://a") b = testinfra.get_host("ssh://b") assert a.file("/etc/passwd").content == b.file("/etc/passwd").content pytest-testinfra-10.1.0/doc/source/backends.rst000066400000000000000000000160451456331477400215130ustar00rootroot00000000000000Connection backends =================== Testinfra comes with several connections backends for remote command execution. When installing, you should select the backends you require as ``extras`` to ensure Python dependencies are satisfied (note various system packaged tools may still be required). For example :: $ pip install pytest-testinfra[ansible,salt] For all backends, commands can be run as superuser with the ``--sudo`` option or as specific user with the ``--sudo-user`` option. local ~~~~~ This is the default backend when no hosts are provided (either via ``--hosts`` or in modules). Commands are run locally in a subprocess under the current user:: $ py.test --sudo test_myinfra.py paramiko ~~~~~~~~ This is the default backend when a hosts list is provided. `Paramiko `_ is a Python implementation of the SSHv2 protocol. Testinfra will not ask you for a password, so you must be able to connect without password (using passwordless keys or using ``ssh-agent``). You can provide an alternate ssh-config:: $ py.test --ssh-config=/path/to/ssh_config --hosts=server docker ~~~~~~ The Docker backend can be used to test *running* Docker containers. It uses the `docker exec `_ command:: $ py.test --hosts='docker://[user@]container_id_or_name' See also the :ref:`Test docker images` example. podman ~~~~~~ The Podman backend can be used to test *running* Podman containers. It uses the `podman exec `_ command:: $ py.test --hosts='podman://[user@]container_id_or_name' ssh ~~~ This is a pure SSH backend using the ``ssh`` command. Example:: $ py.test --hosts='ssh://server' $ py.test --ssh-config=/path/to/ssh_config --hosts='ssh://server' $ py.test --ssh-identity-file=/path/to/key --hosts='ssh://server' $ py.test --hosts='ssh://server?timeout=60&controlpersist=120' $ py.test --hosts='ssh://server' --ssh-extra-args='-o StrictHostKeyChecking=no' By default timeout is set to 10 seconds and ControlPersist is set to 60 seconds. You can disable persistent connection by passing `controlpersist=0` to the options. salt ~~~~ The salt backend uses the `salt Python client API `_ and can be used from the salt-master server:: $ py.test --hosts='salt://*' $ py.test --hosts='salt://minion1,salt://minion2' $ py.test --hosts='salt://web*' $ py.test --hosts='salt://G@os:Debian' Testinfra will use the salt connection channel to run commands. Hosts can be selected by using the `glob` and `compound matchers `_. .. _ansible connection backend: ansible ~~~~~~~ Ansible inventories may be used to describe what hosts Testinfra should use and how to connect them, using Testinfra's Ansible backend. To use the Ansible backend, prefix the ``--hosts`` option with ``ansible://`` e.g:: $ py.test --hosts='ansible://all' # tests all inventory hosts $ py.test --hosts='ansible://host1,ansible://host2' $ py.test --hosts='ansible://web*' An inventory may be specified with the ``--ansible-inventory`` option, otherwise the default (``/etc/ansible/hosts``) is used. The ``ansible_connection`` value in your inventory will be used to determine which backend to use for individual hosts: ``local``, ``ssh``, ``paramiko`` and ``docker`` are supported values. Other connections (or if you are using the ``--force-ansible`` option) will result in testinfra running all commands via Ansible itself, which is substantially slower than the other backends:: $ py.test --force-ansible --hosts='ansible://all' $ py.test --hosts='ansible://host?force_ansible=True' By default, the Ansible connection backend will first try to use ``ansible_ssh_private_key_file`` and ``ansible_private_key_file`` to authenticate, then fall back to the ``ansible_user`` with ``ansible_ssh_pass`` variables (both are required), before finally falling back to your own host's SSH config. This behavior may be overwritten by specifying either the ``--ssh-identity-file`` option or the ``--ssh-config`` option Finally, these environment variables are supported and will be passed along to their corresponding ansible variable (See Ansible documentation): https://docs.ansible.com/ansible/2.3/intro_inventory.html https://docs.ansible.com/ansible/latest/reference_appendices/config.html * ``ANSIBLE_REMOTE_USER`` * ``ANSIBLE_SSH_EXTRA_ARGS`` * ``ANSIBLE_SSH_COMMON_ARGS`` * ``ANSIBLE_REMOTE_PORT`` * ``ANSIBLE_BECOME_USER`` * ``ANSIBLE_BECOME`` kubectl ~~~~~~~ The kubectl backend can be used to test containers running in Kubernetes. It uses the `kubectl exec `_ command and support connecting to a given container name within a pod and using a given namespace:: # will use the default namespace and default container $ py.test --hosts='kubectl://mypod-a1b2c3' # specify container name and namespace $ py.test --hosts='kubectl://somepod-2536ab?container=nginx&namespace=web' # specify the kubeconfig context to use $ py.test --hosts='kubectl://somepod-2536ab?context=k8s-cluster-a&container=nginx' # you can specify kubeconfig either from KUBECONFIG environment variable # or when working with multiple configuration with the "kubeconfig" option $ py.test --hosts='kubectl://somepod-123?kubeconfig=/path/kubeconfig,kubectl://otherpod-123?kubeconfig=/other/kubeconfig' openshift ~~~~~~~~~ The openshift backend can be used to test containers running in OpenShift. It uses the `oc exec `_ command and support connecting to a given container name within a pod and using a given namespace:: # will use the default namespace and default container $ py.test --hosts='openshift://mypod-a1b2c3' # specify container name and namespace $ py.test --hosts='openshift://somepod-2536ab?container=nginx&namespace=web' # you can specify kubeconfig either from KUBECONFIG environment variable # or when working with multiple configuration with the "kubeconfig" option $ py.test --hosts='openshift://somepod-123?kubeconfig=/path/kubeconfig,openshift://otherpod-123?kubeconfig=/other/kubeconfig' winrm ~~~~~ The winrm backend uses `pywinrm `_:: $ py.test --hosts='winrm://Administrator:Password@127.0.0.1' $ py.test --hosts='winrm://vagrant@127.0.0.1:2200?no_ssl=true&no_verify_ssl=true' pywinrm's default read and operation timeout can be overridden using query arguments ``read_timeout_sec`` and ``operation_timeout_sec``:: $ py.test --hosts='winrm://vagrant@127.0.0.1:2200?read_timeout_sec=120&operation_timeout_sec=100' LXC/LXD ~~~~~~~ The LXC backend can be used to test *running* LXC or LXD containers. It uses the `lxc exec `_ command:: $ py.test --hosts='lxc://container_name' pytest-testinfra-10.1.0/doc/source/changelog.rst000066400000000000000000000000411456331477400216550ustar00rootroot00000000000000.. include:: ../../CHANGELOG.rst pytest-testinfra-10.1.0/doc/source/conf.py000066400000000000000000000224051456331477400205030ustar00rootroot00000000000000# Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # # testinfra documentation build configuration file # # This file is execfile()d with the current directory set to its # containing dir. # # Note that not all possible configuration values are present in this # autogenerated file. # # All configuration values have a default; values that are commented out # serve to show the default. import datetime import os import subprocess import sys import alabaster # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. sys.path.insert(0, os.path.abspath("../..")) # -- General configuration ------------------------------------------------ # If your documentation needs a minimal Sphinx version, state it here. # needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ "sphinx.ext.autodoc", "alabaster", ] autodoc_member_order = "bysource" # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] # The suffix of source filenames. source_suffix = ".rst" # The encoding of source files. # source_encoding = 'utf-8-sig' # The master toctree document. master_doc = "index" # General information about the project. project = "testinfra" copyright = "{}, Philippe Pepiot".format(datetime.date.today().year) # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. version = ( subprocess.check_output( ["python3", "setup.py", "--version"], cwd=os.path.join(os.path.dirname(__file__), os.pardir, os.pardir), ) .decode() .strip() ) # The full version, including alpha/beta/rc tags. release = version # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. # language = None # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: # today = '' # Else, today_fmt is used as the format for a strftime call. # today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. exclude_patterns: list[str] = [] # The reST default role (used for this markup: `text`) to use for all # documents. # default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. # add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). add_module_names = False # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. # show_authors = False # The name of the Pygments (syntax highlighting) style to use. pygments_style = "sphinx" # A list of ignored prefixes for module index sorting. # modindex_common_prefix = [] # If true, keep warnings as "system message" paragraphs in the built documents. # keep_warnings = False # -- Options for HTML output ---------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. html_theme = "alabaster" # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. html_theme_options = { "logo": "logo.svg", "github_user": "pytest-dev", "github_repo": "pytest-testinfra", "github_button": True, "extra_nav_links": { "View on github": "https://github.com/pytest-dev/pytest-testinfra", }, } # Add any paths that contain custom themes here, relative to this directory. html_theme_path = [alabaster.get_path()] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". # html_title = None # A shorter title for the navigation bar. Default is the same as html_title. # html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. # html_logo = None # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. # html_favicon = None # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ["_static"] # Add any extra paths that contain custom files (such as robots.txt or # .htaccess) here, relative to this directory. These files are copied # directly to the root of the documentation. # html_extra_path = [] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. # html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. # html_use_smartypants = True # Custom sidebar templates, maps document names to template names. html_sidebars = { "**": [ "about.html", "navigation.html", "searchbox.html", "piwik.html", ], } # Additional templates that should be rendered to pages, maps page names to # template names. # html_additional_pages = {} # If false, no module index is generated. # html_domain_indices = True # If false, no index is generated. # html_use_index = True # If true, the index is split into individual pages for each letter. # html_split_index = False # If true, links to the reST sources are added to the pages. html_show_sourcelink = False # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. # html_show_sphinx = True # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. # html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. # html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). # html_file_suffix = None # Output file base name for HTML help builder. htmlhelp_basename = "testinfradoc" # -- Options for LaTeX output --------------------------------------------- latex_elements: dict[str, tuple[str, ...]] = { # The paper size ('letterpaper' or 'a4paper') # 'papersize': 'letterpaper', # The font size ('10pt', '11pt' or '12pt'). # 'pointsize': '10pt', # Additional stuff for the LaTeX preamble. # 'preamble': '', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ ("index", "testinfra.tex", "testinfra Documentation", "Philippe Pepiot", "manual"), ] # The name of an image file (relative to this directory) to place at the top of # the title page. # latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. # latex_use_parts = False # If true, show page references after internal links. # latex_show_pagerefs = False # If true, show URL addresses after external links. # latex_show_urls = False # Documents to append as an appendix to all manuals. # latex_appendices = [] # If false, no module index is generated. # latex_domain_indices = True # -- Options for manual page output --------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [("index", "testinfra", "testinfra Documentation", ["Philippe Pepiot"], 1)] # If true, show URL addresses after external links. # man_show_urls = False # -- Options for Texinfo output ------------------------------------------- # Grouping the document tree into Texinfo files. List of tuples # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ ( "index", "testinfra", "testinfra Documentation", "Philippe Pepiot", "testinfra", "One line description of project.", "Miscellaneous", ), ] # Documents to append as an appendix to all manuals. # texinfo_appendices = [] # If false, no module index is generated. # texinfo_domain_indices = True # How to display URL addresses: 'footnote', 'no', or 'inline'. # texinfo_show_urls = 'footnote' # If true, do not generate a @detailmenu in the "Top" node's menu. # texinfo_no_detailmenu = False pytest-testinfra-10.1.0/doc/source/examples.rst000066400000000000000000000121261456331477400215530ustar00rootroot00000000000000Examples ======== Parametrize your tests ~~~~~~~~~~~~~~~~~~~~~~ Pytest support `test parametrization `_:: # BAD: If the test fails on nginx, python is not tested def test_packages(host): for name, version in ( ("nginx", "1.6"), ("python", "2.7"), ): pkg = host.package(name) assert pkg.is_installed assert pkg.version.startswith(version) # GOOD: Each package is tested # $ py.test -v test.py # [...] # test.py::test_package[local-nginx-1.6] PASSED # test.py::test_package[local-python-2.7] PASSED # [...] import pytest @pytest.mark.parametrize("name,version", [ ("nginx", "1.6"), ("python", "2.7"), ]) def test_packages(host, name, version): pkg = host.package(name) assert pkg.is_installed assert pkg.version.startswith(version) .. _make modules: Using unittest ~~~~~~~~~~~~~~ Testinfra can be used with the standard Python unit test framework `unittest `_ instead of pytest:: import unittest import testinfra class Test(unittest.TestCase): def setUp(self): self.host = testinfra.get_host("paramiko://root@host") def test_nginx_config(self): self.assertEqual(self.host.run("nginx -t").rc, 0) def test_nginx_service(self): service = self.host.service("nginx") self.assertTrue(service.is_running) self.assertTrue(service.is_enabled) if __name__ == "__main__": unittest.main() :: $ python test.py .. ---------------------------------------------------------------------- Ran 2 tests in 0.705s OK Integration with Vagrant ~~~~~~~~~~~~~~~~~~~~~~~~ `Vagrant `_ is a tool to setup and provision development environments (virtual machines). When your Vagrant machine is up and running, you can easily run your testinfra test suite on it:: vagrant ssh-config > .vagrant/ssh-config py.test --hosts=default --ssh-config=.vagrant/ssh-config tests.py Integration with Jenkins ~~~~~~~~~~~~~~~~~~~~~~~~ `Jenkins `_ is a well known open source continuous integration server. If your Jenkins slave can run Vagrant, your build scripts can be like:: pip install pytest-testinfra paramiko vagrant up vagrant ssh-config > .vagrant/ssh-config py.test --hosts=default --ssh-config=.vagrant/ssh-config --junit-xml junit.xml tests.py Then configure Jenkins to get tests results from the `junit.xml` file. Integration with Nagios ~~~~~~~~~~~~~~~~~~~~~~~ Your tests will usually be validating that the services you are deploying run correctly. This kind of tests are close to monitoring checks, so let's push them to `Nagios `_ ! The Testinfra option `--nagios` enables a behavior compatible with a nagios plugin:: $ py.test -qq --nagios --tb line test_ok.py; echo $? TESTINFRA OK - 2 passed, 0 failed, 0 skipped in 2.30 seconds .. 0 $ py.test -qq --nagios --tb line test_fail.py; echo $? TESTINFRA CRITICAL - 1 passed, 1 failed, 0 skipped in 2.24 seconds .F /usr/lib/python3/dist-packages/example/example.py:95: error: [Errno 111] error msg 2 You can run these tests from the nagios master or in the target host with `NRPE `_. Integration with KitchenCI ~~~~~~~~~~~~~~~~~~~~~~~~~~ KitchenCI (aka Test Kitchen) can use testinfra via its :code:`shell` verifier. Add the following to your :code:`.kitchen.yml`, this requires installing `paramiko` additionally (on your host machine, not in the VM handled by kitchen) :: verifier: name: shell command: py.test --hosts="paramiko://${KITCHEN_USERNAME}@${KITCHEN_HOSTNAME}:${KITCHEN_PORT}?ssh_identity_file=${KITCHEN_SSH_KEY}" --junit-xml "junit-${KITCHEN_INSTANCE}.xml" "test/integration/${KITCHEN_SUITE}" .. _test docker images: Test Docker images ~~~~~~~~~~~~~~~~~~ Docker is a handy way to test your infrastructure code. This recipe shows how to build and run Docker containers with Testinfra by overloading the `host` fixture. .. code-block:: python import pytest import subprocess import testinfra # scope='session' uses the same container for all the tests; # scope='function' uses a new container per test function. @pytest.fixture(scope='session') def host(request): # build local ./Dockerfile subprocess.check_call(['docker', 'build', '-t', 'myimage', '.']) # run a container docker_id = subprocess.check_output( ['docker', 'run', '-d', 'myimage']).decode().strip() # return a testinfra connection to the container yield testinfra.get_host("docker://" + docker_id) # at the end of the test suite, destroy the container subprocess.check_call(['docker', 'rm', '-f', docker_id]) def test_myimage(host): # 'host' now binds to the container assert host.check_output('myapp -v') == 'Myapp 1.0' pytest-testinfra-10.1.0/doc/source/index.rst000066400000000000000000000003001456331477400210330ustar00rootroot00000000000000.. include:: ../../README.rst Documentation ============= .. toctree:: :maxdepth: 3 changelog invocation Connection backends modules api examples support pytest-testinfra-10.1.0/doc/source/invocation.rst000066400000000000000000000035031456331477400221050ustar00rootroot00000000000000Invocation ========== Test multiples hosts ~~~~~~~~~~~~~~~~~~~~ By default Testinfra launch tests on local machine, but you can also test remotes systems using `paramiko `_ (a ssh implementation in python):: $ pip install paramiko $ py.test -v --hosts=localhost,root@webserver:2222 test_myinfra.py ====================== test session starts ====================== platform linux -- Python 2.7.3 -- py-1.4.26 -- pytest-2.6.4 plugins: testinfra collected 3 items test_myinfra.py::test_passwd_file[localhost] PASSED test_myinfra.py::test_nginx_is_installed[localhost] PASSED test_myinfra.py::test_nginx_running_and_enabled[localhost] PASSED test_myinfra.py::test_passwd_file[root@webserver:2222] PASSED test_myinfra.py::test_nginx_is_installed[root@webserver:2222] PASSED test_myinfra.py::test_nginx_running_and_enabled[root@webserver:2222] PASSED =================== 6 passed in 8.49 seconds ==================== You can also set hosts per test module:: testinfra_hosts = ["localhost", "root@webserver:2222"] def test_foo(host): [....] Parallel execution ~~~~~~~~~~~~~~~~~~ If you have a lot of tests, you can use the pytest-xdist_ plugin to run tests using multiples process:: $ pip install pytest-xdist # Launch tests using 3 processes $ py.test -n 3 -v --host=web1,web2,web3,web4,web5,web6 test_myinfra.py Advanced invocation ~~~~~~~~~~~~~~~~~~~ :: # Test recursively all test files (starting with `test_`) in current directory $ py.test # Filter function/hosts with pytest -k option $ py.test --hosts=webserver,dnsserver -k webserver -k nginx For more usages and features, see the Pytest_ documentation. .. _Pytest: https://docs.pytest.org/en/latest/ .. _pytest-xdist: https://pypi.org/project/pytest-xdist/ pytest-testinfra-10.1.0/doc/source/modules.rst000066400000000000000000000112731456331477400214070ustar00rootroot00000000000000.. _modules: Modules ======= Testinfra modules are provided through the `host` `fixture`_, declare it as arguments of your test function to make it available within it. .. code-block:: python def test_foo(host): # [...] host ~~~~ .. autoclass:: testinfra.host.Host :members: .. attribute:: ansible :class:`testinfra.modules.ansible.Ansible` class .. attribute:: addr :class:`testinfra.modules.addr.Addr` class .. attribute:: blockdevice :class:`testinfra.modules.blockdevice.BlockDevice` class .. attribute:: docker :class:`testinfra.modules.docker.Docker` class .. attribute:: environment :class:`testinfra.modules.environment.Environment` class .. attribute:: file :class:`testinfra.modules.file.File` class .. attribute:: group :class:`testinfra.modules.group.Group` class .. attribute:: interface :class:`testinfra.modules.interface.Interface` class .. attribute:: iptables :class:`testinfra.modules.iptables.Iptables` class .. attribute:: mount_point :class:`testinfra.modules.mountpoint.MountPoint` class .. attribute:: package :class:`testinfra.modules.package.Package` class .. attribute:: pip :class:`testinfra.modules.pip.Pip` class .. attribute:: podman :class:`testinfra.modules.podman.Podman` class .. attribute:: process :class:`testinfra.modules.process.Process` class .. attribute:: puppet_resource :class:`testinfra.modules.puppet.PuppetResource` class .. attribute:: facter :class:`testinfra.modules.puppet.Facter` class .. attribute:: salt :class:`testinfra.modules.salt.Salt` class .. attribute:: service :class:`testinfra.modules.service.Service` class .. attribute:: socket :class:`testinfra.modules.socket.Socket` class .. attribute:: sudo :class:`testinfra.modules.sudo.Sudo` class .. attribute:: supervisor :class:`testinfra.modules.supervisor.Supervisor` class .. attribute:: sysctl :class:`testinfra.modules.sysctl.Sysctl` class .. attribute:: system_info :class:`testinfra.modules.systeminfo.SystemInfo` class .. attribute:: user :class:`testinfra.modules.user.User` class Ansible ~~~~~~~ .. autoclass:: testinfra.modules.ansible.Ansible(module_name, module_args=None, check=True) :members: Addr ~~~~ .. autoclass:: testinfra.modules.addr.Addr(name) :members: BlockDevice ~~~~~~~~~~~ .. autoclass:: testinfra.modules.blockdevice.BlockDevice(name) :members: Docker ~~~~~~ .. autoclass:: testinfra.modules.docker.Docker(name) :members: Environment ~~~~~~~~~~~ .. autoclass:: testinfra.modules.environment.Environment(name) :members: File ~~~~ .. autoclass:: testinfra.modules.file.File :members: :undoc-members: :exclude-members: get_module_class Group ~~~~~ .. autoclass:: testinfra.modules.group.Group :members: :undoc-members: Interface ~~~~~~~~~ .. autoclass:: testinfra.modules.interface.Interface :members: :undoc-members: :exclude-members: get_module_class Iptables ~~~~~~~~~ .. autoclass:: testinfra.modules.iptables.Iptables :members: :undoc-members: MountPoint ~~~~~~~~~~ .. autoclass:: testinfra.modules.mountpoint.MountPoint(path) :members: Package ~~~~~~~ .. autoclass:: testinfra.modules.package.Package :members: Pip ~~~~~~~~~~ .. autoclass:: testinfra.modules.pip.Pip :members: Podman ~~~~~~ .. autoclass:: testinfra.modules.podman.Podman(name) :members: Process ~~~~~~~ .. autoclass:: testinfra.modules.process.Process :members: PuppetResource ~~~~~~~~~~~~~~ .. autoclass:: testinfra.modules.puppet.PuppetResource(type, name=None) :members: Facter ~~~~~~ .. autoclass:: testinfra.modules.puppet.Facter(*facts) :members: Salt ~~~~ .. autoclass:: testinfra.modules.salt.Salt(function, args=None, local=False, config=None) :members: Service ~~~~~~~ .. autoclass:: testinfra.modules.service.Service :members: Socket ~~~~~~ .. autoclass:: testinfra.modules.socket.Socket :members: Sudo ~~~~ .. autoclass:: testinfra.modules.sudo.Sudo(user=None) Supervisor ~~~~~~~~~~ .. autoclass:: testinfra.modules.supervisor.Supervisor :members: Sysctl ~~~~~~ .. autoclass:: testinfra.modules.sysctl.Sysctl(name) :members: SystemInfo ~~~~~~~~~~ .. autoclass:: testinfra.modules.systeminfo.SystemInfo :members: User ~~~~ .. autoclass:: testinfra.modules.user.User :members: :exclude-members: get_module_class CommandResult ~~~~~~~~~~~~~ .. autoclass:: testinfra.backend.base.CommandResult :members: .. _fixture: https://docs.pytest.org/en/latest/fixture.html#fixture pytest-testinfra-10.1.0/doc/source/support.rst000066400000000000000000000013771456331477400214570ustar00rootroot00000000000000Support ======= If you have questions or need help with testinfra please consider one of the following Issue Tracker ~~~~~~~~~~~~~ Checkout existing issues on `project issue tracker `_ IRC ~~~ You can also ask questions on IRC in `#pytest `_ channel on [libera.chat](https://libera.chat/) network. pytest documentation ~~~~~~~~~~~~~~~~~~~~ testinfra is implemented as pytest plugin so to get the most out of please read `pytest documentation `_ Community Contributions ~~~~~~~~~~~~~~~~~~~~~~~ * `Molecule `_ is an Automated testing framework for Ansible roles, with native Testinfra support. pytest-testinfra-10.1.0/images/000077500000000000000000000000001456331477400164015ustar00rootroot00000000000000pytest-testinfra-10.1.0/images/debian_bookworm/000077500000000000000000000000001456331477400215425ustar00rootroot00000000000000pytest-testinfra-10.1.0/images/debian_bookworm/Dockerfile000066400000000000000000000063141456331477400235400ustar00rootroot00000000000000FROM debian:bookworm ENV container docker RUN apt-get update && \ DEBIAN_FRONTEND=noninteractive apt-get -y install --no-install-recommends \ lsb-release \ python3-pip \ openssh-server \ puppet \ locales \ sudo \ supervisor \ systemd-sysv \ virtualenv \ iproute2 \ iputils-ping \ iptables \ iptables-persistent && \ rm -rf /var/lib/apt/lists/* RUN mkdir -p /var/run/sshd && \ (cd /lib/systemd/system/sysinit.target.wants/; for i in *; do if ! test $i = systemd-tmpfiles-setup.service; then rm -f $i; fi; done) && \ rm -f /lib/systemd/system/multi-user.target.wants/* && \ rm -f /etc/systemd/system/*.wants/* && \ rm -f /lib/systemd/system/local-fs.target.wants/* && \ rm -f /lib/systemd/system/sockets.target.wants/*udev* && \ rm -f /lib/systemd/system/sockets.target.wants/*initctl* && \ systemctl enable ssh.service && \ systemctl enable supervisor.service && \ systemctl enable netfilter-persistent.service && \ echo "python3 hold" | dpkg --set-selections && \ echo "LANG=fr_FR.ISO-8859-15" > /etc/default/locale && \ echo "LANGUAGE=fr_FR" >> /etc/default/locale && \ echo "fr_FR.ISO-8859-15 ISO-8859-15" >> /etc/locale.gen && \ locale-gen && \ update-locale && \ useradd -m user -c "gecos.comment" && \ adduser user sudo && \ echo "user ALL=NOPASSWD: ALL" > /etc/sudoers.d/user && \ useradd -m unprivileged && \ mkdir -p /root/.ssh && \ echo "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCgDryK4AjJeifuc2N54St13KMNlnGLAtibQSMmvSyrhH7XJ1atnBo1HrJhGZNNBVKM67+zYNc9J3fg3qI1g63vSQAA+nXMsDYwu4BPwupakpwJELcGZJxsUGzjGVotVpqPIX5nW8NBGvkVuObI4UELOleq5mQMTGerJO64KkSVi20FDwPJn3q8GG2zk3pESiDA5ShEyFhYC8vOLfSSYD0LYmShAVGCLEgiNb+OXQL6ZRvzqfFEzL0QvaI/l3mb6b0VFPAO4QWOL0xj3cWzOZXOqht3V85CZvSk8ISdNgwCjXLZsPeaYL/toHNvBF30VMrDZ7w4SDU0ZZLEsc/ezxjb" > /root/.ssh/authorized_keys && \ mkdir -p /home/user/.ssh && \ cp /root/.ssh/authorized_keys /home/user/.ssh/authorized_keys && \ chown -R user:user /home/user/.ssh && \ echo "[program:tail]\ncommand=tail -f /dev/null\nuser=user\ngroup=user\n" > /etc/supervisor/conf.d/tail.conf RUN echo "root:foo" | chpasswd # Enable ssh login for user and fix side effect on environment variables... RUN sed -ri 's/^UsePAM yes$/UsePAM no/' /etc/ssh/sshd_config RUN sed -ri 's/AcceptEnv LANG LC_*/#AcceptEnv LANG LC_*/' /etc/ssh/sshd_config RUN echo "PermitUserEnvironment yes" >> /etc/ssh/sshd_config RUN echo "LANG=fr_FR.ISO-8859-15" >> /root/.ssh/environment RUN echo "user:foo" | chpasswd # Iptables rules RUN echo "*nat\n:PREROUTING ACCEPT [0:0]\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\n:POSTROUTING ACCEPT [0:0]\n-A PREROUTING -d 192.168.0.1/32 -j REDIRECT\nCOMMIT\n*filter\n:INPUT ACCEPT [0:0]\n:FORWARD ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\n-A INPUT -p tcp -m state --state NEW -m tcp --dport 22 -j ACCEPT\nCOMMIT" > /etc/iptables/rules.v4 # Expiration date for user "user" RUN chage -E 20000 -m 7 -M 90 user # Some python3 virtualenv RUN virtualenv /v RUN /v/bin/pip install -U pip RUN /v/bin/pip install 'requests==2.30.0' # install salt RUN python3 -m pip install --break-system-packages --no-cache salt ENV LANG fr_FR.ISO-8859-15 ENV LANGUAGE fr_FR EXPOSE 22 CMD ["/sbin/init"] pytest-testinfra-10.1.0/images/rockylinux9/000077500000000000000000000000001456331477400207015ustar00rootroot00000000000000pytest-testinfra-10.1.0/images/rockylinux9/Dockerfile000066400000000000000000000026761456331477400227060ustar00rootroot00000000000000FROM rockylinux:9 RUN dnf -y install openssh-server procps python311 iputils && dnf clean all &&\ (cd /lib/systemd/system/sysinit.target.wants/; for i in *; do if ! test $i = systemd-tmpfiles-setup.service; then rm -f $i; fi; done) && \ rm -f /lib/systemd/system/multi-user.target.wants/* && \ rm -f /etc/systemd/system/*.wants/* && \ rm -f /lib/systemd/system/local-fs.target.wants/* && \ rm -f /lib/systemd/system/sockets.target.wants/*udev* && \ rm -f /lib/systemd/system/sockets.target.wants/*initctl* && \ rm -f /lib/systemd/system/basic.target.wants/* && \ rm -f /lib/systemd/system/anaconda.target.wants/* && \ rm -f /etc/ssh/ssh_host_ecdsa_key /etc/ssh/ssh_host_rsa_key && \ ssh-keygen -q -N "" -t dsa -f /etc/ssh/ssh_host_ecdsa_key && \ ssh-keygen -q -N "" -t rsa -f /etc/ssh/ssh_host_rsa_key && \ sed -i "s/#UsePrivilegeSeparation.*/UsePrivilegeSeparation no/g" /etc/ssh/sshd_config && \ systemctl enable sshd.service && \ mkdir -p /root/.ssh && \ echo "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCgDryK4AjJeifuc2N54St13KMNlnGLAtibQSMmvSyrhH7XJ1atnBo1HrJhGZNNBVKM67+zYNc9J3fg3qI1g63vSQAA+nXMsDYwu4BPwupakpwJELcGZJxsUGzjGVotVpqPIX5nW8NBGvkVuObI4UELOleq5mQMTGerJO64KkSVi20FDwPJn3q8GG2zk3pESiDA5ShEyFhYC8vOLfSSYD0LYmShAVGCLEgiNb+OXQL6ZRvzqfFEzL0QvaI/l3mb6b0VFPAO4QWOL0xj3cWzOZXOqht3V85CZvSk8ISdNgwCjXLZsPeaYL/toHNvBF30VMrDZ7w4SDU0ZZLEsc/ezxjb" > /root/.ssh/authorized_keys EXPOSE 22 CMD ["/usr/sbin/init"] pytest-testinfra-10.1.0/mypy.ini000066400000000000000000000006141456331477400166340ustar00rootroot00000000000000[mypy] files = . strict = true warn_unused_ignores = true show_error_codes = true # XXX: goal is to enable this disallow_untyped_defs = false disallow_untyped_calls = false check_untyped_defs = false [mypy-salt.*] ignore_missing_imports = True [mypy-winrm.*] ignore_missing_imports = True [mypy-alabaster.*] ignore_missing_imports = True [mypy-setuptools_scm.*] ignore_missing_imports = True pytest-testinfra-10.1.0/pyproject.toml000066400000000000000000000003601456331477400200470ustar00rootroot00000000000000[tool.black] target-version = ['py38'] include = '\.pyi?$' exclude = ''' ( /( \.git | \.tox | \.eggs | build | dist )/ ) ''' [tool.isort] profile = "black" multi_line_output = 3 known_first_party = ["testinfra"] pytest-testinfra-10.1.0/setup.cfg000066400000000000000000000025361456331477400167630ustar00rootroot00000000000000[metadata] name = pytest-testinfra url = https://github.com/pytest-dev/pytest-testinfra description = Test infrastructures long_description = file:README.rst long_description_content_type = text/x-rst author = Philippe Pepiot author_email = phil@philpep.org license_files = LICENSE classifiers = Development Status :: 5 - Production/Stable Environment :: Console Intended Audience :: Developers Intended Audience :: Information Technology Intended Audience :: System Administrators License :: OSI Approved :: Apache Software License Operating System :: POSIX Programming Language :: Python Programming Language :: Python :: 3 Programming Language :: Python :: 3 :: Only Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 Programming Language :: Python :: 3.11 Topic :: Software Development :: Testing Topic :: System :: Systems Administration Framework :: Pytest [options] use_scm_version = True python_requires = >=3.9 packages = find: setup_requires = setuptools_scm install_requires = pytest>=6 extras_require = [options.extras_require] ansible = ansible docker = kubectl = local = lxc = paramiko = paramiko salt = salt winrm = pywinrm [options.entry_points] pytest11 = pytest11.testinfra=testinfra.plugin [tool:pytest] norecursedirs = .tox .git .local *.egg build pytest-testinfra-10.1.0/setup.py000066400000000000000000000020601456331477400166440ustar00rootroot00000000000000#!/usr/bin/env python3 # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import setuptools def local_scheme(version): """Generate a PEP440 compatible version if PEP440_VERSION is enabled""" import os import setuptools_scm.version # only present during setup time return ( "" if "PEP440_VERSION" in os.environ else setuptools_scm.version.get_local_node_and_date(version) ) if __name__ == "__main__": setuptools.setup( use_scm_version={"local_scheme": local_scheme}, setup_requires=["setuptools_scm"], ) pytest-testinfra-10.1.0/test-requirements.txt000066400000000000000000000001051456331477400213710ustar00rootroot00000000000000pytest-cov pytest-xdist paramiko types-paramiko salt pywinrm ansible pytest-testinfra-10.1.0/test/000077500000000000000000000000001456331477400161135ustar00rootroot00000000000000pytest-testinfra-10.1.0/test/conftest.py000066400000000000000000000210651456331477400203160ustar00rootroot00000000000000# Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import itertools import os import subprocess import sys import threading import time import urllib.parse import pytest import testinfra from testinfra.backend import parse_hostspec from testinfra.backend.base import BaseBackend BASETESTDIR = os.path.abspath(os.path.dirname(__file__)) BASEDIR = os.path.abspath(os.path.join(BASETESTDIR, os.pardir)) _HAS_DOCKER = None # Use testinfra to get a handy function to run commands locally local_host = testinfra.get_host("local://") check_output = local_host.check_output def has_docker(): global _HAS_DOCKER if _HAS_DOCKER is None: _HAS_DOCKER = local_host.exists("docker") return _HAS_DOCKER # Generated with # $ echo myhostvar: bar > hostvars.yml # $ echo polichinelle > vault-pass.txt # $ ansible-vault encrypt --vault-password-file vault-pass.txt hostvars.yml # $ cat hostvars.yml ANSIBLE_HOSTVARS = """$ANSIBLE_VAULT;1.1;AES256 39396233323131393835363638373764336364323036313434306134636633353932623363646233 6436653132383662623364313438376662666135346266370a343934663431363661393363386633 64656261336662623036373036363535313964313538366533313334366363613435303066316639 3235393661656230350a326264356530326432393832353064363439393330616634633761393838 3261 """ DOCKER_IMAGES = [ "rockylinux9", "debian_bookworm", ] def setup_ansible_config(tmpdir, name, host, user, port, key): items = [ name, "ansible_ssh_private_key_file={}".format(key), 'ansible_ssh_common_args="-o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -o LogLevel=FATAL"', # noqa "myvar=foo", "ansible_host={}".format(host), "ansible_user={}".format(user), "ansible_port={}".format(port), ] tmpdir.join("inventory").write("[testgroup]\n" + " ".join(items) + "\n") tmpdir.mkdir("host_vars").join(name).write(ANSIBLE_HOSTVARS) tmpdir.mkdir("group_vars").join("testgroup").write( ("---\n" "myhostvar: should_be_overriden\n" "mygroupvar: qux\n") ) vault_password_file = tmpdir.join("vault-pass.txt") vault_password_file.write("polichinelle\n") ansible_cfg = tmpdir.join("ansible.cfg") ansible_cfg.write( ( "[defaults]\n" "vault_password_file={}\n" "host_key_checking=False\n\n" "[ssh_connection]\n" "pipelining=True\n" ).format(str(vault_password_file)) ) def build_docker_container_fixture(image, scope): @pytest.fixture(scope=scope) def func(request): docker_host = os.environ.get("DOCKER_HOST") if docker_host is not None: docker_host = urllib.parse.urlparse(docker_host).hostname or "localhost" else: docker_host = "localhost" cmd = ["docker", "run", "-d", "-P"] if image in DOCKER_IMAGES: cmd.append("--privileged") cmd.append("testinfra:" + image) docker_id = check_output(" ".join(cmd)) def teardown(): check_output("docker rm -f %s", docker_id) request.addfinalizer(teardown) port = check_output("docker port %s 22", docker_id) # IPv4 addresses seem to be reported consistently # in the first line of the output. # To workaround https://github.com/moby/moby/issues/42442 # use only the values of the first line of the command # output port = int(port.splitlines()[0].rsplit(":", 1)[-1]) return docker_id, docker_host, port fname = "_docker_container_{}_{}".format(image, scope) mod = sys.modules[__name__] setattr(mod, fname, func) def initialize_container_fixtures(): for image, scope in itertools.product(DOCKER_IMAGES, ["function", "session"]): build_docker_container_fixture(image, scope) initialize_container_fixtures() @pytest.fixture def host(request, tmpdir_factory): if not has_docker(): pytest.skip() return image, kw = parse_hostspec(request.param) spec = BaseBackend.parse_hostspec(image) for marker in getattr(request.function, "pytestmark", []): if marker.name == "destructive": scope = "function" break else: scope = "session" fname = "_docker_container_{}_{}".format(spec.name, scope) docker_id, docker_host, port = request.getfixturevalue(fname) if kw["connection"] == "docker": hostname = docker_id elif kw["connection"] in ("ansible", "ssh", "paramiko", "safe-ssh"): hostname = spec.name tmpdir = tmpdir_factory.mktemp(str(id(request))) key = tmpdir.join("ssh_key") key.write(open(os.path.join(BASETESTDIR, "ssh_key")).read()) key.chmod(384) # octal 600 if kw["connection"] == "ansible": setup_ansible_config( tmpdir, hostname, docker_host, spec.user or "root", port, str(key) ) os.environ["ANSIBLE_CONFIG"] = str(tmpdir.join("ansible.cfg")) # this force backend cache reloading kw["ansible_inventory"] = str(tmpdir.join("inventory")) else: ssh_config = tmpdir.join("ssh_config") ssh_config.write( ( "Host {}\n" " Hostname {}\n" " Port {}\n" " UserKnownHostsFile /dev/null\n" " StrictHostKeyChecking no\n" " IdentityFile {}\n" " IdentitiesOnly yes\n" " LogLevel FATAL\n" ).format(hostname, docker_host, port, str(key)) ) kw["ssh_config"] = str(ssh_config) # Wait ssh to be up service = testinfra.get_host(docker_id, connection="docker").service if image == "rockylinux9": service_name = "sshd" else: service_name = "ssh" while not service(service_name).is_running: time.sleep(0.5) if kw["connection"] != "ansible": hostspec = (spec.user or "root") + "@" + hostname else: hostspec = spec.name b = testinfra.host.get_host(hostspec, **kw) b.backend.get_hostname = lambda: image return b @pytest.fixture def docker_image(host): return host.backend.get_hostname() def pytest_generate_tests(metafunc): if "host" in metafunc.fixturenames: for marker in getattr(metafunc.function, "pytestmark", []): if marker.name == "testinfra_hosts": hosts = marker.args break else: # Default hosts = ["docker://debian_bookworm"] metafunc.parametrize("host", hosts, indirect=True, scope="function") def pytest_configure(config): if not has_docker(): return def build_image(build_failed, dockerfile, image, image_path): try: subprocess.check_call( [ "docker", "build", "-f", dockerfile, "-t", "testinfra:{0}".format(image), image_path, ] ) except Exception: build_failed.set() raise threads = [] images_path = os.path.join(BASEDIR, "images") build_failed = threading.Event() for image in os.listdir(images_path): image_path = os.path.join(images_path, image) dockerfile = os.path.join(image_path, "Dockerfile") if os.path.exists(dockerfile): threads.append( threading.Thread( target=build_image, args=(build_failed, dockerfile, image, image_path), ) ) for thread in threads: thread.start() for thread in threads: thread.join() if build_failed.is_set(): raise RuntimeError("One or more docker build failed") config.addinivalue_line( "markers", "testinfra_hosts(host_selector): mark test to run on selected hosts" ) config.addinivalue_line("markers", "destructive: mark test as destructive") config.addinivalue_line("markers", "skip_wsl: skip test on WSL, no systemd support") pytest-testinfra-10.1.0/test/ssh_key000066400000000000000000000032131456331477400175020ustar00rootroot00000000000000-----BEGIN RSA PRIVATE KEY----- MIIEowIBAAKCAQEAoA68iuAIyXon7nNjeeErddyjDZZxiwLYm0EjJr0sq4R+1ydW rZwaNR6yYRmTTQVSjOu/s2DXPSd34N6iNYOt70kAAPp1zLA2MLuAT8LqWpKcCRC3 BmScbFBs4xlaLVaajyF+Z1vDQRr5FbjmyOFBCzpXquZkDExnqyTuuCpElYttBQ8D yZ96vBhts5N6REogwOUoRMhYWAvLzi30kmA9C2JkoQFRgixIIjW/jl0C+mUb86nx RMy9EL2iP5d5m+m9FRTwDuEFji9MY93FszmVzqobd1fOQmb0pPCEnTYMAo1y2bD3 mmC/7aBzbwRd9FTKw2e8OEg1NGWSxLHP3s8Y2wIDAQABAoIBADm3trPZwDFvdJDf WWLtGPACpWXT95PqbdPmtFdW5pHfUKIjlHU8kpLPRAIR5/VhUvhwVwvHgzaRUgBs KFBl8MYWLAMuTmaGsLP4GXgp0LrinZQDTAzpISNKCUoHrWYmEcxFhsCc7Zc/s8zq hYaw+/ShkFWXiUKKFuQ3iEIvM9Y9A/3CJDoVJcnsAhaOL9mVjuu978VhSfeEel9y DV7/4A1R6c/t2GImFfQUz3/z7gg+heJ9idLKwybW0NJB/v+R+qNtbYyU1o4YFywW EQa9LiZpiLylMXGO4/txAJwbDyoSCaog/5NiGK9VCgY06XRZgVIL4+65CqEeRH0Q AlI0s4kCgYEA0PBWf5fcKa+Drkv0jVro2ZEzhxLYtf2sLYvaJd+jUBZWb1+OH67i YC4hLYFQQMVe/NCjGSfJbeN/3RpYHggxQI7ZEllPqoTBwFY6IfCA5hnGBrLytUrT jFfYQQU7Dt++zP3Cpuw0OXEzgez2x4svHCLY3KNqzOE7JWGaka715W0CgYEAxBvY 1E7HVjcktX94d2IwNotsZRKCyZFt6D6ePPs8aRfnzGBQoBZmjnG6FPkMeiYRvkj0 96zTwYxvnP1EdPvGrKto3R0F41y4rVbMTY8oSvh0mqyboFzjBODsag7bmAaZAlf0 JWl313a4TMdFsRZ/QhIUFBnJ1y5WRRkfVJuCsmcCgYEAxExIt+9wxSlEyghKZlO2 2FF227x1JeaCUPhHp7WItcGGy3Q3DsU7oak1Oo93WqMULunFkeizci5+/re1eeGw hDqw7nBCTK4VaiKY0zIlqAkm5zxQkssOHZiab9v+NGc511XB/xmDp0QXZEXBRJAb Xo/OttxBhuNEskYU9jIui7ECgYABzGOTptlLIBxVEcMwDRV2Gpc24hGS+aNxYsme s4sdR5vXkvaKUUpFeiODt7j2kczN2utsLgiPGNOZM/VhwUFUKgo/JNn9+Ma0yDv9 ZhevgFHJbVXMBa4LSGjCnDpFTaIvlFDn2uy/bBZKlfU8p4EpQPMwMABa2dDut0lD RF3RdwKBgHMSW5hnT713lXtXWQlnCxl4LrFhwq2rYYnuoj8k/ydGzpcPAbROP149 49CS+Oa35A9g7BXlZflhhRJvYh+T3DAvniHotNWp8GrGmC4Yae3vp+09B5cT7GG9 1kPRf9q3yPmLuddxz3MpF/qhQUlMlhEjyUDFpJvIXShGVBQmNGME -----END RSA PRIVATE KEY----- pytest-testinfra-10.1.0/test/test_backends.py000066400000000000000000000514611456331477400213050ustar00rootroot00000000000000# Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import operator import os import tempfile import pytest import testinfra import testinfra.backend from testinfra.backend.base import BaseBackend, HostSpec from testinfra.backend.winrm import _quote from testinfra.utils.ansible_runner import AnsibleRunner HOSTS = [ "ssh://debian_bookworm", "safe-ssh://debian_bookworm", "docker://debian_bookworm", "paramiko://debian_bookworm", "ansible://debian_bookworm", "ansible://debian_bookworm?force_ansible=True", ] USER_HOSTS = [ "ssh://user@debian_bookworm", "safe-ssh://user@debian_bookworm", "docker://user@debian_bookworm", "paramiko://user@debian_bookworm", "ansible://user@debian_bookworm", "ansible://user@debian_bookworm?force_ansible=True", ] SUDO_HOSTS = [ "ssh://user@debian_bookworm?sudo=True", "safe-ssh://user@debian_bookworm?sudo=True", "docker://user@debian_bookworm?sudo=True", "paramiko://user@debian_bookworm?sudo=True", "ansible://user@debian_bookworm?sudo=True", "ansible://user@debian_bookworm?force_ansible=True&sudo=True", ] SUDO_USER_HOSTS = [ "ssh://debian_bookworm?sudo=True&sudo_user=user", "safe-ssh://debian_bookworm?sudo=True&sudo_user=user", "docker://debian_bookworm?sudo=True&sudo_user=user", "paramiko://debian_bookworm?sudo=True&sudo_user=user", "ansible://debian_bookworm?sudo=True&sudo_user=user", "ansible://debian_bookworm?force_ansible=True&sudo=True&sudo_user=user", ] @pytest.mark.testinfra_hosts(*(HOSTS + USER_HOSTS + SUDO_HOSTS + SUDO_USER_HOSTS)) def test_command(host): assert host.check_output("true") == "" # test that quotting is correct assert host.run("echo a b | grep -q %s", "a c").rc == 1 out = host.run("echo out && echo err >&2 && exit 42") assert out.rc == 42 if host.backend.get_connection_type() == "ansible" and host.backend.force_ansible: assert out.stdout_bytes == b"out" assert out.stderr_bytes == b"err" else: assert out.stdout_bytes == b"out\n" assert out.stderr_bytes == b"err\n" out = host.run("commandthatdoesnotexists") assert out.rc == 127 @pytest.mark.testinfra_hosts(*HOSTS) def test_encoding(host): # bookworm image is fr_FR@ISO-8859-15 cmd = host.run("ls -l %s", "/é") if host.backend.get_connection_type() == "docker": # docker bug ? assert cmd.stderr_bytes == ( b"ls: impossible d'acc\xe9der \xe0 '/\xef\xbf\xbd': " b"Aucun fichier ou dossier de ce type\n" ) elif host.backend.get_connection_type() == "ansible" and host.backend.force_ansible: # XXX: this encoding issue comes directly from ansible # not sure how to handle this... assert cmd.stderr == ( "ls: impossible d'accéder à '/é': " "Aucun fichier ou dossier de ce type" ) else: assert cmd.stderr_bytes == ( b"ls: impossible d'acc\xe9der \xe0 '/\xe9': " b"Aucun fichier ou dossier de ce type\n" ) assert cmd.stderr == ( "ls: impossible d'accéder à '/é': " "Aucun fichier ou dossier de ce type\n" ) @pytest.mark.testinfra_hosts("ansible://debian_bookworm?force_ansible=True") def test_ansible_any_error_fatal(host): os.environ["ANSIBLE_ANY_ERRORS_FATAL"] = "True" try: out = host.run("echo out && echo err >&2 && exit 42") assert out.rc == 42 assert out.stdout == "out" assert out.stderr == "err" finally: del os.environ["ANSIBLE_ANY_ERRORS_FATAL"] @pytest.mark.testinfra_hosts(*(USER_HOSTS + SUDO_USER_HOSTS)) def test_user_connection(host): assert host.user().name == "user" @pytest.mark.testinfra_hosts(*SUDO_HOSTS) def test_sudo(host): assert host.user().name == "root" def test_ansible_get_hosts(): with tempfile.NamedTemporaryFile() as f: f.write( ( b"ungrp\n" b"[g1]\n" b"debian\n" b"[g2]\n" b"rockylinux\n" b"[g3:children]\n" b"g1\n" b"g2\n" b"[g4:children]\n" b"g3" ) ) f.flush() def get_hosts(spec): return AnsibleRunner(f.name).get_hosts(spec) assert get_hosts("all") == ["debian", "rockylinux", "ungrp"] assert get_hosts("*") == ["debian", "rockylinux", "ungrp"] assert get_hosts("g1") == ["debian"] assert get_hosts("*2") == ["rockylinux"] assert get_hosts("*ia*") == ["debian"] assert get_hosts("*3") == ["debian", "rockylinux"] assert get_hosts("*4") == ["debian", "rockylinux"] assert get_hosts("ungrouped") == ["ungrp"] assert get_hosts("un*") == ["ungrp"] assert get_hosts("nope") == [] def test_ansible_get_variables(): with tempfile.NamedTemporaryFile() as f: f.write( ( b"debian a=b c=d\n" b"rockylinux e=f\n" b"[all:vars]\n" b"a=a\n" b"[g]\n" b"debian\n" b"[g:vars]\n" b"x=z\n" ) ) f.flush() def get_vars(host): return AnsibleRunner(f.name).get_variables(host) groups = { "all": ["debian", "rockylinux"], "g": ["debian"], "ungrouped": ["rockylinux"], } assert get_vars("debian") == { "a": "b", "c": "d", "x": "z", "inventory_hostname": "debian", "group_names": ["all", "g"], "groups": groups, } assert get_vars("rockylinux") == { "a": "a", "e": "f", "inventory_hostname": "rockylinux", "group_names": ["all", "ungrouped"], "groups": groups, } @pytest.mark.parametrize( "kwargs,inventory,expected", [ ( {}, b"host ansible_connection=local ansible_become=yes ansible_become_user=u", { # noqa "NAME": "local", "sudo": True, "sudo_user": "u", }, ), ( {}, b"host", { "NAME": "ssh", "host.name": "host", }, ), ( {}, b"host ansible_connection=smart", { "NAME": "ssh", "host.name": "host", }, ), ( {}, b"host ansible_host=127.0.1.1 ansible_user=u ansible_ssh_private_key_file=key ansible_port=2222 ansible_become=yes ansible_become_user=u", { # noqa "NAME": "ssh", "sudo": True, "sudo_user": "u", "host.name": "127.0.1.1", "host.port": "2222", "ssh_identity_file": "key", }, ), ( {}, b"host ansible_host=127.0.1.1 ansible_user=u ansible_private_key_file=key ansible_port=2222 ansible_become=yes ansible_become_user=u", { # noqa "NAME": "ssh", "sudo": True, "sudo_user": "u", "host.name": "127.0.1.1", "host.port": "2222", "ssh_identity_file": "key", }, ), ( {}, b'host ansible_ssh_common_args="-o LogLevel=FATAL"', { "NAME": "ssh", "host.name": "host", "ssh_extra_args": "-o LogLevel=FATAL", }, ), ( {}, b'host ansible_ssh_extra_args="-o LogLevel=FATAL"', { "NAME": "ssh", "host.name": "host", "ssh_extra_args": "-o LogLevel=FATAL", }, ), ( {}, b'host ansible_ssh_common_args="-o StrictHostKeyChecking=no" ansible_ssh_extra_args="-o LogLevel=FATAL"', { # noqa "NAME": "ssh", "host.name": "host", "ssh_extra_args": "-o StrictHostKeyChecking=no -o LogLevel=FATAL", }, ), ( {}, b"host ansible_connection=docker", { "NAME": "docker", "name": "host", "user": None, }, ), ( {}, b"host ansible_connection=community.docker.docker", { "NAME": "docker", "name": "host", "user": None, }, ), ( {}, b"host ansible_connection=docker ansible_become=yes ansible_become_user=u ansible_user=z ansible_host=container", { # noqa "NAME": "docker", "name": "container", "user": "z", "sudo": True, "sudo_user": "u", }, ), ( {"ssh_config": "/ssh_config", "ssh_identity_file": "/id_ed25519"}, b"host", { "NAME": "ssh", "host.name": "host", "ssh_config": "/ssh_config", "ssh_identity_file": "/id_ed25519", }, ), ], ) def test_ansible_get_host(kwargs, inventory, expected): with tempfile.NamedTemporaryFile() as f: f.write(inventory + b"\n") f.flush() backend = AnsibleRunner(f.name).get_host("host", **kwargs).backend for attr, value in expected.items(): assert operator.attrgetter(attr)(backend) == value @pytest.mark.parametrize( "inventory,expected", [ ( b"host", ( "ssh -o ConnectTimeout=10 -o ControlMaster=auto " "-o ControlPersist=60s host true" ), ), # password passing from inventory: user is required ( b"host ansible_user=user ansible_ssh_pass=password", ( "sshpass -p password ssh -o User=user " "-o ConnectTimeout=10 -o ControlMaster=auto " "-o ControlPersist=60s host true" ), ), # identity_file has highest priority ( b"host ansible_user=user ansible_ssh_pass=password ansible_ssh_private_key_file=some_file", ( # noqa "ssh -o User=user -i some_file " "-o ConnectTimeout=10 -o ControlMaster=auto " "-o ControlPersist=60s host true" ), ), # password without usr won't work ( b"host ansible_ssh_pass=password", ( "ssh -o ConnectTimeout=10 -o ControlMaster=auto " "-o ControlPersist=60s host true" ), ), # avoid interference between our ssh backend and ansible_ssh_extra_args ( b'host ansible_ssh_extra_args="-o ConnectTimeout=5 -o ControlMaster=auto ' b'-o ControlPersist=10s"', ( "ssh -o ConnectTimeout=5 -o ControlMaster=auto -o " "ControlPersist=10s host true" ), ), # escape % ( b'host ansible_ssh_extra_args="-o ControlPath ~/.ssh/ansible/cp/%r@%h-%p"', ( # noqa "ssh -o ControlPath ~/.ssh/ansible/cp/%r@%h-%p -o ConnectTimeout=10 " "-o ControlMaster=auto -o ControlPersist=60s host true" ), ), ], ) def test_ansible_ssh_command(inventory, expected): with tempfile.NamedTemporaryFile() as f: f.write(inventory + b"\n") f.flush() backend = AnsibleRunner(f.name).get_host("host").backend cmd, cmd_args = backend._build_ssh_command("true") command = backend.quote(" ".join(cmd), *cmd_args) assert command == expected def test_ansible_no_host(): with tempfile.NamedTemporaryFile() as f: f.write(b"host\n") f.flush() assert AnsibleRunner(f.name).get_hosts() == ["host"] hosts = testinfra.get_hosts( [None], connection="ansible", ansible_inventory=f.name ) assert [h.backend.get_pytest_id() for h in hosts] == ["ansible://host"] with tempfile.NamedTemporaryFile() as f: # empty or no inventory should not return any hosts except for # localhost nohost = ( "No inventory was parsed (missing file ?), " "only implicit localhost is available" ) with pytest.raises(RuntimeError) as exc: assert AnsibleRunner(f.name).get_hosts() == [] assert str(exc.value) == nohost with pytest.raises(RuntimeError) as exc: assert AnsibleRunner(f.name).get_hosts("local*") == [] assert str(exc.value) == nohost assert AnsibleRunner(f.name).get_hosts("localhost") == ["localhost"] def test_ansible_config(): # test testinfra use ANSIBLE_CONFIG tmp = tempfile.NamedTemporaryFile with tmp(suffix=".cfg") as cfg, tmp() as inventory: cfg.write((b"[defaults]\n" b"inventory=" + inventory.name.encode() + b"\n")) cfg.flush() inventory.write(b"h\n") inventory.flush() old = os.environ.get("ANSIBLE_CONFIG") os.environ["ANSIBLE_CONFIG"] = cfg.name try: assert AnsibleRunner(None).get_hosts("all") == ["h"] finally: if old is not None: os.environ["ANSIBLE_CONFIG"] = old else: del os.environ["ANSIBLE_CONFIG"] @pytest.mark.parametrize( "options,expected_cli,expected_args", [ ({}, "--check", []), ({"become": True}, "--become --check", []), ({"check": False}, "", []), ({"diff": True, "check": False}, "--diff", []), ({"one_line": True, "check": False}, "--one-line", []), ( {"become_method": "sudo", "check": False}, "--become-method %s", ["sudo"], ), ( {"become_user": "root", "check": False}, "--become-user %s", ["root"], ), ({"user": "root", "check": False}, "--user %s", ["root"]), ( {"extra_vars": {"target": "production", "foo": 42}, "check": False}, "--extra-vars %s", ['{"target": "production", "foo": 42}'], ), ({"verbose": 0, "check": False}, "", []), ({"verbose": 1, "check": False}, "-v", []), ({"verbose": 2, "check": False}, "-vv", []), ({"verbose": 3, "check": False}, "-vvv", []), ({"verbose": 4, "check": False}, "-vvvv", []), ], ) def test_ansible_options(options, expected_cli, expected_args): runner = AnsibleRunner() cli, args = runner.options_to_cli(options) assert cli == expected_cli assert args == expected_args def test_ansible_unknown_option(): runner = AnsibleRunner() with pytest.raises(KeyError, match="^'unknown'$"): runner.options_to_cli({"unknown": True}) def test_backend_importables(): # just check that all declared backend are importable and NAME is set # correctly for connection_type in testinfra.backend.BACKENDS: obj = testinfra.backend.get_backend_class(connection_type) assert obj.get_connection_type() == connection_type @pytest.mark.testinfra_hosts("docker://rockylinux9", "ssh://rockylinux9") def test_docker_encoding(host): encoding = host.check_output( "python3 -c 'import locale;print(locale.getpreferredencoding())'" ) assert encoding == "UTF-8" string = "ťēꞩƫìṇḟřặ ṧꝕèȃǩ ửƫᵮ8" assert host.check_output("echo %s | tee /tmp/s.txt", string) == string assert host.file("/tmp/s.txt").content_string.strip() == string @pytest.mark.parametrize( "hostspec,expected", [ ("u:P@h:p", HostSpec("h", "p", "u", "P")), ("u@h:p", HostSpec("h", "p", "u", None)), ("u:P@h", HostSpec("h", None, "u", "P")), ("u@h", HostSpec("h", None, "u", None)), ("h", HostSpec("h", None, None, None)), ("pr%C3%A9nom@h", HostSpec("h", None, "prénom", None)), ("pr%C3%A9nom:p%40ss%3Aw0rd@h", HostSpec("h", None, "prénom", "p@ss:w0rd")), # ipv6 matching ("[2001:db8:a0b:12f0::1]", HostSpec("2001:db8:a0b:12f0::1", None, None, None)), ( "user:password@[2001:db8:a0b:12f0::1]", HostSpec("2001:db8:a0b:12f0::1", None, "user", "password"), ), ( "user:password@[2001:4800:7819:103:be76:4eff:fe04:9229]:22", HostSpec( "2001:4800:7819:103:be76:4eff:fe04:9229", "22", "user", "password" ), ), ], ) def test_parse_hostspec(hostspec, expected): assert BaseBackend.parse_hostspec(hostspec) == expected @pytest.mark.parametrize( "hostspec,pod,container,namespace,kubeconfig,context", [ ("kubectl://pod", "pod", None, None, None, None), ("kubectl://pod?namespace=n", "pod", None, "n", None, None), ("kubectl://pod?container=c&namespace=n", "pod", "c", "n", None, None), ("kubectl://pod?namespace=n&kubeconfig=k", "pod", None, "n", "k", None), ("kubectl://pod?context=ctx&container=c", "pod", "c", None, None, "ctx"), ], ) def test_kubectl_hostspec( hostspec, pod, container, namespace, kubeconfig, context, ): backend = testinfra.get_host(hostspec).backend assert backend.name == pod assert backend.container == container assert backend.namespace == namespace assert backend.kubeconfig == kubeconfig assert backend.context == context @pytest.mark.parametrize( "hostspec,pod,container,namespace,kubeconfig", [ ("openshift://pod", "pod", None, None, None), ("openshift://pod?namespace=n", "pod", None, "n", None), ("openshift://pod?container=c&namespace=n", "pod", "c", "n", None), ("openshift://pod?namespace=n&kubeconfig=k", "pod", None, "n", "k"), ], ) def test_openshift_hostspec(hostspec, pod, container, namespace, kubeconfig): backend = testinfra.get_host(hostspec).backend assert backend.name == pod assert backend.container == container assert backend.namespace == namespace assert backend.kubeconfig == kubeconfig @pytest.mark.parametrize( "arg_string,expected", [ ("C:\\Users\\vagrant\\This Dir\\salt", '"C:\\Users\\vagrant\\This Dir\\salt"'), ( "C:\\Users\\vagrant\\AppData\\Local\\Temp\\kitchen\\etc\\salt", '"C:\\Users\\vagrant\\AppData\\Local\\Temp\\kitchen\\etc\\salt"', ), ], ) def test_winrm_quote(arg_string, expected): assert _quote(arg_string) == expected @pytest.mark.parametrize( "hostspec,expected", [ ( "ssh://h", "ssh -o ConnectTimeout=10 -o ControlMaster=auto " "-o ControlPersist=60s h true", ), ( "ssh://h?timeout=1", "ssh -o ConnectTimeout=1 -o ControlMaster=auto " "-o ControlPersist=60s h true", ), ( "ssh://u@h:2222", "ssh -o User=u -o Port=2222 -o ConnectTimeout=10 " "-o ControlMaster=auto -o ControlPersist=60s h true", ), ( "ssh://h:2222?ssh_config=/f", "ssh -F /f -o Port=2222 -o ConnectTimeout=10 " "-o ControlMaster=auto -o ControlPersist=60s h true", ), ( "ssh://u@h?ssh_identity_file=/id", "ssh -o User=u -i /id -o ConnectTimeout=10 " "-o ControlMaster=auto -o ControlPersist=60s h true", ), ( "ssh://h?controlpersist=1", "ssh -o ConnectTimeout=10 " "-o ControlMaster=auto -o ControlPersist=1s h true", ), ("ssh://h?controlpersist=0", "ssh -o ConnectTimeout=10 h true"), ], ) def test_ssh_hostspec(hostspec, expected): backend = testinfra.get_host(hostspec).backend cmd, cmd_args = backend._build_ssh_command("true") command = backend.quote(" ".join(cmd), *cmd_args) assert command == expected def test_get_hosts(): # Hosts returned by get_host must be deduplicated (by name & kwargs) and in # same order as asked hosts = testinfra.backend.get_backends( [ "ssh://foo", "ssh://a", "ssh://a", "ssh://a?timeout=1", ] ) assert [(h.host.name, h.timeout) for h in hosts] == [ ("foo", 10), ("a", 10), ("a", 1), ] pytest-testinfra-10.1.0/test/test_invocation.py000066400000000000000000000036531456331477400217040ustar00rootroot00000000000000# Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. pytest_plugins = ["pytester"] def test_nagios_notest(testdir, request): params = ["--nagios", "-q", "--tb=no"] if not request.config.pluginmanager.hasplugin("pytest11.testinfra"): params.extend(["-p", "testinfra.plugin"]) result = testdir.runpytest(*params) assert result.ret == 0 lines = result.stdout.str().splitlines() assert lines[0].startswith("TESTINFRA OK - 0 passed, 0 failed, 0 skipped") def test_nagios_ok(testdir, request): testdir.makepyfile("def test_ok(): pass") params = ["--nagios", "-q", "--tb=no"] if not request.config.pluginmanager.hasplugin("pytest11.testinfra"): params.extend(["-p", "testinfra.plugin"]) result = testdir.runpytest(*params) assert result.ret == 0 lines = result.stdout.str().splitlines() assert lines[0].startswith("TESTINFRA OK - 1 passed, 0 failed, 0 skipped") assert lines[1][0] == "." def test_nagios_fail(testdir, request): testdir.makepyfile("def test_ok(): pass\ndef test_fail(): assert False") params = ["--nagios", "-q", "--tb=no"] if not request.config.pluginmanager.hasplugin("pytest11.testinfra"): params.extend(["-p", "testinfra.plugin"]) result = testdir.runpytest(*params) assert result.ret == 2 lines = result.stdout.str().splitlines() assert lines[0].startswith("TESTINFRA CRITICAL - 1 passed, 1 failed, 0 skipped") assert lines[1][:2] == ".F" pytest-testinfra-10.1.0/test/test_modules.py000066400000000000000000000474371456331477400212130ustar00rootroot00000000000000# Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import crypt import datetime import os import re import time from ipaddress import IPv4Address, IPv6Address, ip_address import pytest from testinfra.modules.socket import parse_socketspec all_images = pytest.mark.testinfra_hosts( *[ "docker://{}".format(image) for image in ( "rockylinux9", "debian_bookworm", ) ] ) @all_images def test_package(host, docker_image): assert not host.package("zsh").is_installed ssh = host.package("openssh-server") version = { "rockylinux9": "8.", "debian_bookworm": "1:9.2", }[docker_image] assert ssh.is_installed assert ssh.version.startswith(version) release = { "rockylinux9": ".el9", "debian_bookworm": None, }[docker_image] if release is None: with pytest.raises(NotImplementedError): ssh.release else: assert release in ssh.release def test_held_package(host): python = host.package("python3") assert python.is_installed assert python.version.startswith("3.11.") @pytest.mark.testinfra_hosts("docker://rockylinux9") def test_non_default_package_tool(host): # Make non default pkg tool binary present host.run("install -m a+rx /bin/true /usr/bin/dpkg-query") assert host.package("openssh").is_installed @pytest.mark.destructive def test_uninstalled_package_version(host): with pytest.raises(AssertionError) as excinfo: host.package("zsh").version assert ( "The package zsh is not installed, dpkg-query output: unknown ok not-installed" in str(excinfo.value) ) assert host.package("sudo").is_installed host.check_output("apt-get -y remove sudo") assert not host.package("sudo").is_installed with pytest.raises(AssertionError) as excinfo: host.package("sudo").version assert ( "The package sudo is not installed, dpkg-query output: " "deinstall ok config-files 1.9." ) in str(excinfo.value) @all_images def test_systeminfo(host, docker_image): assert host.system_info.type == "linux" release, distribution, codename, arch = { "rockylinux9": (r"^9.\d+$", "rocky", None, "x86_64"), "debian_bookworm": (r"^12", "debian", "bookworm", "x86_64"), }[docker_image] assert host.system_info.distribution == distribution assert host.system_info.codename == codename assert re.match(release, host.system_info.release) @all_images def test_ssh_service(host, docker_image): if docker_image == "rockylinux9": name = "sshd" else: name = "ssh" ssh = host.service(name) # wait at max 10 seconds for ssh is running for _ in range(10): if ssh.is_running: break time.sleep(1) else: raise AssertionError("ssh is not running") assert ssh.is_enabled def test_service_systemd_mask(host): ssh = host.service("ssh") assert not ssh.is_masked host.run("systemctl mask ssh") assert ssh.is_masked host.run("systemctl unmask ssh") assert not ssh.is_masked def test_salt(host): ssh_version = host.salt("pkg.version", "openssh-server", local=True) assert ssh_version.startswith("1:9.2") def test_puppet_resource(host): resource = host.puppet_resource("package", "openssh-server") assert resource["openssh-server"]["ensure"].startswith("1:9.2") def test_facter(host): assert host.facter()["os"]["distro"]["codename"] == "bookworm" assert host.facter("virtual") in ( {"virtual": "docker"}, {"virtual": "hyperv"}, # github action uses hyperv {"virtual": "physical"}, # I've this on my machine... ) def test_sysctl(host): assert host.sysctl("kernel.hostname") == host.check_output("hostname") assert isinstance(host.sysctl("kernel.panic"), int) def test_parse_socketspec(): assert parse_socketspec("tcp://22") == ("tcp", None, 22) assert parse_socketspec("tcp://:::22") == ("tcp", "::", 22) assert parse_socketspec("udp://0.0.0.0:22") == ("udp", "0.0.0.0", 22) assert parse_socketspec("unix://can:be.any/thing:22") == ( "unix", "can:be.any/thing:22", None, ) def test_socket(host): listening = host.socket.get_listening_sockets() for spec in ( "tcp://0.0.0.0:22", "tcp://:::22", "unix:///run/systemd/private", ): assert spec in listening for spec in ( "tcp://22", "tcp://0.0.0.0:22", "tcp://127.0.0.1:22", "tcp://:::22", "tcp://::1:22", "unix:///run/systemd/private", ): socket = host.socket(spec) assert socket.is_listening assert not host.socket("tcp://4242").is_listening if not host.backend.get_connection_type() == "docker": # FIXME for spec in ( "tcp://22", "tcp://0.0.0.0:22", ): assert len(host.socket(spec).clients) >= 1 @all_images def test_process(host, docker_image): init = host.process.get(pid=1) assert init.ppid == 0 assert init.euid == 0 assert init.user == "root" args, comm = { "rockylinux9": ("/usr/sbin/init", "systemd"), "debian_bookworm": ("/sbin/init", "systemd"), }[docker_image] assert init.args == args assert init.comm == comm def test_user(host): user = host.user("sshd") assert user.exists assert user.name == "sshd" assert user.uid == 100 assert user.gid == 65534 assert user.group == "nogroup" assert user.gids == [65534] assert user.groups == ["nogroup"] assert user.shell == "/usr/sbin/nologin" assert user.home == "/run/sshd" assert user.password == "!" def test_user_password_days(host): assert host.user("root").password_max_days == 99999 assert host.user("root").password_min_days == 0 assert host.user("user").password_max_days == 90 assert host.user("user").password_min_days == 7 def test_user_user(host): user = host.user("user") assert user.exists assert user.gecos == "gecos.comment" def test_user_expiration_date(host): assert host.user("root").expiration_date is None assert host.user("user").expiration_date == (datetime.datetime(2024, 10, 4, 0, 0)) def test_nonexistent_user(host): assert not host.user("zzzzzzzzzz").exists def test_current_user(host): assert host.user().name == "root" pw = host.user().password assert crypt.crypt("foo", pw) == pw def test_group(host): assert host.group("root").exists assert host.group("root").gid == 0 def test_empty_command_output(host): assert host.check_output("printf ''") == "" def test_local_command(host): assert host.get_host("local://").check_output("true") == "" def test_file(host): host.check_output("mkdir -p /d && printf foo > /d/f && chmod 600 /d/f") host.check_output('touch "/d/f\nl"') host.check_output('touch "/d/f s"') d = host.file("/d") assert d.is_directory assert not d.is_file assert d.listdir() == ["f", "f?l", "f s"] f = host.file("/d/f") assert f.exists assert f.is_file assert f.content == b"foo" assert f.content_string == "foo" assert f.user == "root" assert f.uid == 0 assert f.gid == 0 assert f.group == "root" assert f.mode == 0o600 assert f.contains("fo") assert not f.is_directory assert not f.is_symlink assert not f.is_pipe assert f.linked_to == "/d/f" assert f.size == 3 assert f.md5sum == "acbd18db4cc2f85cedef654fccc4a4d8" assert f.sha256sum == ( "2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae" ) host.check_output("ln -fsn /d/f /d/l") link = host.file("/d/l") assert link.is_symlink assert link.is_file assert link.linked_to == "/d/f" assert link.linked_to == f assert f == host.file("/d/f") assert not d == f host.check_output("ln /d/f /d/h") hardlink = host.file("/d/h") assert hardlink.is_file assert not hardlink.is_symlink assert isinstance(hardlink.inode, int) assert isinstance(f.inode, int) assert hardlink.inode == f.inode assert f == host.file("/d/f") assert not d == f host.check_output("rm -f /d/p && mkfifo /d/p") assert host.file("/d/p").is_pipe host.check_output("chmod 700 /d/f") assert f.is_executable assert f.mode == 0o700 def test_ansible_unavailable(host): expected = "Ansible module is only available with " "ansible connection backend" with pytest.raises(RuntimeError) as excinfo: host.ansible("setup") assert expected in str(excinfo.value) with pytest.raises(RuntimeError) as excinfo: host.ansible.get_variables() assert expected in str(excinfo.value) @pytest.mark.testinfra_hosts("ansible://debian_bookworm") def test_ansible_module(host): setup = host.ansible("setup")["ansible_facts"] assert setup["ansible_lsb"]["codename"] == "bookworm" passwd = host.ansible("file", "path=/etc/passwd state=file") assert passwd["changed"] is False assert passwd["gid"] == 0 assert passwd["group"] == "root" assert passwd["mode"] == "0644" assert passwd["owner"] == "root" assert isinstance(passwd["size"], int) assert passwd["path"] == "/etc/passwd" # seems to vary with different docker fs backend assert passwd["state"] in ("file", "hard") assert passwd["uid"] == 0 variables = host.ansible.get_variables() assert variables["myvar"] == "foo" assert variables["myhostvar"] == "bar" assert variables["mygroupvar"] == "qux" assert variables["inventory_hostname"] == "debian_bookworm" assert variables["group_names"] == ["all", "testgroup"] assert variables["groups"] == { "all": ["debian_bookworm"], "testgroup": ["debian_bookworm"], } with pytest.raises(host.ansible.AnsibleException) as excinfo: host.ansible("command", "zzz") assert excinfo.value.result["msg"] == "Skipped. You might want to try check=False" try: host.ansible("command", "zzz", check=False) except host.ansible.AnsibleException as exc: assert exc.result["rc"] == 2 # notez que the debian bookworm container is set to LANG=fr_FR assert exc.result["msg"] == ("[Errno 2] Aucun fichier ou dossier " "de ce type") result = host.ansible("command", "echo foo", check=False) assert result["stdout"] == "foo" @pytest.mark.testinfra_hosts( "ansible://debian_bookworm", "ansible://user@debian_bookworm" ) def test_ansible_module_become(host): user_name = host.user().name assert host.ansible("shell", "echo $USER", check=False)["stdout"] == user_name assert ( host.ansible("shell", "echo $USER", check=False, become=True)["stdout"] == "root" ) with host.sudo(): assert host.user().name == "root" assert host.ansible("shell", "echo $USER", check=False)["stdout"] == user_name assert ( host.ansible("shell", "echo $USER", check=False, become=True)["stdout"] == "root" ) @pytest.mark.testinfra_hosts("ansible://debian_bookworm") def test_ansible_module_options(host): assert ( host.ansible( "command", "id --user --name", check=False, become=True, become_user="nobody", )["stdout"] == "nobody" ) @pytest.mark.destructive @pytest.mark.parametrize( "supervisorctl_path,supervisorctl_conf", [ ("supervisorctl", None), ("/usr/bin/supervisorctl", "/etc/supervisor/supervisord.conf"), ], ) def test_supervisor(host, supervisorctl_path, supervisorctl_conf): # Wait supervisord is running for _ in range(20): if host.service("supervisor").is_running: break time.sleep(0.5) else: raise RuntimeError("No running supervisor") for _ in range(20): service = host.supervisor( "tail", supervisorctl_path=supervisorctl_path, supervisorctl_conf=supervisorctl_conf, ) if service.status == "RUNNING": break else: assert service.status == "STARTING" time.sleep(0.5) else: raise RuntimeError("No running tail in supervisor") assert service.is_running proc = host.process.get(pid=service.pid) assert proc.comm == "tail" services = host.supervisor.get_services(supervisorctl_path, supervisorctl_conf) assert len(services) == 1 assert services[0].name == "tail" assert services[0].is_running assert services[0].pid == service.pid # Checking if conf is propagated assert services[0].supervisorctl_path == supervisorctl_path assert services[0].supervisorctl_conf == supervisorctl_conf host.check_output("supervisorctl stop tail") service = host.supervisor( "tail", supervisorctl_path=supervisorctl_path, supervisorctl_conf=supervisorctl_conf, ) assert not service.is_running assert service.status == "STOPPED" assert service.pid is None host.run("service supervisor stop") assert not host.service("supervisor").is_running with pytest.raises(RuntimeError) as excinfo: host.supervisor( "tail", supervisorctl_path=supervisorctl_path, supervisorctl_conf=supervisorctl_conf, ).is_running assert "Is supervisor running" in str(excinfo.value) def test_mountpoint(host): root_mount = host.mount_point("/") assert root_mount.exists assert isinstance(root_mount.options, list) assert "rw" in root_mount.options assert root_mount.filesystem fake_mount = host.mount_point("/fake/mount") assert not fake_mount.exists mountpoints = host.mount_point.get_mountpoints() assert mountpoints assert all(isinstance(m, host.mount_point) for m in mountpoints) assert len([m for m in mountpoints if m.path == "/"]) == 1 def test_sudo_from_root(host): assert host.user().name == "root" with host.sudo("user"): assert host.user().name == "user" assert host.user().name == "root" def test_sudo_fail_from_root(host): assert host.user().name == "root" with pytest.raises(AssertionError) as exc: with host.sudo("unprivileged"): assert host.user().name == "unprivileged" host.check_output("ls /root/invalid") assert str(exc.value).startswith("Unexpected exit code") with host.sudo(): assert host.user().name == "root" @pytest.mark.testinfra_hosts("docker://user@debian_bookworm") def test_sudo_to_root(host): assert host.user().name == "user" with host.sudo(): assert host.user().name == "root" # Test nested sudo with host.sudo("www-data"): assert host.user().name == "www-data" assert host.user().name == "user" def test_command_execution(host): assert host.run("false").failed assert host.run("true").succeeded def test_pip(host): # get_packages assert host.pip.get_packages()["pip"]["version"].startswith("23.") pkg = host.pip.get_packages(pip_path="/v/bin/pip")["requests"] assert pkg["version"] == "2.30.0" # outdated outdated = host.pip.get_outdated_packages(pip_path="/v/bin/pip")["requests"] assert outdated["current"] == pkg["version"] # check assert host.pip.check().succeeded # is_installed assert host.pip("pip").is_installed assert not host.pip("does_not_exist").is_installed pkg = host.pip("requests", pip_path="/v/bin/pip") assert pkg.is_installed # version assert host.pip("pip").version.startswith("23.") assert pkg.version == "2.30.0" assert host.pip("does_not_exist").version == "" def test_environment_home(host): assert host.environment().get("HOME") == "/root" @pytest.mark.skipif( "WSL_DISTRO_NAME" in os.environ, reason="Skip on WSL (Windows Subsystem for Linux)" ) def test_iptables(host): cmd = host.run("systemctl start netfilter-persistent") assert cmd.exit_status == 0, f"{cmd.stdout}\n{cmd.stderr}" ssh_rule_str = "-A INPUT -p tcp -m state --state NEW -m tcp --dport 22 -j ACCEPT" vip_redirect_rule_str = "-A PREROUTING -d 192.168.0.1/32 -j REDIRECT" rules = host.iptables.rules() input_rules = host.iptables.rules("filter", "INPUT") nat_rules = host.iptables.rules("nat") nat_prerouting_rules = host.iptables.rules("nat", "PREROUTING") assert ssh_rule_str in rules assert ssh_rule_str in input_rules assert vip_redirect_rule_str in nat_rules assert vip_redirect_rule_str in nat_prerouting_rules assert host.iptables._has_w_argument is True def test_ip6tables(host): # test ip6tables call works; ipv6 setup is a whole huge thing, but # ensure we at least see the headings try: v6_rules = host.iptables.rules(version=6) except AssertionError as exc_info: if ( "Perhaps ip6tables or your kernel needs to " "be upgraded" in exc_info.args[0] ): pytest.skip( f"IPV6 does not seem to be enabled on the docker host" f"\n{exc_info}" ) else: raise else: assert "-P INPUT ACCEPT" in v6_rules assert "-P FORWARD ACCEPT" in v6_rules assert "-P OUTPUT ACCEPT" in v6_rules v6_filter_rules = host.iptables.rules("filter", "INPUT", version=6) assert "-P INPUT ACCEPT" in v6_filter_rules @all_images def test_addr(host): non_resolvable = host.addr("some_non_resolvable_host") assert not non_resolvable.is_resolvable assert not non_resolvable.is_reachable assert not non_resolvable.port(80).is_reachable # Some arbitrary internal IP, hopefully non reachable # IP addresses are always resolvable no matter what non_reachable_ip = host.addr("10.42.13.73") assert non_reachable_ip.is_resolvable assert non_reachable_ip.ipv4_addresses == ["10.42.13.73"] assert not non_reachable_ip.is_reachable assert not non_reachable_ip.port(80).is_reachable google_dns = host.addr("8.8.8.8") assert google_dns.is_resolvable assert google_dns.ipv4_addresses == ["8.8.8.8"] assert google_dns.port(53).is_reachable assert not google_dns.port(666).is_reachable google_addr = host.addr("google.com") assert google_addr.is_resolvable assert google_addr.port(443).is_reachable assert not google_addr.port(666).is_reachable for ip in google_addr.ipv4_addresses: assert isinstance(ip_address(ip), IPv4Address) for ip in google_addr.ipv6_addresses: assert isinstance(ip_address(ip), IPv6Address) for ip in google_addr.ip_addresses: assert isinstance(ip_address(ip), (IPv4Address, IPv6Address)) @pytest.mark.testinfra_hosts("ansible://debian_bookworm") def test_addr_namespace(host): namespace_lookup = host.addr("localhost", "ns1") assert not namespace_lookup.namespace_exists @pytest.mark.parametrize( "family", ["inet", None], ) def test_interface(host, family): # exist assert host.interface("eth0", family=family).exists assert not host.interface("does_not_exist", family=family).exists # addresses addresses = host.interface.default(family).addresses assert len(addresses) > 0 for add in addresses: try: ip_address(add) except ValueError: pytest.fail(f"{add} is not a valid IP address") # names and default assert "eth0" in host.interface.names() default_itf = host.interface.default(family) assert default_itf.name == "eth0" assert default_itf.exists pytest-testinfra-10.1.0/testinfra/000077500000000000000000000000001456331477400171335ustar00rootroot00000000000000pytest-testinfra-10.1.0/testinfra/__init__.py000066400000000000000000000011651456331477400212470ustar00rootroot00000000000000# Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from testinfra.host import get_host, get_hosts __all__ = ["get_host", "get_hosts"] pytest-testinfra-10.1.0/testinfra/backend/000077500000000000000000000000001456331477400205225ustar00rootroot00000000000000pytest-testinfra-10.1.0/testinfra/backend/__init__.py000066400000000000000000000100701456331477400226310ustar00rootroot00000000000000# Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import importlib import os import urllib.parse from typing import TYPE_CHECKING, Any, Iterable if TYPE_CHECKING: import testinfra.backend.base BACKENDS = { "local": "testinfra.backend.local.LocalBackend", "ssh": "testinfra.backend.ssh.SshBackend", "safe-ssh": "testinfra.backend.ssh.SafeSshBackend", "paramiko": "testinfra.backend.paramiko.ParamikoBackend", "salt": "testinfra.backend.salt.SaltBackend", "docker": "testinfra.backend.docker.DockerBackend", "podman": "testinfra.backend.podman.PodmanBackend", "ansible": "testinfra.backend.ansible.AnsibleBackend", "kubectl": "testinfra.backend.kubectl.KubectlBackend", "winrm": "testinfra.backend.winrm.WinRMBackend", "lxc": "testinfra.backend.lxc.LxcBackend", "openshift": "testinfra.backend.openshift.OpenShiftBackend", "chroot": "testinfra.backend.chroot.ChrootBackend", } def get_backend_class(connection: str) -> type["testinfra.backend.base.BaseBackend"]: try: classpath = BACKENDS[connection] except KeyError: raise RuntimeError("Unknown connection type '{}'".format(connection)) module, name = classpath.rsplit(".", 1) return getattr(importlib.import_module(module), name) # type: ignore[no-any-return] def parse_hostspec(hostspec: str) -> tuple[str, dict[str, Any]]: kw: dict[str, Any] = {} if hostspec is not None and "://" in hostspec: url = urllib.parse.urlparse(hostspec) kw["connection"] = url.scheme host = url.netloc query = urllib.parse.parse_qs(url.query) for key in ("sudo", "ssl", "no_ssl", "no_verify_ssl", "force_ansible"): if query.get(key, ["false"])[0].lower() == "true": kw[key] = True for key in ( "sudo_user", "namespace", "container", "read_timeout_sec", "operation_timeout_sec", "timeout", "controlpersist", "kubeconfig", "context", ): if key in query: kw[key] = query[key][0] for key in ( "ssh_config", "ansible_inventory", "ssh_identity_file", ): if key in query: kw[key] = os.path.expanduser(query[key][0]) else: host = hostspec return host, kw def get_backend(hostspec: str, **kwargs: Any) -> "testinfra.backend.base.BaseBackend": host, kw = parse_hostspec(hostspec) for k, v in kwargs.items(): kw.setdefault(k, v) kw.setdefault("connection", "paramiko") klass = get_backend_class(kw["connection"]) if kw["connection"] == "local": return klass(**kw) return klass(host, **kw) def get_backends( hosts: Iterable[str], **kwargs: Any ) -> list["testinfra.backend.base.BaseBackend"]: backends = {} for hostspec in hosts: host, kw = parse_hostspec(hostspec) for k, v in kwargs.items(): kw.setdefault(k, v) connection = kw.get("connection") if host is None and connection is None: connection = "local" elif host is not None and connection is None: connection = "paramiko" klass = get_backend_class(connection) for name in klass.get_hosts(host, **kw): key = (name, frozenset(kw.items())) if key in backends: continue if connection == "local": backend = klass(**kw) else: backend = klass(name, **kw) backends[key] = backend return list(backends.values()) pytest-testinfra-10.1.0/testinfra/backend/ansible.py000066400000000000000000000057101456331477400225140ustar00rootroot00000000000000# Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import logging import pprint from typing import Any, Optional from testinfra.backend import base from testinfra.utils.ansible_runner import AnsibleRunner logger = logging.getLogger("testinfra") class AnsibleBackend(base.BaseBackend): NAME = "ansible" HAS_RUN_ANSIBLE = True def __init__( self, host: str, ansible_inventory: Optional[str] = None, ssh_config: Optional[str] = None, ssh_identity_file: Optional[str] = None, force_ansible: bool = False, *args: Any, **kwargs: Any, ): self.host = host self.ansible_inventory = ansible_inventory self.ssh_config = ssh_config self.ssh_identity_file = ssh_identity_file self.force_ansible = force_ansible super().__init__(host, *args, **kwargs) @property def ansible_runner(self) -> AnsibleRunner: return AnsibleRunner.get_runner(self.ansible_inventory) def run(self, command: str, *args: str, **kwargs: Any) -> base.CommandResult: command = self.get_command(command, *args) if not self.force_ansible: host = self.ansible_runner.get_host( self.host, ssh_config=self.ssh_config, ssh_identity_file=self.ssh_identity_file, ) if host is not None: return host.run(command) out = self.run_ansible("shell", module_args=command, check=False) return self.result( out["rc"], self.encode(command), out["stdout"], out["stderr"], ) def run_ansible( self, module_name: str, module_args: Optional[str] = None, **kwargs: Any ) -> Any: def get_encoding() -> str: return self.encoding result = self.ansible_runner.run_module( self.host, module_name, module_args, get_encoding=get_encoding, **kwargs ) logger.info( "RUN Ansible(%s, %s, %s): %s", repr(module_name), repr(module_args), repr(kwargs), pprint.pformat(result), ) return result def get_variables(self) -> dict[str, Any]: return self.ansible_runner.get_variables(self.host) @classmethod def get_hosts(cls, host: str, **kwargs: Any) -> list[str]: inventory = kwargs.get("ansible_inventory") return AnsibleRunner.get_runner(inventory).get_hosts(host or "all") pytest-testinfra-10.1.0/testinfra/backend/base.py000066400000000000000000000227011456331477400220100ustar00rootroot00000000000000# Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import abc import dataclasses import locale import logging import shlex import subprocess import urllib.parse from typing import TYPE_CHECKING, Any, Optional, Union if TYPE_CHECKING: import testinfra.host logger = logging.getLogger("testinfra") @dataclasses.dataclass class HostSpec: name: str port: Optional[str] user: Optional[str] password: Optional[str] @dataclasses.dataclass class CommandResult: """Object that encapsulates all returned details of the command execution. Example: >>> cmd = host.run("ls -l /etc/passwd") >>> cmd.rc 0 >>> cmd.stdout '-rw-r--r-- 1 root root 1790 Feb 11 00:28 /etc/passwd\\n' >>> cmd.stderr '' >>> cmd.succeeded True >>> cmd.failed False """ backend: "BaseBackend" exit_status: int command: bytes _stdout: Union[str, bytes] _stderr: Union[str, bytes] @property def succeeded(self) -> bool: """Returns whether the command was successful >>> host.run("true").succeeded True """ return self.exit_status == 0 @property def failed(self) -> bool: """Returns whether the command failed >>> host.run("false").failed True """ return self.exit_status != 0 @property def rc(self) -> int: """Gets the returncode of a command >>> host.run("true").rc 0 """ return self.exit_status @property def stdout(self) -> str: """Gets standard output (stdout) stream of an executed command >>> host.run("mkdir -v new_directory").stdout mkdir: created directory 'new_directory' """ if isinstance(self._stdout, bytes): return self.backend.decode(self._stdout) return self._stdout @property def stderr(self) -> str: """Gets standard error (stderr) stream of an executed command >>> host.run("mkdir new_directory").stderr mkdir: cannot create directory 'new_directory': File exists """ if isinstance(self._stderr, bytes): return self.backend.decode(self._stderr) return self._stderr @property def stdout_bytes(self) -> bytes: """Gets standard output (stdout) stream of an executed command as bytes >>> host.run("mkdir -v new_directory").stdout_bytes b"mkdir: created directory 'new_directory'" """ if isinstance(self._stdout, str): return self.backend.encode(self._stdout) return self._stdout @property def stderr_bytes(self) -> bytes: """Gets standard error (stderr) stream of an executed command as bytes >>> host.run("mkdir new_directory").stderr_bytes b"mkdir: cannot create directory 'new_directory': File exists" """ if isinstance(self._stderr, str): return self.backend.encode(self._stderr) return self._stderr class BaseBackend(metaclass=abc.ABCMeta): """Represent the connection to the remote or local system""" HAS_RUN_SALT = False HAS_RUN_ANSIBLE = False NAME: str def __init__( self, hostname: str, sudo: bool = False, sudo_user: Optional[str] = None, *args: Any, **kwargs: Any, ): self._encoding: Optional[str] = None self._host: Optional["testinfra.host.Host"] = None self.hostname = hostname self.sudo = sudo self.sudo_user = sudo_user super().__init__() def set_host(self, host: "testinfra.host.Host") -> None: self._host = host @classmethod def get_connection_type(cls) -> str: """Return the connection backend used as string. Can be local, paramiko, ssh, docker, salt or ansible """ return cls.NAME def get_hostname(self) -> str: """Return the hostname (for testinfra) of the remote or local system Can be useful for multi-hosts tests: Example: :: import requests def test(TestinfraBackend): host = TestinfraBackend.get_hostname() response = requests.get("http://" + host) assert response.status_code == 200 :: $ testinfra --hosts=server1,server2 test.py test.py::test[paramiko://server1] PASSED test.py::test[paramiko://server2] PASSED """ return self.hostname def get_pytest_id(self) -> str: return self.get_connection_type() + "://" + self.get_hostname() @classmethod def get_hosts(cls, host: str, **kwargs: Any) -> list[str]: if host is None: raise RuntimeError( "One or more hosts is required with the {} backend".format( cls.get_connection_type() ) ) return [host] @staticmethod def quote(command: str, *args: str) -> str: if args: return command % tuple(shlex.quote(a) for a in args) # noqa: S001 return command def get_sudo_command(self, command: str, sudo_user: Optional[str]) -> str: if sudo_user is None: return self.quote("sudo /bin/sh -c %s", command) return self.quote("sudo -u %s /bin/sh -c %s", sudo_user, command) def get_command(self, command: str, *args: str) -> str: command = self.quote(command, *args) if self.sudo: command = self.get_sudo_command(command, self.sudo_user) return command def run(self, command: str, *args: str, **kwargs: Any) -> CommandResult: raise NotImplementedError def run_local(self, command: str, *args: str) -> CommandResult: command = self.quote(command, *args) cmd = self.encode(command) p = subprocess.Popen( cmd, shell=True, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) stdout, stderr = p.communicate() result = self.result(p.returncode, cmd, stdout, stderr) return result @staticmethod def parse_hostspec(hostspec: str) -> HostSpec: name = hostspec port = None user = None password = None if "@" in name: user, name = name.split("@", 1) if ":" in user: user, password = user.split(":", 1) # A literal IPv6 address might be like # [fe80:0::a:b:c]:80 # thus, below in words; if this starts with a '[' assume it # encloses an ipv6 address with a closing ']', with a possible # trailing port after a colon if name.startswith("["): name, port = name.split("]") name = name[1:] if port.startswith(":"): port = port[1:] else: port = None else: if ":" in name: name, port = name.split(":", 1) name = urllib.parse.unquote(name) if user is not None: user = urllib.parse.unquote(user) if password is not None: password = urllib.parse.unquote(password) return HostSpec(name, port, user, password) @staticmethod def parse_containerspec(containerspec: str) -> tuple[str, Optional[str]]: name = containerspec user = None if "@" in name: user, name = name.split("@", 1) return name, user def get_encoding(self) -> str: encoding = None for python in ("python3", "python"): cmd = self.run( "%s -c 'import locale;print(locale.getpreferredencoding())'", python, encoding=None, ) if cmd.rc == 0: encoding = cmd.stdout_bytes.splitlines()[0].decode("ascii") break # Python is not installed, we hope the encoding to be the same as # local machine... if not encoding: encoding = locale.getpreferredencoding() if encoding == "ANSI_X3.4-1968": # Workaround default encoding ascii without LANG set encoding = "UTF-8" return encoding @property def encoding(self) -> str: if self._encoding is None: self._encoding = self.get_encoding() return self._encoding def decode(self, data: bytes) -> str: try: return data.decode("ascii") except UnicodeDecodeError: return data.decode(self.encoding) def encode(self, data: str) -> bytes: try: return data.encode("ascii") except UnicodeEncodeError: return data.encode(self.encoding) def result( self, rc: int, cmd: bytes, stdout: Union[str, bytes], stderr: Union[str, bytes] ) -> CommandResult: result = CommandResult( backend=self, exit_status=rc, command=cmd, _stdout=stdout, _stderr=stderr, ) logger.debug("RUN %s", result) return result pytest-testinfra-10.1.0/testinfra/backend/chroot.py000066400000000000000000000026311456331477400223740ustar00rootroot00000000000000# Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import os.path from typing import Any from testinfra.backend import base class ChrootBackend(base.BaseBackend): """Run commands in a chroot folder Requires root access or sudo Can be invoked by --hosts=/path/to/chroot/ --connection=chroot --sudo """ NAME = "chroot" def __init__(self, name: str, *args: Any, **kwargs: Any): self.name = name super().__init__(self.name, *args, **kwargs) def run(self, command: str, *args: str, **kwargs: Any) -> base.CommandResult: if not os.path.exists(self.name) and os.path.isdir(self.name): raise RuntimeError( "chroot path {} not found or not a directory".format(self.name) ) cmd = self.get_command(command, *args) out = self.run_local("chroot %s /bin/sh -c %s", self.name, cmd) out.command = self.encode(cmd) return out pytest-testinfra-10.1.0/testinfra/backend/docker.py000066400000000000000000000024101456331477400223400ustar00rootroot00000000000000# Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from typing import Any from testinfra.backend import base class DockerBackend(base.BaseBackend): NAME = "docker" def __init__(self, name: str, *args: Any, **kwargs: Any): self.name, self.user = self.parse_containerspec(name) super().__init__(self.name, *args, **kwargs) def run(self, command: str, *args: str, **kwargs: Any) -> base.CommandResult: cmd = self.get_command(command, *args) if self.user is not None: out = self.run_local( "docker exec -u %s %s /bin/sh -c %s", self.user, self.name, cmd ) else: out = self.run_local("docker exec %s /bin/sh -c %s", self.name, cmd) out.command = self.encode(cmd) return out pytest-testinfra-10.1.0/testinfra/backend/kubectl.py000066400000000000000000000036311456331477400225300ustar00rootroot00000000000000# Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from typing import Any from testinfra.backend import base class KubectlBackend(base.BaseBackend): NAME = "kubectl" def __init__(self, name: str, *args: Any, **kwargs: Any): self.name = name self.container = kwargs.get("container") self.namespace = kwargs.get("namespace") self.kubeconfig = kwargs.get("kubeconfig") self.context = kwargs.get("context") super().__init__(self.name, *args, **kwargs) def run(self, command: str, *args: str, **kwargs: Any) -> base.CommandResult: cmd = self.get_command(command, *args) # `kubectl exec` does not support specifying the user to run as. # See https://github.com/kubernetes/kubernetes/issues/30656 kcmd = "kubectl " kcmd_args = [] if self.kubeconfig is not None: kcmd += '--kubeconfig="%s" ' kcmd_args.append(self.kubeconfig) if self.context is not None: kcmd += '--context="%s" ' kcmd_args.append(self.context) if self.namespace is not None: kcmd += "-n %s " kcmd_args.append(self.namespace) if self.container is not None: kcmd += "-c %s " kcmd_args.append(self.container) kcmd += "exec %s -- /bin/sh -c %s" kcmd_args.extend([self.name, cmd]) out = self.run_local(kcmd, *kcmd_args) return out pytest-testinfra-10.1.0/testinfra/backend/local.py000066400000000000000000000020551456331477400221700ustar00rootroot00000000000000# Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from typing import Any from testinfra.backend import base class LocalBackend(base.BaseBackend): NAME = "local" def __init__(self, *args: Any, **kwargs: Any): super().__init__("local", **kwargs) def get_pytest_id(self) -> str: return "local" @classmethod def get_hosts(cls, host: str, **kwargs: Any) -> list[str]: return [host] def run(self, command: str, *args: str, **kwargs: Any) -> base.CommandResult: return self.run_local(self.get_command(command, *args)) pytest-testinfra-10.1.0/testinfra/backend/lxc.py000066400000000000000000000021311456331477400216570ustar00rootroot00000000000000# Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from typing import Any from testinfra.backend import base class LxcBackend(base.BaseBackend): NAME = "lxc" def __init__(self, name: str, *args: Any, **kwargs: Any): self.name = name super().__init__(self.name, *args, **kwargs) def run(self, command: str, *args: str, **kwargs: Any) -> base.CommandResult: cmd = self.get_command(command, *args) out = self.run_local( "lxc exec %s --mode=non-interactive -- " "/bin/sh -c %s", self.name, cmd ) out.command = self.encode(cmd) return out pytest-testinfra-10.1.0/testinfra/backend/openshift.py000066400000000000000000000033741456331477400231020ustar00rootroot00000000000000# Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from typing import Any from testinfra.backend import base class OpenShiftBackend(base.BaseBackend): NAME = "openshift" def __init__(self, name: str, *args: Any, **kwargs: Any): self.name = name self.container = kwargs.get("container") self.namespace = kwargs.get("namespace") self.kubeconfig = kwargs.get("kubeconfig") super().__init__(self.name, *args, **kwargs) def run(self, command: str, *args: str, **kwargs: Any) -> base.CommandResult: cmd = self.get_command(command, *args) # `oc exec` does not support specifying the user to run as. # See https://github.com/kubernetes/kubernetes/issues/30656 oscmd = "oc " oscmd_args = [] if self.kubeconfig is not None: oscmd += '--kubeconfig="%s" ' oscmd_args.append(self.kubeconfig) if self.namespace is not None: oscmd += "-n %s " oscmd_args.append(self.namespace) if self.container is not None: oscmd += "-c %s " oscmd_args.append(self.container) oscmd += "exec %s -- /bin/sh -c %s" oscmd_args.extend([self.name, cmd]) out = self.run_local(oscmd, *oscmd_args) return out pytest-testinfra-10.1.0/testinfra/backend/paramiko.py000066400000000000000000000132141456331477400227000ustar00rootroot00000000000000# Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import os try: import paramiko except ImportError: raise RuntimeError( ( "You must install paramiko package (pip install paramiko) " "to use the paramiko backend" ) ) import functools from typing import Any, Optional import paramiko.pkey import paramiko.ssh_exception from testinfra.backend import base class IgnorePolicy(paramiko.MissingHostKeyPolicy): """Policy for ignoring missing host key.""" def missing_host_key( self, client: paramiko.SSHClient, hostname: str, key: paramiko.pkey.PKey ) -> None: pass class ParamikoBackend(base.BaseBackend): NAME = "paramiko" def __init__( self, hostspec: str, ssh_config: Optional[str] = None, ssh_identity_file: Optional[str] = None, timeout: int = 10, *args: Any, **kwargs: Any, ): self.host = self.parse_hostspec(hostspec) self.ssh_config = ssh_config self.ssh_identity_file = ssh_identity_file self.get_pty = False self.timeout = int(timeout) super().__init__(self.host.name, *args, **kwargs) def _load_ssh_config( self, client: paramiko.SSHClient, cfg: dict[str, Any], ssh_config: paramiko.SSHConfig, ssh_config_dir: str = "~/.ssh", ) -> None: for key, value in ssh_config.lookup(self.host.name).items(): if key == "hostname": cfg[key] = value elif key == "user": cfg["username"] = value elif key == "port": cfg[key] = int(value) elif key == "identityfile": cfg["key_filename"] = os.path.expanduser(value[0]) elif key == "stricthostkeychecking" and value == "no": client.set_missing_host_key_policy(IgnorePolicy()) elif key == "requesttty": self.get_pty = value in ("yes", "force") elif key == "gssapikeyexchange": cfg["gss_auth"] = value == "yes" elif key == "gssapiauthentication": cfg["gss_kex"] = value == "yes" elif key == "proxycommand": cfg["sock"] = paramiko.ProxyCommand(value) elif key == "include": new_config_path = os.path.join( os.path.expanduser(ssh_config_dir), value ) with open(new_config_path) as f: new_ssh_config = paramiko.SSHConfig() new_ssh_config.parse(f) self._load_ssh_config(client, cfg, new_ssh_config, ssh_config_dir) @functools.cached_property def client(self) -> paramiko.SSHClient: client = paramiko.SSHClient() client.set_missing_host_key_policy(paramiko.WarningPolicy()) cfg = { "hostname": self.host.name, "port": int(self.host.port) if self.host.port else 22, "username": self.host.user, "timeout": self.timeout, "password": self.host.password, } if self.ssh_config: ssh_config_dir = os.path.dirname(self.ssh_config) with open(self.ssh_config) as f: ssh_config = paramiko.SSHConfig() ssh_config.parse(f) self._load_ssh_config(client, cfg, ssh_config, ssh_config_dir) else: # fallback reading ~/.ssh/config default_ssh_config = os.path.join(os.path.expanduser("~"), ".ssh", "config") ssh_config_dir = os.path.dirname(default_ssh_config) try: with open(default_ssh_config) as f: ssh_config = paramiko.SSHConfig() ssh_config.parse(f) except IOError: pass else: self._load_ssh_config(client, cfg, ssh_config, ssh_config_dir) if self.ssh_identity_file: cfg["key_filename"] = self.ssh_identity_file client.connect(**cfg) # type: ignore[arg-type] return client def _exec_command(self, command: bytes) -> tuple[int, bytes, bytes]: transport = self.client.get_transport() assert transport is not None chan = transport.open_session() if self.get_pty: chan.get_pty() chan.exec_command(command) rc = chan.recv_exit_status() stdout = b"".join(chan.makefile("rb")) stderr = b"".join(chan.makefile_stderr("rb")) return rc, stdout, stderr def run(self, command: str, *args: str, **kwargs: Any) -> base.CommandResult: command = self.get_command(command, *args) cmd = self.encode(command) try: rc, stdout, stderr = self._exec_command(cmd) except (paramiko.ssh_exception.SSHException, ConnectionResetError): transport = self.client.get_transport() assert transport is not None if not transport.is_active(): # try to reinit connection (once) del self.client rc, stdout, stderr = self._exec_command(cmd) else: raise return self.result(rc, cmd, stdout, stderr) pytest-testinfra-10.1.0/testinfra/backend/podman.py000066400000000000000000000024101456331477400223470ustar00rootroot00000000000000# Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from typing import Any from testinfra.backend import base class PodmanBackend(base.BaseBackend): NAME = "podman" def __init__(self, name: str, *args: Any, **kwargs: Any): self.name, self.user = self.parse_containerspec(name) super().__init__(self.name, *args, **kwargs) def run(self, command: str, *args: str, **kwargs: Any) -> base.CommandResult: cmd = self.get_command(command, *args) if self.user is not None: out = self.run_local( "podman exec -u %s %s /bin/sh -c %s", self.user, self.name, cmd ) else: out = self.run_local("podman exec %s /bin/sh -c %s", self.name, cmd) out.command = self.encode(cmd) return out pytest-testinfra-10.1.0/testinfra/backend/salt.py000066400000000000000000000047001456331477400220400ustar00rootroot00000000000000# Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. try: import salt.client except ImportError: raise RuntimeError("You must install salt package to use the salt backend") from typing import Any, Optional from testinfra.backend import base class SaltBackend(base.BaseBackend): HAS_RUN_SALT = True NAME = "salt" def __init__(self, host: str, *args: Any, **kwargs: Any): self.host = host self._client: Optional[salt.client.LocalClient] = None super().__init__(self.host, *args, **kwargs) @property def client(self) -> salt.client.LocalClient: if self._client is None: self._client = salt.client.LocalClient() return self._client def run(self, command: str, *args: str, **kwargs: Any) -> base.CommandResult: command = self.get_command(command, *args) out = self.run_salt("cmd.run_all", [command]) return self.result( out["retcode"], self.encode(command), stdout=out["stdout"], stderr=out["stderr"], ) def run_salt(self, func: str, args: Any = None) -> Any: out = self.client.cmd(self.host, func, args or []) if self.host not in out: raise RuntimeError( "Error while running {}({}): {}. " "Minion not connected ?".format(func, args, out) ) return out[self.host] @classmethod def get_hosts(cls, host: str, **kwargs: Any) -> list[str]: if host is None: host = "*" if any(c in host for c in "@*[?"): client = salt.client.LocalClient() if "@" in host: hosts = client.cmd(host, "test.true", tgt_type="compound").keys() else: hosts = client.cmd(host, "test.true").keys() if not hosts: raise RuntimeError("No host matching '{}'".format(host)) return sorted(hosts) return super().get_hosts(host, **kwargs) pytest-testinfra-10.1.0/testinfra/backend/ssh.py000066400000000000000000000115571456331477400217020ustar00rootroot00000000000000# Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import base64 from typing import Any, Optional from testinfra.backend import base class SshBackend(base.BaseBackend): """Run command through ssh command""" NAME = "ssh" def __init__( self, hostspec: str, ssh_config: Optional[str] = None, ssh_identity_file: Optional[str] = None, timeout: int = 10, controlpath: str = "", controlpersist: int = 60, ssh_extra_args: Optional[str] = None, *args: Any, **kwargs: Any, ): self.host = self.parse_hostspec(hostspec) self.ssh_config = ssh_config self.ssh_identity_file = ssh_identity_file self.timeout = int(timeout) self.controlpath = controlpath self.controlpersist = int(controlpersist) self.ssh_extra_args = ssh_extra_args super().__init__(self.host.name, *args, **kwargs) def run(self, command: str, *args: str, **kwargs: Any) -> base.CommandResult: return self.run_ssh(self.get_command(command, *args)) def _build_ssh_command(self, command: str) -> tuple[list[str], list[str]]: if not self.host.password: cmd = ["ssh"] cmd_args = [] else: cmd = ["sshpass", "-p", "%s", "ssh"] cmd_args = [self.host.password] if self.ssh_extra_args: cmd.append(self.ssh_extra_args.replace("%", "%%")) if self.ssh_config: cmd.append("-F %s") cmd_args.append(self.ssh_config) if self.host.user: cmd.append("-o User=%s") cmd_args.append(self.host.user) if self.host.port: cmd.append("-o Port=%s") cmd_args.append(self.host.port) if self.ssh_identity_file: cmd.append("-i %s") cmd_args.append(self.ssh_identity_file) if "connecttimeout" not in (self.ssh_extra_args or "").lower(): cmd.append("-o ConnectTimeout={}".format(self.timeout)) if self.controlpersist and ( "controlmaster" not in (self.ssh_extra_args or "").lower() ): cmd.append( "-o ControlMaster=auto -o ControlPersist={}s".format( self.controlpersist ) ) if ( "ControlMaster" in " ".join(cmd) and self.controlpath and ("controlpath" not in (self.ssh_extra_args or "").lower()) ): cmd.append("-o ControlPath={}".format(self.controlpath)) cmd.append("%s %s") cmd_args.extend([self.host.name, command]) return cmd, cmd_args def run_ssh(self, command: str) -> base.CommandResult: cmd, cmd_args = self._build_ssh_command(command) out = self.run_local(" ".join(cmd), *cmd_args) out.command = self.encode(command) if out.rc == 255: # ssh exits with the exit status of the remote command or with 255 # if an error occurred. raise RuntimeError(out) return out class SafeSshBackend(SshBackend): """Run command using ssh command but try to get a more sane output When using ssh (or a potentially bugged wrapper) additional output can be added in stdout/stderr and exit status may not be propagate correctly To avoid that kind of bugs, we wrap the command to have an output like this: TESTINFRA_START;EXIT_STATUS;STDOUT;STDERR;TESTINFRA_END where STDOUT/STDERR are base64 encoded, then we parse that magic string to get sanes variables """ NAME = "safe-ssh" def run(self, command: str, *args: str, **kwargs: Any) -> base.CommandResult: orig_command = self.get_command(command, *args) orig_command = self.get_command("sh -c %s", orig_command) out = self.run_ssh( ( """of=$(mktemp)&&ef=$(mktemp)&&{} >$of 2>$ef; r=$?;""" """echo "TESTINFRA_START;$r;$(base64 < $of);$(base64 < $ef);""" """TESTINFRA_END";rm -f $of $ef""" ).format(orig_command) ) start = out.stdout.find("TESTINFRA_START;") + len("TESTINFRA_START;") end = out.stdout.find("TESTINFRA_END") - 1 rc, stdout, stderr = out.stdout[start:end].split(";") return self.result( int(rc), self.encode(orig_command), base64.b64decode(stdout), base64.b64decode(stderr), ) pytest-testinfra-10.1.0/testinfra/backend/winrm.py000066400000000000000000000064401456331477400222340ustar00rootroot00000000000000# Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import re from typing import Any, Optional from testinfra.backend import base try: import winrm except ImportError: raise RuntimeError( ( "You must install the pywinrm package (pip install pywinrm) " "to use the winrm backend" ) ) import winrm.protocol _find_unsafe = re.compile(r"[^\w@%+=:,./-]", re.ASCII) # (gtmanfred) This is copied from pipes.quote, but changed to use double quotes # instead of single quotes. This is used by the winrm backend. def _quote(s: str) -> str: """Return a shell-escaped version of the string *s*.""" if not s: return "''" if _find_unsafe.search(s) is None: return s # use single quotes, and put single quotes into double quotes # the string $'b is then quoted as '$'"'"'b' return '"' + s.replace('"', '"\'"\'"') + '"' class WinRMBackend(base.BaseBackend): """Run command through winrm command""" NAME = "winrm" def __init__( self, hostspec: str, no_ssl: bool = False, no_verify_ssl: bool = False, read_timeout_sec: Optional[int] = None, operation_timeout_sec: Optional[int] = None, *args: Any, **kwargs: Any, ): self.host = self.parse_hostspec(hostspec) self.conn_args: dict[str, Any] = { "endpoint": "{}://{}{}/wsman".format( "http" if no_ssl else "https", self.host.name, ":{}".format(self.host.port) if self.host.port else "", ), "transport": "ntlm", "username": self.host.user, "password": self.host.password, } if no_verify_ssl: self.conn_args["server_cert_validation"] = "ignore" if read_timeout_sec is not None: self.conn_args["read_timeout_sec"] = read_timeout_sec if operation_timeout_sec is not None: self.conn_args["operation_timeout_sec"] = operation_timeout_sec super().__init__(self.host.name, *args, **kwargs) def run(self, command: str, *args: str, **kwargs: Any) -> base.CommandResult: return self.run_winrm(self.get_command(command, *args)) def run_winrm(self, command: str, *args: str) -> base.CommandResult: p = winrm.protocol.Protocol(**self.conn_args) shell_id = p.open_shell() command_id = p.run_command(shell_id, command, *args) stdout, stderr, rc = p.get_command_output(shell_id, command_id) p.cleanup_command(shell_id, command_id) p.close_shell(shell_id) return self.result(rc, self.encode(command), stdout, stderr) @staticmethod def quote(command: str, *args: str) -> str: if args: return command % tuple(_quote(a) for a in args) # noqa: S001 return command pytest-testinfra-10.1.0/testinfra/host.py000066400000000000000000000142071456331477400204660ustar00rootroot00000000000000# Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import functools import os from collections.abc import Iterable from typing import Any import testinfra.backend import testinfra.backend.base import testinfra.modules import testinfra.modules.base class Host: _host_cache: dict[tuple[str, frozenset[tuple[str, Any]]], "Host"] = {} _hosts_cache: dict[ tuple[frozenset[str], frozenset[tuple[str, Any]]], list["Host"] ] = {} def __init__(self, backend: testinfra.backend.base.BaseBackend): self.backend = backend super().__init__() def __repr__(self) -> str: return "".format(self.backend.get_pytest_id()) @functools.cached_property def has_command_v(self) -> bool: """Return True if `command -v` is available""" return self.run("command -v command").rc == 0 def exists(self, command: str) -> bool: """Return True if given command exist in $PATH""" if self.has_command_v: out = self.run("command -v %s", command) else: out = self.run_expect([0, 1], "which %s", command) return out.rc == 0 def find_command( self, command: str, extrapaths: Iterable[str] = ("/sbin", "/usr/sbin") ) -> str: """Return path of given command raise ValueError if command cannot be found """ if self.has_command_v: out = self.run("command -v %s", command) else: out = self.run_expect([0, 1], "which %s", command) if out.rc == 0: return out.stdout.rstrip("\r\n") for basedir in extrapaths: path = os.path.join(basedir, command) if self.exists(path): return path raise ValueError('cannot find "{}" command'.format(command)) def run( self, command: str, *args: str, **kwargs: Any ) -> testinfra.backend.base.CommandResult: """Run given command and return rc (exit status), stdout and stderr >>> cmd = host.run("ls -l /etc/passwd") >>> cmd.rc 0 >>> cmd.stdout '-rw-r--r-- 1 root root 1790 Feb 11 00:28 /etc/passwd\\n' >>> cmd.stderr '' >>> cmd.succeeded True >>> cmd.failed False Good practice: always use shell arguments quoting to avoid shell injection >>> cmd = host.run("ls -l -- %s", "/;echo inject") CommandResult( rc=2, stdout='', stderr=( 'ls: cannot access /;echo inject: No such file or directory\\n'), command="ls -l '/;echo inject'") """ return self.backend.run(command, *args, **kwargs) def run_expect( self, expected: list[int], command: str, *args: str, **kwargs: Any ) -> testinfra.backend.base.CommandResult: """Run command and check it return an expected exit status :param expected: A list of expected exit status :raises: AssertionError """ __tracebackhide__ = True out = self.run(command, *args, **kwargs) assert out.rc in expected, "Unexpected exit code {} for {}".format(out.rc, out) return out def run_test( self, command: str, *args: str, **kwargs: Any ) -> testinfra.backend.base.CommandResult: """Run command and check it return an exit status of 0 or 1 :raises: AssertionError """ return self.run_expect([0, 1], command, *args, **kwargs) def check_output(self, command: str, *args: str, **kwargs: Any) -> str: """Get stdout of a command which has run successfully :returns: stdout without trailing newline :raises: AssertionError """ __tracebackhide__ = True out = self.run(command, *args, **kwargs) assert out.rc == 0, "Unexpected exit code {} for {}".format(out.rc, out) return out.stdout.rstrip("\r\n") def __getattr__(self, name: str) -> type[testinfra.modules.base.Module]: if name in testinfra.modules.modules: module_class = testinfra.modules.get_module_class(name) obj = module_class.get_module(self) setattr(self, name, obj) return obj raise AttributeError( "'{}' object has no attribute '{}'".format(self.__class__.__name__, name) ) @classmethod def get_host(cls, hostspec: str, **kwargs: Any) -> "Host": """Return a Host instance from `hostspec` `hostspec` should be like `://?param1=value1¶m2=value2` Params can also be passed in `**kwargs` (eg. get_host("local://", sudo=True) is equivalent to get_host("local://?sudo=true")) Examples:: >>> get_host("local://", sudo=True) >>> get_host("paramiko://user@host", ssh_config="/path/my_ssh_config") >>> get_host("ansible://all?ansible_inventory=/etc/ansible/inventory") """ key = (hostspec, frozenset(kwargs.items())) cache = cls._host_cache if key not in cache: backend = testinfra.backend.get_backend(hostspec, **kwargs) cache[key] = host = cls(backend) backend.set_host(host) return cache[key] @classmethod def get_hosts(cls, hosts: Iterable[str], **kwargs: Any) -> list["Host"]: key = (frozenset(hosts), frozenset(kwargs.items())) cache = cls._hosts_cache if key not in cache: cache[key] = [] for backend in testinfra.backend.get_backends(hosts, **kwargs): host = cls(backend) backend.set_host(host) cache[key].append(host) return cache[key] get_host = Host.get_host get_hosts = Host.get_hosts pytest-testinfra-10.1.0/testinfra/main.py000066400000000000000000000013071456331477400204320ustar00rootroot00000000000000# Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import warnings import pytest def main() -> int: warnings.warn("calling testinfra is deprecated, call py.test instead", stacklevel=1) return pytest.main() pytest-testinfra-10.1.0/testinfra/modules/000077500000000000000000000000001456331477400206035ustar00rootroot00000000000000pytest-testinfra-10.1.0/testinfra/modules/__init__.py000066400000000000000000000034051456331477400227160ustar00rootroot00000000000000# Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import importlib from typing import TYPE_CHECKING if TYPE_CHECKING: import testinfra.modules.base modules = { "addr": "addr:Addr", "ansible": "ansible:Ansible", "command": "command:Command", "docker": "docker:Docker", "podman": "podman:Podman", "environment": "environment:Environment", "file": "file:File", "group": "group:Group", "interface": "interface:Interface", "iptables": "iptables:Iptables", "mount_point": "mountpoint:MountPoint", "package": "package:Package", "pip": "pip:Pip", "process": "process:Process", "puppet_resource": "puppet:PuppetResource", "facter": "puppet:Facter", "salt": "salt:Salt", "service": "service:Service", "socket": "socket:Socket", "sudo": "sudo:Sudo", "supervisor": "supervisor:Supervisor", "sysctl": "sysctl:Sysctl", "system_info": "systeminfo:SystemInfo", "user": "user:User", "block_device": "blockdevice:BlockDevice", } def get_module_class(name: str) -> type["testinfra.modules.base.Module"]: modname, classname = modules[name].split(":") modname = ".".join([__name__, modname]) module = importlib.import_module(modname) return getattr(module, classname) # type: ignore[no-any-return] pytest-testinfra-10.1.0/testinfra/modules/addr.py000066400000000000000000000112031456331477400220640ustar00rootroot00000000000000# Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from testinfra.modules.base import Module class _AddrPort: def __init__(self, addr, port): self._addr = addr self._port = str(port) @property def is_reachable(self): """Return if port is reachable""" if not self._addr._host.exists("nc"): if self._addr.namespace: # in this case cannot use namespace raise NotImplementedError( "nc command not available, namespace cannot be used" ) # Fallback to bash if netcat is not available return ( self._addr.run_expect( [0, 1, 124], "timeout 1 bash -c 'cat < /dev/null > /dev/tcp/%s/%s'", self._addr.name, self._port, ).rc == 0 ) return ( self._addr.run( "{}nc -w 1 -z {} {}".format( self._addr._prefix, self._addr.name, self._port ) ).rc == 0 ) class Addr(Module): """Test remote address Example: >>> google = host.addr("google.com") >>> google.is_resolvable True >>> '173.194.32.225' in google.ipv4_addresses True >>> google.is_reachable True >>> google.port(443).is_reachable True >>> google.port(666).is_reachable False Can also be use within a network namespace_. >>> localhost = host.addr("localhost", "ns1") >>> localhost.is_resolvable True Network namespaces can only be used if ip_ command is available because in this case, the module use ip-netns_ as command prefix. In the other case, it will raise NotImplementedError. .. _namespace: https://man7.org/linux/man-pages/man7/namespaces.7.html .. _ip: https://man7.org/linux/man-pages/man8/ip.8.html .. _ip-netns: https://man7.org/linux/man-pages/man8/ip-netns.8.html """ def __init__(self, name, namespace=None): self._name = name self._namespace = namespace if self.namespace and not self._host.exists("ip"): raise NotImplementedError( "ip command not available, namespace cannot be used" ) super().__init__() @property def name(self): """Return host name""" return self._name @property def namespace(self): """Return network namespace""" return self._namespace @property def _prefix(self): """Return the prefix to use for commands""" prefix = "" if self.namespace: prefix = "ip netns exec {} ".format(self.namespace) return prefix @property def namespace_exists(self): """Test if the network namespace exists""" # could use ip netns list instead return ( self.namespace and self.run_test("test -e /var/run/netns/%s", self.namespace).rc == 0 ) @property def is_resolvable(self): """Return if address is resolvable""" return len(self.ip_addresses) > 0 @property def is_reachable(self): """Return if address is reachable""" return ( self.run_expect( [0, 1, 2], "{}ping -W 1 -c 1 {}".format(self._prefix, self.name) ).rc == 0 ) @property def ip_addresses(self): """Return IP addresses of host""" return self._resolve("ahosts") @property def ipv4_addresses(self): """Return IPv4 addresses of host""" return self._resolve("ahostsv4") @property def ipv6_addresses(self): """Return IPv6 addresses of host""" return self._resolve("ahostsv6") def port(self, port): """Return address-port pair""" return _AddrPort(self, port) def __repr__(self): return "".format(self.name) def _resolve(self, method): result = self.run_expect( [0, 1, 2], "{}getent {} {}".format(self._prefix, method, self.name) ) lines = result.stdout.splitlines() return list({line.split()[0] for line in lines}) pytest-testinfra-10.1.0/testinfra/modules/ansible.py000066400000000000000000000101311456331477400225660ustar00rootroot00000000000000# Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import functools import pprint from testinfra.modules.base import InstanceModule class AnsibleException(Exception): """Exception raised when an error occur in an ansible call result from ansible can be accessed through the ``result`` attribute >>> try: ... host.ansible("command", "echo foo") ... except host.ansible.AnsibleException as exc: ... assert exc.result['failed'] is True ... assert exc.result['msg'] == 'Skipped. You might want to try check=False' # noqa """ def __init__(self, result): self.result = result super().__init__("Unexpected error: {}".format(pprint.pformat(result))) def need_ansible(func): @functools.wraps(func) def wrapper(self, *args, **kwargs): if not self._host.backend.HAS_RUN_ANSIBLE: raise RuntimeError( ("Ansible module is only available with ansible " "connection backend") ) return func(self, *args, **kwargs) return wrapper class Ansible(InstanceModule): """Run Ansible module functions This module is only available with the :ref:`ansible connection backend` connection backend. `Check mode `_ is enabled by default, you can disable it with `check=False`. `Become `_ is `False` by default. You can enable it with `become=True`. Ansible arguments that are not related to the Ansible inventory or connection (both managed by testinfra) are also accepted through keyword arguments: - ``become_method`` *str* sudo, su, doas, etc. - ``become_user`` *str* become this user. - ``diff`` *bool*: when changing (small) files and templates, show the differences in those files. - ``extra_vars`` *dict* serialized to a JSON string, passed to Ansible. - ``one_line`` *bool*: condense output. - ``user`` *str* connect as this user. - ``verbose`` *int* level of verbosity >>> host.ansible("apt", "name=nginx state=present")["changed"] False >>> host.ansible("apt", "name=nginx state=present", become=True)["changed"] False >>> host.ansible("command", "echo foo", check=False)["stdout"] 'foo' >>> host.ansible("setup")["ansible_facts"]["ansible_lsb"]["codename"] 'jessie' >>> host.ansible("file", "path=/etc/passwd")["mode"] '0640' >>> host.ansible( ... "command", ... "id --user --name", ... check=False, ... become=True, ... become_user="http", ... )["stdout"] 'http' >>> host.ansible( ... "apt", ... "name={{ packages }}", ... check=False, ... extra_vars={"packages": ["neovim", "vim"]}, ... ) # Installs neovim and vim. """ AnsibleException = AnsibleException @need_ansible def __call__( self, module_name, module_args=None, check=True, become=False, **kwargs ): result = self._host.backend.run_ansible( module_name, module_args, check=check, become=become, **kwargs ) if result.get("failed", False): raise AnsibleException(result) return result @need_ansible def get_variables(self): """Returns a dict of ansible variables >>> host.ansible.get_variables() { 'inventory_hostname': 'localhost', 'group_names': ['ungrouped'], 'foo': 'bar', } """ return self._host.backend.get_variables() def __repr__(self): return "" pytest-testinfra-10.1.0/testinfra/modules/base.py000066400000000000000000000032461456331477400220740ustar00rootroot00000000000000# Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from typing import TYPE_CHECKING class Module: if TYPE_CHECKING: import testinfra.host _host: testinfra.host.Host @classmethod def get_module(cls, _host: "testinfra.host.Host") -> type["Module"]: klass = cls.get_module_class(_host) return type( klass.__name__, (klass,), {"_host": _host}, ) @classmethod def get_module_class(cls, host): return cls @classmethod def run(cls, *args, **kwargs): return cls._host.run(*args, **kwargs) @classmethod def run_test(cls, *args, **kwargs): return cls._host.run_test(*args, **kwargs) @classmethod def run_expect(cls, *args, **kwargs): return cls._host.run_expect(*args, **kwargs) @classmethod def check_output(cls, *args, **kwargs): return cls._host.check_output(*args, **kwargs) @classmethod def find_command(cls, *args, **kwargs): return cls._host.find_command(*args, **kwargs) class InstanceModule(Module): @classmethod def get_module(cls, _host): klass = super().get_module(_host) return klass() pytest-testinfra-10.1.0/testinfra/modules/blockdevice.py000066400000000000000000000100611456331477400234250ustar00rootroot00000000000000# Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import functools from testinfra.modules.base import Module class BlockDevice(Module): """Information for block device. Should be used with sudo or under root. If device is not a block device, RuntimeError is raised. """ @property def _data(self): raise NotImplementedError def __init__(self, device): self.device = device super().__init__() @property def is_partition(self): """Return True if the device is a partition. >>> host.block_device("/dev/sda1").is_partition True >>> host.block_device("/dev/sda").is_partition False """ return self._data["start_sector"] > 0 @property def size(self): """Return size if the device in bytes. >>> host.block_device("/dev/sda1").size 512110190592 """ return self._data["size"] @property def sector_size(self): """Return sector size for the device in bytes. >>> host.block_device("/dev/sda1").sector_size 512 """ return self._data["sector_size"] @property def block_size(self): """Return block size for the device in bytes. >>> host.block_device("/dev/sda").block_size 4096 """ return self._data["block_size"] @property def start_sector(self): """Return start sector of the device on the underlying device. Usually the value is zero for full devices and is non-zero for partitions. >>> host.block_device("/dev/sda1").start_sector 2048 >>> host.block_device("/dev/md0").start_sector 0 """ return self._data["sector_size"] @property def is_writable(self): """Return True if device is writable (have no RO status) >>> host.block_device("/dev/sda").is_writable True >>> host.block_device("/dev/loop1").is_writable False """ mode = self._data["rw_mode"] if mode == "rw": return True if mode == "ro": return False raise ValueError("Unexpected value for rw: {}".format(mode)) @property def ra(self): """Return Read Ahead for the device in 512-bytes sectors. >>> host.block_device("/dev/sda").ra 256 """ return self._data["read_ahead"] @classmethod def get_module_class(cls, host): if host.system_info.type == "linux": return LinuxBlockDevice raise NotImplementedError def __repr__(self): return "".format(self.device) class LinuxBlockDevice(BlockDevice): @functools.cached_property def _data(self): header = ["RO", "RA", "SSZ", "BSZ", "StartSec", "Size", "Device"] command = "blockdev --report %s" blockdev = self.run(command, self.device) if blockdev.rc != 0: raise RuntimeError("Failed to gather data: {}".format(blockdev.stderr)) output = blockdev.stdout.splitlines() if len(output) < 2: raise RuntimeError("No data from {}".format(self.device)) if output[0].split() != header: raise RuntimeError("Unknown output of blockdev: {}".format(output[0])) fields = output[1].split() return { "rw_mode": str(fields[0]), "read_ahead": int(fields[1]), "sector_size": int(fields[2]), "block_size": int(fields[3]), "start_sector": int(fields[4]), "size": int(fields[5]), } pytest-testinfra-10.1.0/testinfra/modules/command.py000066400000000000000000000015261456331477400225770ustar00rootroot00000000000000# Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from testinfra.modules.base import InstanceModule class Command(InstanceModule): def __call__(self, command, *args, **kwargs): return self.run(command, *args, **kwargs) def exists(self, command): return self._host.exists(command) def __repr__(self): return "" pytest-testinfra-10.1.0/testinfra/modules/docker.py000066400000000000000000000072661456331477400224370ustar00rootroot00000000000000# Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import json from testinfra.modules.base import Module class Docker(Module): """Test docker containers running on system. Example: >>> nginx = host.docker("app_nginx") >>> nginx.is_running True >>> nginx.id '7e67dc7495ca8f451d346b775890bdc0fb561ecdc97b68fb59ff2f77b509a8fe' >>> nginx.name 'app_nginx' """ def __init__(self, name): self._name = name super().__init__() def inspect(self): output = self.check_output("docker inspect %s", self._name) return json.loads(output)[0] @property def is_running(self): return self.inspect()["State"]["Running"] @property def is_restarting(self): return self.inspect()["State"]["Restarting"] @property def id(self): return self.inspect()["Id"] @property def name(self): return self.inspect()["Name"][1:] # get rid of slash in front @classmethod def client_version(cls): """Docker client version""" return cls.version("{{.Client.Version}}") @classmethod def server_version(cls): """Docker server version""" return cls.version("{{.Server.Version}}") @classmethod def version(cls, format=None): """Docker version_ with an optional format (Go template). >>> host.docker.version() Client: Docker Engine - Community ... >>> host.docker.version("{{.Client.Context}}")) default .. _version: https://docs.docker.com/engine/reference/commandline/version/ """ cmd = "docker version" if format: cmd = "{} --format '{}'".format(cmd, format) return cls.check_output(cmd) @classmethod def get_containers(cls, **filters): """Return a list of containers By default return list of all containers, including non-running containers. Filtering can be done using filters keys defined on https://docs.docker.com/engine/reference/commandline/ps/#filtering Multiple filters for a given key is handled by giving a list of string as value. >>> host.docker.get_containers() [, , ] # Get all running containers >>> host.docker.get_containers(status="running") [] # Get containers named "nginx" >>> host.docker.get_containers(name="nginx") [] # Get containers named "nginx" or "redis" >>> host.docker.get_containers(name=["nginx", "redis"]) [, ] """ cmd = "docker ps --all --quiet --format '{{.Names}}'" args = [] for key, value in filters.items(): if isinstance(value, (list, tuple)): values = value else: values = [value] for v in values: cmd += " --filter %s=%s" args += [key, v] result = [] for docker_id in cls(None).check_output(cmd, *args).splitlines(): result.append(cls(docker_id)) return result def __repr__(self): return "".format(self._name) pytest-testinfra-10.1.0/testinfra/modules/environment.py000066400000000000000000000017721456331477400235300ustar00rootroot00000000000000# Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from testinfra.modules.base import InstanceModule class Environment(InstanceModule): """Get Environment variables Example: >>> host.environment() { "EDITOR": "vim", "SHELL": "/bin/bash", [...] } """ def __call__(self): ret_val = dict( i.split("=", 1) for i in self.check_output("env -0").split("\x00") if i ) return ret_val def __repr__(self): return "" pytest-testinfra-10.1.0/testinfra/modules/file.py000066400000000000000000000322761456331477400221060ustar00rootroot00000000000000# Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import datetime from testinfra.modules.base import Module class File(Module): """Test various files attributes""" def __init__(self, path): self.path = path super().__init__() @property def exists(self): """Test if file exists >>> host.file("/etc/passwd").exists True >>> host.file("/nonexistent").exists False """ return self.run_test("test -e %s", self.path).rc == 0 @property def is_file(self): """Test if the path is a regular file""" return self.run_test("test -f %s", self.path).rc == 0 @property def is_directory(self): """Test if the path exists and a directory""" return self.run_test("test -d %s", self.path).rc == 0 @property def is_executable(self): """Test if the path exists and permission to execute is granted""" return self.run_test("test -x %s", self.path).rc == 0 @property def is_pipe(self): """Test if the path exists and is a pipe""" return self.run_test("test -p %s", self.path).rc == 0 @property def is_socket(self): """Test if the path exists and is a socket""" return self.run_test("test -S %s", self.path).rc == 0 @property def is_symlink(self): """Test if the path exists and is a symbolic link""" return self.run_test("test -L %s", self.path).rc == 0 @property def linked_to(self): """Resolve symlink >>> host.file("/var/lock").linked_to '/run/lock' """ res = self.run_expect([0, 127], "realpath %s", self.path) if res.rc == 0: return res.stdout.strip() return self.check_output("readlink -f %s", self.path) @property def user(self): """Return file owner as string >>> host.file("/etc/passwd").user 'root' """ raise NotImplementedError @property def uid(self): """Return file user id as integer >>> host.file("/etc/passwd").uid 0 """ raise NotImplementedError @property def group(self): """Return file group name as string""" raise NotImplementedError @property def gid(self): """Return file group id as integer""" raise NotImplementedError @property def mode(self): """Return file mode as octal integer >>> host.file("/etc/shadow").mode 416 # Oo640 octal >>> host.file("/etc/shadow").mode == 0o640 True >>> oct(host.file("/etc/shadow").mode) == '0o640' True You can also utilize the file mode constants from the stat_ library for testing file mode. >>> import stat >>> host.file("/etc/shadow").mode == stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP True .. _oct(x): https://docs.python.org/3/library/functions.html#oct .. _stat: https://docs.python.org/3/library/stat.html """ # noqa raise NotImplementedError def contains(self, pattern): """Checks content of file for pattern This uses grep and thus follows the grep regex syntax. """ return self.run_test("grep -qs -- %s %s", pattern, self.path).rc == 0 @property def md5sum(self): """Compute the MD5 message digest of the file content""" raise NotImplementedError @property def sha256sum(self): """Compute the SHA256 message digest of the file content""" raise NotImplementedError def _get_content(self, decode): out = self.run_test("cat -- %s", self.path) if out.rc != 0: raise RuntimeError("Unexpected output {}".format(out)) if decode: return out.stdout return out.stdout_bytes @property def content(self): """Return file content as bytes >>> host.file("/tmp/foo").content b'caf\\xc3\\xa9' """ return self._get_content(False) @property def content_string(self): """Return file content as string >>> host.file("/tmp/foo").content_string 'café' """ return self._get_content(True) @property def mtime(self): """Return time of last modification as datetime.datetime object >>> host.file("/etc/passwd").mtime datetime.datetime(2015, 3, 15, 20, 25, 40) """ raise NotImplementedError @property def size(self): """Return size of file in bytes""" raise NotImplementedError def listdir(self): """Return list of items under the directory >>> host.file("/tmp").listdir() ['foo_file', 'bar_dir'] """ out = self.run_test("ls -1 -q -- %s", self.path) if out.rc != 0: raise RuntimeError("Unexpected output {}".format(out)) return out.stdout.splitlines() def __repr__(self): return "".format(self.path) def __eq__(self, other): if isinstance(other, File): return self.path == other.path if isinstance(other, str): return self.path == other return False @classmethod def get_module_class(cls, host): if host.system_info.type == "linux": return GNUFile if host.system_info.type == "netbsd": return NetBSDFile if host.system_info.type.endswith("bsd"): return BSDFile if host.system_info.type == "darwin": return DarwinFile if host.system_info.type == "windows": return WindowsFile raise NotImplementedError class GNUFile(File): @property def user(self): return self.check_output("stat -Lc %%U %s", self.path) @property def uid(self): return int(self.check_output("stat -Lc %%u %s", self.path)) @property def group(self): return self.check_output("stat -Lc %%G %s", self.path) @property def gid(self): return int(self.check_output("stat -Lc %%g %s", self.path)) @property def mode(self): # Supply a base of 8 when parsing an octal integer # e.g. int('644', 8) -> 420 return int(self.check_output("stat -Lc %%a %s", self.path), 8) @property def mtime(self): ts = self.check_output("stat -Lc %%Y %s", self.path) return datetime.datetime.fromtimestamp(float(ts)) @property def size(self): return int(self.check_output("stat -Lc %%s %s", self.path)) @property def inode(self): return int(self.check_output("stat -Lc %%i %s", self.path)) @property def md5sum(self): return self.check_output("md5sum %s | cut -d' ' -f1", self.path) @property def sha256sum(self): return self.check_output("sha256sum %s | cut -d ' ' -f 1", self.path) class BSDFile(File): @property def user(self): return self.check_output("stat -f %%Su %s", self.path) @property def uid(self): return int(self.check_output("stat -f %%u %s", self.path)) @property def group(self): return self.check_output("stat -f %%Sg %s", self.path) @property def gid(self): return int(self.check_output("stat -f %%g %s", self.path)) @property def mode(self): # Supply a base of 8 when parsing an octal integer # e.g. int('644', 8) -> 420 return int(self.check_output("stat -f %%Lp %s", self.path), 8) @property def mtime(self): ts = self.check_output("stat -f %%m %s", self.path) return datetime.datetime.fromtimestamp(float(ts)) @property def size(self): return int(self.check_output("stat -f %%z %s", self.path)) @property def md5sum(self): return self.check_output("md5 < %s", self.path) @property def sha256sum(self): return self.check_output("sha256 < %s", self.path) class DarwinFile(BSDFile): @property def linked_to(self): link_script = """ TARGET_FILE='{0}' cd `dirname $TARGET_FILE` TARGET_FILE=`basename $TARGET_FILE` while [ -L "$TARGET_FILE" ] do TARGET_FILE=`readlink $TARGET_FILE` cd `dirname $TARGET_FILE` TARGET_FILE=`basename $TARGET_FILE` done PHYS_DIR=`pwd -P` RESULT=$PHYS_DIR/$TARGET_FILE echo $RESULT """.format( self.path ) return self.check_output(link_script) class NetBSDFile(BSDFile): @property def sha256sum(self): return self.check_output("cksum -a sha256 < %s", self.path) class WindowsFile(File): @property def exists(self): """Test if file exists >>> host.file(r"C:/Users").exists True >>> host.file(r"C:/nonexistent").exists False """ return ( self.check_output(r"powershell -command \"Test-Path '%s'\"", self.path) == "True" ) @property def is_file(self): return ( self.check_output( r"powershell -command \"(Get-Item '%s') -is [System.IO.FileInfo]\"", self.path, ) == "True" ) @property def is_directory(self): return ( self.check_output( r"powershell -command \"(Get-Item '%s') -is [System.IO.DirectoryInfo]\"", self.path, ) == "True" ) @property def is_pipe(self): raise NotImplementedError @property def is_socket(self): raise NotImplementedError @property def is_symlink(self): return ( self.check_output( r"powershell -command \"(Get-Item -Path '%s').Attributes -band [System.IO.FileAttributes]::ReparsePoint\"", self.path, ) == "True" ) @property def linked_to(self): """Resolve symlink >>> host.file("C:/Users/lock").linked_to 'C:/Program Files/lock' """ return self.check_output( r"powershell -command \"(Get-Item -Path '%s' -ReadOnly).FullName\"", self.path, ) @property def user(self): raise NotImplementedError @property def uid(self): raise NotImplementedError @property def group(self): raise NotImplementedError @property def gid(self): raise NotImplementedError @property def mode(self): raise NotImplementedError def contains(self, pattern): """Checks content of file for pattern This follows the regex syntax. """ return ( self.run_test( r"powershell -command \"Select-String -Path '%s' -Pattern '%s'\"", self.path, pattern, ).stdout != "" ) @property def md5sum(self): raise NotImplementedError @property def sha256sum(self): raise NotImplementedError def _get_content(self, decode): out = self.run_expect([0], r"powershell -command \"cat -- '%s'\"", self.path) if decode: return out.stdout return out.stdout_bytes @property def content(self): """Return file content as bytes >>> host.file("C:/Windows/Temp/foo").content b'caf\\xc3\\xa9' """ return self._get_content(False) @property def content_string(self): """Return file content as string >>> host.file("C:/Windows/Temp/foo").content_string 'café' """ return self._get_content(True) @property def mtime(self): """Return time of last modification as datetime.datetime object >>> host.file("C:/Windows/passwd").mtime datetime.datetime(2015, 3, 15, 20, 25, 40) """ date_time_str = self.check_output( r"powershell -command \"Get-ChildItem -Path '%s' | Select-Object -ExpandProperty LastWriteTime\"", self.path, ) return datetime.datetime.strptime( date_time_str.strip(), "%A, %B %d, %Y %I:%M:%S %p" ) @property def size(self): """Return size of file in bytes""" return int( self.check_output( r"powershell -command \"Get-Item -Path '%s' | Select-Object -ExpandProperty Length\"", self.path, ) ) def listdir(self): """Return list of items under the directory >>> host.file("C:/Windows/Temp").listdir() ['foo_file', 'bar_dir'] """ out = self.check_output( r"powershell -command \"Get-ChildItem -Path '%s' | Select-Object -ExpandProperty Name\"", self.path, ) return [item.strip() for item in out.strip().split("\n")] pytest-testinfra-10.1.0/testinfra/modules/group.py000066400000000000000000000026271456331477400223200ustar00rootroot00000000000000# Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from testinfra.modules.base import Module class Group(Module): """Test unix group""" def __init__(self, name): self.name = name super().__init__() @property def exists(self): """Test if group exists >>> host.group("wheel").exists True >>> host.group("nosuchgroup").exists False """ return self.run_expect([0, 2], "getent group %s", self.name).rc == 0 @property def gid(self): return int(self.check_output("getent group %s | cut -d':' -f3", self.name)) @property def members(self): """Return all users that are members of this group.""" users = self.check_output("getent group %s | cut -d':' -f4", self.name) if users: return users.split(",") return [] def __repr__(self): return "".format(self.name) pytest-testinfra-10.1.0/testinfra/modules/interface.py000066400000000000000000000140301456331477400231130ustar00rootroot00000000000000# Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import functools import json import re from testinfra.modules.base import Module class Interface(Module): """Test network interfaces >>> host.interface("eth0").exists True Optionally, the protocol family to use can be enforced. >>> host.interface("eth0", "inet6").addresses ['fe80::e291:f5ff:fe98:6b8c'] """ def __init__(self, name, family=None): self.name = name self.family = family super().__init__() @property def exists(self): raise NotImplementedError @property def speed(self): raise NotImplementedError @property def addresses(self): """Return ipv4 and ipv6 addresses on the interface >>> host.interface("eth0").addresses ['192.168.31.254', '192.168.31.252', 'fe80::e291:f5ff:fe98:6b8c'] """ raise NotImplementedError @property def link(self): """Return the link properties associated with the interface. >>> host.interface("lo").link {'address': '00:00:00:00:00:00', 'broadcast': '00:00:00:00:00:00', 'flags': ['LOOPBACK', 'UP', 'LOWER_UP'], 'group': 'default', 'ifindex': 1, 'ifname': 'lo', 'link_type': 'loopback', 'linkmode': 'DEFAULT', 'mtu': 65536, 'operstate': 'UNKNOWN', 'qdisc': 'noqueue', 'txqlen': 1000} """ raise NotImplementedError def routes(self, scope=None): """Return the routes associated with the interface, optionally filtered by scope ("host", "link" or "global"). >>> host.interface("eth0").routes() [{'dst': 'default', 'flags': [], 'gateway': '192.0.2.1', 'metric': 3003, 'prefsrc': '192.0.2.5', 'protocol': 'dhcp'}, {'dst': '192.0.2.0/24', 'flags': [], 'metric': 3003, 'prefsrc': '192.0.2.5', 'protocol': 'dhcp', 'scope': 'link'}] """ raise NotImplementedError def __repr__(self): return "".format(self.name) @classmethod def get_module_class(cls, host): if host.system_info.type == "linux": return LinuxInterface if host.system_info.type.endswith("bsd"): return BSDInterface raise NotImplementedError @classmethod def names(cls): """Return the names of all the interfaces. >>> host.interface.names() ['lo', 'tunl0', 'ip6tnl0', 'eth0'] """ raise NotImplementedError @classmethod def default(cls, family=None): """Return the interface used for the default route. >>> host.interface.default() Optionally, the protocol family to use can be enforced. >>> host.interface.default("inet6") None """ raise NotImplementedError class LinuxInterface(Interface): @functools.cached_property def _ip(self): ip_cmd = self.find_command("ip") if self.family is not None: ip_cmd = f"{ip_cmd} -f {self.family}" return ip_cmd @property def exists(self): return self.run_test("{} link show %s".format(self._ip), self.name).rc == 0 @property def speed(self): return int(self.check_output("cat /sys/class/net/%s/speed", self.name)) @property def addresses(self): stdout = self.check_output("{} addr show %s".format(self._ip), self.name) addrs = [] for line in stdout.splitlines(): splitted = [e.strip() for e in line.split(" ") if e] if splitted and splitted[0] in ("inet", "inet6"): addrs.append(splitted[1].split("/", 1)[0]) return addrs @property def link(self): return json.loads( self.check_output(f"{self._ip} --json link show %s", self.name) ) def routes(self, scope=None): cmd = f"{self._ip} --json route list dev %s" if scope is None: out = self.check_output(cmd, self.name) else: out = self.check_output(cmd + " scope %s", self.name, scope) return json.loads(out) @classmethod def default(cls, family=None): _default = cls(None, family=family) out = cls.check_output("{} route ls".format(_default._ip)) for line in out.splitlines(): if "default" in line: match = re.search(r"dev\s(\S+)", line) if match: _default.name = match.group(1) return _default @classmethod def names(cls): # -o is to tell the ip command to return 1 line per interface out = cls.check_output("{} -o link show".format(cls(None)._ip)) interfaces = [] for line in out.splitlines(): interfaces.append(line.strip().split(": ", 2)[1].split("@", 1)[0]) return interfaces class BSDInterface(Interface): @property def exists(self): return self.run_test("ifconfig %s", self.name).rc == 0 @property def speed(self): raise NotImplementedError @property def addresses(self): stdout = self.check_output("ifconfig %s", self.name) addrs = [] for line in stdout.splitlines(): if line.startswith("\tinet "): addrs.append(line.split(" ", 2)[1]) elif line.startswith("\tinet6 "): addr = line.split(" ", 2)[1] if "%" in addr: addr = addr.split("%", 1)[0] addrs.append(addr) return addrs pytest-testinfra-10.1.0/testinfra/modules/iptables.py000066400000000000000000000055371456331477400227720ustar00rootroot00000000000000# Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from testinfra.modules.base import InstanceModule class Iptables(InstanceModule): """Test iptables rule exists""" def __init__(self): super().__init__() # support for -w argument (since 1.6.0) # https://git.netfilter.org/iptables/commit/?id=aaa4ace72b # centos 6 has no support # centos 7 has 1.4 patched self._has_w_argument = None def _iptables_command(self, version): if version == 4: iptables = "iptables" elif version == 6: iptables = "ip6tables" else: raise RuntimeError("Invalid version: {}".format(version)) if self._has_w_argument is False: return iptables else: return "{} -w 90".format(iptables) def _run_iptables(self, version, cmd, *args): ipt_cmd = "{} {}".format(self._iptables_command(version), cmd) if self._has_w_argument is None: result = self.run_expect([0, 2], ipt_cmd, *args) if result.rc == 2: self._has_w_argument = False return self._run_iptables(version, cmd, *args) else: self._has_w_argument = True return result.stdout.rstrip("\r\n") else: return self.check_output(ipt_cmd, *args) def rules(self, table="filter", chain=None, version=4): """Returns list of iptables rules Based on output of `iptables -t TABLE -S CHAIN` command optionally takes takes the following arguments: - table: defaults to `filter` - chain: defaults to all chains - version: default 4 (iptables), optionally 6 (ip6tables) >>> host.iptables.rules() [ '-P INPUT ACCEPT', '-P FORWARD ACCEPT', '-P OUTPUT ACCEPT', '-A INPUT -i lo -j ACCEPT', '-A INPUT -j REJECT' '-A FORWARD -j REJECT' ] >>> host.iptables.rules("nat", "INPUT") ['-P PREROUTING ACCEPT'] """ cmd, args = "-t %s -S", [table] if chain: cmd += " %s" args += [chain] rules = [] for line in self._run_iptables(version, cmd, *args).splitlines(): line = line.replace("\t", " ") rules.append(line) return rules pytest-testinfra-10.1.0/testinfra/modules/mountpoint.py000066400000000000000000000104641456331477400233760ustar00rootroot00000000000000# Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from testinfra.modules.base import Module class MountPoint(Module): """Test Mount Points""" def __init__(self, path, _attrs_cache=None): self.path = path self._attrs_cache = _attrs_cache super().__init__() @classmethod def _iter_mountpoints(cls): raise NotImplementedError @property def exists(self): """Return True if the mountpoint exists >>> host.mount_point("/").exists True >>> host.mount_point("/not/a/mountpoint").exists False """ return bool(self._attrs) @property def _attrs(self): if self._attrs_cache is None: for mountpoint in self._iter_mountpoints(): if mountpoint["path"] == self.path: self._attrs_cache = mountpoint break else: self._attrs_cache = {} return self._attrs_cache @property def filesystem(self): """Returns the filesystem type associated >>> host.mount_point("/").filesystem 'ext4' """ return self._attrs["filesystem"] @property def device(self): """Return the device associated >>> host.mount_point("/").device '/dev/sda1' """ return self._attrs["device"] @property def options(self): """Return a list of options that a mount point has been created with >>> host.mount_point("/").options ['rw', 'relatime', 'data=ordered'] """ return self._attrs["options"] @classmethod def get_mountpoints(cls): """Returns a list of MountPoint instances >>> host.mount_point.get_mountpoints() [, ] """ # noqa mountpoints = [] for mountpoint in cls._iter_mountpoints(): mountpoints.append(cls(mountpoint["path"], mountpoint)) return mountpoints @classmethod def get_module_class(cls, host): if host.system_info.type == "linux": return LinuxMountPoint if host.system_info.type.endswith("bsd"): return BSDMountPoint raise NotImplementedError def __repr__(self): return ( "" ).format( self.path, self.device, self.filesystem, ",".join(self.options), ) class LinuxMountPoint(MountPoint): @classmethod def _iter_mountpoints(cls): check_output = cls(None).check_output for line in check_output("cat /proc/mounts").splitlines(): splitted = line.split() # ignore rootfs # https://www.kernel.org/doc/Documentation/filesystems/ramfs-rootfs-initramfs.txt # suggests that most OS mount the filesystem over it, leaving # rootfs would result in ambiguity when resolving a mountpoint. if splitted[0] == "rootfs": continue yield { "path": splitted[1], "device": splitted[0], "filesystem": splitted[2], "options": splitted[3].split(","), } class BSDMountPoint(MountPoint): @classmethod def _iter_mountpoints(cls): check_output = cls(None).check_output for line in check_output("mount -p").splitlines(): splitted = line.split() yield { "path": splitted[1], "device": splitted[0], "filesystem": splitted[2], "options": splitted[3].split(","), } pytest-testinfra-10.1.0/testinfra/modules/package.py000066400000000000000000000147721456331477400225630ustar00rootroot00000000000000# Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import json from testinfra.modules.base import Module class Package(Module): """Test packages status and version""" def __init__(self, name): self.name = name super().__init__() @property def is_installed(self): """Test if the package is installed >>> host.package("nginx").is_installed True Supported package systems: - apk (Alpine) - apt (Debian, Ubuntu, ...) - brew (macOS) - pacman (Arch, Manjaro ) - pkg (FreeBSD) - pkg_info (NetBSD) - pkg_info (OpenBSD) - rpm (RHEL, RockyLinux, Fedora, ...) """ raise NotImplementedError @property def release(self): """Return the release specific info from the package version >>> host.package("nginx").release '1.el6' """ raise NotImplementedError @property def version(self): """Return package version as returned by the package system >>> host.package("nginx").version '1.2.1-2.2+wheezy3' """ raise NotImplementedError def __repr__(self): return "".format(self.name) @classmethod def get_module_class(cls, host): if host.system_info.type == "windows": return ChocolateyPackage if host.system_info.type == "freebsd": return FreeBSDPackage if host.system_info.type in ("openbsd", "netbsd"): return OpenBSDPackage if host.system_info.distribution in ("debian", "ubuntu"): return DebianPackage if host.system_info.distribution and ( host.system_info.distribution.lower() in ( "almalinux", "centos", "cloudlinux", "fedora", "ol", "opensuse-leap", "opensuse-tumbleweed", "rhel", "rocky", ) ): return RpmPackage if host.system_info.distribution in ("arch", "manjarolinux"): return ArchPackage if host.exists("apk"): return AlpinePackage # Fallback conditions if host.exists("dpkg-query"): return DebianPackage if host.exists("rpm"): return RpmPackage if host.exists("brew"): return HomebrewPackage raise NotImplementedError class DebianPackage(Package): @property def is_installed(self): result = self.run_test("dpkg-query -f '${Status}' -W %s", self.name) if result.rc == 1: return False out = result.stdout.strip().split() installed_status = ["ok", "installed"] return out[0] in ["install", "hold"] and out[1:3] == installed_status @property def release(self): raise NotImplementedError @property def version(self): out = self.check_output("dpkg-query -f '${Status} ${Version}' -W %s", self.name) splitted = out.split() assert splitted[0].lower() in ( "install", "hold", ), "The package {} is not installed, dpkg-query output: {}".format( self.name, out ) return splitted[3] class FreeBSDPackage(Package): @property def is_installed(self): EX_UNAVAILABLE = 69 return ( self.run_expect([0, EX_UNAVAILABLE], "pkg query %%n %s", self.name).rc == 0 ) @property def release(self): raise NotImplementedError @property def version(self): return self.check_output("pkg query %%v %s", self.name) class OpenBSDPackage(Package): @property def is_installed(self): return self.run_test("pkg_info -e %s", "{}-*".format(self.name)).rc == 0 @property def release(self): raise NotImplementedError @property def version(self): out = self.check_output("pkg_info -e %s", "{}-*".format(self.name)) # OpenBSD: inst:zsh-5.0.5p0 # NetBSD: zsh-5.0.7nb1 return out.split(self.name + "-", 1)[1] class RpmPackage(Package): @property def is_installed(self): return self.run_test("rpm -q %s", self.name).rc == 0 @property def version(self): return self.check_output('rpm -q --queryformat="%%{VERSION}" %s', self.name) @property def release(self): return self.check_output('rpm -q --queryformat="%%{RELEASE}" %s', self.name) class AlpinePackage(Package): @property def is_installed(self): return self.run_test("apk -e info %s", self.name).rc == 0 @property def version(self): out = self.check_output("apk -e -v info %s", self.name).split("-") return out[-2] @property def release(self): out = self.check_output("apk -e -v info %s", self.name).split("-") return out[-1] class ArchPackage(Package): @property def is_installed(self): return self.run_test("pacman -Q %s", self.name).rc == 0 @property def version(self): out = self.check_output("pacman -Q %s", self.name).split(" ") return out[1] @property def release(self): raise NotImplementedError class ChocolateyPackage(Package): @property def is_installed(self): return self.run_test("choco info -lo %s", self.name).rc == 0 @property def version(self): _, version = self.check_output("choco info -lo %s -r", self.name).split("|", 1) return version @property def release(self): raise NotImplementedError class HomebrewPackage(Package): @property def is_installed(self): info = self.check_output("brew info --formula --json %s", self.name) return len(json.loads(info)[0]["installed"]) > 0 @property def version(self): info = self.check_output("brew info --formula --json %s", self.name) version = json.loads(info)[0]["installed"][0]["version"] return version @property def release(self): raise NotImplementedError pytest-testinfra-10.1.0/testinfra/modules/pip.py000066400000000000000000000113351456331477400217500ustar00rootroot00000000000000# Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import json import re from testinfra.modules.base import Module def _re_match(line, regexp): match = regexp.match(line) if match is None: raise RuntimeError("could not parse {0}".format(line)) return match.groups() class Pip(Module): """Test pip package manager and packages""" def __init__(self, name, pip_path="pip"): self.name = name self.pip_path = pip_path super().__init__() @property def is_installed(self): """Test if the package is installed >>> host.package("pip").is_installed True """ return self.run_test("%s show %s", self.pip_path, self.name).rc == 0 @property def version(self): """Return package version as returned by pip >>> host.package("pip").version '18.1' """ return self.check_output( "%s show %s | grep Version: | cut -d' ' -f2", self.pip_path, self.name, ) @classmethod def check(cls, pip_path="pip"): """Verify installed packages have compatible dependencies. >>> cmd = host.pip.check() >>> cmd.rc 0 >>> cmd.stdout No broken requirements found. Can only be used if `pip check`_ command is available, for pip versions >= 9.0.0_. .. _pip check: https://pip.pypa.io/en/stable/reference/pip_check/ .. _9.0.0: https://pip.pypa.io/en/stable/news/#id526 """ return cls.run_expect([0, 1], "%s check", pip_path) @classmethod def get_packages(cls, pip_path="pip"): """Get all installed packages and versions returned by `pip list`: >>> host.pip.get_packages(pip_path='~/venv/website/bin/pip') {'Django': {'version': '1.10.2'}, 'mywebsite': {'version': '1.0a3', 'path': '/srv/website'}, 'psycopg2': {'version': '2.6.2'}} """ out = cls.run_expect([0, 2], "%s list --no-index --format=json", pip_path) pkgs = {} if out.rc == 0: for pkg in json.loads(out.stdout): # XXX: --output=json does not return install path pkgs[pkg["name"]] = {"version": pkg["version"]} else: # pip < 9 output_re = re.compile(r"^(.+) \((.+)\)$") for line in cls.check_output("%s list --no-index", pip_path).splitlines(): if line.startswith("Warning: "): # Warning: cannot find svn location for ... continue name, version = _re_match(line, output_re) if "," in version: version, path = version.split(",", 1) pkgs[name] = {"version": version, "path": path.strip()} else: pkgs[name] = {"version": version} return pkgs @classmethod def get_outdated_packages(cls, pip_path="pip"): """Get all outdated packages with current and latest version >>> host.pip.get_outdated_packages( ... pip_path='~/venv/website/bin/pip') {'Django': {'current': '1.10.2', 'latest': '1.10.3'}} """ out = cls.run_expect([0, 2], "%s list -o --format=json", pip_path) pkgs = {} if out.rc == 0: for pkg in json.loads(out.stdout): pkgs[pkg["name"]] = { "current": pkg["version"], "latest": pkg["latest_version"], } else: # pip < 9 # pip 8: pytest (3.4.2) - Latest: 3.5.0 [wheel] # pip < 8: pytest (Current: 3.4.2 Latest: 3.5.0 [wheel]) regexpes = [ re.compile(r"^(.+?) \((.+)\) - Latest: (.+) .*$"), re.compile(r"^(.+?) \(Current: (.+) Latest: (.+) .*$"), ] for line in cls.check_output("%s list -o", pip_path).splitlines(): if line.startswith("Warning: "): # Warning: cannot find svn location for ... continue output_re = regexpes[1] if "Current:" in line else regexpes[0] name, current, latest = _re_match(line, output_re) pkgs[name] = {"current": current, "latest": latest} return pkgs pytest-testinfra-10.1.0/testinfra/modules/podman.py000066400000000000000000000053451456331477400224420ustar00rootroot00000000000000# Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import json from testinfra.modules.base import Module class Podman(Module): """Test podman containers running on system. Example: >>> nginx = host.podman("app_nginx") >>> nginx.is_running True >>> nginx.id '7e67dc7495ca8f451d346b775890bdc0fb561ecdc97b68fb59ff2f77b509a8fe' >>> nginx.name 'app_nginx' """ def __init__(self, name): self._name = name super().__init__() def inspect(self): output = self.check_output("podman inspect %s", self._name) return json.loads(output)[0] @property def is_running(self): return self.inspect()["State"]["Running"] @property def id(self): return self.inspect()["Id"] @property def name(self): return self.inspect()["Name"] @classmethod def get_containers(cls, **filters): """Return a list of containers By default return list of all containers, including non-running containers. Filtering can be done using filters keys defined in podman-ps(1). Multiple filters for a given key is handled by giving a list of string as value. >>> host.podman.get_containers() [, , ] # Get all running containers >>> host.podman.get_containers(status="running") [] # Get containers named "nginx" >>> host.podman.get_containers(name="nginx") [] # Get containers named "nginx" or "redis" >>> host.podman.get_containers(name=["nginx", "redis"]) [, ] """ cmd = "podman ps --all --format '{{.Names}}'" args = [] for key, value in filters.items(): if isinstance(value, (list, tuple)): values = value else: values = [value] for v in values: cmd += " --filter %s=%s" args += [key, v] result = [] for podman_id in cls(None).check_output(cmd, *args).splitlines(): result.append(cls(podman_id)) return result def __repr__(self): return "".format(self._name) pytest-testinfra-10.1.0/testinfra/modules/process.py000066400000000000000000000145331456331477400226410ustar00rootroot00000000000000# Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from typing import Any from testinfra.modules.base import InstanceModule def int_or_float(value): try: return int(value) except ValueError: try: return float(value) except ValueError: return value class _Process(dict[str, Any]): def __getattr__(self, key): try: return self.__getitem__(key) except KeyError: attrs = self["_get_process_attribute_by_pid"](self["pid"], key) if attrs["lstart"] != self["lstart"]: raise RuntimeError( ( "Process with pid {} start time changed from {} to {}." " This mean the process you are working on does not not " "exist anymore" ).format(self["pid"], self["lstart"], attrs["lstart"]) ) return attrs[key] def __repr__(self): return "".format(self["comm"], self["pid"]) class Process(InstanceModule): """Test Processes attributes Processes are selected using ``filter()`` or ``get()``, attributes names are described in the `ps(1) man page `_. >>> master = host.process.get(user="root", comm="nginx") # Here is the master nginx process (running as root) >>> master.args 'nginx: master process /usr/sbin/nginx -g daemon on; master_process on;' # Here are the worker processes (Parent PID = master PID) >>> workers = host.process.filter(ppid=master.pid) >>> len(workers) 4 # Nginx don't eat memory >>> sum([w.pmem for w in workers]) 0.8 # But php does ! >>> sum([p.pmem for p in host.process.filter(comm="php5-fpm")]) 19.2 """ def filter(self, **filters): """Get a list of matching process >>> host.process.filter(user="root", comm="zsh") [, , ...] """ match = [] for attrs in self._get_processes(**filters): for key, value in filters.items(): if str(attrs[key]) != str(value): break else: attrs[ "_get_process_attribute_by_pid" ] = self._get_process_attribute_by_pid match.append(_Process(attrs)) return match def get(self, **filters): """Get one matching process Raise ``RuntimeError`` if no process found or multiple process matching filters. """ matches = self.filter(**filters) if not matches: raise RuntimeError("No process found") if len(matches) > 1: raise RuntimeError("Multiple process found: {}".format(matches)) return matches[0] def _get_processes(self, **filters): raise NotImplementedError def _get_process_attribute_by_pid(self, pid, name): raise NotImplementedError @classmethod def get_module_class(cls, host): if host.file("/bin/ps").linked_to == "/bin/busybox": return BusyboxProcess if host.file("/bin/busybox").exists: if host.file("/bin/ps").inode == host.file("/bin/busybox").inode: return BusyboxProcess if host.system_info.type == "linux" or host.system_info.type.endswith("bsd"): return PosixProcess raise NotImplementedError def __repr__(self): return "" class PosixProcess(Process): # Should be portable on both Linux and BSD def _get_processes(self, **filters): cmd = "ps -Aww -o %s" # lstart and args attributes contains spaces. Put them at the end of the list attributes = sorted( ({"pid", "comm", "pcpu", "pmem"} | set(filters)) - {"lstart", "args"} ) + ["lstart", "args"] arg = ":50,".join(attributes) procs = [] # skip first line (header) for line in self.check_output(cmd, arg).splitlines()[1:]: splitted = line.split() attrs = {} i = 0 for i, key in enumerate(attributes[:-2]): attrs[key] = int_or_float(splitted[i]) attrs["lstart"] = " ".join(splitted[i + 1 : i + 6]) attrs["args"] = " ".join(splitted[i + 6 :]) procs.append(attrs) return procs def _get_process_attribute_by_pid(self, pid, name): out = self.check_output("ps -ww -p %s -o lstart,%s", str(pid), name) splitted = out.splitlines()[1].split() return { "lstart": " ".join(splitted[:5]), name: int_or_float(splitted[5]), } class BusyboxProcess(Process): def _get_processes(self, **filters): cmd = "ps -A -o %s" # "args" attribute contains spaces. Put them at the end of the list attributes = sorted(({"pid", "comm", "time"} | set(filters)) - {"args"}) + [ "args" ] arg = ",".join(attributes) procs = [] # skip first line (header) for line in self.check_output(cmd, arg).splitlines()[1:]: splitted = line.split() attrs = {} i = 0 for i, key in enumerate(attributes[:-1]): attrs[key] = int_or_float(splitted[i]) attrs["lstart"] = attrs["time"] attrs["args"] = " ".join(splitted[i + 1 :]) procs.append(attrs) return procs def _get_process_attribute_by_pid(self, pid, name): out = self.check_output("ps -o pid,time,%s", name) # skip first line (header) for line in out.splitlines()[1:]: splitted = line.split() if int(splitted[0]) == pid: return { "lstart": splitted[1], name: int_or_float(splitted[2]), } pytest-testinfra-10.1.0/testinfra/modules/puppet.py000066400000000000000000000056511456331477400225010ustar00rootroot00000000000000# Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import json from typing import Dict from testinfra.modules.base import InstanceModule def parse_puppet_resource(data: str) -> Dict[str, Dict[str, str]]: """Parse data returned by 'puppet resource' $ puppet resource user user { 'root': ensure => 'present', comment => 'root', gid => '0', home => '/root', shell => '/usr/bin/zsh', uid => '0', } user { 'sshd': ensure => 'present', gid => '65534', home => '/var/run/sshd', shell => '/usr/sbin/nologin', uid => '106', } [...] """ state: Dict[str, Dict[str, str]] = {} current = None for line in data.splitlines(): if not current: current = line.split("'")[1] state[current] = {} elif current and line == "}": current = None elif current: key, value = line.split(" => ") key = key.strip() value = value.split("'")[1] state[current][key] = value return state class PuppetResource(InstanceModule): """Get puppet resources Run ``puppet resource --types`` to get a list of available types. >>> host.puppet_resource("user", "www-data") { 'www-data': { 'ensure': 'present', 'comment': 'www-data', 'gid': '33', 'home': '/var/www', 'shell': '/usr/sbin/nologin', 'uid': '33', }, } """ def __call__(self, resource_type, name=None): cmd = "puppet resource %s" args = [resource_type] if name is not None: cmd += " %s" args.append(name) # TODO(phil): Since puppet 4.0.0 puppet resource has a --to_yaml option return parse_puppet_resource(self.check_output(cmd, *args)) def __repr__(self): return "" class Facter(InstanceModule): """Get facts with `facter `_ >>> host.facter() { "operatingsystem": "Debian", "kernel": "linux", [...] } >>> host.facter("kernelversion", "is_virtual") { "kernelversion": "3.16.0", "is_virtual": "false" } """ def __call__(self, *facts): cmd = "facter --json --puppet " + " ".join(facts) return json.loads(self.check_output(cmd)) def __repr__(self): return "" pytest-testinfra-10.1.0/testinfra/modules/salt.py000066400000000000000000000032531456331477400221230ustar00rootroot00000000000000# Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import json from testinfra.modules.base import InstanceModule class Salt(InstanceModule): """Run salt module functions >>> host.salt("pkg.version", "nginx") '1.6.2-5' >>> host.salt("pkg.version", ["nginx", "php5-fpm"]) {'nginx': '1.6.2-5', 'php5-fpm': '5.6.7+dfsg-1'} >>> host.salt("grains.item", ["osarch", "mem_total", "num_cpus"]) {'osarch': 'amd64', 'num_cpus': 4, 'mem_total': 15520} Run ``salt-call sys.doc`` to get a complete list of functions """ def __call__(self, function, args=None, local=False, config=None): args = args or [] if isinstance(args, str): args = [args] if self._host.backend.HAS_RUN_SALT: return self._host.backend.run_salt(function, args) cmd_args = [] cmd = "salt-call --out=json" if local: cmd += " --local" if config is not None: cmd += " -c %s" cmd_args.append(config) cmd += " %s" + len(args) * " %s" cmd_args += [function] + args return json.loads(self.check_output(cmd, *cmd_args))["local"] def __repr__(self): return "" pytest-testinfra-10.1.0/testinfra/modules/service.py000066400000000000000000000244321456331477400226220ustar00rootroot00000000000000# Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import functools from testinfra.modules.base import Module class Service(Module): """Test services Implementations: - Linux: detect Systemd, Upstart or OpenRC, fallback to SysV - FreeBSD: service(1) - OpenBSD: ``/etc/rc.d/$name check`` for ``is_running`` ``rcctl ls on`` for ``is_enabled`` (only OpenBSD >= 5.8) - NetBSD: ``/etc/rc.d/$name onestatus`` for ``is_running`` (``is_enabled`` is not yet implemented) """ def __init__(self, name): self.name = name super().__init__() @property def exists(self): """Test if service is exists""" raise NotImplementedError @property def is_running(self): """Test if service is running""" raise NotImplementedError @property def is_enabled(self): """Test if service is enabled""" raise NotImplementedError @property def is_valid(self): """Test if service is valid This method is only available in the systemd implementation, it will raise ``NotImplementedError`` in others implementation """ raise NotImplementedError @property def is_masked(self): """Test if service is masked This method is only available in the systemd implementation, it will raise ``NotImplementedError`` in others implementations """ raise NotImplementedError @functools.cached_property def systemd_properties(self): """Properties of the service (unit). Return service properties as a `dict`, empty properties are not returned. >>> ntp = host.service("ntp") >>> ntp.systemd_properties["FragmentPath"] '/lib/systemd/system/ntp.service' This method is only available in the systemd implementation, it will raise ``NotImplementedError`` in others implementations Note: based on `systemctl show`_ .. _systemctl show: https://man7.org/linux/man-pages/man1/systemctl.1.html """ raise NotImplementedError @classmethod def get_module_class(cls, host): if host.system_info.type == "linux": if host.file("/run/systemd/system/").is_directory or ( host.exists("systemctl") and "systemd" in host.file("/sbin/init").linked_to ): return SystemdService if ( host.exists("initctl") and host.exists("status") and host.file("/etc/init").is_directory ): return UpstartService if host.exists("rc-service"): return OpenRCService return SysvService if host.system_info.type == "freebsd": return FreeBSDService if host.system_info.type == "openbsd": return OpenBSDService if host.system_info.type == "netbsd": return NetBSDService if host.system_info.type == "windows": return WindowsService raise NotImplementedError def __repr__(self): return "".format(self.name) class SysvService(Service): @functools.cached_property def _service_command(self): return self.find_command("service") @property def is_running(self): # based on /lib/lsb/init-functions # 0: program running # 1: program is dead and pid file exists # 3: not running and pid file does not exists # 4: Unable to determine status # 8: starting (alpine specific ?) return ( self.run_expect( [0, 1, 3, 8], "%s %s status", self._service_command, self.name ).rc == 0 ) @property def is_enabled(self): return bool( self.check_output( "find -L /etc/rc?.d/ -name %s", "S??" + self.name, ) ) class SystemdService(SysvService): suffix_list = [ "service", "socket", "device", "mount", "automount", "swap", "target", "path", "timer", "slice", "scope", ] """ List of valid suffixes for systemd unit files See systemd.unit(5) for more details """ def _has_systemd_suffix(self): """ Check if service name has a known systemd unit suffix """ unit_suffix = self.name.split(".")[-1] return unit_suffix in self.suffix_list @property def exists(self): cmd = self.run_test('systemctl list-unit-files | grep -q"^%s"', self.name) return cmd.rc == 0 @property def is_running(self): out = self.run_expect([0, 1, 3], "systemctl is-active %s", self.name) if out.rc == 1: # Failed to connect to bus: No such file or directory return super().is_running return out.rc == 0 @property def is_enabled(self): cmd = self.run_test("systemctl is-enabled %s", self.name) if cmd.rc == 0: return True if cmd.stdout.strip() == "disabled": return False # Fallback on SysV - only for non-systemd units # https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=760616 if not self._has_systemd_suffix(): return super().is_enabled raise RuntimeError( "Unable to determine state of {0}. Does this service exist?".format( self.name ) ) @property def is_valid(self): # systemd-analyze requires a full unit name. if self._has_systemd_suffix(): name = self.name else: name = self.name + ".service" cmd = self.run("systemd-analyze verify %s", name) # A bad unit file still returns a rc of 0, so check the # stdout for anything. Nothing means no warns/errors. # Docs at https://www.freedesktop.org/software/systemd/man/systemd # -analyze.html#Examples%20for%20verify assert (cmd.stdout, cmd.stderr) == ("", "") return True @property def is_masked(self): cmd = self.run_test("systemctl is-enabled %s", self.name) return cmd.stdout.strip() == "masked" @functools.cached_property def systemd_properties(self): out = self.check_output("systemctl show %s", self.name) out_d = {} if out: # maxsplit is required because values can contain `=` out_d = dict( map(lambda pair: pair.split("=", maxsplit=1), out.splitlines()) ) return out_d class UpstartService(SysvService): @property def exists(self): return self._host.file(f"/etc/init/{self.name}.conf").exists @property def is_enabled(self): if ( self.run( "grep -q '^start on' /etc/init/%s.conf", self.name, ).rc == 0 and self.run( "grep -q '^manual' /etc/init/%s.override", self.name, ).rc != 0 ): return True # Fallback on SysV return super().is_enabled @property def is_running(self): cmd = self.run_test("status %s", self.name) if cmd.rc == 0 and len(cmd.stdout.split()) > 1: return "running" in cmd.stdout.split()[1] return super().is_running class OpenRCService(SysvService): @functools.cached_property def _service_command(self): return self.find_command("rc-service") @property def is_enabled(self): return bool( self.check_output( "find /etc/runlevels/ -name %s", self.name, ) ) class FreeBSDService(Service): @property def exists(self): return self._host.file(f"/etc/rc.d/{self.name}").exists @property def is_running(self): return self.run_test("service %s onestatus", self.name).rc == 0 @property def is_enabled(self): # Return list of enabled services like # /etc/rc.d/sshd # /etc/rc.d/sendmail for path in self.check_output("service -e").splitlines(): if path and path.rsplit("/", 1)[1] == self.name: return True return False class OpenBSDService(Service): @property def exists(self): return self._host.file(f"/etc/rc.d/{self.name}").exists @property def is_running(self): return self.run_test("/etc/rc.d/%s check", self.name).rc == 0 @property def is_enabled(self): if self.name in self.check_output("rcctl ls on").splitlines(): return True if self.name in self.check_output("rcctl ls off").splitlines(): return False raise RuntimeError( f"Unable to determine state of {self.name}. Does this service exist?" ) class NetBSDService(Service): @property def exists(self): return self._host.file(f"/etc/rc.d/{self.name}").exists @property def is_running(self): return self.run_test("/etc/rc.d/%s onestatus", self.name).rc == 0 @property def is_enabled(self): raise NotImplementedError class WindowsService(Service): @property def exists(self): out = self.check_output( f"Get-Service -Name {self.name} -ErrorAction SilentlyContinue" ) return self.name in out @property def is_running(self): return ( self.check_output( "Get-Service '%s' | Select -ExpandProperty Status", self.name, ) == "Running" ) @property def is_enabled(self): return ( self.check_output( "Get-Service '%s' | Select -ExpandProperty StartType", self.name, ) == "Automatic" ) pytest-testinfra-10.1.0/testinfra/modules/socket.py000066400000000000000000000306411456331477400224510ustar00rootroot00000000000000# Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import functools import socket from typing import List, Optional, Tuple from testinfra.modules.base import Module def parse_socketspec(socketspec): protocol, address = socketspec.split("://", 1) if protocol not in ("udp", "tcp", "unix"): raise RuntimeError( "Cannot validate protocol '{}'. Should be tcp, udp or unix".format(protocol) ) if protocol == "unix": # unix:///foo/bar.sock host = address port = None elif ":" in address: # tcp://127.0.0.1:22 # tcp://:::22 host, port = address.rsplit(":", 1) else: # tcp://22 host = None port = address family = None if protocol != "unix" and host is not None: for f in (socket.AF_INET, socket.AF_INET6): try: socket.inet_pton(f, host) except socket.error: pass else: family = f break if family is None: raise RuntimeError("Cannot validate ip address '{}'".format(host)) if port is not None: try: port = int(port) except ValueError: raise RuntimeError("Cannot validate port '{}'".format(port)) return protocol, host, port class Socket(Module): """Test listening tcp/udp and unix sockets ``socketspec`` must be specified as ``://:`` This module requires the ``netstat`` command to on the target host. Example: - Unix sockets: ``unix:///var/run/docker.sock`` - All ipv4 and ipv6 tcp sockets on port 22: ``tcp://22`` - All ipv4 sockets on port 22: ``tcp://0.0.0.0:22`` - All ipv6 sockets on port 22: ``tcp://:::22`` - udp socket on 127.0.0.1 port 69: ``udp://127.0.0.1:69`` """ _command = None def __init__(self, socketspec): if socketspec is not None: self.protocol, self.host, self.port = parse_socketspec(socketspec) else: self.protocol = self.host = self.port = None super().__init__() @property def is_listening(self): """Test if socket is listening >>> host.socket("unix:///var/run/docker.sock").is_listening False >>> # This HTTP server listen on all ipv4 addresses but not on ipv6 >>> host.socket("tcp://0.0.0.0:80").is_listening True >>> host.socket("tcp://:::80").is_listening False >>> host.socket("tcp://80").is_listening False .. note:: If you don't specify a host for udp and tcp sockets, then the socket is listening if and only if the socket listen on **both** all ipv4 and ipv6 addresses (ie 0.0.0.0 and ::) """ sockets = list(self._iter_sockets(True)) if self.protocol == "unix": return ("unix", self.host) in sockets allipv4 = (self.protocol, "0.0.0.0", self.port) in sockets allipv6 = (self.protocol, "::", self.port) in sockets return any([allipv6, all([allipv4, allipv6])]) or ( self.host is not None and ( (":" in self.host and allipv6 in sockets) or (":" not in self.host and allipv4 in sockets) or (self.protocol, self.host, self.port) in sockets ) ) @property def clients(self) -> List[Optional[Tuple[str, int]]]: """Return a list of clients connected to a listening socket For tcp and udp sockets a list of pair (address, port) is returned. For unix sockets a list of None is returned (thus you can make a len() for counting clients). >>> host.socket("tcp://22").clients [('2001:db8:0:1', 44298), ('192.168.31.254', 34866)] >>> host.socket("unix:///var/run/docker.sock") [None, None, None] """ sockets: List[Optional[Tuple[str, int]]] = [] for sock in self._iter_sockets(False): if sock[0] != self.protocol: continue if self.protocol == "unix": if sock[1] == self.host: sockets.append(None) continue if sock[2] != self.port: continue if ( self.host is None or (self.host == "0.0.0.0" and ":" not in sock[3]) or (self.host == "::" and ":" in sock[3]) or self.host == sock[3] ): sockets.append((sock[3], sock[4])) return sockets @classmethod def get_listening_sockets(cls): """Return a list of all listening sockets >>> host.socket.get_listening_sockets() ['tcp://0.0.0.0:22', 'tcp://:::22', 'unix:///run/systemd/private', ...] """ sockets = [] for sock in cls(None)._iter_sockets(True): if sock[0] == "unix": sockets.append("unix://" + sock[1]) else: sockets.append( "{}://{}:{}".format( sock[0], sock[1], sock[2], ) ) return sockets def _iter_sockets(self, listening): raise NotImplementedError def __repr__(self): return "".format( self.protocol, self.host + ":" if self.host else "", self.port, ) @classmethod def get_module_class(cls, host): if host.system_info.type == "linux": for cmd, impl in ( ("ss", LinuxSocketSS), ("netstat", LinuxSocketNetstat), ): try: command = host.find_command(cmd) except ValueError: pass else: return type(impl.__name__, (impl,), {"_command": command}) raise RuntimeError( 'could not use the Socket module, either "ss" or "netstat"' " utility is required in $PATH" ) if host.system_info.type.endswith("bsd"): return BSDSocket raise NotImplementedError class LinuxSocketSS(Socket): def _iter_sockets(self, listening): cmd = "%s --numeric" if listening: cmd += " --listening" else: cmd += " --all" if self.protocol == "tcp": cmd += " --tcp" elif self.protocol == "udp": cmd += " --udp" elif self.protocol == "unix": cmd += " --unix" for line in self.run(cmd, self._command).stdout_bytes.splitlines()[1:]: # Ignore unix datagram sockets. if line.split(None, 1)[0] == b"u_dgr": continue splitted = line.decode().split() # If listing only TCP or UDP sockets, output has 5 columns: # (State, Recv-Q, Send-Q, Local Address:Port, Peer Address:Port) if self.protocol in ("tcp", "udp"): protocol = self.protocol status, local, remote = (splitted[0], splitted[3], splitted[4]) # If listing all or just unix sockets, output has 6 columns: # Netid, State, Recv-Q, Send-Q, LocalAddress:Port, PeerAddress:Port else: protocol, status, local, remote = ( splitted[0], splitted[1], splitted[4], splitted[5], ) # ss reports unix socket as u_str. if protocol == "u_str": protocol = "unix" host, port = local, None elif protocol in ("tcp", "udp"): host, port = local.rsplit(":", 1) port = int(port) # new versions of ss output ipv6 addresses enclosed in [] if host and host[0] == "[" and host[-1] == "]": host = host[1:-1] else: continue # UDP listening sockets may be in 'UNCONN' status. if listening and status in ("LISTEN", "UNCONN"): if host == "*" and protocol in ("tcp", "udp"): yield protocol, "::", port yield protocol, "0.0.0.0", port elif protocol in ("tcp", "udp"): yield protocol, host, port else: yield protocol, host elif not listening and status == "ESTAB": if protocol in ("tcp", "udp"): remote_host, remote_port = remote.rsplit(":", 1) remote_port = int(remote_port) yield protocol, host, port, remote_host, remote_port else: yield protocol, remote class LinuxSocketNetstat(Socket): def _iter_sockets(self, listening): cmd = "%s -n" if listening: cmd += " -l" if self.protocol == "tcp": cmd += " -t" elif self.protocol == "udp": cmd += " -u" elif self.protocol == "unix": cmd += " --unix" for line in self.check_output(cmd, self._command).splitlines(): line = line.replace("\t", " ") splitted = line.split() protocol = splitted[0] if protocol in ("udp", "tcp", "tcp6", "udp6"): if protocol == "udp6": protocol = "udp" elif protocol == "tcp6": protocol = "tcp" address = splitted[3] host, port = address.rsplit(":", 1) port = int(port) if listening: yield protocol, host, port else: remote = splitted[4] remote_host, remote_port = remote.rsplit(":", 1) remote_port = int(remote_port) yield protocol, host, port, remote_host, remote_port elif protocol == "unix": yield protocol, splitted[-1] class BSDSocket(Socket): @functools.cached_property def _command(self): return self.find_command("netstat") def _iter_sockets(self, listening): cmd = "%s -n" if listening: cmd += " -a" if self.protocol == "unix": cmd += " -f unix" for line in self.check_output(cmd, self._command).splitlines(): line = line.replace("\t", " ") splitted = line.split() # FreeBSD: tcp4/tcp6 # OpeNBSD/NetBSD: tcp/tcp6 if splitted[0] in ("tcp", "udp", "udp4", "tcp4", "tcp6", "udp6"): address = splitted[3] if address == "*.*": # On OpenBSD 6.3 (issue #338) # udp 0 0 *.* *.* # udp6 0 0 *.* *.* continue host, port = address.rsplit(".", 1) port = int(port) if host == "*": if splitted[0] in ("udp6", "tcp6"): host = "::" else: host = "0.0.0.0" if splitted[0] in ("udp", "udp6", "udp4"): protocol = "udp" elif splitted[0] in ("tcp", "tcp6", "tcp4"): protocol = "tcp" remote = splitted[4] if remote == "*.*" and listening: yield protocol, host, port elif not listening: remote_host, remote_port = remote.rsplit(".", 1) remote_port = int(remote_port) yield protocol, host, port, remote_host, remote_port elif len(splitted) == 9 and splitted[1] in ("stream", "dgram"): if (splitted[4] != "0" and listening) or ( splitted[4] == "0" and not listening ): yield "unix", splitted[-1] pytest-testinfra-10.1.0/testinfra/modules/sudo.py000066400000000000000000000030601456331477400221260ustar00rootroot00000000000000# Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import contextlib from testinfra.modules.base import InstanceModule class Sudo(InstanceModule): """Sudo module allow to run certain portion of code under another user. It is used as a context manager and can be nested. >>> Command.check_output("whoami") 'phil' >>> with host.sudo(): ... host.check_output("whoami") ... with host.sudo("www-data"): ... host.check_output("whoami") ... 'root' 'www-data' """ @contextlib.contextmanager def __call__(self, user=None): old_get_command = self._host.backend.get_command quote = self._host.backend.quote get_sudo_command = self._host.backend.get_sudo_command def get_command(command, *args): return old_get_command(get_sudo_command(quote(command, *args), user)) self._host.backend.get_command = get_command try: yield finally: self._host.backend.get_command = old_get_command def __repr__(self): return "" pytest-testinfra-10.1.0/testinfra/modules/supervisor.py000066400000000000000000000122211456331477400233740ustar00rootroot00000000000000# Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from typing import NoReturn from testinfra.modules.base import Module STATUS = [ "STOPPED", "STARTING", "RUNNING", "BACKOFF", "STOPPING", "EXITED", "FATAL", "UNKNOWN", ] def supervisor_not_running() -> NoReturn: raise RuntimeError("Cannot get supervisor status. Is supervisor running ?") class Supervisor(Module): """Test supervisor managed services >>> gunicorn = host.supervisor("gunicorn") >>> gunicorn.status 'RUNNING' >>> gunicorn.is_running True >>> gunicorn.pid 4242 The path where supervisorctl and its configuration file reside can be specified. >>> gunicorn = host.supervisor("gunicorn", "/usr/bin/supervisorctl", "/etc/supervisor/supervisord.conf") >>> gunicorn.status 'RUNNING' """ def __init__( self, name, supervisorctl_path="supervisorctl", supervisorctl_conf=None, _attrs_cache=None, ): self.name = name self.supervisorctl_path = supervisorctl_path self.supervisorctl_conf = supervisorctl_conf self._attrs_cache = _attrs_cache super().__init__() @staticmethod def _parse_status(line): splitted = line.split() name = splitted[0] status = splitted[1] # some old supervisorctl versions exit status is 0 even if it cannot # connect to supervisord socket and output the error to stdout. So we # check that parsed status is a known status. if status not in STATUS: supervisor_not_running() if status == "RUNNING": pid = splitted[3] if pid[-1] == ",": pid = int(pid[:-1]) else: pid = int(pid) else: pid = None return {"name": name, "status": status, "pid": pid} @property def _attrs(self): if self._attrs_cache is None: if self.supervisorctl_conf: out = self.run_expect( [0, 3, 4], "%s -c %s status %s", self.supervisorctl_path, self.supervisorctl_conf, self.name, ) else: out = self.run_expect( [0, 3, 4], "%s status %s", self.supervisorctl_path, self.name ) if out.rc == 4: supervisor_not_running() line = out.stdout.rstrip("\r\n") attrs = self._parse_status(line) assert attrs["name"] == self.name self._attrs_cache = attrs return self._attrs_cache @property def is_running(self): """Return True if managed service is in status RUNNING""" return self.status == "RUNNING" @property def status(self): """Return the status of the managed service Status can be STOPPED, STARTING, RUNNING, BACKOFF, STOPPING, EXITED, FATAL, UNKNOWN. See http://supervisord.org/subprocess.html#process-states """ return self._attrs["status"] @property def pid(self): """Return the pid (as int) of the managed service""" return self._attrs["pid"] @classmethod def get_services( cls, supervisorctl_path="supervisorctl", supervisorctl_conf=None, ): """Get a list of services running under supervisor >>> host.supervisor.get_services() [ ] The path where supervisorctl and its configuration file reside can be specified. >>> host.supervisor.get_services("/usr/bin/supervisorctl", "/etc/supervisor/supervisord.conf") [ ] """ services = [] if supervisorctl_conf: out = cls.check_output( "%s -c %s status", supervisorctl_path, supervisorctl_conf ) else: out = cls.check_output("%s status", supervisorctl_path) for line in out.splitlines(): attrs = cls._parse_status(line) service = cls( attrs["name"], supervisorctl_path=supervisorctl_path, supervisorctl_conf=supervisorctl_conf, _attrs_cache=attrs, ) services.append(service) return services def __repr__(self): return "".format( self.name, self.status, self.pid, ) pytest-testinfra-10.1.0/testinfra/modules/sysctl.py000066400000000000000000000021731456331477400225010ustar00rootroot00000000000000# Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import functools from testinfra.modules.base import InstanceModule class Sysctl(InstanceModule): """Test kernel parameters >>> host.sysctl("kernel.osrelease") "3.16.0-4-amd64" >>> host.sysctl("vm.dirty_ratio") 20 """ @functools.cached_property def _sysctl_command(self): return self.find_command("sysctl") def __call__(self, name): value = self.check_output("%s -n %s", self._sysctl_command, name) try: return int(value) except ValueError: return value def __repr__(self): return "" pytest-testinfra-10.1.0/testinfra/modules/systeminfo.py000066400000000000000000000133171456331477400233620ustar00rootroot00000000000000# Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import functools import re from testinfra.modules.base import InstanceModule class SystemInfo(InstanceModule): """Return system information""" @functools.cached_property def sysinfo(self): sysinfo = { "type": None, "distribution": None, "codename": None, "release": None, "arch": None, } uname = self.run_expect([0, 1], "uname -s") if uname.rc == 1 or uname.stdout.lower().startswith("msys"): # FIXME: find a better way to detect windows here sysinfo.update(**self._get_windows_sysinfo()) return sysinfo sysinfo["type"] = uname.stdout.rstrip("\r\n").lower() if sysinfo["type"] == "linux": sysinfo.update(**self._get_linux_sysinfo()) elif sysinfo["type"] == "darwin": sysinfo.update(**self._get_darwin_sysinfo()) else: # BSD sysinfo["release"] = self.check_output("uname -r") sysinfo["distribution"] = sysinfo["type"] sysinfo["codename"] = None sysinfo["arch"] = self.check_output("uname -m") return sysinfo def _get_linux_sysinfo(self): sysinfo = {} # https://www.freedesktop.org/software/systemd/man/os-release.html os_release = self.run("cat /etc/os-release") if os_release.rc == 0: for line in os_release.stdout.splitlines(): for key, attname in ( ("ID=", "distribution"), ("VERSION_ID=", "release"), ("VERSION_CODENAME=", "codename"), ): if line.startswith(key): sysinfo[attname] = ( line[len(key) :].replace('"', "").replace("'", "").strip() ) # Arch doesn't have releases if "distribution" in sysinfo and sysinfo["distribution"] == "arch": sysinfo["release"] = "rolling" return sysinfo # RedHat / CentOS 6 haven't /etc/os-release redhat_release = self.run("cat /etc/redhat-release") if redhat_release.rc == 0: match = re.match( r"^(.+) release ([^ ]+) .*$", redhat_release.stdout.strip() ) if match: sysinfo["distribution"], sysinfo["release"] = match.groups() return sysinfo # Alpine doesn't have /etc/os-release alpine_release = self.run("cat /etc/alpine-release") if alpine_release.rc == 0: sysinfo["distribution"] = "alpine" sysinfo["release"] = alpine_release.stdout.strip() return sysinfo # LSB lsb = self.run("lsb_release -a") if lsb.rc == 0: for line in lsb.stdout.splitlines(): key, value = line.split(":", 1) key = key.strip().lower() value = value.strip().lower() if key == "distributor id": sysinfo["distribution"] = value elif key == "release": sysinfo["release"] = value elif key == "codename": sysinfo["codename"] = value return sysinfo return sysinfo def _get_darwin_sysinfo(self): sysinfo = {} sw_vers = self.run("sw_vers") if sw_vers.rc == 0: for line in sw_vers.stdout.splitlines(): key, value = line.split(":", 1) key = key.strip().lower() value = value.strip() if key == "productname": sysinfo["distribution"] = value elif key == "productversion": sysinfo["release"] = value return sysinfo def _get_windows_sysinfo(self): sysinfo = {} for line in self.check_output('systeminfo | findstr /B /C:"OS"').splitlines(): key, value = line.split(":", 1) key = key.strip().replace(" ", "_").lower() value = value.strip() if key == "os_name": sysinfo["distribution"] = value sysinfo["type"] = value.split(" ")[1].lower() elif key == "os_version": sysinfo["release"] = value sysinfo["arch"] = self.check_output("echo %PROCESSOR_ARCHITECTURE%") return sysinfo @property def type(self): """OS type >>> host.system_info.type 'linux' """ return self.sysinfo["type"] @property def distribution(self): """Distribution name >>> host.system_info.distribution 'debian' """ return self.sysinfo["distribution"] @property def release(self): """Distribution release number >>> host.system_info.release '10.2' """ return self.sysinfo["release"] @property def codename(self): """Release code name >>> host.system_info.codename 'bullseye' """ return self.sysinfo["codename"] @property def arch(self): """Host architecture >>> host.system_info.arch 'x86_64' """ return self.sysinfo["arch"] pytest-testinfra-10.1.0/testinfra/modules/user.py000066400000000000000000000145741456331477400221460ustar00rootroot00000000000000# Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import datetime from testinfra.modules.base import Module class User(Module): """Test unix users If name is not supplied, test the current user """ def __init__(self, name=None): self._name = name super().__init__() @property def name(self): """Return user name""" if self._name is None: self._name = self.check_output("id -nu") return self._name @property def exists(self): """Test if user exists >>> host.user("root").exists True >>> host.user("nosuchuser").exists False """ return self.run_test("id %s", self.name).rc == 0 @property def uid(self): """Return user ID""" return int(self.check_output("id -u %s", self.name)) @property def gid(self): """Return effective group ID""" return int(self.check_output("id -g %s", self.name)) @property def group(self): """Return effective group name""" return self.check_output("id -ng %s", self.name) @property def gids(self): """Return the list of user group IDs""" return [ int(gid) for gid in self.check_output( "id -G %s", self.name, ).split(" ") ] @property def groups(self): """Return the list of user group names""" return self.check_output("id -nG %s", self.name).split(" ") @property def home(self): """Return the user home directory""" return self.check_output("getent passwd %s", self.name).split(":")[5] @property def shell(self): """Return the user login shell""" return self.check_output("getent passwd %s", self.name).split(":")[6] @property def password(self): """Return the encrypted user password""" return self.check_output("getent shadow %s", self.name).split(":")[1] @property def password_max_days(self): """Return the maximum number of days between password changes""" days = self.check_output("getent shadow %s", self.name).split(":")[4] try: return int(days) except ValueError: return None @property def password_min_days(self): """Return the minimum number of days between password changes""" days = self.check_output("getent shadow %s", self.name).split(":")[3] try: return int(days) except ValueError: return None @property def gecos(self): """Return the user comment/gecos field""" return self.check_output("getent passwd %s", self.name).split(":")[4] @property def expiration_date(self): """Return the account expiration date >>> host.user("phil").expiration_date datetime.datetime(2020, 1, 1, 0, 0) >>> host.user("root").expiration_date None """ days = self.check_output("getent shadow %s", self.name).split(":")[7] try: days = int(days) except ValueError: return None if days > 0: epoch = datetime.datetime.utcfromtimestamp(0) return epoch + datetime.timedelta(days=int(days)) @classmethod def get_module_class(cls, host): if host.system_info.type.endswith("bsd"): return BSDUser if host.system_info.type == "windows": return WindowsUser return super().get_module_class(host) def __repr__(self): return "".format(self.name) class BSDUser(User): @property def password(self): return self.check_output("getent passwd %s", self.name).split(":")[1] @property def expiration_date(self): seconds = self.check_output("getent passwd %s", self.name).split(":")[6] try: seconds = int(seconds) except ValueError: return None if seconds > 0: epoch = datetime.datetime.utcfromtimestamp(0) return epoch + datetime.timedelta(seconds=int(seconds)) class WindowsUser(User): @property def name(self): """Return user name""" if self._name is None: self._name = self.check_output("echo %username%") return self._name @property def exists(self): """Test if user exists >>> host.user("Administrator").exists True >>> host.user("nosuchuser").exists False """ return self.run_test("net user %s", self.name).rc == 0 @property def uid(self): raise NotImplementedError @property def gid(self): raise NotImplementedError @property def group(self): raise NotImplementedError @property def gids(self): raise NotImplementedError @property def groups(self): """Return the list of user local group names""" local_groups = self.check_output( 'net user %s | findstr /B /C:"Local ' 'Group Memberships"', self.name ) local_groups = local_groups.split()[3:] return [g.replace("*", "") for g in local_groups] @property def home(self): raise NotImplementedError @property def shell(self): raise NotImplementedError @property def gecos(self): comment = self.check_output('net user %s | find /B /C:"Comment"', self.name) return comment.split().strip()[1] @property def password(self): raise NotImplementedError @property def expiration_date(self): expiration = self.check_output( 'net user %s | findstr /B /C:"Password \ expires"', self.name, ) expiration = expiration.split().strip()[1] if expiration == "Never": return None return datetime.datetime.strptime(expiration, "%m/%d/%Y %H:%M%S %p") pytest-testinfra-10.1.0/testinfra/plugin.py000066400000000000000000000147741456331477400210200ustar00rootroot00000000000000# Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from __future__ import annotations import logging import shutil import sys import tempfile import time from typing import AnyStr, cast import pytest import testinfra import testinfra.host import testinfra.modules @pytest.fixture(scope="module") def _testinfra_host(request: pytest.FixtureRequest) -> testinfra.host.Host: return cast(testinfra.host.Host, request.param) @pytest.fixture(scope="module") def host(_testinfra_host: testinfra.host.Host) -> testinfra.host.Host: return _testinfra_host host.__doc__ = testinfra.host.Host.__doc__ def pytest_addoption(parser: pytest.Parser) -> None: group = parser.getgroup("testinfra") group.addoption( "--connection", action="store", dest="connection", help=( "Remote connection backend (paramiko, ssh, safe-ssh, " "salt, docker, ansible, podman)" ), ) group.addoption( "--hosts", action="store", dest="hosts", help="Hosts list (comma separated)", ) group.addoption( "--ssh-config", action="store", dest="ssh_config", help="SSH config file", ) group.addoption( "--ssh-extra-args", action="store", dest="ssh_extra_args", help="SSH extra args", ) group.addoption( "--ssh-identity-file", action="store", dest="ssh_identity_file", help="SSH identify file", ) group.addoption( "--sudo", action="store_true", dest="sudo", help="Use sudo", ) group.addoption( "--sudo-user", action="store", dest="sudo_user", help="sudo user", ) group.addoption( "--ansible-inventory", action="store", dest="ansible_inventory", help="Ansible inventory file", ) group.addoption( "--force-ansible", action="store_true", dest="force_ansible", help=( "Force use of ansible connection backend only (slower but all " "ansible connection options are handled)" ), ) group.addoption( "--nagios", action="store_true", dest="nagios", help="Nagios plugin", ) def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: if "_testinfra_host" in metafunc.fixturenames: if metafunc.config.option.hosts is not None: hosts = metafunc.config.option.hosts.split(",") elif hasattr(metafunc.module, "testinfra_hosts"): hosts = metafunc.module.testinfra_hosts else: hosts = [None] params = testinfra.get_hosts( hosts, connection=metafunc.config.option.connection, ssh_config=metafunc.config.option.ssh_config, ssh_identity_file=metafunc.config.option.ssh_identity_file, sudo=metafunc.config.option.sudo, sudo_user=metafunc.config.option.sudo_user, ansible_inventory=metafunc.config.option.ansible_inventory, force_ansible=metafunc.config.option.force_ansible, ) params = sorted(params, key=lambda x: x.backend.get_pytest_id()) ids = [e.backend.get_pytest_id() for e in params] metafunc.parametrize( "_testinfra_host", params, ids=ids, scope="module", indirect=True ) class NagiosReporter: def __init__(self, out): self.passed = 0 self.failed = 0 self.skipped = 0 self.start_time = time.time() self.total_time = None self.out = out def pytest_runtest_logreport(self, report: pytest.TestReport) -> None: if report.passed: if report.when == "call": # ignore setup/teardown self.passed += 1 elif report.failed: self.failed += 1 elif report.skipped: self.skipped += 1 def report(self) -> int: if self.failed: status = b"CRITICAL" ret = 2 else: status = b"OK" ret = 0 out = sys.stdout.buffer out.write( (b"TESTINFRA %s - %d passed, %d failed, %d skipped in %.2f " b"seconds\n") % ( status, self.passed, self.failed, self.skipped, time.time() - self.start_time, ) ) self.out.seek(0) shutil.copyfileobj(self.out, out) return ret class SpooledTemporaryFile(tempfile.SpooledTemporaryFile[AnyStr]): def __init__(self, *args, **kwargs): if "b" in kwargs.get("mode", "b"): self._out_encoding = kwargs.pop("encoding") else: self._out_encoding = kwargs.get("encoding") super().__init__(*args, **kwargs) def write(self, s): # avoid traceback in py.io.terminalwriter.write_out # TypeError: a bytes-like object is required, not 'str' if isinstance(s, str): s = s.encode(self._out_encoding) return super().write(s) @pytest.hookimpl(trylast=True) def pytest_configure(config): if config.getoption("--verbose", 0) > 1: root = logging.getLogger() if not root.handlers: root.addHandler(logging.NullHandler()) logging.getLogger("testinfra").setLevel(logging.DEBUG) if config.getoption("--nagios"): # disable & re-enable terminalreporter to write in a tempfile reporter = config.pluginmanager.getplugin("terminalreporter") if reporter: out = SpooledTemporaryFile(encoding=sys.stdout.encoding) config.pluginmanager.unregister(reporter) reporter = reporter.__class__(config, out) config.pluginmanager.register(reporter, "terminalreporter") config.pluginmanager.register(NagiosReporter(out), "nagiosreporter") @pytest.hookimpl(trylast=True) def pytest_sessionfinish(session, exitstatus): reporter = session.config.pluginmanager.getplugin("nagiosreporter") if reporter: session.exitstatus = reporter.report() pytest-testinfra-10.1.0/testinfra/utils/000077500000000000000000000000001456331477400202735ustar00rootroot00000000000000pytest-testinfra-10.1.0/testinfra/utils/__init__.py000066400000000000000000000000001456331477400223720ustar00rootroot00000000000000pytest-testinfra-10.1.0/testinfra/utils/ansible_runner.py000066400000000000000000000324521456331477400236610ustar00rootroot00000000000000# Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import configparser import fnmatch import functools import ipaddress import json import os import tempfile from typing import Any, Callable, Iterator, Optional, Union import testinfra import testinfra.host __all__ = ["AnsibleRunner"] local = testinfra.get_host("local://") def get_ansible_config() -> configparser.ConfigParser: fname = os.environ.get("ANSIBLE_CONFIG") if not fname: for possible in ( "ansible.cfg", os.path.join(os.path.expanduser("~"), ".ansible.cfg"), os.path.join("/", "etc", "ansible", "ansible.cfg"), ): if os.path.exists(possible): fname = possible break config = configparser.ConfigParser() if not fname: return config config.read(fname) return config Inventory = dict[str, Any] def get_ansible_inventory( config: configparser.ConfigParser, inventory_file: Optional[str] ) -> Inventory: # Disable ansible verbosity to avoid # https://github.com/ansible/ansible/issues/59973 cmd = "ANSIBLE_VERBOSITY=0 ansible-inventory --list" args = [] if inventory_file: cmd += " -i %s" args += [inventory_file] return json.loads(local.check_output(cmd, *args)) # type: ignore[no-any-return] def get_ansible_host( config: configparser.ConfigParser, inventory: Inventory, host: str, ssh_config: Optional[str] = None, ssh_identity_file: Optional[str] = None, ) -> Optional[testinfra.host.Host]: if is_empty_inventory(inventory): if host == "localhost": return testinfra.get_host("local://") return None hostvars = inventory["_meta"].get("hostvars", {}).get(host, {}) connection = hostvars.get("ansible_connection", "ssh") if connection not in ( "smart", "ssh", "paramiko_ssh", "local", "docker", "community.docker.docker", "lxc", "lxd", ): # unhandled connection type, must use force_ansible=True return None connection = { "community.docker.docker": "docker", "lxd": "lxc", "paramiko_ssh": "paramiko", "smart": "ssh", }.get(connection, connection) options: dict[str, Any] = { "ansible_become": { "ini": { "section": "privilege_escalation", "key": "become", }, "environment": "ANSIBLE_BECOME", }, "ansible_become_user": { "ini": { "section": "privilege_escalation", "key": "become_user", }, "environment": "ANSIBLE_BECOME_USER", }, "ansible_port": { "ini": { "section": "defaults", "key": "remote_port", }, "environment": "ANSIBLE_REMOTE_PORT", }, "ansible_ssh_common_args": { "ini": { "section": "ssh_connection", "key": "ssh_common_args", }, "environment": "ANSIBLE_SSH_COMMON_ARGS", }, "ansible_ssh_extra_args": { "ini": { "section": "ssh_connection", "key": "ssh_extra_args", }, "environment": "ANSIBLE_SSH_EXTRA_ARGS", }, "ansible_user": { "ini": { "section": "defaults", "key": "remote_user", }, "environment": "ANSIBLE_REMOTE_USER", }, } def get_config( name: str, default: Union[None, bool, str] = None ) -> Union[None, bool, str]: value = default option = options.get(name, {}) ini = option.get("ini") if ini: value = config.get(ini["section"], ini["key"], fallback=default) if name in hostvars: value = hostvars[name] var = option.get("environment") if var and var in os.environ: value = os.environ[var] return value testinfra_host = get_config("ansible_host", host) assert isinstance(testinfra_host, str), testinfra_host user = get_config("ansible_user") password = get_config("ansible_ssh_pass") port = get_config("ansible_port") kwargs: dict[str, Union[None, str, bool]] = {} if get_config("ansible_become", False): kwargs["sudo"] = True kwargs["sudo_user"] = get_config("ansible_become_user") if ssh_config is not None: kwargs["ssh_config"] = ssh_config if ssh_identity_file is not None: kwargs["ssh_identity_file"] = ssh_identity_file # Support both keys as advertised by Ansible if "ansible_ssh_private_key_file" in hostvars: kwargs["ssh_identity_file"] = hostvars["ansible_ssh_private_key_file"] elif "ansible_private_key_file" in hostvars: kwargs["ssh_identity_file"] = hostvars["ansible_private_key_file"] kwargs["ssh_extra_args"] = " ".join( [ config.get("ssh_connection", "ssh_args", fallback=""), get_config("ansible_ssh_common_args", ""), # type: ignore[list-item] get_config("ansible_ssh_extra_args", ""), # type: ignore[list-item] ] ).strip() control_path = config.get("ssh_connection", "control_path", fallback="", raw=True) if control_path: directory = config.get( "persistent_connection", "control_path_dir", fallback="~/.ansible/cp" ) control_path = control_path % ({"directory": directory}) # noqa: S001 # restore original "%%" control_path = control_path.replace("%", "%%") kwargs["controlpath"] = control_path spec = "{}://".format(connection) # Fallback to user:password auth when identity file is not used if user and password and not kwargs.get("ssh_identity_file"): spec += "{}:{}@".format(user, password) elif user: spec += "{}@".format(user) try: version = ipaddress.ip_address(testinfra_host).version except ValueError: version = None if version == 6: spec += "[" + testinfra_host + "]" else: spec += testinfra_host if port: spec += ":{}".format(port) return testinfra.get_host(spec, **kwargs) def itergroup(inventory: Inventory, group: str) -> Iterator[str]: for host in inventory.get(group, {}).get("hosts", []): yield host for g in inventory.get(group, {}).get("children", []): for host in itergroup(inventory, g): yield host def is_empty_inventory(inventory: Inventory) -> bool: return not any(True for _ in itergroup(inventory, "all")) class AnsibleRunner: _runners: dict[Optional[str], "AnsibleRunner"] = {} _known_options = { # Boolean arguments. "become": { "cli": "--become", "type": "boolean", }, "check": { "cli": "--check", "type": "boolean", }, "diff": { "cli": "--diff", "type": "boolean", }, "one_line": { "cli": "--one-line", "type": "boolean", }, # String arguments. "become_method": { "cli": "--become-method", "type": "string", }, "become_user": { "cli": "--become-user", "type": "string", }, "user": { "cli": "--user", "type": "string", }, # Arguments serialized as JSON. "extra_vars": { "cli": "--extra-vars", "type": "json", }, } def __init__(self, inventory_file: Optional[str] = None): self.inventory_file = inventory_file self._host_cache: dict[str, Optional[testinfra.host.Host]] = {} super().__init__() def get_hosts(self, pattern: str = "all") -> list[str]: inventory = self.inventory result = set() if is_empty_inventory(inventory): # empty inventory should not return any hosts except for localhost if pattern == "localhost": result.add("localhost") else: raise RuntimeError( "No inventory was parsed (missing file ?), " "only implicit localhost is available" ) else: for group in inventory: groupmatch = fnmatch.fnmatch(group, pattern) if groupmatch: result |= set(itergroup(inventory, group)) for host in inventory[group].get("hosts", []): if fnmatch.fnmatch(host, pattern): result.add(host) return sorted(result) @functools.cached_property def inventory(self) -> Inventory: return get_ansible_inventory(self.ansible_config, self.inventory_file) @functools.cached_property def ansible_config(self) -> configparser.ConfigParser: return get_ansible_config() def get_variables(self, host: str) -> dict[str, Any]: inventory = self.inventory # inventory_hostname, group_names and groups are for backward # compatibility with testinfra 2.X hostvars: dict[str, Any] = inventory["_meta"].get("hostvars", {}).get(host, {}) hostvars.setdefault("inventory_hostname", host) group_names = [] groups = {} for group in sorted(inventory): if group == "_meta": continue groups[group] = sorted(itergroup(inventory, group)) if host in groups[group]: group_names.append(group) hostvars.setdefault("group_names", group_names) hostvars.setdefault("groups", groups) return hostvars def get_host(self, host: str, **kwargs: Any) -> Optional[testinfra.host.Host]: try: return self._host_cache[host] except KeyError: self._host_cache[host] = get_ansible_host( self.ansible_config, self.inventory, host, **kwargs ) return self._host_cache[host] def options_to_cli(self, options: dict[str, Any]) -> tuple[str, list[str]]: verbose = options.pop("verbose", 0) args = {"become": False, "check": True} args.update(options) cli: list[str] = [] cli_args: list[str] = [] if verbose: cli.append("-" + "v" * verbose) for arg_name, value in args.items(): option = self._known_options[arg_name] opt_cli = option["cli"] opt_type = option["type"] if opt_type == "boolean": if value: cli.append(opt_cli) elif opt_type == "string": assert isinstance(value, str) cli.append(opt_cli + " %s") cli_args.append(value) elif opt_type == "json": cli.append(opt_cli + " %s") value_json = json.dumps(value) cli_args.append(value_json) else: raise TypeError("Unsupported argument type '{}'.".format(opt_type)) return " ".join(cli), cli_args def run_module( self, host: str, module_name: str, module_args: Optional[str], get_encoding: Optional[Callable[[], str]] = None, **options: Any, ) -> Any: cmd, args = "ansible --tree %s", [] if self.inventory_file: cmd += " -i %s" args += [self.inventory_file] cmd += " -m %s" args += [module_name] if module_args: cmd += " --args %s" args += [module_args] options_cli, options_args = self.options_to_cli(options) if options_cli: cmd += " " + options_cli args.extend(options_args) cmd += " %s" args += [host] with tempfile.TemporaryDirectory() as d: args.insert(0, d) out = local.run_expect([0, 2, 8], cmd, *args) files = os.listdir(d) if not files and "skipped" in out.stdout.lower(): return { "failed": True, "skipped": True, "msg": "Skipped. You might want to try check=False", } if not files: raise RuntimeError(f"{out}") fpath = os.path.join(d, files[0]) try: with open(fpath, "r", encoding="ascii") as f: return json.load(f) except UnicodeDecodeError: if get_encoding is None: raise with open(fpath, "r", encoding=get_encoding()) as f: return json.load(f) @classmethod def get_runner(cls, inventory: Optional[str]) -> "AnsibleRunner": try: return cls._runners[inventory] except KeyError: cls._runners[inventory] = cls(inventory) return cls._runners[inventory] pytest-testinfra-10.1.0/tox.ini000066400000000000000000000015061456331477400164510ustar00rootroot00000000000000[tox] minversion = 4.0.16 envlist= lint py docs packaging [testenv] description = Runs unittests deps= -rtest-requirements.txt commands= {envpython} -m pytest {posargs:-v -n 4 --cov testinfra --cov-report xml --cov-report term test} usedevelop=True passenv= HOME TRAVIS DOCKER_CERT_PATH DOCKER_HOST DOCKER_TLS_VERIFY WSL_DISTRO_NAME [testenv:lint] description = Performs linting tasks deps = pre-commit>=2.6.0 commands= pre-commit run -a [testenv:docs] deps=-rdev-requirements.txt commands=sphinx-build -W -b html doc/source doc/build [testenv:packaging] description = Validate project packaging skip_install = true setenv = PEP440_VERSION=true deps= check-manifest commands= {envpython} -m check_manifest {toxinidir} {envpython} setup.py sdist {envpython} setup.py bdist_wheel