├── .editorconfig ├── .github ├── FUNDING.yml ├── dependabot.yml ├── release-drafter.yml └── workflows │ ├── release.yml │ └── tox.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yaml ├── CHANGELOG.rst ├── CONTRIBUTING.rst ├── LICENSE ├── Makefile ├── README.rst ├── ansible.cfg ├── doc ├── Makefile └── source │ ├── _static │ └── logo.svg │ ├── _templates │ └── piwik.html │ ├── api.rst │ ├── backends.rst │ ├── changelog.rst │ ├── conf.py │ ├── examples.rst │ ├── index.rst │ ├── invocation.rst │ ├── modules.rst │ └── support.rst ├── hatch.toml ├── images ├── debian_bookworm │ └── Dockerfile └── rockylinux9 │ └── Dockerfile ├── mypy.ini ├── pyproject.toml ├── ruff.toml ├── test ├── conftest.py ├── ssh_key ├── test_backends.py ├── test_invocation.py └── test_modules.py ├── testinfra ├── __init__.py ├── backend │ ├── __init__.py │ ├── ansible.py │ ├── base.py │ ├── chroot.py │ ├── docker.py │ ├── kubectl.py │ ├── local.py │ ├── lxc.py │ ├── openshift.py │ ├── paramiko.py │ ├── podman.py │ ├── salt.py │ ├── ssh.py │ └── winrm.py ├── host.py ├── main.py ├── modules │ ├── __init__.py │ ├── addr.py │ ├── ansible.py │ ├── base.py │ ├── blockdevice.py │ ├── command.py │ ├── docker.py │ ├── environment.py │ ├── file.py │ ├── group.py │ ├── interface.py │ ├── iptables.py │ ├── mountpoint.py │ ├── package.py │ ├── pip.py │ ├── podman.py │ ├── process.py │ ├── puppet.py │ ├── salt.py │ ├── service.py │ ├── socket.py │ ├── sudo.py │ ├── supervisor.py │ ├── sysctl.py │ ├── systeminfo.py │ └── user.py ├── plugin.py └── utils │ ├── __init__.py │ └── ansible_runner.py └── tox.ini /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 4 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | end_of_line = lf 9 | charset = utf-8 10 | 11 | [*.py] 12 | max_line_length = 79 13 | 14 | [*.yml] 15 | indent_size = 2 16 | max_line_length = 79 17 | 18 | [doc/**.rst] 19 | max_line_length = 79 20 | 21 | [Dockerfile] 22 | indent_size = 2 23 | 24 | [Makefile] 25 | indent_style = tab 26 | max_line_length = 79 27 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | liberapay: philpep 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | updates: 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: daily 8 | labels: 9 | - "dependencies" 10 | - "skip-changelog" 11 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # see https://github.com/ansible/devtools 3 | _extends: ansible/devtools 4 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: release 3 | 4 | on: 5 | release: 6 | types: [published] 7 | 8 | jobs: 9 | pypi: 10 | name: Publish to PyPI registry 11 | environment: 12 | name: pypi 13 | url: https://pypi.org/p/pytest-testinfra 14 | runs-on: ubuntu-22.04 15 | permissions: 16 | id-token: write 17 | env: 18 | FORCE_COLOR: 1 19 | PY_COLORS: 1 20 | steps: 21 | - uses: actions/checkout@v4 22 | - name: Switch to using Python 3.11 by default 23 | uses: actions/setup-python@v5 24 | with: 25 | python-version: "3.11" 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install --upgrade pip 29 | pip install tox 30 | - name: Build dists 31 | run: | 32 | tox -e packaging 33 | - name: Publish to pypi.org 34 | uses: pypa/gh-action-pypi-publish@release/v1 35 | -------------------------------------------------------------------------------- /.github/workflows/tox.yml: -------------------------------------------------------------------------------- 1 | name: tox 2 | 3 | on: 4 | create: # is used for publishing to TestPyPI 5 | tags: # any tag regardless of its name, no branches 6 | - "**" 7 | push: # only publishes pushes to the main branch to TestPyPI 8 | branches: # any integration branch but not tag 9 | - "main" 10 | pull_request: 11 | release: 12 | types: 13 | - published # It seems that you can publish directly without creating 14 | schedule: 15 | - cron: 1 0 15 * * # Run each month on day 15 at 0:01 UTC 16 | 17 | concurrency: 18 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }} 19 | cancel-in-progress: true 20 | 21 | jobs: 22 | lint: 23 | runs-on: ubuntu-latest 24 | strategy: 25 | fail-fast: false 26 | matrix: 27 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] 28 | steps: 29 | - uses: actions/checkout@v4 30 | - name: Set up Python ${{ matrix.python-version }} 31 | uses: actions/setup-python@v5 32 | with: 33 | python-version: ${{ matrix.python-version }} 34 | - name: Install dependencies 35 | run: | 36 | python -m pip install --upgrade pip 37 | pip install tox 38 | - name: Run tox -e lint 39 | run: | 40 | tox -e lint 41 | - name: Run tox -e mypy 42 | run: | 43 | tox -e mypy 44 | build: 45 | runs-on: ubuntu-latest 46 | strategy: 47 | fail-fast: false 48 | matrix: 49 | toxenv: [docs, packaging, py311] 50 | steps: 51 | - uses: actions/checkout@v4 52 | - name: Set up Python 3.11 53 | uses: actions/setup-python@v5 54 | with: 55 | python-version: "3.11" 56 | - name: Install dependencies 57 | run: | 58 | python -m pip install --upgrade pip 59 | pip install tox 60 | - name: Run tox -e ${{ matrix.toxenv }} 61 | run: | 62 | tox -e ${{ matrix.toxenv }} 63 | check: # This job does nothing and is only used for the branch protection 64 | if: always() 65 | permissions: 66 | pull-requests: write # allow codenotify to comment on pull-request 67 | needs: 68 | - lint 69 | - build 70 | runs-on: ubuntu-latest 71 | steps: 72 | - name: Decide whether the needed jobs succeeded or failed 73 | uses: re-actors/alls-green@release/v1 74 | with: 75 | jobs: ${{ toJSON(needs) }} 76 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py~ 2 | *.pyc 3 | *.egg-info 4 | *.egg 5 | .eggs 6 | *.swp 7 | .local 8 | .coverage* 9 | .tox 10 | .python-version 11 | build 12 | dist 13 | *.log 14 | AUTHORS 15 | ChangeLog 16 | coverage.xml 17 | junit*.xml 18 | doc/build 19 | .cache 20 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | ci: 3 | # format compatible with commitlint 4 | autoupdate_commit_msg: "chore: pre-commit autoupdate" 5 | autoupdate_schedule: monthly 6 | autofix_commit_msg: | 7 | chore: auto fixes from pre-commit.com hooks 8 | 9 | for more information, see https://pre-commit.ci 10 | # exclude: > 11 | # (?x)^( 12 | # )$ 13 | repos: 14 | - repo: meta 15 | hooks: 16 | - id: check-useless-excludes 17 | - repo: https://github.com/pre-commit/pre-commit-hooks.git 18 | rev: v4.4.0 19 | hooks: 20 | - id: end-of-file-fixer 21 | - id: trailing-whitespace 22 | - id: mixed-line-ending 23 | - id: fix-byte-order-marker 24 | - id: check-executables-have-shebangs 25 | - id: check-merge-conflict 26 | - id: debug-statements 27 | language_version: python3 28 | - repo: local 29 | hooks: 30 | - id: ruff-check 31 | name: ruff-check 32 | entry: ruff check 33 | language: system 34 | types: [python] 35 | - id: ruff-format 36 | name: ruff 37 | entry: ruff format --check --diff 38 | language: system 39 | types: [python] 40 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | build: 3 | os: ubuntu-22.04 4 | tools: 5 | python: "3.11" 6 | python: 7 | install: 8 | - method: pip 9 | path: . 10 | extra_requirements: 11 | - doc 12 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | ######################### 2 | Contributing to testinfra 3 | ######################### 4 | 5 | First, thanks for contributing to testinfra and make it even more awesome ! 6 | 7 | Pull requests 8 | ============= 9 | 10 | You're encouraged to setup a full test environment, to add tests and check if 11 | all the tests pass *before* submitting your pull request. To run the complete 12 | test suite you must install: 13 | 14 | - `Docker `_ 15 | - `tox `_ 16 | 17 | To run all tests run:: 18 | 19 | tox 20 | 21 | To run only some selected tests:: 22 | 23 | # Only tests matching 'ansible' on 4 processes with pytest-xdist 24 | tox -- -v -n 4 -k ansible test 25 | 26 | # Only modules tests on a specific Python 3, e.g., 3.8 and spawn a pdb on error 27 | tox -e py38 -- -v --pdb test/test_modules.py 28 | 29 | 30 | Code style 31 | ========== 32 | 33 | Your code must pass without errors under `ruff `_ 34 | 35 | pip install ruff 36 | ruff check 37 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: doc 2 | 3 | doc: 4 | $(MAKE) -C doc html 5 | 6 | 7 | .PHONY: all doc 8 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ################################## 2 | Testinfra test your infrastructure 3 | ################################## 4 | 5 | Latest documentation: https://testinfra.readthedocs.io/en/latest 6 | 7 | About 8 | ===== 9 | 10 | With Testinfra you can write unit tests in Python to test *actual state* of 11 | your servers configured by management tools like Salt_, Ansible_, Puppet_, 12 | Chef_ and so on. 13 | 14 | Testinfra aims to be a Serverspec_ equivalent in python and is written as 15 | a plugin to the powerful Pytest_ test engine 16 | 17 | License 18 | ======= 19 | 20 | `Apache License 2.0 `_ 21 | 22 | The logo is licensed under the `Creative Commons NoDerivatives 4.0 License `_ 23 | If you have some other use in mind, contact us. 24 | 25 | Quick start 26 | =========== 27 | 28 | Install testinfra using pip:: 29 | 30 | $ pip install pytest-testinfra 31 | 32 | # or install the devel version 33 | $ pip install 'git+https://github.com/pytest-dev/pytest-testinfra@main#egg=pytest-testinfra' 34 | 35 | 36 | Write your first tests file to `test_myinfra.py`: 37 | 38 | .. code-block:: python 39 | 40 | def test_passwd_file(host): 41 | passwd = host.file("/etc/passwd") 42 | assert passwd.contains("root") 43 | assert passwd.user == "root" 44 | assert passwd.group == "root" 45 | assert passwd.mode == 0o644 46 | 47 | 48 | def test_nginx_is_installed(host): 49 | nginx = host.package("nginx") 50 | assert nginx.is_installed 51 | assert nginx.version.startswith("1.2") 52 | 53 | 54 | def test_nginx_running_and_enabled(host): 55 | nginx = host.service("nginx") 56 | assert nginx.is_running 57 | assert nginx.is_enabled 58 | 59 | 60 | And run it:: 61 | 62 | $ pytest -v test_myinfra.py 63 | 64 | 65 | ====================== test session starts ====================== 66 | platform linux -- Python 2.7.3 -- py-1.4.26 -- pytest-2.6.4 67 | plugins: testinfra 68 | collected 3 items 69 | 70 | test_myinfra.py::test_passwd_file[local] PASSED 71 | test_myinfra.py::test_nginx_is_installed[local] PASSED 72 | test_myinfra.py::test_nginx_running_and_enabled[local] PASSED 73 | 74 | =================== 3 passed in 0.66 seconds ==================== 75 | 76 | 77 | .. _Salt: https://saltstack.com/ 78 | .. _Ansible: https://www.ansible.com/ 79 | .. _Puppet: https://puppetlabs.com/ 80 | .. _Chef: https://www.chef.io/ 81 | .. _Serverspec: https://serverspec.org/ 82 | .. _Pytest: https://pytest.org/ 83 | -------------------------------------------------------------------------------- /ansible.cfg: -------------------------------------------------------------------------------- 1 | [defaults] 2 | transport=ssh 3 | host_key_checking=False 4 | 5 | [ssh_connection] 6 | pipelining=True 7 | -------------------------------------------------------------------------------- /doc/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(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/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/testinfra.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/testinfra.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/testinfra" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/testinfra" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /doc/source/_templates/piwik.html: -------------------------------------------------------------------------------- 1 | 13 | 14 | -------------------------------------------------------------------------------- /doc/source/api.rst: -------------------------------------------------------------------------------- 1 | API 2 | === 3 | 4 | .. _connection api: 5 | 6 | Connection API 7 | ~~~~~~~~~~~~~~ 8 | 9 | You can use testinfra outside of pytest. You can dynamically get a 10 | `host` instance and call functions or access members of the respective modules:: 11 | 12 | >>> import testinfra 13 | >>> host = testinfra.get_host("paramiko://root@server:2222", sudo=True) 14 | >>> host.file("/etc/shadow").mode == 0o640 15 | True 16 | 17 | For instance you could make a test to compare two files on two different servers:: 18 | 19 | import testinfra 20 | 21 | def test_same_passwd(): 22 | a = testinfra.get_host("ssh://a") 23 | b = testinfra.get_host("ssh://b") 24 | assert a.file("/etc/passwd").content == b.file("/etc/passwd").content 25 | -------------------------------------------------------------------------------- /doc/source/backends.rst: -------------------------------------------------------------------------------- 1 | Connection backends 2 | =================== 3 | 4 | Testinfra comes with several connections backends for remote command 5 | execution. 6 | 7 | When installing, you should select the backends you require as 8 | ``extras`` to ensure Python dependencies are satisfied (note various 9 | system packaged tools may still be required). For example :: 10 | 11 | $ pip install pytest-testinfra[ansible,salt] 12 | 13 | For all backends, commands can be run as superuser with the ``--sudo`` 14 | option or as specific user with the ``--sudo-user`` option. 15 | 16 | General Host specification 17 | ~~~~~~~~~~~~~~~~~~~~~~~~~~ 18 | 19 | The ``--hosts`` parameter in Testinfra is used to specify the target hosts for the tests. 20 | 21 | You can specify multiple hosts by separating each target with a comma, allowing you to run tests using different backends across different environments or machines. 22 | 23 | The user, password, and port fields are optional, providing flexibility depending on your authentication and connection requirements. 24 | 25 | Please also read the details for the individual backends, as the host spec is handled slightly differently from backend to backend. 26 | 27 | **Syntax:** 28 | 29 | :: 30 | 31 | --hosts=://:@: 32 | 33 | 34 | **Components:** 35 | 36 | * ````: type of backend to be used (e.g., ssh, docker, paramiko, local) 37 | * ````: username for authentication (optional) 38 | * ````: password for authentication (optional) 39 | * ````: target hostname or IP address 40 | * ````: target port number (optional) 41 | 42 | Special characters (e.g. ":") in the user and password fields need to be percent-encoded according to RFC 3986. This can be done using ``urllib.parse.quote()`` in Python. 43 | 44 | For example:: 45 | 46 | import urllib.parse 47 | 48 | user = urllib.parse.quote('user:name') 49 | password = urllib.parse.quote('p@ssw:rd') 50 | host = 'hostname' 51 | port = 22 52 | 53 | host_spec = f"ssh://{user}:{password}@{host}:{port}" 54 | print(host_spec) 55 | 56 | This will ensure that any special characters are properly encoded, making the connection string valid. 57 | 58 | **Examples:** 59 | 60 | SSH Backend with Full Specification:: 61 | 62 | testinfra --hosts=ssh://user:password@hostname:22 63 | 64 | Docker Backend:: 65 | 66 | testinfra --hosts=docker://container_id 67 | 68 | Mixed Backends:: 69 | 70 | testinfra --hosts=ssh://user:password@hostname:22,docker://container_id,local:// 71 | 72 | 73 | local 74 | ~~~~~ 75 | 76 | This is the default backend when no hosts are provided (either via 77 | ``--hosts`` or in modules). Commands are run locally in a subprocess under 78 | the current user:: 79 | 80 | $ pytest --sudo test_myinfra.py 81 | 82 | 83 | paramiko 84 | ~~~~~~~~ 85 | 86 | This is the default backend when a hosts list is provided. `Paramiko 87 | `_ is a Python implementation of the SSHv2 88 | protocol. Testinfra will not ask you for a password, so you must be 89 | able to connect without password (using passwordless keys or using 90 | ``ssh-agent``). 91 | 92 | You can provide an alternate ssh-config:: 93 | 94 | $ pytest --ssh-config=/path/to/ssh_config --hosts=server 95 | 96 | 97 | docker 98 | ~~~~~~ 99 | 100 | The Docker backend can be used to test *running* Docker containers. It uses the 101 | `docker exec `_ command:: 102 | 103 | $ pytest --hosts='docker://[user@]container_id_or_name' 104 | 105 | See also the :ref:`Test docker images` example. 106 | 107 | 108 | podman 109 | ~~~~~~ 110 | 111 | The Podman backend can be used to test *running* Podman containers. It uses the 112 | `podman exec `_ command:: 113 | 114 | $ pytest --hosts='podman://[user@]container_id_or_name' 115 | 116 | 117 | ssh 118 | ~~~ 119 | 120 | This is a pure SSH backend using the ``ssh`` command. Example:: 121 | 122 | $ pytest --hosts='ssh://server' 123 | $ pytest --ssh-config=/path/to/ssh_config --hosts='ssh://server' 124 | $ pytest --ssh-identity-file=/path/to/key --hosts='ssh://server' 125 | $ pytest --hosts='ssh://server?timeout=60&controlpersist=120' 126 | $ pytest --hosts='ssh://server' --ssh-extra-args='-o StrictHostKeyChecking=no' 127 | 128 | By default timeout is set to 10 seconds and ControlPersist is set to 60 seconds. 129 | You can disable persistent connection by passing `controlpersist=0` to the options. 130 | 131 | 132 | salt 133 | ~~~~ 134 | 135 | The salt backend uses the `salt Python client API 136 | `_ and can be used from the salt-master server:: 137 | 138 | $ pytest --hosts='salt://*' 139 | $ pytest --hosts='salt://minion1,salt://minion2' 140 | $ pytest --hosts='salt://web*' 141 | $ pytest --hosts='salt://G@os:Debian' 142 | 143 | Testinfra will use the salt connection channel to run commands. 144 | 145 | Hosts can be selected by using the `glob` and `compound matchers 146 | `_. 147 | 148 | 149 | .. _ansible connection backend: 150 | 151 | ansible 152 | ~~~~~~~ 153 | 154 | Ansible inventories may be used to describe what hosts Testinfra should use 155 | and how to connect them, using Testinfra's Ansible backend. 156 | 157 | To use the Ansible backend, prefix the ``--hosts`` option with ``ansible://`` e.g:: 158 | 159 | $ pytest --hosts='ansible://all' # tests all inventory hosts 160 | $ pytest --hosts='ansible://host1,ansible://host2' 161 | $ pytest --hosts='ansible://web*' 162 | 163 | An inventory may be specified with the ``--ansible-inventory`` option, otherwise 164 | the default (``/etc/ansible/hosts``) is used. 165 | 166 | The ``ansible_connection`` value in your inventory will be used to determine 167 | which backend to use for individual hosts: ``local``, ``ssh``, ``paramiko`` and ``docker`` 168 | are supported values. Other connections (or if you are using the ``--force-ansible`` 169 | option) will result in testinfra running all commands via Ansible itself, 170 | which is substantially slower than the other backends:: 171 | 172 | $ pytest --force-ansible --hosts='ansible://all' 173 | $ pytest --hosts='ansible://host?force_ansible=True' 174 | 175 | By default, the Ansible connection backend will first try to use 176 | ``ansible_ssh_private_key_file`` and ``ansible_private_key_file`` to authenticate, 177 | then fall back to the ``ansible_user`` with ``ansible_ssh_pass`` variables (both 178 | are required), before finally falling back to your own host's SSH config. 179 | 180 | This behavior may be overwritten by specifying either the ``--ssh-identity-file`` 181 | option or the ``--ssh-config`` option 182 | 183 | Finally, these environment variables are supported and will be passed along to 184 | their corresponding ansible variable (See Ansible documentation): 185 | 186 | https://docs.ansible.com/ansible/2.3/intro_inventory.html 187 | 188 | https://docs.ansible.com/ansible/latest/reference_appendices/config.html 189 | 190 | * ``ANSIBLE_REMOTE_USER`` 191 | * ``ANSIBLE_SSH_EXTRA_ARGS`` 192 | * ``ANSIBLE_SSH_COMMON_ARGS`` 193 | * ``ANSIBLE_REMOTE_PORT`` 194 | * ``ANSIBLE_BECOME_USER`` 195 | * ``ANSIBLE_BECOME`` 196 | 197 | kubectl 198 | ~~~~~~~ 199 | 200 | The kubectl backend can be used to test containers running in Kubernetes. It 201 | uses the `kubectl exec `_ command and 202 | support connecting to a given container name within a pod and using a given 203 | namespace:: 204 | 205 | # will use the default namespace and default container 206 | $ pytest --hosts='kubectl://mypod-a1b2c3' 207 | # specify container name and namespace 208 | $ pytest --hosts='kubectl://somepod-2536ab?container=nginx&namespace=web' 209 | # specify the kubeconfig context to use 210 | $ pytest --hosts='kubectl://somepod-2536ab?context=k8s-cluster-a&container=nginx' 211 | # you can specify kubeconfig either from KUBECONFIG environment variable 212 | # or when working with multiple configuration with the "kubeconfig" option 213 | $ pytest --hosts='kubectl://somepod-123?kubeconfig=/path/kubeconfig,kubectl://otherpod-123?kubeconfig=/other/kubeconfig' 214 | 215 | openshift 216 | ~~~~~~~~~ 217 | 218 | The openshift backend can be used to test containers running in OpenShift. It 219 | uses the `oc exec `_ command and 220 | support connecting to a given container name within a pod and using a given 221 | namespace:: 222 | 223 | # will use the default namespace and default container 224 | $ pytest --hosts='openshift://mypod-a1b2c3' 225 | # specify container name and namespace 226 | $ pytest --hosts='openshift://somepod-2536ab?container=nginx&namespace=web' 227 | # you can specify kubeconfig either from KUBECONFIG environment variable 228 | # or when working with multiple configuration with the "kubeconfig" option 229 | $ pytest --hosts='openshift://somepod-123?kubeconfig=/path/kubeconfig,openshift://otherpod-123?kubeconfig=/other/kubeconfig' 230 | 231 | winrm 232 | ~~~~~ 233 | 234 | The winrm backend uses `pywinrm `_:: 235 | 236 | $ pytest --hosts='winrm://Administrator:Password@127.0.0.1' 237 | $ pytest --hosts='winrm://vagrant@127.0.0.1:2200?no_ssl=true&no_verify_ssl=true' 238 | 239 | pywinrm's default read and operation timeout can be overridden using query 240 | arguments ``read_timeout_sec`` and ``operation_timeout_sec``:: 241 | 242 | $ pytest --hosts='winrm://vagrant@127.0.0.1:2200?read_timeout_sec=120&operation_timeout_sec=100' 243 | 244 | LXC/LXD 245 | ~~~~~~~ 246 | 247 | The LXC backend can be used to test *running* LXC or LXD containers. It uses the 248 | `lxc exec `_ command:: 249 | 250 | $ pytest --hosts='lxc://container_name' 251 | -------------------------------------------------------------------------------- /doc/source/changelog.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../../CHANGELOG.rst 2 | -------------------------------------------------------------------------------- /doc/source/examples.rst: -------------------------------------------------------------------------------- 1 | Examples 2 | ======== 3 | 4 | Parametrize your tests 5 | ~~~~~~~~~~~~~~~~~~~~~~ 6 | 7 | Pytest support `test parametrization `_:: 8 | 9 | # BAD: If the test fails on nginx, python is not tested 10 | def test_packages(host): 11 | for name, version in ( 12 | ("nginx", "1.6"), 13 | ("python", "2.7"), 14 | ): 15 | pkg = host.package(name) 16 | assert pkg.is_installed 17 | assert pkg.version.startswith(version) 18 | 19 | 20 | # GOOD: Each package is tested 21 | # $ pytest -v test.py 22 | # [...] 23 | # test.py::test_package[local-nginx-1.6] PASSED 24 | # test.py::test_package[local-python-2.7] PASSED 25 | # [...] 26 | import pytest 27 | 28 | @pytest.mark.parametrize("name,version", [ 29 | ("nginx", "1.6"), 30 | ("python", "2.7"), 31 | ]) 32 | def test_packages(host, name, version): 33 | pkg = host.package(name) 34 | assert pkg.is_installed 35 | assert pkg.version.startswith(version) 36 | 37 | 38 | .. _make modules: 39 | 40 | 41 | Using unittest 42 | ~~~~~~~~~~~~~~ 43 | 44 | Testinfra can be used with the standard Python unit test framework `unittest 45 | `_ instead of pytest:: 46 | 47 | import unittest 48 | import testinfra 49 | 50 | 51 | class Test(unittest.TestCase): 52 | 53 | def setUp(self): 54 | self.host = testinfra.get_host("paramiko://root@host") 55 | 56 | def test_nginx_config(self): 57 | self.assertEqual(self.host.run("nginx -t").rc, 0) 58 | 59 | def test_nginx_service(self): 60 | service = self.host.service("nginx") 61 | self.assertTrue(service.is_running) 62 | self.assertTrue(service.is_enabled) 63 | 64 | 65 | if __name__ == "__main__": 66 | unittest.main() 67 | 68 | 69 | :: 70 | 71 | $ python test.py 72 | .. 73 | ---------------------------------------------------------------------- 74 | Ran 2 tests in 0.705s 75 | 76 | OK 77 | 78 | 79 | Integration with Vagrant 80 | ~~~~~~~~~~~~~~~~~~~~~~~~ 81 | 82 | `Vagrant `_ is a tool to setup and provision 83 | development environments (virtual machines). 84 | 85 | When your Vagrant machine is up and running, you can easily run your testinfra 86 | test suite on it:: 87 | 88 | vagrant ssh-config > .vagrant/ssh-config 89 | pytest --hosts=default --ssh-config=.vagrant/ssh-config tests.py 90 | 91 | 92 | Integration with Jenkins 93 | ~~~~~~~~~~~~~~~~~~~~~~~~ 94 | 95 | `Jenkins `_ is a well known open source continuous 96 | integration server. 97 | 98 | If your Jenkins slave can run Vagrant, your build scripts can be like:: 99 | 100 | 101 | pip install pytest-testinfra paramiko 102 | vagrant up 103 | vagrant ssh-config > .vagrant/ssh-config 104 | pytest --hosts=default --ssh-config=.vagrant/ssh-config --junit-xml junit.xml tests.py 105 | 106 | 107 | Then configure Jenkins to get tests results from the `junit.xml` file. 108 | 109 | 110 | Integration with Nagios 111 | ~~~~~~~~~~~~~~~~~~~~~~~ 112 | 113 | Your tests will usually be validating that the services you are deploying run correctly. 114 | This kind of tests are close to monitoring checks, so let's push them to 115 | `Nagios `_ ! 116 | 117 | The Testinfra option `--nagios` enables a behavior compatible with a nagios plugin:: 118 | 119 | 120 | $ pytest -qq --nagios --tb line test_ok.py; echo $? 121 | TESTINFRA OK - 2 passed, 0 failed, 0 skipped in 2.30 seconds 122 | .. 123 | 0 124 | 125 | $ pytest -qq --nagios --tb line test_fail.py; echo $? 126 | TESTINFRA CRITICAL - 1 passed, 1 failed, 0 skipped in 2.24 seconds 127 | .F 128 | /usr/lib/python3/dist-packages/example/example.py:95: error: [Errno 111] error msg 129 | 2 130 | 131 | 132 | You can run these tests from the nagios master or in the target host with 133 | `NRPE `_. 134 | 135 | 136 | Integration with KitchenCI 137 | ~~~~~~~~~~~~~~~~~~~~~~~~~~ 138 | 139 | KitchenCI (aka Test Kitchen) can use testinfra via its :code:`shell` verifier. 140 | Add the following to your :code:`.kitchen.yml`, this requires installing `paramiko` 141 | additionally (on your host machine, not in the VM handled by kitchen) :: 142 | 143 | verifier: 144 | name: shell 145 | command: pytest --hosts="paramiko://${KITCHEN_USERNAME}@${KITCHEN_HOSTNAME}:${KITCHEN_PORT}?ssh_identity_file=${KITCHEN_SSH_KEY}" --junit-xml "junit-${KITCHEN_INSTANCE}.xml" "test/integration/${KITCHEN_SUITE}" 146 | 147 | 148 | .. _test docker images: 149 | 150 | Test Docker images 151 | ~~~~~~~~~~~~~~~~~~ 152 | 153 | Docker is a handy way to test your infrastructure code. This recipe shows how to 154 | build and run Docker containers with Testinfra by overloading the `host` 155 | fixture. 156 | 157 | .. code-block:: python 158 | 159 | import pytest 160 | import subprocess 161 | import testinfra 162 | 163 | 164 | # scope='session' uses the same container for all the tests; 165 | # scope='function' uses a new container per test function. 166 | @pytest.fixture(scope='session') 167 | def host(request): 168 | # build local ./Dockerfile 169 | subprocess.check_call(['docker', 'build', '-t', 'myimage', '.']) 170 | # run a container 171 | docker_id = subprocess.check_output( 172 | ['docker', 'run', '-d', 'myimage']).decode().strip() 173 | # return a testinfra connection to the container 174 | yield testinfra.get_host("docker://" + docker_id) 175 | # at the end of the test suite, destroy the container 176 | subprocess.check_call(['docker', 'rm', '-f', docker_id]) 177 | 178 | 179 | def test_myimage(host): 180 | # 'host' now binds to the container 181 | assert host.check_output('myapp -v') == 'Myapp 1.0' 182 | -------------------------------------------------------------------------------- /doc/source/index.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../../README.rst 2 | 3 | Documentation 4 | ============= 5 | 6 | .. toctree:: 7 | :maxdepth: 3 8 | 9 | changelog 10 | invocation 11 | Connection backends 12 | modules 13 | api 14 | examples 15 | support 16 | -------------------------------------------------------------------------------- /doc/source/invocation.rst: -------------------------------------------------------------------------------- 1 | Invocation 2 | ========== 3 | 4 | 5 | Test multiples hosts 6 | ~~~~~~~~~~~~~~~~~~~~ 7 | 8 | By default Testinfra launch tests on local machine, but you can also 9 | test remotes systems using `paramiko `_ (a 10 | ssh implementation in python):: 11 | 12 | $ pip install paramiko 13 | $ pytest -v --hosts=localhost,root@webserver:2222 test_myinfra.py 14 | 15 | ====================== test session starts ====================== 16 | platform linux -- Python 2.7.3 -- py-1.4.26 -- pytest-2.6.4 17 | plugins: testinfra 18 | collected 3 items 19 | 20 | test_myinfra.py::test_passwd_file[localhost] PASSED 21 | test_myinfra.py::test_nginx_is_installed[localhost] PASSED 22 | test_myinfra.py::test_nginx_running_and_enabled[localhost] PASSED 23 | test_myinfra.py::test_passwd_file[root@webserver:2222] PASSED 24 | test_myinfra.py::test_nginx_is_installed[root@webserver:2222] PASSED 25 | test_myinfra.py::test_nginx_running_and_enabled[root@webserver:2222] PASSED 26 | 27 | =================== 6 passed in 8.49 seconds ==================== 28 | 29 | 30 | You can also set hosts per test module:: 31 | 32 | testinfra_hosts = ["localhost", "root@webserver:2222"] 33 | 34 | def test_foo(host): 35 | [....] 36 | 37 | 38 | 39 | Parallel execution 40 | ~~~~~~~~~~~~~~~~~~ 41 | 42 | If you have a lot of tests, you can use the pytest-xdist_ plugin to run tests using multiples process:: 43 | 44 | 45 | $ pip install pytest-xdist 46 | 47 | # Launch tests using 3 processes 48 | $ pytest -n 3 -v --host=web1,web2,web3,web4,web5,web6 test_myinfra.py 49 | 50 | 51 | Advanced invocation 52 | ~~~~~~~~~~~~~~~~~~~ 53 | 54 | :: 55 | 56 | # Test recursively all test files (starting with `test_`) in current directory 57 | $ pytest 58 | 59 | # Filter function/hosts with pytest -k option 60 | $ pytest --hosts=webserver,dnsserver -k webserver -k nginx 61 | 62 | 63 | For more usages and features, see the Pytest_ documentation. 64 | 65 | 66 | .. _Pytest: https://docs.pytest.org/en/latest/ 67 | .. _pytest-xdist: https://pypi.org/project/pytest-xdist/ 68 | -------------------------------------------------------------------------------- /doc/source/modules.rst: -------------------------------------------------------------------------------- 1 | .. _modules: 2 | 3 | Modules 4 | ======= 5 | 6 | Testinfra modules are provided through the `host` `fixture`_, declare it as 7 | arguments of your test function to make it available within it. 8 | 9 | .. code-block:: python 10 | 11 | def test_foo(host): 12 | # [...] 13 | 14 | 15 | host 16 | ~~~~ 17 | 18 | .. autoclass:: testinfra.host.Host 19 | :members: 20 | 21 | .. attribute:: ansible 22 | 23 | :class:`testinfra.modules.ansible.Ansible` class 24 | 25 | .. attribute:: addr 26 | 27 | :class:`testinfra.modules.addr.Addr` class 28 | 29 | .. attribute:: blockdevice 30 | 31 | :class:`testinfra.modules.blockdevice.BlockDevice` class 32 | 33 | .. attribute:: docker 34 | 35 | :class:`testinfra.modules.docker.Docker` class 36 | 37 | .. attribute:: environment 38 | 39 | :class:`testinfra.modules.environment.Environment` class 40 | 41 | .. attribute:: file 42 | 43 | :class:`testinfra.modules.file.File` class 44 | 45 | .. attribute:: group 46 | 47 | :class:`testinfra.modules.group.Group` class 48 | 49 | .. attribute:: interface 50 | 51 | :class:`testinfra.modules.interface.Interface` class 52 | 53 | .. attribute:: iptables 54 | 55 | :class:`testinfra.modules.iptables.Iptables` class 56 | 57 | .. attribute:: mount_point 58 | 59 | :class:`testinfra.modules.mountpoint.MountPoint` class 60 | 61 | .. attribute:: package 62 | 63 | :class:`testinfra.modules.package.Package` class 64 | 65 | .. attribute:: pip 66 | 67 | :class:`testinfra.modules.pip.Pip` class 68 | 69 | .. attribute:: podman 70 | 71 | :class:`testinfra.modules.podman.Podman` class 72 | 73 | .. attribute:: process 74 | 75 | :class:`testinfra.modules.process.Process` class 76 | 77 | .. attribute:: puppet_resource 78 | 79 | :class:`testinfra.modules.puppet.PuppetResource` class 80 | 81 | .. attribute:: facter 82 | 83 | :class:`testinfra.modules.puppet.Facter` class 84 | 85 | .. attribute:: salt 86 | 87 | :class:`testinfra.modules.salt.Salt` class 88 | 89 | .. attribute:: service 90 | 91 | :class:`testinfra.modules.service.Service` class 92 | 93 | .. attribute:: socket 94 | 95 | :class:`testinfra.modules.socket.Socket` class 96 | 97 | .. attribute:: sudo 98 | 99 | :class:`testinfra.modules.sudo.Sudo` class 100 | 101 | .. attribute:: supervisor 102 | 103 | :class:`testinfra.modules.supervisor.Supervisor` class 104 | 105 | .. attribute:: sysctl 106 | 107 | :class:`testinfra.modules.sysctl.Sysctl` class 108 | 109 | .. attribute:: system_info 110 | 111 | :class:`testinfra.modules.systeminfo.SystemInfo` class 112 | 113 | .. attribute:: user 114 | 115 | :class:`testinfra.modules.user.User` class 116 | 117 | 118 | 119 | 120 | Ansible 121 | ~~~~~~~ 122 | 123 | .. autoclass:: testinfra.modules.ansible.Ansible(module_name, module_args=None, check=True) 124 | :members: 125 | 126 | 127 | Addr 128 | ~~~~ 129 | 130 | .. autoclass:: testinfra.modules.addr.Addr(name) 131 | :members: 132 | 133 | 134 | BlockDevice 135 | ~~~~~~~~~~~ 136 | 137 | .. autoclass:: testinfra.modules.blockdevice.BlockDevice(name) 138 | :members: 139 | 140 | 141 | Docker 142 | ~~~~~~ 143 | 144 | .. autoclass:: testinfra.modules.docker.Docker(name) 145 | :members: 146 | 147 | 148 | Environment 149 | ~~~~~~~~~~~ 150 | 151 | .. autoclass:: testinfra.modules.environment.Environment(name) 152 | :members: 153 | 154 | File 155 | ~~~~ 156 | 157 | .. autoclass:: testinfra.modules.file.File 158 | :members: 159 | :undoc-members: 160 | :exclude-members: get_module_class 161 | 162 | 163 | Group 164 | ~~~~~ 165 | 166 | .. autoclass:: testinfra.modules.group.Group 167 | :members: 168 | :undoc-members: 169 | 170 | 171 | Interface 172 | ~~~~~~~~~ 173 | 174 | .. autoclass:: testinfra.modules.interface.Interface 175 | :members: 176 | :undoc-members: 177 | :exclude-members: get_module_class 178 | 179 | 180 | Iptables 181 | ~~~~~~~~~ 182 | 183 | .. autoclass:: testinfra.modules.iptables.Iptables 184 | :members: 185 | :undoc-members: 186 | 187 | 188 | MountPoint 189 | ~~~~~~~~~~ 190 | 191 | .. autoclass:: testinfra.modules.mountpoint.MountPoint(path) 192 | :members: 193 | 194 | 195 | Package 196 | ~~~~~~~ 197 | 198 | .. autoclass:: testinfra.modules.package.Package 199 | :members: 200 | 201 | 202 | Pip 203 | ~~~~~~~~~~ 204 | 205 | .. autoclass:: testinfra.modules.pip.Pip 206 | :members: 207 | 208 | 209 | Podman 210 | ~~~~~~ 211 | 212 | .. autoclass:: testinfra.modules.podman.Podman(name) 213 | :members: 214 | 215 | 216 | Process 217 | ~~~~~~~ 218 | 219 | .. autoclass:: testinfra.modules.process.Process 220 | :members: 221 | 222 | 223 | PuppetResource 224 | ~~~~~~~~~~~~~~ 225 | 226 | .. autoclass:: testinfra.modules.puppet.PuppetResource(type, name=None) 227 | :members: 228 | 229 | 230 | Facter 231 | ~~~~~~ 232 | 233 | .. autoclass:: testinfra.modules.puppet.Facter(*facts) 234 | :members: 235 | 236 | 237 | Salt 238 | ~~~~ 239 | 240 | .. autoclass:: testinfra.modules.salt.Salt(function, args=None, local=False, config=None) 241 | :members: 242 | 243 | 244 | Service 245 | ~~~~~~~ 246 | 247 | .. autoclass:: testinfra.modules.service.Service 248 | :members: 249 | 250 | 251 | Socket 252 | ~~~~~~ 253 | 254 | .. autoclass:: testinfra.modules.socket.Socket 255 | :members: 256 | 257 | 258 | Sudo 259 | ~~~~ 260 | 261 | .. autoclass:: testinfra.modules.sudo.Sudo(user=None) 262 | 263 | 264 | Supervisor 265 | ~~~~~~~~~~ 266 | 267 | .. autoclass:: testinfra.modules.supervisor.Supervisor 268 | :members: 269 | 270 | 271 | Sysctl 272 | ~~~~~~ 273 | 274 | .. autoclass:: testinfra.modules.sysctl.Sysctl(name) 275 | :members: 276 | 277 | 278 | SystemInfo 279 | ~~~~~~~~~~ 280 | 281 | .. autoclass:: testinfra.modules.systeminfo.SystemInfo 282 | :members: 283 | 284 | 285 | User 286 | ~~~~ 287 | 288 | .. autoclass:: testinfra.modules.user.User 289 | :members: 290 | :exclude-members: get_module_class 291 | 292 | 293 | CommandResult 294 | ~~~~~~~~~~~~~ 295 | 296 | .. autoclass:: testinfra.backend.base.CommandResult 297 | :members: 298 | 299 | 300 | .. _fixture: https://docs.pytest.org/en/latest/fixture.html#fixture 301 | -------------------------------------------------------------------------------- /doc/source/support.rst: -------------------------------------------------------------------------------- 1 | Support 2 | ======= 3 | 4 | If you have questions or need help with testinfra please consider one of the 5 | following 6 | 7 | Issue Tracker 8 | ~~~~~~~~~~~~~ 9 | 10 | Checkout existing issues on `project issue tracker `_ 11 | 12 | IRC 13 | ~~~ 14 | 15 | You can also ask questions on IRC in `#pytest `_ channel on [libera.chat](https://libera.chat/) network. 16 | 17 | pytest documentation 18 | ~~~~~~~~~~~~~~~~~~~~ 19 | 20 | testinfra is implemented as pytest plugin so to get the most out of please 21 | read `pytest documentation `_ 22 | 23 | Community Contributions 24 | ~~~~~~~~~~~~~~~~~~~~~~~ 25 | 26 | * `Molecule `_ is an Automated testing framework for Ansible roles, with native Testinfra support. 27 | -------------------------------------------------------------------------------- /hatch.toml: -------------------------------------------------------------------------------- 1 | [version] 2 | source = "vcs" 3 | 4 | [build.targets.sdist] 5 | include = [ 6 | "testinfra", 7 | "test", 8 | "ansible.cfg", 9 | "tox.ini", 10 | "images", 11 | "mypy.ini", 12 | "CONTRIBUTING.rst", 13 | "doc", 14 | "Makefile", 15 | ".pre-commit-config.yaml", 16 | ] 17 | 18 | [build.targets.wheel] 19 | only-include = ["testinfra"] 20 | -------------------------------------------------------------------------------- /images/debian_bookworm/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM debian:bookworm 2 | 3 | ENV container docker 4 | 5 | RUN apt-get update && \ 6 | DEBIAN_FRONTEND=noninteractive apt-get -y install --no-install-recommends \ 7 | lsb-release \ 8 | python3-pip \ 9 | openssh-server \ 10 | puppet \ 11 | locales \ 12 | sudo \ 13 | supervisor \ 14 | systemd-sysv \ 15 | virtualenv \ 16 | iproute2 \ 17 | iputils-ping \ 18 | iptables \ 19 | iptables-persistent && \ 20 | rm -rf /var/lib/apt/lists/* 21 | RUN mkdir -p /var/run/sshd && \ 22 | (cd /lib/systemd/system/sysinit.target.wants/; for i in *; do if ! test $i = systemd-tmpfiles-setup.service; then rm -f $i; fi; done) && \ 23 | rm -f /lib/systemd/system/multi-user.target.wants/* && \ 24 | rm -f /etc/systemd/system/*.wants/* && \ 25 | rm -f /lib/systemd/system/local-fs.target.wants/* && \ 26 | rm -f /lib/systemd/system/sockets.target.wants/*udev* && \ 27 | rm -f /lib/systemd/system/sockets.target.wants/*initctl* && \ 28 | systemctl enable ssh.service && \ 29 | systemctl enable supervisor.service && \ 30 | systemctl enable netfilter-persistent.service && \ 31 | echo "python3 hold" | dpkg --set-selections && \ 32 | echo "LANG=fr_FR.ISO-8859-15" > /etc/default/locale && \ 33 | echo "LANGUAGE=fr_FR" >> /etc/default/locale && \ 34 | echo "fr_FR.ISO-8859-15 ISO-8859-15" >> /etc/locale.gen && \ 35 | locale-gen && \ 36 | update-locale && \ 37 | useradd -m user -c "gecos.comment" && \ 38 | adduser user sudo && \ 39 | echo "user ALL=NOPASSWD: ALL" > /etc/sudoers.d/user && \ 40 | useradd -m unprivileged && \ 41 | mkdir -p /root/.ssh && \ 42 | echo "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCgDryK4AjJeifuc2N54St13KMNlnGLAtibQSMmvSyrhH7XJ1atnBo1HrJhGZNNBVKM67+zYNc9J3fg3qI1g63vSQAA+nXMsDYwu4BPwupakpwJELcGZJxsUGzjGVotVpqPIX5nW8NBGvkVuObI4UELOleq5mQMTGerJO64KkSVi20FDwPJn3q8GG2zk3pESiDA5ShEyFhYC8vOLfSSYD0LYmShAVGCLEgiNb+OXQL6ZRvzqfFEzL0QvaI/l3mb6b0VFPAO4QWOL0xj3cWzOZXOqht3V85CZvSk8ISdNgwCjXLZsPeaYL/toHNvBF30VMrDZ7w4SDU0ZZLEsc/ezxjb" > /root/.ssh/authorized_keys && \ 43 | mkdir -p /home/user/.ssh && \ 44 | cp /root/.ssh/authorized_keys /home/user/.ssh/authorized_keys && \ 45 | chown -R user:user /home/user/.ssh && \ 46 | echo "[program:tail]\ncommand=tail -f /dev/null\nuser=user\ngroup=user\n" > /etc/supervisor/conf.d/tail.conf 47 | 48 | RUN echo "root:foo" | chpasswd 49 | 50 | # Enable ssh login for user and fix side effect on environment variables... 51 | RUN sed -ri 's/^UsePAM yes$/UsePAM no/' /etc/ssh/sshd_config 52 | RUN sed -ri 's/AcceptEnv LANG LC_*/#AcceptEnv LANG LC_*/' /etc/ssh/sshd_config 53 | RUN echo "PermitUserEnvironment yes" >> /etc/ssh/sshd_config 54 | RUN echo "LANG=fr_FR.ISO-8859-15" >> /root/.ssh/environment 55 | RUN echo "user:foo" | chpasswd 56 | 57 | # Iptables rules 58 | 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 59 | 60 | # Expiration date for user "user" 61 | RUN chage -E 2030-01-01 -m 7 -M 90 user 62 | 63 | # Some python3 virtualenv 64 | RUN virtualenv /v 65 | RUN /v/bin/pip install -U pip 66 | RUN /v/bin/pip install 'requests==2.30.0' 67 | 68 | # install salt 69 | RUN python3 -m pip install --break-system-packages --no-cache salt tornado distro looseversion msgpack pyyaml packaging jinja2 70 | 71 | ENV LANG fr_FR.ISO-8859-15 72 | ENV LANGUAGE fr_FR 73 | 74 | EXPOSE 22 75 | CMD ["/sbin/init"] 76 | -------------------------------------------------------------------------------- /images/rockylinux9/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM rockylinux:9 2 | 3 | RUN dnf -y install openssh-server procps python311 iputils && dnf clean all &&\ 4 | (cd /lib/systemd/system/sysinit.target.wants/; for i in *; do if ! test $i = systemd-tmpfiles-setup.service; then rm -f $i; fi; done) && \ 5 | rm -f /lib/systemd/system/multi-user.target.wants/* && \ 6 | rm -f /etc/systemd/system/*.wants/* && \ 7 | rm -f /lib/systemd/system/local-fs.target.wants/* && \ 8 | rm -f /lib/systemd/system/sockets.target.wants/*udev* && \ 9 | rm -f /lib/systemd/system/sockets.target.wants/*initctl* && \ 10 | rm -f /lib/systemd/system/basic.target.wants/* && \ 11 | rm -f /lib/systemd/system/anaconda.target.wants/* && \ 12 | rm -f /etc/ssh/ssh_host_ecdsa_key /etc/ssh/ssh_host_rsa_key && \ 13 | ssh-keygen -q -N "" -t dsa -f /etc/ssh/ssh_host_ecdsa_key && \ 14 | ssh-keygen -q -N "" -t rsa -f /etc/ssh/ssh_host_rsa_key && \ 15 | sed -i "s/#UsePrivilegeSeparation.*/UsePrivilegeSeparation no/g" /etc/ssh/sshd_config && \ 16 | systemctl enable sshd.service && \ 17 | mkdir -p /root/.ssh && \ 18 | echo "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCgDryK4AjJeifuc2N54St13KMNlnGLAtibQSMmvSyrhH7XJ1atnBo1HrJhGZNNBVKM67+zYNc9J3fg3qI1g63vSQAA+nXMsDYwu4BPwupakpwJELcGZJxsUGzjGVotVpqPIX5nW8NBGvkVuObI4UELOleq5mQMTGerJO64KkSVi20FDwPJn3q8GG2zk3pESiDA5ShEyFhYC8vOLfSSYD0LYmShAVGCLEgiNb+OXQL6ZRvzqfFEzL0QvaI/l3mb6b0VFPAO4QWOL0xj3cWzOZXOqht3V85CZvSk8ISdNgwCjXLZsPeaYL/toHNvBF30VMrDZ7w4SDU0ZZLEsc/ezxjb" > /root/.ssh/authorized_keys 19 | 20 | EXPOSE 22 21 | CMD ["/usr/sbin/init"] 22 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | files = . 3 | strict = true 4 | warn_unused_ignores = true 5 | show_error_codes = true 6 | # XXX: goal is to enable this 7 | disallow_untyped_defs = false 8 | disallow_untyped_calls = false 9 | check_untyped_defs = false 10 | 11 | [mypy-salt.*] 12 | ignore_missing_imports = True 13 | 14 | [mypy-winrm.*] 15 | ignore_missing_imports = True 16 | 17 | [mypy-alabaster.*] 18 | ignore_missing_imports = True 19 | 20 | [mypy-setuptools_scm.*] 21 | ignore_missing_imports = True 22 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling", "hatch-vcs"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "pytest-testinfra" 7 | description = "Test infrastructures" 8 | requires-python = ">=3.9" 9 | dynamic = ["version"] 10 | readme = "README.rst" 11 | license-files = ["LICENSE"] 12 | authors = [{ name = "Philippe Pepiot", email = "phil@philpep.org" }] 13 | classifiers = [ 14 | "Development Status :: 5 - Production/Stable", 15 | "Environment :: Console", 16 | "Intended Audience :: Developers", 17 | "Intended Audience :: Information Technology", 18 | "Intended Audience :: System Administrators", 19 | "License :: OSI Approved :: Apache Software License", 20 | "Operating System :: POSIX", 21 | "Programming Language :: Python", 22 | "Programming Language :: Python :: 3", 23 | "Programming Language :: Python :: 3 :: Only", 24 | "Programming Language :: Python :: 3.9", 25 | "Programming Language :: Python :: 3.10", 26 | "Programming Language :: Python :: 3.11", 27 | "Topic :: Software Development :: Testing", 28 | "Topic :: System :: Systems Administration", 29 | "Framework :: Pytest", 30 | ] 31 | 32 | dependencies = [ 33 | "pytest>=6", 34 | ] 35 | 36 | [project.optional-dependencies] 37 | ansible = ["ansible"] 38 | paramiko = ["paramiko"] 39 | salt = ["salt", "tornado", "distro", "looseversion", "msgpack"] 40 | winrm = ["pywinrm"] 41 | test = [ 42 | "pytest-cov", 43 | "pytest-xdist", 44 | "pytest-testinfra[ansible,paramiko,salt,winrm]", 45 | ] 46 | typing = [ 47 | "mypy", 48 | "types-paramiko", 49 | ] 50 | lint = [ 51 | "ruff", 52 | ] 53 | doc = [ 54 | "sphinx>=7.1,<7.2", 55 | "alabaster>=0.7.2", 56 | ] 57 | dev = ["pytest-testinfra[test,typing,doc,lint]"] 58 | 59 | [project.entry-points."pytest11"] 60 | "pytest11.testinfra" = "testinfra.plugin" 61 | -------------------------------------------------------------------------------- /ruff.toml: -------------------------------------------------------------------------------- 1 | target-version = "py39" 2 | 3 | [lint] 4 | select = [ 5 | # pycodestyle 6 | "E", 7 | # Pyflakes 8 | "F", 9 | # pyupgrade 10 | "UP", 11 | # flake8-bugbear 12 | "B", 13 | # flake8-debugger 14 | "T10", 15 | # flake8-logging 16 | "G", 17 | # flake8-comprehension 18 | "C4", 19 | # flake8-simplify 20 | "SIM", 21 | # flake8-print 22 | "T20", 23 | # individual rules 24 | "RUF100", # unused-noqa 25 | # imports 26 | "I", 27 | ] 28 | 29 | # For error codes, see https://docs.astral.sh/ruff/rules/#error-e 30 | ignore = [ 31 | # argument is shadowing a Python builtin 32 | "A001", 33 | "A002", 34 | "A005", 35 | # attribute overriding builtin name 36 | "A003", 37 | # [bugbear] Do not use mutable data structures for argument defaults (I choose by myself) 38 | "B006", 39 | # [bugbear] Do not perform function calls in argument defaults (conflict with fastapi dependency system) 40 | "B008", 41 | # [bugbear] Function definition does not bind loop variable 42 | "B023", 43 | # closing bracket does not match indentation of opening bracket's line (disagree with emacs python mode) 44 | # "E123", 45 | # continuation line over-indented for hanging indent line (disagree with emacs python mode) 46 | # "E126", 47 | # whitespace before ':' 48 | "E203", 49 | # missing whitespace around arithmetic operator (disagree when in parameter) 50 | "E226", 51 | # line too long 52 | "E501", 53 | # multiple statements on one line (def) (not compatible with black) 54 | # "E704", 55 | # do not assign lambda expression (this is inconvenient) 56 | "E731", 57 | # Logging statement uses exception in arguments (seems legit to display only str representation of exc) 58 | # "G200", 59 | # [string-format] format string does contain unindexed parameters (don't care of py2.6) 60 | # "P101", 61 | # [string-format] docstring does contain unindexed parameters 62 | # "P102", 63 | # [string-format] other string does contain unindexed parameters 64 | # "P103", 65 | # Line break occurred before a binary operator (to keep operators aligned) 66 | # "W503", 67 | ] 68 | 69 | [lint.isort] 70 | known-first-party = ["testinfra"] 71 | section-order = ["future", "standard-library", "third-party", "first-party", "local-folder"] 72 | -------------------------------------------------------------------------------- /test/conftest.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License, Version 2.0 (the "License"); 2 | # you may not use this file except in compliance with the License. 3 | # You may obtain a copy of the License at 4 | # 5 | # http://www.apache.org/licenses/LICENSE-2.0 6 | # 7 | # Unless required by applicable law or agreed to in writing, software 8 | # distributed under the License is distributed on an "AS IS" BASIS, 9 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | # See the License for the specific language governing permissions and 11 | # limitations under the License. 12 | 13 | import itertools 14 | import os 15 | import subprocess 16 | import sys 17 | import threading 18 | import time 19 | import urllib.parse 20 | 21 | import pytest 22 | 23 | import testinfra 24 | from testinfra.backend import parse_hostspec 25 | from testinfra.backend.base import BaseBackend 26 | 27 | BASETESTDIR = os.path.abspath(os.path.dirname(__file__)) 28 | BASEDIR = os.path.abspath(os.path.join(BASETESTDIR, os.pardir)) 29 | _HAS_DOCKER = None 30 | 31 | # Use testinfra to get a handy function to run commands locally 32 | local_host = testinfra.get_host("local://") 33 | check_output = local_host.check_output 34 | 35 | 36 | def has_docker(): 37 | global _HAS_DOCKER 38 | if _HAS_DOCKER is None: 39 | _HAS_DOCKER = local_host.exists("docker") 40 | return _HAS_DOCKER 41 | 42 | 43 | # Generated with 44 | # $ echo myhostvar: bar > hostvars.yml 45 | # $ echo polichinelle > vault-pass.txt 46 | # $ ansible-vault encrypt --vault-password-file vault-pass.txt hostvars.yml 47 | # $ cat hostvars.yml 48 | ANSIBLE_HOSTVARS = """$ANSIBLE_VAULT;1.1;AES256 49 | 39396233323131393835363638373764336364323036313434306134636633353932623363646233 50 | 6436653132383662623364313438376662666135346266370a343934663431363661393363386633 51 | 64656261336662623036373036363535313964313538366533313334366363613435303066316639 52 | 3235393661656230350a326264356530326432393832353064363439393330616634633761393838 53 | 3261 54 | """ 55 | 56 | DOCKER_IMAGES = [ 57 | "rockylinux9", 58 | "debian_bookworm", 59 | ] 60 | 61 | 62 | def setup_ansible_config(tmpdir, name, host, user, port, key): 63 | items = [ 64 | name, 65 | f"ansible_ssh_private_key_file={key}", 66 | 'ansible_ssh_common_args="-o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -o LogLevel=FATAL"', 67 | "myvar=foo", 68 | f"ansible_host={host}", 69 | f"ansible_user={user}", 70 | f"ansible_port={port}", 71 | ] 72 | tmpdir.join("inventory").write("[testgroup]\n" + " ".join(items) + "\n") 73 | tmpdir.mkdir("host_vars").join(name).write(ANSIBLE_HOSTVARS) 74 | tmpdir.mkdir("group_vars").join("testgroup").write( 75 | "---\nmyhostvar: should_be_overriden\nmygroupvar: qux\n" 76 | ) 77 | vault_password_file = tmpdir.join("vault-pass.txt") 78 | vault_password_file.write("polichinelle\n") 79 | ansible_cfg = tmpdir.join("ansible.cfg") 80 | ansible_cfg.write( 81 | "[defaults]\n" 82 | f"vault_password_file={str(vault_password_file)}\n" 83 | "host_key_checking=False\n\n" 84 | "[ssh_connection]\n" 85 | "pipelining=True\n" 86 | ) 87 | 88 | 89 | def build_docker_container_fixture(image, scope): 90 | @pytest.fixture(scope=scope) 91 | def func(request): 92 | docker_host = os.environ.get("DOCKER_HOST") 93 | if docker_host is not None: 94 | docker_host = urllib.parse.urlparse(docker_host).hostname or "localhost" 95 | else: 96 | docker_host = "localhost" 97 | 98 | cmd = ["docker", "run", "-d", "-P"] 99 | if image in DOCKER_IMAGES: 100 | cmd.append("--privileged") 101 | 102 | cmd.append("testinfra:" + image) 103 | docker_id = check_output(" ".join(cmd)) 104 | 105 | def teardown(): 106 | check_output("docker rm -f %s", docker_id) 107 | 108 | request.addfinalizer(teardown) 109 | 110 | port = check_output("docker port %s 22", docker_id) 111 | # IPv4 addresses seem to be reported consistently in the first line 112 | # of the output. To workaround https://github.com/moby/moby/issues/42442 113 | # use only the first line of the command output. 114 | port = int(port.splitlines()[0].rsplit(":", 1)[-1]) 115 | 116 | return docker_id, docker_host, port 117 | 118 | fname = f"_docker_container_{image}_{scope}" 119 | mod = sys.modules[__name__] 120 | setattr(mod, fname, func) 121 | 122 | 123 | def initialize_container_fixtures(): 124 | for image, scope in itertools.product(DOCKER_IMAGES, ["function", "session"]): 125 | build_docker_container_fixture(image, scope) 126 | 127 | 128 | initialize_container_fixtures() 129 | 130 | 131 | @pytest.fixture 132 | def host(request, tmpdir_factory): 133 | if not has_docker(): 134 | pytest.skip() 135 | return 136 | image, kw = parse_hostspec(request.param) 137 | spec = BaseBackend.parse_hostspec(image) 138 | 139 | for marker in getattr(request.function, "pytestmark", []): 140 | if marker.name == "destructive": 141 | scope = "function" 142 | break 143 | else: 144 | scope = "session" 145 | 146 | fname = f"_docker_container_{spec.name}_{scope}" 147 | docker_id, docker_host, port = request.getfixturevalue(fname) 148 | 149 | if kw["connection"] == "docker": 150 | hostname = docker_id 151 | elif kw["connection"] in ("ansible", "ssh", "paramiko", "safe-ssh"): 152 | hostname = spec.name 153 | tmpdir = tmpdir_factory.mktemp(str(id(request))) 154 | key = tmpdir.join("ssh_key") 155 | with open(os.path.join(BASETESTDIR, "ssh_key")) as f: 156 | key.write(f.read()) 157 | key.chmod(384) # octal 600 158 | if kw["connection"] == "ansible": 159 | setup_ansible_config( 160 | tmpdir, hostname, docker_host, spec.user or "root", port, str(key) 161 | ) 162 | os.environ["ANSIBLE_CONFIG"] = str(tmpdir.join("ansible.cfg")) 163 | # this force backend cache reloading 164 | kw["ansible_inventory"] = str(tmpdir.join("inventory")) 165 | else: 166 | ssh_config = tmpdir.join("ssh_config") 167 | ssh_config.write( 168 | f"Host {hostname}\n" 169 | f" Hostname {docker_host}\n" 170 | f" Port {port}\n" 171 | " UserKnownHostsFile /dev/null\n" 172 | " StrictHostKeyChecking no\n" 173 | f" IdentityFile {str(key)}\n" 174 | " IdentitiesOnly yes\n" 175 | " LogLevel FATAL\n" 176 | ) 177 | kw["ssh_config"] = str(ssh_config) 178 | 179 | # Wait ssh to be up 180 | service = testinfra.get_host(docker_id, connection="docker").service 181 | 182 | service_name = "sshd" if image == "rockylinux9" else "ssh" 183 | 184 | while not service(service_name).is_running: 185 | time.sleep(0.5) 186 | 187 | if kw["connection"] != "ansible": 188 | hostspec = (spec.user or "root") + "@" + hostname 189 | else: 190 | hostspec = spec.name 191 | 192 | b = testinfra.host.get_host(hostspec, **kw) 193 | b.backend.get_hostname = lambda: image 194 | return b 195 | 196 | 197 | @pytest.fixture 198 | def docker_image(host): 199 | return host.backend.get_hostname() 200 | 201 | 202 | def pytest_generate_tests(metafunc): 203 | if "host" in metafunc.fixturenames: 204 | for marker in getattr(metafunc.function, "pytestmark", []): 205 | if marker.name == "testinfra_hosts": 206 | hosts = marker.args 207 | break 208 | else: 209 | # Default 210 | hosts = ["docker://debian_bookworm"] 211 | metafunc.parametrize("host", hosts, indirect=True, scope="function") 212 | 213 | 214 | def pytest_configure(config): 215 | if not has_docker(): 216 | return 217 | 218 | def build_image(build_failed, dockerfile, image, image_path): 219 | try: 220 | subprocess.check_call( 221 | [ 222 | "docker", 223 | "build", 224 | "-f", 225 | dockerfile, 226 | "-t", 227 | f"testinfra:{image}", 228 | image_path, 229 | ] 230 | ) 231 | except Exception: 232 | build_failed.set() 233 | raise 234 | 235 | threads = [] 236 | images_path = os.path.join(BASEDIR, "images") 237 | build_failed = threading.Event() 238 | for image in os.listdir(images_path): 239 | image_path = os.path.join(images_path, image) 240 | dockerfile = os.path.join(image_path, "Dockerfile") 241 | if os.path.exists(dockerfile): 242 | threads.append( 243 | threading.Thread( 244 | target=build_image, 245 | args=(build_failed, dockerfile, image, image_path), 246 | ) 247 | ) 248 | 249 | for thread in threads: 250 | thread.start() 251 | for thread in threads: 252 | thread.join() 253 | if build_failed.is_set(): 254 | raise RuntimeError("One or more docker build failed") 255 | 256 | config.addinivalue_line( 257 | "markers", "testinfra_hosts(host_selector): mark test to run on selected hosts" 258 | ) 259 | config.addinivalue_line("markers", "destructive: mark test as destructive") 260 | config.addinivalue_line("markers", "skip_wsl: skip test on WSL, no systemd support") 261 | -------------------------------------------------------------------------------- /test/ssh_key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEowIBAAKCAQEAoA68iuAIyXon7nNjeeErddyjDZZxiwLYm0EjJr0sq4R+1ydW 3 | rZwaNR6yYRmTTQVSjOu/s2DXPSd34N6iNYOt70kAAPp1zLA2MLuAT8LqWpKcCRC3 4 | BmScbFBs4xlaLVaajyF+Z1vDQRr5FbjmyOFBCzpXquZkDExnqyTuuCpElYttBQ8D 5 | yZ96vBhts5N6REogwOUoRMhYWAvLzi30kmA9C2JkoQFRgixIIjW/jl0C+mUb86nx 6 | RMy9EL2iP5d5m+m9FRTwDuEFji9MY93FszmVzqobd1fOQmb0pPCEnTYMAo1y2bD3 7 | mmC/7aBzbwRd9FTKw2e8OEg1NGWSxLHP3s8Y2wIDAQABAoIBADm3trPZwDFvdJDf 8 | WWLtGPACpWXT95PqbdPmtFdW5pHfUKIjlHU8kpLPRAIR5/VhUvhwVwvHgzaRUgBs 9 | KFBl8MYWLAMuTmaGsLP4GXgp0LrinZQDTAzpISNKCUoHrWYmEcxFhsCc7Zc/s8zq 10 | hYaw+/ShkFWXiUKKFuQ3iEIvM9Y9A/3CJDoVJcnsAhaOL9mVjuu978VhSfeEel9y 11 | DV7/4A1R6c/t2GImFfQUz3/z7gg+heJ9idLKwybW0NJB/v+R+qNtbYyU1o4YFywW 12 | EQa9LiZpiLylMXGO4/txAJwbDyoSCaog/5NiGK9VCgY06XRZgVIL4+65CqEeRH0Q 13 | AlI0s4kCgYEA0PBWf5fcKa+Drkv0jVro2ZEzhxLYtf2sLYvaJd+jUBZWb1+OH67i 14 | YC4hLYFQQMVe/NCjGSfJbeN/3RpYHggxQI7ZEllPqoTBwFY6IfCA5hnGBrLytUrT 15 | jFfYQQU7Dt++zP3Cpuw0OXEzgez2x4svHCLY3KNqzOE7JWGaka715W0CgYEAxBvY 16 | 1E7HVjcktX94d2IwNotsZRKCyZFt6D6ePPs8aRfnzGBQoBZmjnG6FPkMeiYRvkj0 17 | 96zTwYxvnP1EdPvGrKto3R0F41y4rVbMTY8oSvh0mqyboFzjBODsag7bmAaZAlf0 18 | JWl313a4TMdFsRZ/QhIUFBnJ1y5WRRkfVJuCsmcCgYEAxExIt+9wxSlEyghKZlO2 19 | 2FF227x1JeaCUPhHp7WItcGGy3Q3DsU7oak1Oo93WqMULunFkeizci5+/re1eeGw 20 | hDqw7nBCTK4VaiKY0zIlqAkm5zxQkssOHZiab9v+NGc511XB/xmDp0QXZEXBRJAb 21 | Xo/OttxBhuNEskYU9jIui7ECgYABzGOTptlLIBxVEcMwDRV2Gpc24hGS+aNxYsme 22 | s4sdR5vXkvaKUUpFeiODt7j2kczN2utsLgiPGNOZM/VhwUFUKgo/JNn9+Ma0yDv9 23 | ZhevgFHJbVXMBa4LSGjCnDpFTaIvlFDn2uy/bBZKlfU8p4EpQPMwMABa2dDut0lD 24 | RF3RdwKBgHMSW5hnT713lXtXWQlnCxl4LrFhwq2rYYnuoj8k/ydGzpcPAbROP149 25 | 49CS+Oa35A9g7BXlZflhhRJvYh+T3DAvniHotNWp8GrGmC4Yae3vp+09B5cT7GG9 26 | 1kPRf9q3yPmLuddxz3MpF/qhQUlMlhEjyUDFpJvIXShGVBQmNGME 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /test/test_invocation.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License, Version 2.0 (the "License"); 2 | # you may not use this file except in compliance with the License. 3 | # You may obtain a copy of the License at 4 | # 5 | # http://www.apache.org/licenses/LICENSE-2.0 6 | # 7 | # Unless required by applicable law or agreed to in writing, software 8 | # distributed under the License is distributed on an "AS IS" BASIS, 9 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | # See the License for the specific language governing permissions and 11 | # limitations under the License. 12 | 13 | pytest_plugins = ["pytester"] 14 | 15 | 16 | def test_nagios_notest(testdir, request): 17 | params = ["--nagios", "-q", "--tb=no"] 18 | if not request.config.pluginmanager.hasplugin("pytest11.testinfra"): 19 | params.extend(["-p", "testinfra.plugin"]) 20 | result = testdir.runpytest(*params) 21 | assert result.ret == 0 22 | lines = result.stdout.str().splitlines() 23 | assert lines[0].startswith("TESTINFRA OK - 0 passed, 0 failed, 0 skipped") 24 | 25 | 26 | def test_nagios_ok(testdir, request): 27 | testdir.makepyfile("def test_ok(): pass") 28 | params = ["--nagios", "-q", "--tb=no"] 29 | if not request.config.pluginmanager.hasplugin("pytest11.testinfra"): 30 | params.extend(["-p", "testinfra.plugin"]) 31 | result = testdir.runpytest(*params) 32 | assert result.ret == 0 33 | lines = result.stdout.str().splitlines() 34 | assert lines[0].startswith("TESTINFRA OK - 1 passed, 0 failed, 0 skipped") 35 | assert lines[1][0] == "." 36 | 37 | 38 | def test_nagios_fail(testdir, request): 39 | testdir.makepyfile("def test_ok(): pass\ndef test_fail(): assert False") 40 | params = ["--nagios", "-q", "--tb=no"] 41 | if not request.config.pluginmanager.hasplugin("pytest11.testinfra"): 42 | params.extend(["-p", "testinfra.plugin"]) 43 | result = testdir.runpytest(*params) 44 | assert result.ret == 2 45 | lines = result.stdout.str().splitlines() 46 | assert lines[0].startswith("TESTINFRA CRITICAL - 1 passed, 1 failed, 0 skipped") 47 | assert lines[1][:2] == ".F" 48 | -------------------------------------------------------------------------------- /testinfra/__init__.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License, Version 2.0 (the "License"); 2 | # you may not use this file except in compliance with the License. 3 | # You may obtain a copy of the License at 4 | # 5 | # http://www.apache.org/licenses/LICENSE-2.0 6 | # 7 | # Unless required by applicable law or agreed to in writing, software 8 | # distributed under the License is distributed on an "AS IS" BASIS, 9 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | # See the License for the specific language governing permissions and 11 | # limitations under the License. 12 | 13 | from testinfra.host import get_host, get_hosts 14 | 15 | __all__ = ["get_host", "get_hosts"] 16 | -------------------------------------------------------------------------------- /testinfra/backend/__init__.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License, Version 2.0 (the "License"); 2 | # you may not use this file except in compliance with the License. 3 | # You may obtain a copy of the License at 4 | # 5 | # http://www.apache.org/licenses/LICENSE-2.0 6 | # 7 | # Unless required by applicable law or agreed to in writing, software 8 | # distributed under the License is distributed on an "AS IS" BASIS, 9 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | # See the License for the specific language governing permissions and 11 | # limitations under the License. 12 | 13 | import importlib 14 | import os 15 | import urllib.parse 16 | from collections.abc import Iterable 17 | from typing import TYPE_CHECKING, Any 18 | 19 | if TYPE_CHECKING: 20 | import testinfra.backend.base 21 | 22 | BACKENDS = { 23 | "local": "testinfra.backend.local.LocalBackend", 24 | "ssh": "testinfra.backend.ssh.SshBackend", 25 | "safe-ssh": "testinfra.backend.ssh.SafeSshBackend", 26 | "paramiko": "testinfra.backend.paramiko.ParamikoBackend", 27 | "salt": "testinfra.backend.salt.SaltBackend", 28 | "docker": "testinfra.backend.docker.DockerBackend", 29 | "podman": "testinfra.backend.podman.PodmanBackend", 30 | "ansible": "testinfra.backend.ansible.AnsibleBackend", 31 | "kubectl": "testinfra.backend.kubectl.KubectlBackend", 32 | "winrm": "testinfra.backend.winrm.WinRMBackend", 33 | "lxc": "testinfra.backend.lxc.LxcBackend", 34 | "openshift": "testinfra.backend.openshift.OpenShiftBackend", 35 | "chroot": "testinfra.backend.chroot.ChrootBackend", 36 | } 37 | 38 | 39 | def get_backend_class(connection: str) -> type["testinfra.backend.base.BaseBackend"]: 40 | try: 41 | classpath = BACKENDS[connection] 42 | except KeyError: 43 | raise RuntimeError(f"Unknown connection type '{connection}'") from None 44 | module, name = classpath.rsplit(".", 1) 45 | return getattr(importlib.import_module(module), name) # type: ignore[no-any-return] 46 | 47 | 48 | def parse_hostspec(hostspec: str) -> tuple[str, dict[str, Any]]: 49 | kw: dict[str, Any] = {} 50 | if hostspec is not None and "://" in hostspec: 51 | url = urllib.parse.urlparse(hostspec) 52 | kw["connection"] = url.scheme 53 | host = url.netloc 54 | query = urllib.parse.parse_qs(url.query) 55 | for key in ("sudo", "ssl", "no_ssl", "no_verify_ssl", "force_ansible"): 56 | if query.get(key, ["false"])[0].lower() == "true": 57 | kw[key] = True 58 | for key in ( 59 | "sudo_user", 60 | "namespace", 61 | "container", 62 | "read_timeout_sec", 63 | "operation_timeout_sec", 64 | "timeout", 65 | "controlpersist", 66 | "kubeconfig", 67 | "context", 68 | ): 69 | if key in query: 70 | kw[key] = query[key][0] 71 | for key in ( 72 | "ssh_config", 73 | "ansible_inventory", 74 | "ssh_identity_file", 75 | ): 76 | if key in query: 77 | kw[key] = os.path.expanduser(query[key][0]) 78 | else: 79 | host = hostspec 80 | return host, kw 81 | 82 | 83 | def get_backend(hostspec: str, **kwargs: Any) -> "testinfra.backend.base.BaseBackend": 84 | host, kw = parse_hostspec(hostspec) 85 | for k, v in kwargs.items(): 86 | kw.setdefault(k, v) 87 | kw.setdefault("connection", "paramiko") 88 | klass = get_backend_class(kw["connection"]) 89 | if kw["connection"] == "local": 90 | return klass(**kw) 91 | return klass(host, **kw) 92 | 93 | 94 | def get_backends( 95 | hosts: Iterable[str], **kwargs: Any 96 | ) -> list["testinfra.backend.base.BaseBackend"]: 97 | backends = {} 98 | for hostspec in hosts: 99 | host, kw = parse_hostspec(hostspec) 100 | for k, v in kwargs.items(): 101 | kw.setdefault(k, v) 102 | connection = kw.get("connection") 103 | if host is None and connection is None: 104 | connection = "local" 105 | elif host is not None and connection is None: 106 | connection = "paramiko" 107 | klass = get_backend_class(connection) 108 | for name in klass.get_hosts(host, **kw): 109 | key = (name, frozenset(kw.items())) 110 | if key in backends: 111 | continue 112 | backend = klass(**kw) if connection == "local" else klass(name, **kw) 113 | backends[key] = backend 114 | return list(backends.values()) 115 | -------------------------------------------------------------------------------- /testinfra/backend/ansible.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License, Version 2.0 (the "License"); 2 | # you may not use this file except in compliance with the License. 3 | # You may obtain a copy of the License at 4 | # 5 | # http://www.apache.org/licenses/LICENSE-2.0 6 | # 7 | # Unless required by applicable law or agreed to in writing, software 8 | # distributed under the License is distributed on an "AS IS" BASIS, 9 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | # See the License for the specific language governing permissions and 11 | # limitations under the License. 12 | 13 | import json 14 | import logging 15 | import pprint 16 | from typing import Any, Optional 17 | 18 | from testinfra.backend import base 19 | from testinfra.utils.ansible_runner import AnsibleRunner 20 | 21 | logger = logging.getLogger("testinfra") 22 | 23 | 24 | class AnsibleBackend(base.BaseBackend): 25 | NAME = "ansible" 26 | HAS_RUN_ANSIBLE = True 27 | 28 | def __init__( 29 | self, 30 | host: str, 31 | ansible_inventory: Optional[str] = None, 32 | ssh_config: Optional[str] = None, 33 | ssh_identity_file: Optional[str] = None, 34 | force_ansible: bool = False, 35 | *args: Any, 36 | **kwargs: Any, 37 | ): 38 | self.host = host 39 | self.ansible_inventory = ansible_inventory 40 | self.ssh_config = ssh_config 41 | self.ssh_identity_file = ssh_identity_file 42 | self.force_ansible = force_ansible 43 | super().__init__(host, *args, **kwargs) 44 | 45 | @property 46 | def ansible_runner(self) -> AnsibleRunner: 47 | return AnsibleRunner.get_runner(self.ansible_inventory) 48 | 49 | def run(self, command: str, *args: str, **kwargs: Any) -> base.CommandResult: 50 | command = self.get_command(command, *args) 51 | if not self.force_ansible: 52 | host = self.ansible_runner.get_host( 53 | self.host, 54 | ssh_config=self.ssh_config, 55 | ssh_identity_file=self.ssh_identity_file, 56 | ) 57 | if host is not None: 58 | return host.run(command) 59 | out = self.run_ansible("shell", module_args=command, check=False) 60 | if "module_stdout" in out: 61 | data = json.loads(out["module_stdout"]) 62 | stdout = data["stdout"] 63 | stderr = data["stderr"] 64 | else: 65 | # bw compat 66 | stdout = out["stdout"] 67 | stderr = out["stderr"] 68 | return self.result(out["rc"], self.encode(command), stdout, stderr) 69 | 70 | def run_ansible( 71 | self, module_name: str, module_args: Optional[str] = None, **kwargs: Any 72 | ) -> Any: 73 | def get_encoding() -> str: 74 | return self.encoding 75 | 76 | result = self.ansible_runner.run_module( 77 | self.host, module_name, module_args, get_encoding=get_encoding, **kwargs 78 | ) 79 | logger.info( 80 | "RUN Ansible(%s, %s, %s): %s", 81 | repr(module_name), 82 | repr(module_args), 83 | repr(kwargs), 84 | pprint.pformat(result), 85 | ) 86 | return result 87 | 88 | def get_variables(self) -> dict[str, Any]: 89 | return self.ansible_runner.get_variables(self.host) 90 | 91 | @classmethod 92 | def get_hosts(cls, host: str, **kwargs: Any) -> list[str]: 93 | inventory = kwargs.get("ansible_inventory") 94 | return AnsibleRunner.get_runner(inventory).get_hosts(host or "all") 95 | -------------------------------------------------------------------------------- /testinfra/backend/chroot.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License, Version 2.0 (the "License"); 2 | # you may not use this file except in compliance with the License. 3 | # You may obtain a copy of the License at 4 | # 5 | # http://www.apache.org/licenses/LICENSE-2.0 6 | # 7 | # Unless required by applicable law or agreed to in writing, software 8 | # distributed under the License is distributed on an "AS IS" BASIS, 9 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | # See the License for the specific language governing permissions and 11 | # limitations under the License. 12 | import os.path 13 | from typing import Any 14 | 15 | from testinfra.backend import base 16 | 17 | 18 | class ChrootBackend(base.BaseBackend): 19 | """Run commands in a chroot folder 20 | 21 | Requires root access or sudo 22 | Can be invoked by --hosts=/path/to/chroot/ --connection=chroot --sudo 23 | """ 24 | 25 | NAME = "chroot" 26 | 27 | def __init__(self, name: str, *args: Any, **kwargs: Any): 28 | self.name = name 29 | super().__init__(self.name, *args, **kwargs) 30 | 31 | def run(self, command: str, *args: str, **kwargs: Any) -> base.CommandResult: 32 | if not os.path.exists(self.name) and os.path.isdir(self.name): 33 | raise RuntimeError(f"chroot path {self.name} not found or not a directory") 34 | cmd = self.get_command(command, *args) 35 | out = self.run_local("chroot %s /bin/sh -c %s", self.name, cmd) 36 | out.command = self.encode(cmd) 37 | return out 38 | -------------------------------------------------------------------------------- /testinfra/backend/docker.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License, Version 2.0 (the "License"); 2 | # you may not use this file except in compliance with the License. 3 | # You may obtain a copy of the License at 4 | # 5 | # http://www.apache.org/licenses/LICENSE-2.0 6 | # 7 | # Unless required by applicable law or agreed to in writing, software 8 | # distributed under the License is distributed on an "AS IS" BASIS, 9 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | # See the License for the specific language governing permissions and 11 | # limitations under the License. 12 | 13 | from typing import Any 14 | 15 | from testinfra.backend import base 16 | 17 | 18 | class DockerBackend(base.BaseBackend): 19 | NAME = "docker" 20 | 21 | def __init__(self, name: str, *args: Any, **kwargs: Any): 22 | self.name, self.user = self.parse_containerspec(name) 23 | super().__init__(self.name, *args, **kwargs) 24 | 25 | def run(self, command: str, *args: str, **kwargs: Any) -> base.CommandResult: 26 | cmd = self.get_command(command, *args) 27 | if self.user is not None: 28 | out = self.run_local( 29 | "docker exec -u %s %s /bin/sh -c %s", self.user, self.name, cmd 30 | ) 31 | else: 32 | out = self.run_local("docker exec %s /bin/sh -c %s", self.name, cmd) 33 | out.command = self.encode(cmd) 34 | return out 35 | -------------------------------------------------------------------------------- /testinfra/backend/kubectl.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License, Version 2.0 (the "License"); 2 | # you may not use this file except in compliance with the License. 3 | # You may obtain a copy of the License at 4 | # 5 | # http://www.apache.org/licenses/LICENSE-2.0 6 | # 7 | # Unless required by applicable law or agreed to in writing, software 8 | # distributed under the License is distributed on an "AS IS" BASIS, 9 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | # See the License for the specific language governing permissions and 11 | # limitations under the License. 12 | 13 | from typing import Any 14 | 15 | from testinfra.backend import base 16 | 17 | 18 | class KubectlBackend(base.BaseBackend): 19 | NAME = "kubectl" 20 | 21 | def __init__(self, name: str, *args: Any, **kwargs: Any): 22 | self.name = name 23 | self.container = kwargs.get("container") 24 | self.namespace = kwargs.get("namespace") 25 | self.kubeconfig = kwargs.get("kubeconfig") 26 | self.context = kwargs.get("context") 27 | super().__init__(self.name, *args, **kwargs) 28 | 29 | def run(self, command: str, *args: str, **kwargs: Any) -> base.CommandResult: 30 | cmd = self.get_command(command, *args) 31 | # `kubectl exec` does not support specifying the user to run as. 32 | # See https://github.com/kubernetes/kubernetes/issues/30656 33 | kcmd = "kubectl " 34 | kcmd_args = [] 35 | if self.kubeconfig is not None: 36 | kcmd += '--kubeconfig="%s" ' 37 | kcmd_args.append(self.kubeconfig) 38 | if self.context is not None: 39 | kcmd += '--context="%s" ' 40 | kcmd_args.append(self.context) 41 | if self.namespace is not None: 42 | kcmd += "-n %s " 43 | kcmd_args.append(self.namespace) 44 | if self.container is not None: 45 | kcmd += "-c %s " 46 | kcmd_args.append(self.container) 47 | kcmd += "exec %s -- /bin/sh -c %s" 48 | kcmd_args.extend([self.name, cmd]) 49 | out = self.run_local(kcmd, *kcmd_args) 50 | return out 51 | -------------------------------------------------------------------------------- /testinfra/backend/local.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License, Version 2.0 (the "License"); 2 | # you may not use this file except in compliance with the License. 3 | # You may obtain a copy of the License at 4 | # 5 | # http://www.apache.org/licenses/LICENSE-2.0 6 | # 7 | # Unless required by applicable law or agreed to in writing, software 8 | # distributed under the License is distributed on an "AS IS" BASIS, 9 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | # See the License for the specific language governing permissions and 11 | # limitations under the License. 12 | 13 | from typing import Any 14 | 15 | from testinfra.backend import base 16 | 17 | 18 | class LocalBackend(base.BaseBackend): 19 | NAME = "local" 20 | 21 | def __init__(self, *args: Any, **kwargs: Any): 22 | super().__init__("local", **kwargs) 23 | 24 | def get_pytest_id(self) -> str: 25 | return "local" 26 | 27 | @classmethod 28 | def get_hosts(cls, host: str, **kwargs: Any) -> list[str]: 29 | return [host] 30 | 31 | def run(self, command: str, *args: str, **kwargs: Any) -> base.CommandResult: 32 | return self.run_local(self.get_command(command, *args)) 33 | -------------------------------------------------------------------------------- /testinfra/backend/lxc.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License, Version 2.0 (the "License"); 2 | # you may not use this file except in compliance with the License. 3 | # You may obtain a copy of the License at 4 | # 5 | # http://www.apache.org/licenses/LICENSE-2.0 6 | # 7 | # Unless required by applicable law or agreed to in writing, software 8 | # distributed under the License is distributed on an "AS IS" BASIS, 9 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | # See the License for the specific language governing permissions and 11 | # limitations under the License. 12 | 13 | from typing import Any 14 | 15 | from testinfra.backend import base 16 | 17 | 18 | class LxcBackend(base.BaseBackend): 19 | NAME = "lxc" 20 | 21 | def __init__(self, name: str, *args: Any, **kwargs: Any): 22 | self.name = name 23 | super().__init__(self.name, *args, **kwargs) 24 | 25 | def run(self, command: str, *args: str, **kwargs: Any) -> base.CommandResult: 26 | cmd = self.get_command(command, *args) 27 | out = self.run_local( 28 | "lxc exec %s --mode=non-interactive -- /bin/sh -c %s", self.name, cmd 29 | ) 30 | out.command = self.encode(cmd) 31 | return out 32 | -------------------------------------------------------------------------------- /testinfra/backend/openshift.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License, Version 2.0 (the "License"); 2 | # you may not use this file except in compliance with the License. 3 | # You may obtain a copy of the License at 4 | # 5 | # http://www.apache.org/licenses/LICENSE-2.0 6 | # 7 | # Unless required by applicable law or agreed to in writing, software 8 | # distributed under the License is distributed on an "AS IS" BASIS, 9 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | # See the License for the specific language governing permissions and 11 | # limitations under the License. 12 | 13 | from typing import Any 14 | 15 | from testinfra.backend import base 16 | 17 | 18 | class OpenShiftBackend(base.BaseBackend): 19 | NAME = "openshift" 20 | 21 | def __init__(self, name: str, *args: Any, **kwargs: Any): 22 | self.name = name 23 | self.container = kwargs.get("container") 24 | self.namespace = kwargs.get("namespace") 25 | self.kubeconfig = kwargs.get("kubeconfig") 26 | super().__init__(self.name, *args, **kwargs) 27 | 28 | def run(self, command: str, *args: str, **kwargs: Any) -> base.CommandResult: 29 | cmd = self.get_command(command, *args) 30 | # `oc exec` does not support specifying the user to run as. 31 | # See https://github.com/kubernetes/kubernetes/issues/30656 32 | oscmd = "oc " 33 | oscmd_args = [] 34 | if self.kubeconfig is not None: 35 | oscmd += '--kubeconfig="%s" ' 36 | oscmd_args.append(self.kubeconfig) 37 | if self.namespace is not None: 38 | oscmd += "-n %s " 39 | oscmd_args.append(self.namespace) 40 | if self.container is not None: 41 | oscmd += "-c %s " 42 | oscmd_args.append(self.container) 43 | oscmd += "exec %s -- /bin/sh -c %s" 44 | oscmd_args.extend([self.name, cmd]) 45 | out = self.run_local(oscmd, *oscmd_args) 46 | return out 47 | -------------------------------------------------------------------------------- /testinfra/backend/paramiko.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License, Version 2.0 (the "License"); 2 | # you may not use this file except in compliance with the License. 3 | # You may obtain a copy of the License at 4 | # 5 | # http://www.apache.org/licenses/LICENSE-2.0 6 | # 7 | # Unless required by applicable law or agreed to in writing, software 8 | # distributed under the License is distributed on an "AS IS" BASIS, 9 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | # See the License for the specific language governing permissions and 11 | # limitations under the License. 12 | 13 | import os 14 | 15 | try: 16 | import paramiko 17 | except ImportError: 18 | raise RuntimeError( 19 | "You must install paramiko package (pip install paramiko) " 20 | "to use the paramiko backend" 21 | ) from None 22 | 23 | import functools 24 | from typing import Any, Optional 25 | 26 | import paramiko.pkey 27 | import paramiko.ssh_exception 28 | 29 | from testinfra.backend import base 30 | 31 | 32 | class IgnorePolicy(paramiko.MissingHostKeyPolicy): 33 | """Policy for ignoring a missing host key.""" 34 | 35 | def missing_host_key( 36 | self, client: paramiko.SSHClient, hostname: str, key: paramiko.pkey.PKey 37 | ) -> None: 38 | pass 39 | 40 | 41 | class ParamikoBackend(base.BaseBackend): 42 | NAME = "paramiko" 43 | 44 | def __init__( 45 | self, 46 | hostspec: str, 47 | ssh_config: Optional[str] = None, 48 | ssh_identity_file: Optional[str] = None, 49 | timeout: int = 10, 50 | *args: Any, 51 | **kwargs: Any, 52 | ): 53 | self.host = self.parse_hostspec(hostspec) 54 | self.ssh_config = ssh_config 55 | self.ssh_identity_file = ssh_identity_file 56 | self.get_pty = False 57 | self.timeout = int(timeout) 58 | super().__init__(self.host.name, *args, **kwargs) 59 | 60 | def _load_ssh_config( 61 | self, 62 | client: paramiko.SSHClient, 63 | cfg: dict[str, Any], 64 | ssh_config: paramiko.SSHConfig, 65 | ssh_config_dir: str = "~/.ssh", 66 | ) -> None: 67 | for key, value in ssh_config.lookup(self.host.name).items(): 68 | if key == "hostname": 69 | cfg[key] = value 70 | elif key == "user": 71 | cfg["username"] = value 72 | elif key == "port": 73 | cfg[key] = int(value) 74 | elif key == "identityfile": 75 | cfg["key_filename"] = os.path.expanduser(value[0]) 76 | elif key == "stricthostkeychecking" and value == "no": 77 | client.set_missing_host_key_policy(IgnorePolicy()) 78 | elif key == "requesttty": 79 | self.get_pty = value in ("yes", "force") 80 | elif key == "gssapikeyexchange": 81 | cfg["gss_auth"] = value == "yes" 82 | elif key == "gssapiauthentication": 83 | cfg["gss_kex"] = value == "yes" 84 | elif key == "proxycommand": 85 | cfg["sock"] = paramiko.ProxyCommand(value) 86 | elif key == "include": 87 | new_config_path = os.path.join( 88 | os.path.expanduser(ssh_config_dir), value 89 | ) 90 | with open(new_config_path) as f: 91 | new_ssh_config = paramiko.SSHConfig() 92 | new_ssh_config.parse(f) 93 | self._load_ssh_config(client, cfg, new_ssh_config, ssh_config_dir) 94 | 95 | @functools.cached_property 96 | def client(self) -> paramiko.SSHClient: 97 | client = paramiko.SSHClient() 98 | client.set_missing_host_key_policy(paramiko.WarningPolicy()) 99 | cfg = { 100 | "hostname": self.host.name, 101 | "port": int(self.host.port) if self.host.port else 22, 102 | "username": self.host.user, 103 | "timeout": self.timeout, 104 | "password": self.host.password, 105 | } 106 | if self.ssh_config: 107 | ssh_config_dir = os.path.dirname(self.ssh_config) 108 | 109 | with open(self.ssh_config) as f: 110 | ssh_config = paramiko.SSHConfig() 111 | ssh_config.parse(f) 112 | self._load_ssh_config(client, cfg, ssh_config, ssh_config_dir) 113 | else: 114 | # fallback reading ~/.ssh/config 115 | default_ssh_config = os.path.join(os.path.expanduser("~"), ".ssh", "config") 116 | ssh_config_dir = os.path.dirname(default_ssh_config) 117 | 118 | try: 119 | with open(default_ssh_config) as f: 120 | ssh_config = paramiko.SSHConfig() 121 | ssh_config.parse(f) 122 | except OSError: 123 | pass 124 | else: 125 | self._load_ssh_config(client, cfg, ssh_config, ssh_config_dir) 126 | 127 | if self.ssh_identity_file: 128 | cfg["key_filename"] = self.ssh_identity_file 129 | client.connect(**cfg) # type: ignore[arg-type] 130 | return client 131 | 132 | def _exec_command(self, command: bytes) -> tuple[int, bytes, bytes]: 133 | transport = self.client.get_transport() 134 | assert transport is not None 135 | chan = transport.open_session() 136 | if self.get_pty: 137 | chan.get_pty() 138 | chan.exec_command(command) 139 | stdout = b"".join(chan.makefile("rb")) 140 | stderr = b"".join(chan.makefile_stderr("rb")) 141 | rc = chan.recv_exit_status() 142 | return rc, stdout, stderr 143 | 144 | def run(self, command: str, *args: str, **kwargs: Any) -> base.CommandResult: 145 | command = self.get_command(command, *args) 146 | cmd = self.encode(command) 147 | try: 148 | rc, stdout, stderr = self._exec_command(cmd) 149 | except (paramiko.ssh_exception.SSHException, ConnectionResetError): 150 | transport = self.client.get_transport() 151 | assert transport is not None 152 | if not transport.is_active(): 153 | # try to reinit connection (once) 154 | del self.client 155 | rc, stdout, stderr = self._exec_command(cmd) 156 | else: 157 | raise 158 | 159 | return self.result(rc, cmd, stdout, stderr) 160 | -------------------------------------------------------------------------------- /testinfra/backend/podman.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License, Version 2.0 (the "License"); 2 | # you may not use this file except in compliance with the License. 3 | # You may obtain a copy of the License at 4 | # 5 | # http://www.apache.org/licenses/LICENSE-2.0 6 | # 7 | # Unless required by applicable law or agreed to in writing, software 8 | # distributed under the License is distributed on an "AS IS" BASIS, 9 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | # See the License for the specific language governing permissions and 11 | # limitations under the License. 12 | 13 | from typing import Any 14 | 15 | from testinfra.backend import base 16 | 17 | 18 | class PodmanBackend(base.BaseBackend): 19 | NAME = "podman" 20 | 21 | def __init__(self, name: str, *args: Any, **kwargs: Any): 22 | self.name, self.user = self.parse_containerspec(name) 23 | super().__init__(self.name, *args, **kwargs) 24 | 25 | def run(self, command: str, *args: str, **kwargs: Any) -> base.CommandResult: 26 | cmd = self.get_command(command, *args) 27 | if self.user is not None: 28 | out = self.run_local( 29 | "podman exec -u %s %s /bin/sh -c %s", self.user, self.name, cmd 30 | ) 31 | else: 32 | out = self.run_local("podman exec %s /bin/sh -c %s", self.name, cmd) 33 | out.command = self.encode(cmd) 34 | return out 35 | -------------------------------------------------------------------------------- /testinfra/backend/salt.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License, Version 2.0 (the "License"); 2 | # you may not use this file except in compliance with the License. 3 | # You may obtain a copy of the License at 4 | # 5 | # http://www.apache.org/licenses/LICENSE-2.0 6 | # 7 | # Unless required by applicable law or agreed to in writing, software 8 | # distributed under the License is distributed on an "AS IS" BASIS, 9 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | # See the License for the specific language governing permissions and 11 | # limitations under the License. 12 | 13 | try: 14 | import salt.client 15 | except ImportError: 16 | raise RuntimeError( 17 | "You must install salt package to use the salt backend" 18 | ) from None 19 | 20 | from typing import Any, Optional 21 | 22 | from testinfra.backend import base 23 | 24 | 25 | class SaltBackend(base.BaseBackend): 26 | HAS_RUN_SALT = True 27 | NAME = "salt" 28 | 29 | def __init__(self, host: str, *args: Any, **kwargs: Any): 30 | self.host = host 31 | self._client: Optional[salt.client.LocalClient] = None 32 | super().__init__(self.host, *args, **kwargs) 33 | 34 | @property 35 | def client(self) -> salt.client.LocalClient: 36 | if self._client is None: 37 | self._client = salt.client.LocalClient() 38 | return self._client 39 | 40 | def run(self, command: str, *args: str, **kwargs: Any) -> base.CommandResult: 41 | command = self.get_command(command, *args) 42 | out = self.run_salt("cmd.run_all", [command]) 43 | return self.result( 44 | out["retcode"], 45 | self.encode(command), 46 | stdout=out["stdout"], 47 | stderr=out["stderr"], 48 | ) 49 | 50 | def run_salt(self, func: str, args: Any = None) -> Any: 51 | out = self.client.cmd(self.host, func, args or []) 52 | if self.host not in out: 53 | raise RuntimeError( 54 | f"Error while running {func}({args}): {out}. Minion not connected ?" 55 | ) 56 | return out[self.host] 57 | 58 | @classmethod 59 | def get_hosts(cls, host: str, **kwargs: Any) -> list[str]: 60 | if host is None: 61 | host = "*" 62 | if any(c in host for c in "@*[?"): 63 | client = salt.client.LocalClient() 64 | if "@" in host: 65 | hosts = client.cmd(host, "test.true", tgt_type="compound").keys() 66 | else: 67 | hosts = client.cmd(host, "test.true").keys() 68 | if not hosts: 69 | raise RuntimeError(f"No host matching '{host}'") 70 | return sorted(hosts) 71 | return super().get_hosts(host, **kwargs) 72 | -------------------------------------------------------------------------------- /testinfra/backend/ssh.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License, Version 2.0 (the "License"); 2 | # you may not use this file except in compliance with the License. 3 | # You may obtain a copy of the License at 4 | # 5 | # http://www.apache.org/licenses/LICENSE-2.0 6 | # 7 | # Unless required by applicable law or agreed to in writing, software 8 | # distributed under the License is distributed on an "AS IS" BASIS, 9 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | # See the License for the specific language governing permissions and 11 | # limitations under the License. 12 | 13 | import base64 14 | from typing import Any, Optional 15 | 16 | from testinfra.backend import base 17 | 18 | 19 | class SshBackend(base.BaseBackend): 20 | """Run command through ssh command""" 21 | 22 | NAME = "ssh" 23 | 24 | def __init__( 25 | self, 26 | hostspec: str, 27 | ssh_config: Optional[str] = None, 28 | ssh_identity_file: Optional[str] = None, 29 | timeout: int = 10, 30 | controlpath: Optional[str] = None, 31 | controlpersist: int = 60, 32 | ssh_extra_args: Optional[str] = None, 33 | *args: Any, 34 | **kwargs: Any, 35 | ): 36 | self.host = self.parse_hostspec(hostspec) 37 | self.ssh_config = ssh_config 38 | self.ssh_identity_file = ssh_identity_file 39 | self.timeout = int(timeout) 40 | self.controlpath = controlpath 41 | self.controlpersist = int(controlpersist) 42 | self.ssh_extra_args = ssh_extra_args 43 | super().__init__(self.host.name, *args, **kwargs) 44 | 45 | def run(self, command: str, *args: str, **kwargs: Any) -> base.CommandResult: 46 | return self.run_ssh(self.get_command(command, *args)) 47 | 48 | def _build_ssh_command(self, command: str) -> tuple[list[str], list[str]]: 49 | if not self.host.password: 50 | cmd = ["ssh"] 51 | cmd_args = [] 52 | else: 53 | cmd = ["sshpass", "-p", "%s", "ssh"] 54 | cmd_args = [self.host.password] 55 | 56 | if self.ssh_extra_args: 57 | cmd.append(self.ssh_extra_args.replace("%", "%%")) 58 | if self.ssh_config: 59 | cmd.append("-F %s") 60 | cmd_args.append(self.ssh_config) 61 | if self.host.user: 62 | cmd.append("-o User=%s") 63 | cmd_args.append(self.host.user) 64 | if self.host.port: 65 | cmd.append("-o Port=%s") 66 | cmd_args.append(self.host.port) 67 | if self.ssh_identity_file: 68 | cmd.append("-i %s") 69 | cmd_args.append(self.ssh_identity_file) 70 | if "connecttimeout" not in (self.ssh_extra_args or "").lower(): 71 | cmd.append(f"-o ConnectTimeout={self.timeout}") 72 | if self.controlpersist and ( 73 | "controlmaster" not in (self.ssh_extra_args or "").lower() 74 | ): 75 | cmd.append( 76 | f"-o ControlMaster=auto -o ControlPersist={self.controlpersist}s" 77 | ) 78 | if ( 79 | "ControlMaster" in " ".join(cmd) 80 | and self.controlpath 81 | and ("controlpath" not in (self.ssh_extra_args or "").lower()) 82 | ): 83 | cmd.append(f"-o ControlPath={self.controlpath}") 84 | cmd.append("%s %s") 85 | cmd_args.extend([self.host.name, command]) 86 | return cmd, cmd_args 87 | 88 | def run_ssh(self, command: str) -> base.CommandResult: 89 | cmd, cmd_args = self._build_ssh_command(command) 90 | out = self.run_local(" ".join(cmd), *cmd_args) 91 | out.command = self.encode(command) 92 | if out.rc == 255: 93 | # ssh exits with the exit status of the remote command or with 255 94 | # if an error occurred. 95 | raise RuntimeError(out) 96 | return out 97 | 98 | 99 | class SafeSshBackend(SshBackend): 100 | """Run command using ssh command but try to get a more sane output 101 | 102 | When using ssh (or a potentially bugged wrapper), additional output can be 103 | added in stdout/stderr and exit status may not be propagated correctly 104 | 105 | To avoid that kind of bugs, we wrap the command to have an output like 106 | this: 107 | 108 | TESTINFRA_START;EXIT_STATUS;STDOUT;STDERR;TESTINFRA_END 109 | 110 | where STDOUT/STDERR are base64 encoded, then we parse that magic string to 111 | get sanes variables 112 | """ 113 | 114 | NAME = "safe-ssh" 115 | 116 | def run(self, command: str, *args: str, **kwargs: Any) -> base.CommandResult: 117 | orig_command = self.get_command(command, *args) 118 | orig_command = self.get_command("sh -c %s", orig_command) 119 | 120 | out = self.run_ssh( 121 | f"""of=$(mktemp)&&ef=$(mktemp)&&{orig_command} >$of 2>$ef; r=$?;""" 122 | """echo "TESTINFRA_START;$r;$(base64 < $of);$(base64 < $ef);""" 123 | """TESTINFRA_END";rm -f $of $ef""" 124 | ) 125 | 126 | start = out.stdout.find("TESTINFRA_START;") + len("TESTINFRA_START;") 127 | end = out.stdout.find("TESTINFRA_END") - 1 128 | rc, stdout, stderr = out.stdout[start:end].split(";") 129 | return self.result( 130 | int(rc), 131 | self.encode(orig_command), 132 | base64.b64decode(stdout), 133 | base64.b64decode(stderr), 134 | ) 135 | -------------------------------------------------------------------------------- /testinfra/backend/winrm.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License, Version 2.0 (the "License"); 2 | # you may not use this file except in compliance with the License. 3 | # You may obtain a copy of the License at 4 | # 5 | # http://www.apache.org/licenses/LICENSE-2.0 6 | # 7 | # Unless required by applicable law or agreed to in writing, software 8 | # distributed under the License is distributed on an "AS IS" BASIS, 9 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | # See the License for the specific language governing permissions and 11 | # limitations under the License. 12 | 13 | import re 14 | from typing import Any, Optional 15 | 16 | from testinfra.backend import base 17 | 18 | try: 19 | import winrm 20 | except ImportError: 21 | raise RuntimeError( 22 | "You must install the pywinrm package (pip install pywinrm) " 23 | "to use the winrm backend" 24 | ) from None 25 | 26 | import winrm.protocol 27 | 28 | _find_unsafe = re.compile(r"[^\w@%+=:,./-]", re.ASCII) 29 | 30 | 31 | # (gtmanfred) This is copied from pipes.quote, but changed to use double quotes 32 | # instead of single quotes. This is used by the winrm backend. 33 | def _quote(s: str) -> str: 34 | """Return a shell-escaped version of the string *s*.""" 35 | if not s: 36 | return "''" 37 | if _find_unsafe.search(s) is None: 38 | return s 39 | 40 | # use single quotes, and put single quotes into double quotes 41 | # the string $'b is then quoted as '$'"'"'b' 42 | return '"' + s.replace('"', '"\'"\'"') + '"' 43 | 44 | 45 | class WinRMBackend(base.BaseBackend): 46 | """Run command through winrm command""" 47 | 48 | NAME = "winrm" 49 | 50 | def __init__( 51 | self, 52 | hostspec: str, 53 | no_ssl: bool = False, 54 | no_verify_ssl: bool = False, 55 | read_timeout_sec: Optional[int] = None, 56 | operation_timeout_sec: Optional[int] = None, 57 | *args: Any, 58 | **kwargs: Any, 59 | ): 60 | self.host = self.parse_hostspec(hostspec) 61 | self.conn_args: dict[str, Any] = { 62 | "endpoint": "{}://{}{}/wsman".format( 63 | "http" if no_ssl else "https", 64 | self.host.name, 65 | f":{self.host.port}" if self.host.port else "", 66 | ), 67 | "transport": "ntlm", 68 | "username": self.host.user, 69 | "password": self.host.password, 70 | } 71 | if no_verify_ssl: 72 | self.conn_args["server_cert_validation"] = "ignore" 73 | if read_timeout_sec is not None: 74 | self.conn_args["read_timeout_sec"] = read_timeout_sec 75 | if operation_timeout_sec is not None: 76 | self.conn_args["operation_timeout_sec"] = operation_timeout_sec 77 | super().__init__(self.host.name, *args, **kwargs) 78 | 79 | def run(self, command: str, *args: str, **kwargs: Any) -> base.CommandResult: 80 | return self.run_winrm(self.get_command(command, *args)) 81 | 82 | def run_winrm(self, command: str) -> base.CommandResult: 83 | p = winrm.protocol.Protocol(**self.conn_args) 84 | shell_id = p.open_shell() 85 | command_id = p.run_command(shell_id, command) 86 | stdout, stderr, rc = p.get_command_output(shell_id, command_id) 87 | p.cleanup_command(shell_id, command_id) 88 | p.close_shell(shell_id) 89 | return self.result(rc, self.encode(command), stdout, stderr) 90 | 91 | @staticmethod 92 | def quote(command: str, *args: str) -> str: 93 | if args: 94 | return command % tuple(_quote(a) for a in args) 95 | return command 96 | -------------------------------------------------------------------------------- /testinfra/host.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License, Version 2.0 (the "License"); 2 | # you may not use this file except in compliance with the License. 3 | # You may obtain a copy of the License at 4 | # 5 | # http://www.apache.org/licenses/LICENSE-2.0 6 | # 7 | # Unless required by applicable law or agreed to in writing, software 8 | # distributed under the License is distributed on an "AS IS" BASIS, 9 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | # See the License for the specific language governing permissions and 11 | # limitations under the License. 12 | 13 | import functools 14 | import os 15 | from collections.abc import Iterable 16 | from typing import Any 17 | 18 | import testinfra.backend 19 | import testinfra.backend.base 20 | import testinfra.modules 21 | import testinfra.modules.base 22 | 23 | 24 | class Host: 25 | _host_cache: dict[tuple[str, frozenset[tuple[str, Any]]], "Host"] = {} 26 | _hosts_cache: dict[ 27 | tuple[frozenset[str], frozenset[tuple[str, Any]]], list["Host"] 28 | ] = {} 29 | 30 | def __init__(self, backend: testinfra.backend.base.BaseBackend): 31 | self.backend = backend 32 | super().__init__() 33 | 34 | def __repr__(self) -> str: 35 | return f"" 36 | 37 | @functools.cached_property 38 | def has_command_v(self) -> bool: 39 | """Return True if `command -v` is available""" 40 | return self.run("command -v command").rc == 0 41 | 42 | def exists(self, command: str) -> bool: 43 | """Return True if given command exist in $PATH""" 44 | if self.has_command_v: 45 | out = self.run("command -v %s", command) 46 | else: 47 | out = self.run_expect([0, 1], "which %s", command) 48 | return out.rc == 0 49 | 50 | def find_command( 51 | self, command: str, extrapaths: Iterable[str] = ("/sbin", "/usr/sbin") 52 | ) -> str: 53 | """Return the path of the given command 54 | 55 | raise ValueError if command cannot be found 56 | """ 57 | if self.has_command_v: 58 | out = self.run("command -v %s", command) 59 | else: 60 | out = self.run_expect([0, 1], "which %s", command) 61 | if out.rc == 0: 62 | return out.stdout.rstrip("\r\n") 63 | for basedir in extrapaths: 64 | path = os.path.join(basedir, command) 65 | if self.exists(path): 66 | return path 67 | raise ValueError(f'cannot find "{command}" command') 68 | 69 | def run( 70 | self, command: str, *args: str, **kwargs: Any 71 | ) -> testinfra.backend.base.CommandResult: 72 | """Run given command and return rc (exit status), stdout and stderr 73 | 74 | >>> cmd = host.run("ls -l /etc/passwd") 75 | >>> cmd.rc 76 | 0 77 | >>> cmd.stdout 78 | '-rw-r--r-- 1 root root 1790 Feb 11 00:28 /etc/passwd\\n' 79 | >>> cmd.stderr 80 | '' 81 | >>> cmd.succeeded 82 | True 83 | >>> cmd.failed 84 | False 85 | 86 | 87 | Good practice: always use shell arguments quoting to avoid shell 88 | injection 89 | 90 | 91 | >>> cmd = host.run("ls -l -- %s", "/;echo inject") 92 | CommandResult( 93 | rc=2, stdout='', 94 | stderr=( 95 | 'ls: cannot access /;echo inject: No such file or directory\\n'), 96 | command="ls -l '/;echo inject'") 97 | """ 98 | return self.backend.run(command, *args, **kwargs) 99 | 100 | def run_expect( 101 | self, expected: list[int], command: str, *args: str, **kwargs: Any 102 | ) -> testinfra.backend.base.CommandResult: 103 | """Run command and check it return an expected exit status 104 | 105 | :param expected: A list of expected exit status 106 | :raises: AssertionError 107 | """ 108 | __tracebackhide__ = True 109 | out = self.run(command, *args, **kwargs) 110 | assert out.rc in expected, f"Unexpected exit code {out.rc} for {out}" 111 | return out 112 | 113 | def run_test( 114 | self, command: str, *args: str, **kwargs: Any 115 | ) -> testinfra.backend.base.CommandResult: 116 | """Run command and check it return an exit status of 0 or 1 117 | 118 | :raises: AssertionError 119 | """ 120 | return self.run_expect([0, 1], command, *args, **kwargs) 121 | 122 | def check_output(self, command: str, *args: str, **kwargs: Any) -> str: 123 | """Get stdout of a command which has run successfully 124 | 125 | :returns: stdout without trailing newline 126 | :raises: AssertionError 127 | """ 128 | __tracebackhide__ = True 129 | out = self.run(command, *args, **kwargs) 130 | assert out.rc == 0, f"Unexpected exit code {out.rc} for {out}" 131 | return out.stdout.rstrip("\r\n") 132 | 133 | def __getattr__(self, name: str) -> type[testinfra.modules.base.Module]: 134 | if name in testinfra.modules.modules: 135 | module_class = testinfra.modules.get_module_class(name) 136 | obj = module_class.get_module(self) 137 | setattr(self, name, obj) 138 | return obj 139 | raise AttributeError( 140 | f"'{self.__class__.__name__}' object has no attribute '{name}'" 141 | ) 142 | 143 | @classmethod 144 | def get_host(cls, hostspec: str, **kwargs: Any) -> "Host": 145 | """Return a Host instance from `hostspec` 146 | 147 | `hostspec` should be like 148 | `://?param1=value1¶m2=value2` 149 | 150 | Params can also be passed in `**kwargs` (e.g. get_host("local://", 151 | sudo=True) is equivalent to get_host("local://?sudo=true")) 152 | 153 | Examples:: 154 | 155 | >>> get_host("local://", sudo=True) 156 | >>> get_host("paramiko://user@host", ssh_config="/path/my_ssh_config") 157 | >>> get_host("ansible://all?ansible_inventory=/etc/ansible/inventory") 158 | """ 159 | key = (hostspec, frozenset(kwargs.items())) 160 | cache = cls._host_cache 161 | if key not in cache: 162 | backend = testinfra.backend.get_backend(hostspec, **kwargs) 163 | cache[key] = host = cls(backend) 164 | backend.set_host(host) 165 | return cache[key] 166 | 167 | @classmethod 168 | def get_hosts(cls, hosts: Iterable[str], **kwargs: Any) -> list["Host"]: 169 | key = (frozenset(hosts), frozenset(kwargs.items())) 170 | cache = cls._hosts_cache 171 | if key not in cache: 172 | cache[key] = [] 173 | for backend in testinfra.backend.get_backends(hosts, **kwargs): 174 | host = cls(backend) 175 | backend.set_host(host) 176 | cache[key].append(host) 177 | return cache[key] 178 | 179 | 180 | get_host = Host.get_host 181 | get_hosts = Host.get_hosts 182 | -------------------------------------------------------------------------------- /testinfra/main.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License, Version 2.0 (the "License"); 2 | # you may not use this file except in compliance with the License. 3 | # You may obtain a copy of the License at 4 | # 5 | # http://www.apache.org/licenses/LICENSE-2.0 6 | # 7 | # Unless required by applicable law or agreed to in writing, software 8 | # distributed under the License is distributed on an "AS IS" BASIS, 9 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | # See the License for the specific language governing permissions and 11 | # limitations under the License. 12 | 13 | import warnings 14 | 15 | import pytest 16 | 17 | 18 | def main() -> int: 19 | warnings.warn("calling testinfra is deprecated, call pytest instead", stacklevel=1) 20 | return pytest.main() 21 | -------------------------------------------------------------------------------- /testinfra/modules/__init__.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License, Version 2.0 (the "License"); 2 | # you may not use this file except in compliance with the License. 3 | # You may obtain a copy of the License at 4 | # 5 | # http://www.apache.org/licenses/LICENSE-2.0 6 | # 7 | # Unless required by applicable law or agreed to in writing, software 8 | # distributed under the License is distributed on an "AS IS" BASIS, 9 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | # See the License for the specific language governing permissions and 11 | # limitations under the License. 12 | 13 | import importlib 14 | from typing import TYPE_CHECKING 15 | 16 | if TYPE_CHECKING: 17 | import testinfra.modules.base 18 | 19 | modules = { 20 | "addr": "addr:Addr", 21 | "ansible": "ansible:Ansible", 22 | "command": "command:Command", 23 | "docker": "docker:Docker", 24 | "podman": "podman:Podman", 25 | "environment": "environment:Environment", 26 | "file": "file:File", 27 | "group": "group:Group", 28 | "interface": "interface:Interface", 29 | "iptables": "iptables:Iptables", 30 | "mount_point": "mountpoint:MountPoint", 31 | "package": "package:Package", 32 | "pip": "pip:Pip", 33 | "process": "process:Process", 34 | "puppet_resource": "puppet:PuppetResource", 35 | "facter": "puppet:Facter", 36 | "salt": "salt:Salt", 37 | "service": "service:Service", 38 | "socket": "socket:Socket", 39 | "sudo": "sudo:Sudo", 40 | "supervisor": "supervisor:Supervisor", 41 | "sysctl": "sysctl:Sysctl", 42 | "system_info": "systeminfo:SystemInfo", 43 | "user": "user:User", 44 | "block_device": "blockdevice:BlockDevice", 45 | } 46 | 47 | 48 | def get_module_class(name: str) -> type["testinfra.modules.base.Module"]: 49 | modname, classname = modules[name].split(":") 50 | modname = ".".join([__name__, modname]) 51 | module = importlib.import_module(modname) 52 | return getattr(module, classname) # type: ignore[no-any-return] 53 | -------------------------------------------------------------------------------- /testinfra/modules/addr.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License, Version 2.0 (the "License"); 2 | # you may not use this file except in compliance with the License. 3 | # You may obtain a copy of the License at 4 | # 5 | # http://www.apache.org/licenses/LICENSE-2.0 6 | # 7 | # Unless required by applicable law or agreed to in writing, software 8 | # distributed under the License is distributed on an "AS IS" BASIS, 9 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | # See the License for the specific language governing permissions and 11 | # limitations under the License. 12 | 13 | from testinfra.modules.base import Module 14 | 15 | 16 | class _AddrPort: 17 | def __init__(self, addr, port): 18 | self._addr = addr 19 | self._port = str(port) 20 | 21 | @property 22 | def is_reachable(self): 23 | """Return if port is reachable""" 24 | if not self._addr._host.exists("nc"): 25 | if self._addr.namespace: 26 | # in this case cannot use namespace 27 | raise NotImplementedError( 28 | "nc command not available, namespace cannot be used" 29 | ) 30 | # Fallback to bash if netcat is not available 31 | return ( 32 | self._addr.run_expect( 33 | [0, 1, 124], 34 | "timeout 1 bash -c 'cat < /dev/null > /dev/tcp/%s/%s'", 35 | self._addr.name, 36 | self._port, 37 | ).rc 38 | == 0 39 | ) 40 | 41 | return ( 42 | self._addr.run( 43 | f"{self._addr._prefix}nc -w 1 -z {self._addr.name} {self._port}" 44 | ).rc 45 | == 0 46 | ) 47 | 48 | 49 | class Addr(Module): 50 | """Test remote address 51 | 52 | Example: 53 | 54 | >>> google = host.addr("google.com") 55 | >>> google.is_resolvable 56 | True 57 | >>> '173.194.32.225' in google.ipv4_addresses 58 | True 59 | >>> google.is_reachable 60 | True 61 | >>> google.port(443).is_reachable 62 | True 63 | >>> google.port(666).is_reachable 64 | False 65 | 66 | It can also be used within a network namespace_. 67 | 68 | >>> localhost = host.addr("localhost", "ns1") 69 | >>> localhost.is_resolvable 70 | True 71 | 72 | Network namespaces can only be used if ip_ command is available 73 | because in this case, the module uses ip-netns_ as command prefix. 74 | In the other case, it will raise a NotImplementedError. 75 | 76 | .. _namespace: https://man7.org/linux/man-pages/man7/namespaces.7.html 77 | .. _ip: https://man7.org/linux/man-pages/man8/ip.8.html 78 | .. _ip-netns: https://man7.org/linux/man-pages/man8/ip-netns.8.html 79 | """ 80 | 81 | def __init__(self, name, namespace=None): 82 | self._name = name 83 | self._namespace = namespace 84 | if self.namespace and not self._host.exists("ip"): 85 | raise NotImplementedError( 86 | "ip command not available, namespace cannot be used" 87 | ) 88 | super().__init__() 89 | 90 | @property 91 | def name(self): 92 | """Return host name""" 93 | return self._name 94 | 95 | @property 96 | def namespace(self): 97 | """Return network namespace""" 98 | return self._namespace 99 | 100 | @property 101 | def _prefix(self): 102 | """Return the prefix to use for commands""" 103 | prefix = "" 104 | if self.namespace: 105 | prefix = f"ip netns exec {self.namespace} " 106 | return prefix 107 | 108 | @property 109 | def namespace_exists(self): 110 | """Test if the network namespace exists""" 111 | # could use ip netns list instead 112 | return ( 113 | self.namespace 114 | and self.run_test("test -e /var/run/netns/%s", self.namespace).rc == 0 115 | ) 116 | 117 | @property 118 | def is_resolvable(self): 119 | """Return if address is resolvable""" 120 | return len(self.ip_addresses) > 0 121 | 122 | @property 123 | def is_reachable(self): 124 | """Return if address is reachable""" 125 | return ( 126 | self.run_expect([0, 1, 2], f"{self._prefix}ping -W 1 -c 1 {self.name}").rc 127 | == 0 128 | ) 129 | 130 | @property 131 | def ip_addresses(self): 132 | """Return IP addresses of host""" 133 | return self._resolve("ahosts") 134 | 135 | @property 136 | def ipv4_addresses(self): 137 | """Return IPv4 addresses of host""" 138 | return self._resolve("ahostsv4") 139 | 140 | @property 141 | def ipv6_addresses(self): 142 | """Return IPv6 addresses of host""" 143 | return self._resolve("ahostsv6") 144 | 145 | def port(self, port): 146 | """Return address-port pair""" 147 | return _AddrPort(self, port) 148 | 149 | def __repr__(self): 150 | return f"" 151 | 152 | def _resolve(self, method): 153 | result = self.run_expect( 154 | [0, 1, 2], f"{self._prefix}getent {method} {self.name}" 155 | ) 156 | lines = result.stdout.splitlines() 157 | return list({line.split()[0] for line in lines}) 158 | -------------------------------------------------------------------------------- /testinfra/modules/ansible.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License, Version 2.0 (the "License"); 2 | # you may not use this file except in compliance with the License. 3 | # You may obtain a copy of the License at 4 | # 5 | # http://www.apache.org/licenses/LICENSE-2.0 6 | # 7 | # Unless required by applicable law or agreed to in writing, software 8 | # distributed under the License is distributed on an "AS IS" BASIS, 9 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | # See the License for the specific language governing permissions and 11 | # limitations under the License. 12 | 13 | import functools 14 | import pprint 15 | 16 | from testinfra.modules.base import InstanceModule 17 | 18 | 19 | class AnsibleException(Exception): 20 | """Exception raised when an error occurs in an ansible call. 21 | 22 | Result from ansible can be accessed through the ``result`` attribute. 23 | 24 | >>> try: 25 | ... host.ansible("command", "echo foo") 26 | ... except host.ansible.AnsibleException as exc: 27 | ... assert exc.result['failed'] is True 28 | ... assert exc.result['msg'] == 'Skipped. You might want to try check=False' # noqa 29 | """ 30 | 31 | def __init__(self, result): 32 | self.result = result 33 | super().__init__(f"Unexpected error: {pprint.pformat(result)}") 34 | 35 | 36 | def need_ansible(func): 37 | @functools.wraps(func) 38 | def wrapper(self, *args, **kwargs): 39 | if not self._host.backend.HAS_RUN_ANSIBLE: 40 | raise RuntimeError( 41 | "Ansible module is only available with ansible connection backend" 42 | ) 43 | return func(self, *args, **kwargs) 44 | 45 | return wrapper 46 | 47 | 48 | class Ansible(InstanceModule): 49 | """Run Ansible module functions 50 | 51 | This module is only available with the :ref:`ansible connection 52 | backend` connection backend. 53 | 54 | `Check mode 55 | `_ is 56 | enabled by default, you can disable it with `check=False`. 57 | 58 | `Become 59 | `_ is 60 | `False` by default. You can enable it with `become=True`. 61 | 62 | Ansible arguments that are not related to the Ansible inventory or 63 | connection (both managed by testinfra) are also accepted through keyword 64 | arguments: 65 | 66 | - ``become_method`` *str* sudo, su, doas, etc. 67 | - ``become_user`` *str* become this user. 68 | - ``diff`` *bool*: when changing (small) files and templates, show the 69 | differences in those files. 70 | - ``extra_vars`` *dict* serialized to a JSON string, passed to 71 | Ansible. 72 | - ``one_line`` *bool*: condense output. 73 | - ``user`` *str* connect as this user. 74 | - ``verbose`` *int* level of verbosity 75 | 76 | >>> host.ansible("apt", "name=nginx state=present")["changed"] 77 | False 78 | >>> host.ansible("apt", "name=nginx state=present", become=True)["changed"] 79 | False 80 | >>> host.ansible("command", "echo foo", check=False)["stdout"] 81 | 'foo' 82 | >>> host.ansible("setup")["ansible_facts"]["ansible_lsb"]["codename"] 83 | 'jessie' 84 | >>> host.ansible("file", "path=/etc/passwd")["mode"] 85 | '0640' 86 | >>> host.ansible( 87 | ... "command", 88 | ... "id --user --name", 89 | ... check=False, 90 | ... become=True, 91 | ... become_user="http", 92 | ... )["stdout"] 93 | 'http' 94 | >>> host.ansible( 95 | ... "apt", 96 | ... "name={{ packages }}", 97 | ... check=False, 98 | ... extra_vars={"packages": ["neovim", "vim"]}, 99 | ... ) 100 | # Installs neovim and vim. 101 | 102 | """ 103 | 104 | AnsibleException = AnsibleException 105 | 106 | @need_ansible 107 | def __call__( 108 | self, module_name, module_args=None, check=True, become=False, **kwargs 109 | ): 110 | result = self._host.backend.run_ansible( 111 | module_name, module_args, check=check, become=become, **kwargs 112 | ) 113 | if result.get("failed", False): 114 | raise AnsibleException(result) 115 | return result 116 | 117 | @need_ansible 118 | def get_variables(self): 119 | """Returns a dict of ansible variables 120 | 121 | >>> host.ansible.get_variables() 122 | { 123 | 'inventory_hostname': 'localhost', 124 | 'group_names': ['ungrouped'], 125 | 'foo': 'bar', 126 | } 127 | 128 | """ 129 | return self._host.backend.get_variables() 130 | 131 | def __repr__(self): 132 | return "" 133 | -------------------------------------------------------------------------------- /testinfra/modules/base.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License, Version 2.0 (the "License"); 2 | # you may not use this file except in compliance with the License. 3 | # You may obtain a copy of the License at 4 | # 5 | # http://www.apache.org/licenses/LICENSE-2.0 6 | # 7 | # Unless required by applicable law or agreed to in writing, software 8 | # distributed under the License is distributed on an "AS IS" BASIS, 9 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | # See the License for the specific language governing permissions and 11 | # limitations under the License. 12 | from typing import TYPE_CHECKING 13 | 14 | 15 | class Module: 16 | if TYPE_CHECKING: 17 | import testinfra.host 18 | 19 | _host: testinfra.host.Host 20 | 21 | @classmethod 22 | def get_module(cls, _host: "testinfra.host.Host") -> type["Module"]: 23 | klass = cls.get_module_class(_host) 24 | return type( 25 | klass.__name__, 26 | (klass,), 27 | {"_host": _host}, 28 | ) 29 | 30 | @classmethod 31 | def get_module_class(cls, host): 32 | return cls 33 | 34 | @classmethod 35 | def run(cls, *args, **kwargs): 36 | return cls._host.run(*args, **kwargs) 37 | 38 | @classmethod 39 | def run_test(cls, *args, **kwargs): 40 | return cls._host.run_test(*args, **kwargs) 41 | 42 | @classmethod 43 | def run_expect(cls, *args, **kwargs): 44 | return cls._host.run_expect(*args, **kwargs) 45 | 46 | @classmethod 47 | def check_output(cls, *args, **kwargs): 48 | return cls._host.check_output(*args, **kwargs) 49 | 50 | @classmethod 51 | def find_command(cls, *args, **kwargs): 52 | return cls._host.find_command(*args, **kwargs) 53 | 54 | 55 | class InstanceModule(Module): 56 | @classmethod 57 | def get_module(cls, _host): 58 | klass = super().get_module(_host) 59 | return klass() 60 | -------------------------------------------------------------------------------- /testinfra/modules/blockdevice.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License, Version 2.0 (the "License"); 2 | # you may not use this file except in compliance with the License. 3 | # You may obtain a copy of the License at 4 | # 5 | # http://www.apache.org/licenses/LICENSE-2.0 6 | # 7 | # Unless required by applicable law or agreed to in writing, software 8 | # distributed under the License is distributed on an "AS IS" BASIS, 9 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | # See the License for the specific language governing permissions and 11 | # limitations under the License. 12 | 13 | import functools 14 | 15 | from testinfra.modules.base import Module 16 | 17 | 18 | class BlockDevice(Module): 19 | """Information for a block device. 20 | 21 | Should be used with sudo or under root. 22 | 23 | If the device is not a block device, RuntimeError is raised. 24 | """ 25 | 26 | @property 27 | def _data(self): 28 | raise NotImplementedError 29 | 30 | def __init__(self, device): 31 | self.device = device 32 | super().__init__() 33 | 34 | @property 35 | def is_partition(self): 36 | """Return True if the device is a partition. 37 | 38 | >>> host.block_device("/dev/sda1").is_partition 39 | True 40 | 41 | >>> host.block_device("/dev/sda").is_partition 42 | False 43 | 44 | 45 | """ 46 | return self._data["start_sector"] > 0 47 | 48 | @property 49 | def size(self): 50 | """Return size if the device in bytes. 51 | 52 | >>> host.block_device("/dev/sda1").size 53 | 512110190592 54 | 55 | """ 56 | return self._data["size"] 57 | 58 | @property 59 | def sector_size(self): 60 | """Return sector size for the device in bytes. 61 | 62 | >>> host.block_device("/dev/sda1").sector_size 63 | 512 64 | """ 65 | return self._data["sector_size"] 66 | 67 | @property 68 | def block_size(self): 69 | """Return block size for the device in bytes. 70 | 71 | >>> host.block_device("/dev/sda").block_size 72 | 4096 73 | """ 74 | return self._data["block_size"] 75 | 76 | @property 77 | def start_sector(self): 78 | """Return start sector of the device on the underlying device. 79 | 80 | Usually the value is zero for full devices and is non-zero 81 | for partitions. 82 | 83 | >>> host.block_device("/dev/sda1").start_sector 84 | 2048 85 | 86 | >>> host.block_device("/dev/md0").start_sector 87 | 0 88 | """ 89 | return self._data["sector_size"] 90 | 91 | @property 92 | def is_writable(self): 93 | """Return True if the device is writable (have no RO status) 94 | 95 | >>> host.block_device("/dev/sda").is_writable 96 | True 97 | 98 | >>> host.block_device("/dev/loop1").is_writable 99 | False 100 | """ 101 | mode = self._data["rw_mode"] 102 | if mode == "rw": 103 | return True 104 | if mode == "ro": 105 | return False 106 | raise ValueError(f"Unexpected value for rw: {mode}") 107 | 108 | @property 109 | def ra(self): 110 | """Return Read Ahead for the device in 512-bytes sectors. 111 | 112 | >>> host.block_device("/dev/sda").ra 113 | 256 114 | """ 115 | return self._data["read_ahead"] 116 | 117 | @classmethod 118 | def get_module_class(cls, host): 119 | if host.system_info.type == "linux": 120 | return LinuxBlockDevice 121 | raise NotImplementedError 122 | 123 | def __repr__(self): 124 | return f"" 125 | 126 | 127 | class LinuxBlockDevice(BlockDevice): 128 | @functools.cached_property 129 | def _data(self): 130 | header = ["RO", "RA", "SSZ", "BSZ", "StartSec", "Size", "Device"] 131 | command = "blockdev --report %s" 132 | blockdev = self.run(command, self.device) 133 | if blockdev.rc != 0: 134 | raise RuntimeError(f"Failed to gather data: {blockdev.stderr}") 135 | output = blockdev.stdout.splitlines() 136 | if len(output) < 2: 137 | raise RuntimeError(f"No data from {self.device}") 138 | if output[0].split() != header: 139 | raise RuntimeError(f"Unknown output of blockdev: {output[0]}") 140 | fields = output[1].split() 141 | return { 142 | "rw_mode": str(fields[0]), 143 | "read_ahead": int(fields[1]), 144 | "sector_size": int(fields[2]), 145 | "block_size": int(fields[3]), 146 | "start_sector": int(fields[4]), 147 | "size": int(fields[5]), 148 | } 149 | -------------------------------------------------------------------------------- /testinfra/modules/command.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License, Version 2.0 (the "License"); 2 | # you may not use this file except in compliance with the License. 3 | # You may obtain a copy of the License at 4 | # 5 | # http://www.apache.org/licenses/LICENSE-2.0 6 | # 7 | # Unless required by applicable law or agreed to in writing, software 8 | # distributed under the License is distributed on an "AS IS" BASIS, 9 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | # See the License for the specific language governing permissions and 11 | # limitations under the License. 12 | 13 | from testinfra.modules.base import InstanceModule 14 | 15 | 16 | class Command(InstanceModule): 17 | def __call__(self, command, *args, **kwargs): 18 | return self.run(command, *args, **kwargs) 19 | 20 | def exists(self, command): 21 | return self._host.exists(command) 22 | 23 | def __repr__(self): 24 | return "" 25 | -------------------------------------------------------------------------------- /testinfra/modules/docker.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License, Version 2.0 (the "License"); 2 | # you may not use this file except in compliance with the License. 3 | # You may obtain a copy of the License at 4 | # 5 | # http://www.apache.org/licenses/LICENSE-2.0 6 | # 7 | # Unless required by applicable law or agreed to in writing, software 8 | # distributed under the License is distributed on an "AS IS" BASIS, 9 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | # See the License for the specific language governing permissions and 11 | # limitations under the License. 12 | import json 13 | 14 | from testinfra.modules.base import Module 15 | 16 | 17 | class Docker(Module): 18 | """Test docker containers running on system. 19 | 20 | Example: 21 | 22 | >>> nginx = host.docker("app_nginx") 23 | >>> nginx.is_running 24 | True 25 | >>> nginx.id 26 | '7e67dc7495ca8f451d346b775890bdc0fb561ecdc97b68fb59ff2f77b509a8fe' 27 | >>> nginx.name 28 | 'app_nginx' 29 | """ 30 | 31 | def __init__(self, name): 32 | self._name = name 33 | super().__init__() 34 | 35 | def inspect(self): 36 | output = self.check_output("docker inspect %s", self._name) 37 | return json.loads(output)[0] 38 | 39 | @property 40 | def is_running(self): 41 | return self.inspect()["State"]["Running"] 42 | 43 | @property 44 | def is_restarting(self): 45 | return self.inspect()["State"]["Restarting"] 46 | 47 | @property 48 | def id(self): 49 | return self.inspect()["Id"] 50 | 51 | @property 52 | def name(self): 53 | return self.inspect()["Name"][1:] # get rid of slash in front 54 | 55 | @classmethod 56 | def client_version(cls): 57 | """Docker client version""" 58 | return cls.version("{{.Client.Version}}") 59 | 60 | @classmethod 61 | def server_version(cls): 62 | """Docker server version""" 63 | return cls.version("{{.Server.Version}}") 64 | 65 | @classmethod 66 | def version(cls, format=None): 67 | """Docker version_ with an optional format (Go template). 68 | 69 | >>> host.docker.version() 70 | Client: Docker Engine - Community 71 | ... 72 | >>> host.docker.version("{{.Client.Context}}")) 73 | default 74 | 75 | .. _version: https://docs.docker.com/engine/reference/commandline/version/ 76 | """ 77 | cmd = "docker version" 78 | if format: 79 | cmd = f"{cmd} --format '{format}'" 80 | return cls.check_output(cmd) 81 | 82 | @classmethod 83 | def get_containers(cls, **filters): 84 | """Return a list of containers 85 | 86 | By default, return a list of all containers, including non-running 87 | containers. 88 | 89 | Filtering can be done using filters keys defined on 90 | https://docs.docker.com/engine/reference/commandline/ps/#filtering 91 | 92 | Multiple filters for a given key are handled by giving a list of strings 93 | as value. 94 | 95 | >>> host.docker.get_containers() 96 | [, , ] 97 | # Get all running containers 98 | >>> host.docker.get_containers(status="running") 99 | [] 100 | # Get containers named "nginx" 101 | >>> host.docker.get_containers(name="nginx") 102 | [] 103 | # Get containers named "nginx" or "redis" 104 | >>> host.docker.get_containers(name=["nginx", "redis"]) 105 | [, ] 106 | """ 107 | cmd = "docker ps --all --quiet --format '{{.Names}}'" 108 | args = [] 109 | for key, value in filters.items(): 110 | values = value if isinstance(value, (list, tuple)) else [value] 111 | for v in values: 112 | cmd += " --filter %s=%s" 113 | args += [key, v] 114 | result = [] 115 | for docker_id in cls(None).check_output(cmd, *args).splitlines(): 116 | result.append(cls(docker_id)) 117 | return result 118 | 119 | def __repr__(self): 120 | return f"" 121 | -------------------------------------------------------------------------------- /testinfra/modules/environment.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License, Version 2.0 (the "License"); 2 | # you may not use this file except in compliance with the License. 3 | # You may obtain a copy of the License at 4 | # 5 | # http://www.apache.org/licenses/LICENSE-2.0 6 | # 7 | # Unless required by applicable law or agreed to in writing, software 8 | # distributed under the License is distributed on an "AS IS" BASIS, 9 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | # See the License for the specific language governing permissions and 11 | # limitations under the License. 12 | 13 | from testinfra.modules.base import InstanceModule 14 | 15 | 16 | class Environment(InstanceModule): 17 | """Get Environment variables 18 | 19 | Example: 20 | 21 | >>> host.environment() 22 | { 23 | "EDITOR": "vim", 24 | "SHELL": "/bin/bash", 25 | [...] 26 | } 27 | """ 28 | 29 | def __call__(self): 30 | ret_val = dict( 31 | i.split("=", 1) for i in self.check_output("env -0").split("\x00") if i 32 | ) 33 | return ret_val 34 | 35 | def __repr__(self): 36 | return "" 37 | -------------------------------------------------------------------------------- /testinfra/modules/group.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License, Version 2.0 (the "License"); 2 | # you may not use this file except in compliance with the License. 3 | # You may obtain a copy of the License at 4 | # 5 | # http://www.apache.org/licenses/LICENSE-2.0 6 | # 7 | # Unless required by applicable law or agreed to in writing, software 8 | # distributed under the License is distributed on an "AS IS" BASIS, 9 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | # See the License for the specific language governing permissions and 11 | # limitations under the License. 12 | 13 | from testinfra.modules.base import Module 14 | 15 | 16 | class Group(Module): 17 | """Test unix group""" 18 | 19 | def __init__(self, name): 20 | self.name = name 21 | super().__init__() 22 | 23 | @property 24 | def exists(self): 25 | """Test if the group exists 26 | 27 | >>> host.group("wheel").exists 28 | True 29 | >>> host.group("nosuchgroup").exists 30 | False 31 | """ 32 | return self.run_expect([0, 2], "getent group %s", self.name).rc == 0 33 | 34 | @property 35 | def get_all_groups(self): 36 | """Returns a list of local and remote group names 37 | 38 | >>> host.group("anyname").get_all_groups 39 | ["root", "wheel", "man", "tty", <...>] 40 | """ 41 | all_groups = [ 42 | line.split(":")[0] 43 | for line in self.check_output("getent group").splitlines() 44 | ] 45 | return all_groups 46 | 47 | @property 48 | def get_local_groups(self): 49 | """Returns a list of local group names 50 | 51 | >>> host.group("anyname").get_local_groups 52 | ["root", "wheel", "man", "tty", <...>] 53 | """ 54 | local_groups = [ 55 | line.split(":")[0] 56 | for line in self.check_output("cat /etc/group").splitlines() 57 | ] 58 | return local_groups 59 | 60 | @property 61 | def gid(self): 62 | return int(self.check_output("getent group %s | cut -d':' -f3", self.name)) 63 | 64 | @property 65 | def members(self): 66 | """Return all users that are members of this group.""" 67 | users = self.check_output("getent group %s | cut -d':' -f4", self.name) 68 | if users: 69 | return users.split(",") 70 | return [] 71 | 72 | def __repr__(self): 73 | return f"" 74 | -------------------------------------------------------------------------------- /testinfra/modules/interface.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License, Version 2.0 (the "License"); 2 | # you may not use this file except in compliance with the License. 3 | # You may obtain a copy of the License at 4 | # 5 | # http://www.apache.org/licenses/LICENSE-2.0 6 | # 7 | # Unless required by applicable law or agreed to in writing, software 8 | # distributed under the License is distributed on an "AS IS" BASIS, 9 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | # See the License for the specific language governing permissions and 11 | # limitations under the License. 12 | 13 | import functools 14 | import json 15 | import re 16 | 17 | from testinfra.modules.base import Module 18 | 19 | 20 | class Interface(Module): 21 | """Test network interfaces 22 | 23 | >>> host.interface("eth0").exists 24 | True 25 | 26 | Optionally, the protocol family to use can be enforced. 27 | 28 | >>> host.interface("eth0", "inet6").addresses 29 | ['fe80::e291:f5ff:fe98:6b8c'] 30 | """ 31 | 32 | def __init__(self, name, family=None): 33 | self.name = name 34 | self.family = family 35 | super().__init__() 36 | 37 | @property 38 | def exists(self): 39 | raise NotImplementedError 40 | 41 | @property 42 | def speed(self): 43 | raise NotImplementedError 44 | 45 | @property 46 | def addresses(self): 47 | """Return ipv4 and ipv6 addresses on the interface 48 | 49 | >>> host.interface("eth0").addresses 50 | ['192.168.31.254', '192.168.31.252', 'fe80::e291:f5ff:fe98:6b8c'] 51 | """ 52 | raise NotImplementedError 53 | 54 | @property 55 | def link(self): 56 | """Return the link properties associated with the interface. 57 | 58 | >>> host.interface("lo").link 59 | {'address': '00:00:00:00:00:00', 60 | 'broadcast': '00:00:00:00:00:00', 61 | 'flags': ['LOOPBACK', 'UP', 'LOWER_UP'], 62 | 'group': 'default', 63 | 'ifindex': 1, 64 | 'ifname': 'lo', 65 | 'link_type': 'loopback', 66 | 'linkmode': 'DEFAULT', 67 | 'mtu': 65536, 68 | 'operstate': 'UNKNOWN', 69 | 'qdisc': 'noqueue', 70 | 'txqlen': 1000} 71 | """ 72 | raise NotImplementedError 73 | 74 | def routes(self, scope=None): 75 | """Return the routes associated with the interface, optionally filtered by scope 76 | ("host", "link" or "global"). 77 | 78 | >>> host.interface("eth0").routes() 79 | [{'dst': 'default', 80 | 'flags': [], 81 | 'gateway': '192.0.2.1', 82 | 'metric': 3003, 83 | 'prefsrc': '192.0.2.5', 84 | 'protocol': 'dhcp'}, 85 | {'dst': '192.0.2.0/24', 86 | 'flags': [], 87 | 'metric': 3003, 88 | 'prefsrc': '192.0.2.5', 89 | 'protocol': 'dhcp', 90 | 'scope': 'link'}] 91 | """ 92 | raise NotImplementedError 93 | 94 | def __repr__(self): 95 | return f"" 96 | 97 | @classmethod 98 | def get_module_class(cls, host): 99 | if host.system_info.type == "linux": 100 | return LinuxInterface 101 | if host.system_info.type.endswith("bsd"): 102 | return BSDInterface 103 | raise NotImplementedError 104 | 105 | @classmethod 106 | def names(cls): 107 | """Return the names of all the interfaces. 108 | 109 | >>> host.interface.names() 110 | ['lo', 'tunl0', 'ip6tnl0', 'eth0'] 111 | """ 112 | raise NotImplementedError 113 | 114 | @classmethod 115 | def default(cls, family=None): 116 | """Return the interface used for the default route. 117 | 118 | >>> host.interface.default() 119 | 120 | 121 | Optionally, the protocol family to use can be enforced. 122 | 123 | >>> host.interface.default("inet6") 124 | None 125 | """ 126 | raise NotImplementedError 127 | 128 | 129 | class LinuxInterface(Interface): 130 | @functools.cached_property 131 | def _ip(self): 132 | ip_cmd = self.find_command("ip") 133 | if self.family is not None: 134 | ip_cmd = f"{ip_cmd} -f {self.family}" 135 | return ip_cmd 136 | 137 | @property 138 | def exists(self): 139 | return self.run_test(f"{self._ip} link show %s", self.name).rc == 0 140 | 141 | @property 142 | def speed(self): 143 | return int(self.check_output("cat /sys/class/net/%s/speed", self.name)) 144 | 145 | @property 146 | def addresses(self): 147 | stdout = self.check_output(f"{self._ip} addr show %s", self.name) 148 | addrs = [] 149 | for line in stdout.splitlines(): 150 | splitted = [e.strip() for e in line.split(" ") if e] 151 | if splitted and splitted[0] in ("inet", "inet6"): 152 | addrs.append(splitted[1].split("/", 1)[0]) 153 | return addrs 154 | 155 | @property 156 | def link(self): 157 | return json.loads( 158 | self.check_output(f"{self._ip} --json link show %s", self.name) 159 | ) 160 | 161 | def routes(self, scope=None): 162 | cmd = f"{self._ip} --json route list dev %s" 163 | 164 | if scope is None: 165 | out = self.check_output(cmd, self.name) 166 | else: 167 | out = self.check_output(cmd + " scope %s", self.name, scope) 168 | 169 | return json.loads(out) 170 | 171 | @classmethod 172 | def default(cls, family=None): 173 | _default = cls(None, family=family) 174 | out = cls.check_output(f"{_default._ip} route ls") 175 | for line in out.splitlines(): 176 | if "default" in line: 177 | match = re.search(r"dev\s(\S+)", line) 178 | if match: 179 | _default.name = match.group(1) 180 | return _default 181 | 182 | @classmethod 183 | def names(cls): 184 | # -o is to tell the ip command to return one line per interface 185 | out = cls.check_output(f"{cls(None)._ip} -o link show") 186 | interfaces = [] 187 | for line in out.splitlines(): 188 | interfaces.append(line.strip().split(": ", 2)[1].split("@", 1)[0]) 189 | return interfaces 190 | 191 | 192 | class BSDInterface(Interface): 193 | @property 194 | def exists(self): 195 | return self.run_test("ifconfig %s", self.name).rc == 0 196 | 197 | @property 198 | def speed(self): 199 | raise NotImplementedError 200 | 201 | @property 202 | def addresses(self): 203 | stdout = self.check_output("ifconfig %s", self.name) 204 | addrs = [] 205 | for line in stdout.splitlines(): 206 | if line.startswith("\tinet "): 207 | addrs.append(line.split(" ", 2)[1]) 208 | elif line.startswith("\tinet6 "): 209 | addr = line.split(" ", 2)[1] 210 | if "%" in addr: 211 | addr = addr.split("%", 1)[0] 212 | addrs.append(addr) 213 | return addrs 214 | -------------------------------------------------------------------------------- /testinfra/modules/iptables.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License, Version 2.0 (the "License"); 2 | # you may not use this file except in compliance with the License. 3 | # You may obtain a copy of the License at 4 | # 5 | # http://www.apache.org/licenses/LICENSE-2.0 6 | # 7 | # Unless required by applicable law or agreed to in writing, software 8 | # distributed under the License is distributed on an "AS IS" BASIS, 9 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | # See the License for the specific language governing permissions and 11 | # limitations under the License. 12 | 13 | from testinfra.modules.base import InstanceModule 14 | 15 | 16 | class Iptables(InstanceModule): 17 | """Test iptables rule exists""" 18 | 19 | def __init__(self): 20 | super().__init__() 21 | # support for -w argument (since 1.6.0) 22 | # https://git.netfilter.org/iptables/commit/?id=aaa4ace72b 23 | # centos 6 has no support 24 | # centos 7 has 1.4 patched 25 | self._has_w_argument = None 26 | 27 | def _iptables_command(self, version): 28 | if version == 4: 29 | iptables = "iptables" 30 | elif version == 6: 31 | iptables = "ip6tables" 32 | else: 33 | raise RuntimeError(f"Invalid version: {version}") 34 | if self._has_w_argument is False: 35 | return iptables 36 | else: 37 | return f"{iptables} -w 90" 38 | 39 | def _run_iptables(self, version, cmd, *args): 40 | ipt_cmd = f"{self._iptables_command(version)} {cmd}" 41 | if self._has_w_argument is None: 42 | result = self.run_expect([0, 2], ipt_cmd, *args) 43 | if result.rc == 2: 44 | self._has_w_argument = False 45 | return self._run_iptables(version, cmd, *args) 46 | else: 47 | self._has_w_argument = True 48 | return result.stdout.rstrip("\r\n") 49 | else: 50 | return self.check_output(ipt_cmd, *args) 51 | 52 | def rules(self, table="filter", chain=None, version=4): 53 | """Returns list of iptables rules 54 | 55 | Based on output of `iptables -t TABLE -S CHAIN` command 56 | 57 | optionally takes the following arguments: 58 | - table: defaults to `filter` 59 | - chain: defaults to all chains 60 | - version: default 4 (iptables), optionally 6 (ip6tables) 61 | 62 | >>> host.iptables.rules() 63 | [ 64 | '-P INPUT ACCEPT', 65 | '-P FORWARD ACCEPT', 66 | '-P OUTPUT ACCEPT', 67 | '-A INPUT -i lo -j ACCEPT', 68 | '-A INPUT -j REJECT' 69 | '-A FORWARD -j REJECT' 70 | ] 71 | >>> host.iptables.rules("nat", "INPUT") 72 | ['-P PREROUTING ACCEPT'] 73 | 74 | """ 75 | cmd, args = "-t %s -S", [table] 76 | if chain: 77 | cmd += " %s" 78 | args += [chain] 79 | 80 | rules = [] 81 | for line in self._run_iptables(version, cmd, *args).splitlines(): 82 | line = line.replace("\t", " ") 83 | rules.append(line) 84 | return rules 85 | -------------------------------------------------------------------------------- /testinfra/modules/mountpoint.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License, Version 2.0 (the "License"); 2 | # you may not use this file except in compliance with the License. 3 | # You may obtain a copy of the License at 4 | # 5 | # http://www.apache.org/licenses/LICENSE-2.0 6 | # 7 | # Unless required by applicable law or agreed to in writing, software 8 | # distributed under the License is distributed on an "AS IS" BASIS, 9 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | # See the License for the specific language governing permissions and 11 | # limitations under the License. 12 | 13 | from testinfra.modules.base import Module 14 | 15 | 16 | class MountPoint(Module): 17 | """Test Mount Points""" 18 | 19 | def __init__(self, path, _attrs_cache=None): 20 | self.path = path 21 | self._attrs_cache = _attrs_cache 22 | super().__init__() 23 | 24 | @classmethod 25 | def _iter_mountpoints(cls): 26 | raise NotImplementedError 27 | 28 | @property 29 | def exists(self): 30 | """Return True if the mountpoint exists 31 | 32 | >>> host.mount_point("/").exists 33 | True 34 | 35 | >>> host.mount_point("/not/a/mountpoint").exists 36 | False 37 | 38 | """ 39 | return bool(self._attrs) 40 | 41 | @property 42 | def _attrs(self): 43 | if self._attrs_cache is None: 44 | for mountpoint in self._iter_mountpoints(): 45 | if mountpoint["path"] == self.path: 46 | self._attrs_cache = mountpoint 47 | break 48 | else: 49 | self._attrs_cache = {} 50 | return self._attrs_cache 51 | 52 | @property 53 | def filesystem(self): 54 | """Returns the filesystem type associated 55 | 56 | >>> host.mount_point("/").filesystem 57 | 'ext4' 58 | 59 | """ 60 | return self._attrs["filesystem"] 61 | 62 | @property 63 | def device(self): 64 | """Return the device associated 65 | 66 | >>> host.mount_point("/").device 67 | '/dev/sda1' 68 | 69 | """ 70 | return self._attrs["device"] 71 | 72 | @property 73 | def options(self): 74 | """Return a list of options that a mount point has been created with 75 | 76 | >>> host.mount_point("/").options 77 | ['rw', 'relatime', 'data=ordered'] 78 | 79 | """ 80 | return self._attrs["options"] 81 | 82 | @classmethod 83 | def get_mountpoints(cls): 84 | """Returns a list of MountPoint instances 85 | 86 | >>> host.mount_point.get_mountpoints() 87 | [, 88 | ] 89 | """ 90 | mountpoints = [] 91 | for mountpoint in cls._iter_mountpoints(): 92 | mountpoints.append(cls(mountpoint["path"], mountpoint)) 93 | return mountpoints 94 | 95 | @classmethod 96 | def get_module_class(cls, host): 97 | if host.system_info.type == "linux": 98 | return LinuxMountPoint 99 | if host.system_info.type.endswith("bsd"): 100 | return BSDMountPoint 101 | raise NotImplementedError 102 | 103 | def __repr__(self): 104 | if self.exists: 105 | d = self.device 106 | f = self.filesystem 107 | o = ",".join(self.options) 108 | else: 109 | d = "" 110 | f = "" 111 | o = "" 112 | return ( 113 | f'' 115 | ) 116 | 117 | 118 | class LinuxMountPoint(MountPoint): 119 | @classmethod 120 | def _iter_mountpoints(cls): 121 | check_output = cls(None).check_output 122 | for line in check_output("cat /proc/mounts").splitlines(): 123 | splitted = line.split() 124 | # ignore rootfs 125 | # https://www.kernel.org/doc/Documentation/filesystems/ramfs-rootfs-initramfs.txt 126 | # suggests that most OS mount the filesystem over it, leaving 127 | # rootfs would result in ambiguity when resolving a mountpoint. 128 | if splitted[0] == "rootfs": 129 | continue 130 | 131 | yield { 132 | "path": splitted[1], 133 | "device": splitted[0], 134 | "filesystem": splitted[2], 135 | "options": splitted[3].split(","), 136 | } 137 | 138 | 139 | class BSDMountPoint(MountPoint): 140 | @classmethod 141 | def _iter_mountpoints(cls): 142 | check_output = cls(None).check_output 143 | for line in check_output("mount -p").splitlines(): 144 | splitted = line.split() 145 | yield { 146 | "path": splitted[1], 147 | "device": splitted[0], 148 | "filesystem": splitted[2], 149 | "options": splitted[3].split(","), 150 | } 151 | -------------------------------------------------------------------------------- /testinfra/modules/package.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License, Version 2.0 (the "License"); 2 | # you may not use this file except in compliance with the License. 3 | # You may obtain a copy of the License at 4 | # 5 | # http://www.apache.org/licenses/LICENSE-2.0 6 | # 7 | # Unless required by applicable law or agreed to in writing, software 8 | # distributed under the License is distributed on an "AS IS" BASIS, 9 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | # See the License for the specific language governing permissions and 11 | # limitations under the License. 12 | import json 13 | 14 | from testinfra.modules.base import Module 15 | 16 | 17 | class Package(Module): 18 | """Test packages status and version""" 19 | 20 | def __init__(self, name): 21 | self.name = name 22 | super().__init__() 23 | 24 | @property 25 | def is_installed(self): 26 | """Test if the package is installed 27 | 28 | >>> host.package("nginx").is_installed 29 | True 30 | 31 | Supported package systems: 32 | 33 | - apk (Alpine) 34 | - apt (Debian, Ubuntu, ...) 35 | - brew (macOS) 36 | - pacman (Arch, Manjaro ) 37 | - pkg (FreeBSD) 38 | - pkg_info (NetBSD) 39 | - pkg_info (OpenBSD) 40 | - rpm (RHEL, RockyLinux, Fedora, ...) 41 | """ 42 | raise NotImplementedError 43 | 44 | @property 45 | def release(self): 46 | """Return the release-specific info from the package version 47 | 48 | >>> host.package("nginx").release 49 | '1.el6' 50 | """ 51 | raise NotImplementedError 52 | 53 | @property 54 | def version(self): 55 | """Return package version as returned by the package system 56 | 57 | >>> host.package("nginx").version 58 | '1.2.1-2.2+wheezy3' 59 | """ 60 | raise NotImplementedError 61 | 62 | def __repr__(self): 63 | return f"" 64 | 65 | @classmethod 66 | def get_module_class(cls, host): 67 | if host.system_info.type == "windows": 68 | return ChocolateyPackage 69 | if host.system_info.type == "freebsd": 70 | return FreeBSDPackage 71 | if host.system_info.type in ("openbsd", "netbsd"): 72 | return OpenBSDPackage 73 | if host.system_info.distribution in ("debian", "ubuntu"): 74 | return DebianPackage 75 | if host.system_info.distribution and ( 76 | host.system_info.distribution.lower() 77 | in ( 78 | "almalinux", 79 | "centos", 80 | "cloudlinux", 81 | "fedora", 82 | "ol", 83 | "opensuse-leap", 84 | "opensuse-tumbleweed", 85 | "rhel", 86 | "rocky", 87 | ) 88 | ): 89 | return RpmPackage 90 | if host.system_info.distribution in ("arch", "manjarolinux"): 91 | return ArchPackage 92 | if host.exists("apk"): 93 | return AlpinePackage 94 | # Fallback conditions 95 | if host.exists("dpkg-query"): 96 | return DebianPackage 97 | if host.exists("rpm"): 98 | return RpmPackage 99 | if host.exists("brew"): 100 | return HomebrewPackage 101 | raise NotImplementedError 102 | 103 | 104 | class DebianPackage(Package): 105 | @property 106 | def is_installed(self): 107 | result = self.run_test("dpkg-query -f '${Status}' -W %s", self.name) 108 | if result.rc == 1: 109 | return False 110 | out = result.stdout.strip().split() 111 | installed_status = ["ok", "installed"] 112 | return out[0] in ["install", "hold"] and out[1:3] == installed_status 113 | 114 | @property 115 | def release(self): 116 | raise NotImplementedError 117 | 118 | @property 119 | def version(self): 120 | out = self.check_output("dpkg-query -f '${Status} ${Version}' -W %s", self.name) 121 | splitted = out.split() 122 | assert splitted[0].lower() in ( 123 | "install", 124 | "hold", 125 | ), f"The package {self.name} is not installed, dpkg-query output: {out}" 126 | return splitted[3] 127 | 128 | 129 | class FreeBSDPackage(Package): 130 | @property 131 | def is_installed(self): 132 | EX_UNAVAILABLE = 69 133 | return ( 134 | self.run_expect([0, EX_UNAVAILABLE], "pkg query %%n %s", self.name).rc == 0 135 | ) 136 | 137 | @property 138 | def release(self): 139 | raise NotImplementedError 140 | 141 | @property 142 | def version(self): 143 | return self.check_output("pkg query %%v %s", self.name) 144 | 145 | 146 | class OpenBSDPackage(Package): 147 | @property 148 | def is_installed(self): 149 | return self.run_test("pkg_info -e %s", f"{self.name}-*").rc == 0 150 | 151 | @property 152 | def release(self): 153 | raise NotImplementedError 154 | 155 | @property 156 | def version(self): 157 | out = self.check_output("pkg_info -e %s", f"{self.name}-*") 158 | # OpenBSD: inst:zsh-5.0.5p0 159 | # NetBSD: zsh-5.0.7nb1 160 | return out.split(self.name + "-", 1)[1] 161 | 162 | 163 | class RpmPackage(Package): 164 | @property 165 | def is_installed(self): 166 | result = self.run_test("rpm -q --quiet %s 2>&1", self.name) 167 | if result.succeeded: 168 | return True 169 | elif result.failed and result.stdout == "": 170 | return False 171 | else: 172 | raise RuntimeError( 173 | f"Could not check if RPM package '{self.name}' is installed. {result.stdout}" 174 | ) 175 | 176 | @property 177 | def version(self): 178 | return self.check_output('rpm -q --queryformat="%%{VERSION}" %s', self.name) 179 | 180 | @property 181 | def release(self): 182 | return self.check_output('rpm -q --queryformat="%%{RELEASE}" %s', self.name) 183 | 184 | 185 | class AlpinePackage(Package): 186 | @property 187 | def is_installed(self): 188 | return self.run_test("apk -e info %s", self.name).rc == 0 189 | 190 | @property 191 | def version(self): 192 | out = self.check_output("apk -e -v info %s", self.name).split("-") 193 | return out[-2] 194 | 195 | @property 196 | def release(self): 197 | out = self.check_output("apk -e -v info %s", self.name).split("-") 198 | return out[-1] 199 | 200 | 201 | class ArchPackage(Package): 202 | @property 203 | def is_installed(self): 204 | return self.run_test("pacman -Q %s", self.name).rc == 0 205 | 206 | @property 207 | def version(self): 208 | out = self.check_output("pacman -Q %s", self.name).split(" ") 209 | return out[1] 210 | 211 | @property 212 | def release(self): 213 | raise NotImplementedError 214 | 215 | 216 | class ChocolateyPackage(Package): 217 | @property 218 | def is_installed(self): 219 | return self.run_test("choco info -lo %s", self.name).rc == 0 220 | 221 | @property 222 | def version(self): 223 | _, version = self.check_output("choco info -lo %s -r", self.name).split("|", 1) 224 | return version 225 | 226 | @property 227 | def release(self): 228 | raise NotImplementedError 229 | 230 | 231 | class HomebrewPackage(Package): 232 | @property 233 | def is_installed(self): 234 | info = self.check_output("brew info --formula --json %s", self.name) 235 | return len(json.loads(info)[0]["installed"]) > 0 236 | 237 | @property 238 | def version(self): 239 | info = self.check_output("brew info --formula --json %s", self.name) 240 | version = json.loads(info)[0]["installed"][0]["version"] 241 | return version 242 | 243 | @property 244 | def release(self): 245 | raise NotImplementedError 246 | -------------------------------------------------------------------------------- /testinfra/modules/pip.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License, Version 2.0 (the "License"); 2 | # you may not use this file except in compliance with the License. 3 | # You may obtain a copy of the License at 4 | # 5 | # http://www.apache.org/licenses/LICENSE-2.0 6 | # 7 | # Unless required by applicable law or agreed to in writing, software 8 | # distributed under the License is distributed on an "AS IS" BASIS, 9 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | # See the License for the specific language governing permissions and 11 | # limitations under the License. 12 | 13 | import json 14 | import re 15 | 16 | from testinfra.modules.base import Module 17 | 18 | 19 | def _re_match(line, regexp): 20 | match = regexp.match(line) 21 | if match is None: 22 | raise RuntimeError(f"could not parse {line}") 23 | return match.groups() 24 | 25 | 26 | class Pip(Module): 27 | """Test pip package manager and packages""" 28 | 29 | def __init__(self, name, pip_path="pip"): 30 | self.name = name 31 | self.pip_path = pip_path 32 | super().__init__() 33 | 34 | @property 35 | def is_installed(self): 36 | """Test if the package is installed 37 | 38 | >>> host.package("pip").is_installed 39 | True 40 | """ 41 | return self.run_test("%s show %s", self.pip_path, self.name).rc == 0 42 | 43 | @property 44 | def version(self): 45 | """Return package version as returned by pip 46 | 47 | >>> host.package("pip").version 48 | '18.1' 49 | """ 50 | return self.check_output( 51 | "%s show %s | grep Version: | cut -d' ' -f2", 52 | self.pip_path, 53 | self.name, 54 | ) 55 | 56 | @classmethod 57 | def check(cls, pip_path="pip"): 58 | """Verify installed packages have compatible dependencies. 59 | 60 | >>> cmd = host.pip.check() 61 | >>> cmd.rc 62 | 0 63 | >>> cmd.stdout 64 | No broken requirements found. 65 | 66 | Can only be used if `pip check`_ command is available, 67 | for pip versions >= 9.0.0_. 68 | 69 | .. _pip check: https://pip.pypa.io/en/stable/reference/pip_check/ 70 | .. _9.0.0: https://pip.pypa.io/en/stable/news/#id526 71 | """ 72 | return cls.run_expect([0, 1], "%s check", pip_path) 73 | 74 | @classmethod 75 | def get_packages(cls, pip_path="pip"): 76 | """Get all installed packages and versions returned by `pip list`: 77 | 78 | >>> host.pip.get_packages(pip_path='~/venv/website/bin/pip') 79 | {'Django': {'version': '1.10.2'}, 80 | 'mywebsite': {'version': '1.0a3', 'path': '/srv/website'}, 81 | 'psycopg2': {'version': '2.6.2'}} 82 | """ 83 | out = cls.run_expect([0, 2], "%s list --no-index --format=json", pip_path) 84 | pkgs = {} 85 | if out.rc == 0: 86 | for pkg in json.loads(out.stdout): 87 | # XXX: --output=json does not return install path 88 | pkgs[pkg["name"]] = {"version": pkg["version"]} 89 | else: 90 | # pip < 9 91 | output_re = re.compile(r"^(.+) \((.+)\)$") 92 | for line in cls.check_output("%s list --no-index", pip_path).splitlines(): 93 | if line.startswith("Warning: "): 94 | # Warning: cannot find svn location for ... 95 | continue 96 | name, version = _re_match(line, output_re) 97 | if "," in version: 98 | version, path = version.split(",", 1) 99 | pkgs[name] = {"version": version, "path": path.strip()} 100 | else: 101 | pkgs[name] = {"version": version} 102 | return pkgs 103 | 104 | @classmethod 105 | def get_outdated_packages(cls, pip_path="pip"): 106 | """Get all outdated packages with the current and latest version 107 | 108 | >>> host.pip.get_outdated_packages( 109 | ... pip_path='~/venv/website/bin/pip') 110 | {'Django': {'current': '1.10.2', 'latest': '1.10.3'}} 111 | """ 112 | out = cls.run_expect([0, 2], "%s list -o --format=json", pip_path) 113 | pkgs = {} 114 | if out.rc == 0: 115 | for pkg in json.loads(out.stdout): 116 | pkgs[pkg["name"]] = { 117 | "current": pkg["version"], 118 | "latest": pkg["latest_version"], 119 | } 120 | else: 121 | # pip < 9 122 | # pip 8: pytest (3.4.2) - Latest: 3.5.0 [wheel] 123 | # pip < 8: pytest (Current: 3.4.2 Latest: 3.5.0 [wheel]) 124 | regexpes = [ 125 | re.compile(r"^(.+?) \((.+)\) - Latest: (.+) .*$"), 126 | re.compile(r"^(.+?) \(Current: (.+) Latest: (.+) .*$"), 127 | ] 128 | for line in cls.check_output("%s list -o", pip_path).splitlines(): 129 | if line.startswith("Warning: "): 130 | # Warning: cannot find svn location for ... 131 | continue 132 | output_re = regexpes[1] if "Current:" in line else regexpes[0] 133 | name, current, latest = _re_match(line, output_re) 134 | pkgs[name] = {"current": current, "latest": latest} 135 | return pkgs 136 | -------------------------------------------------------------------------------- /testinfra/modules/podman.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License, Version 2.0 (the "License"); 2 | # you may not use this file except in compliance with the License. 3 | # You may obtain a copy of the License at 4 | # 5 | # http://www.apache.org/licenses/LICENSE-2.0 6 | # 7 | # Unless required by applicable law or agreed to in writing, software 8 | # distributed under the License is distributed on an "AS IS" BASIS, 9 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | # See the License for the specific language governing permissions and 11 | # limitations under the License. 12 | import json 13 | 14 | from testinfra.modules.base import Module 15 | 16 | 17 | class Podman(Module): 18 | """Test podman containers running on system. 19 | 20 | Example: 21 | 22 | >>> nginx = host.podman("app_nginx") 23 | >>> nginx.is_running 24 | True 25 | >>> nginx.id 26 | '7e67dc7495ca8f451d346b775890bdc0fb561ecdc97b68fb59ff2f77b509a8fe' 27 | >>> nginx.name 28 | 'app_nginx' 29 | """ 30 | 31 | def __init__(self, name): 32 | self._name = name 33 | super().__init__() 34 | 35 | def inspect(self): 36 | output = self.check_output("podman inspect %s", self._name) 37 | return json.loads(output)[0] 38 | 39 | @property 40 | def is_running(self): 41 | return self.inspect()["State"]["Running"] 42 | 43 | @property 44 | def id(self): 45 | return self.inspect()["Id"] 46 | 47 | @property 48 | def name(self): 49 | return self.inspect()["Name"] 50 | 51 | @classmethod 52 | def get_containers(cls, **filters): 53 | """Return a list of containers 54 | 55 | By default, return a list of all containers, including non-running 56 | containers. 57 | 58 | Filtering can be done using filters keys defined in 59 | podman-ps(1). 60 | 61 | Multiple filters for a given key are handled by giving a list of 62 | strings as value. 63 | 64 | >>> host.podman.get_containers() 65 | [, , ] 66 | # Get all running containers 67 | >>> host.podman.get_containers(status="running") 68 | [] 69 | # Get containers named "nginx" 70 | >>> host.podman.get_containers(name="nginx") 71 | [] 72 | # Get containers named "nginx" or "redis" 73 | >>> host.podman.get_containers(name=["nginx", "redis"]) 74 | [, ] 75 | """ 76 | cmd = "podman ps --all --format '{{.Names}}'" 77 | args = [] 78 | for key, value in filters.items(): 79 | values = value if isinstance(value, (list, tuple)) else [value] 80 | for v in values: 81 | cmd += " --filter %s=%s" 82 | args += [key, v] 83 | result = [] 84 | for podman_id in cls(None).check_output(cmd, *args).splitlines(): 85 | result.append(cls(podman_id)) 86 | return result 87 | 88 | def __repr__(self): 89 | return f"" 90 | -------------------------------------------------------------------------------- /testinfra/modules/process.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License, Version 2.0 (the "License"); 2 | # you may not use this file except in compliance with the License. 3 | # You may obtain a copy of the License at 4 | # 5 | # http://www.apache.org/licenses/LICENSE-2.0 6 | # 7 | # Unless required by applicable law or agreed to in writing, software 8 | # distributed under the License is distributed on an "AS IS" BASIS, 9 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | # See the License for the specific language governing permissions and 11 | # limitations under the License. 12 | 13 | from typing import Any 14 | 15 | from testinfra.modules.base import InstanceModule 16 | 17 | 18 | def int_or_float(value): 19 | try: 20 | return int(value) 21 | except ValueError: 22 | try: 23 | return float(value) 24 | except ValueError: 25 | return value 26 | 27 | 28 | class _Process(dict[str, Any]): 29 | def __getattr__(self, key): 30 | try: 31 | return self.__getitem__(key) 32 | except KeyError: 33 | attrs = self["_get_process_attribute_by_pid"](self["pid"], key) 34 | if attrs["lstart"] != self["lstart"]: 35 | raise RuntimeError( 36 | ( 37 | "Process with pid {} start time changed from {} to {}." 38 | " This mean the process you are working on does not not " 39 | "exist anymore" 40 | ).format(self["pid"], self["lstart"], attrs["lstart"]) 41 | ) from None 42 | return attrs[key] 43 | 44 | def __repr__(self): 45 | return "".format(self["comm"], self["pid"]) 46 | 47 | 48 | class Process(InstanceModule): 49 | """Test Processes attributes 50 | 51 | Processes are selected using ``filter()`` or ``get()``, attributes names 52 | are described in the `ps(1) man page 53 | `_. 55 | 56 | >>> master = host.process.get(user="root", comm="nginx") 57 | # Here is the master nginx process (running as root) 58 | >>> master.args 59 | 'nginx: master process /usr/sbin/nginx -g daemon on; master_process on;' 60 | # Here are the worker processes (Parent PID = master PID) 61 | >>> workers = host.process.filter(ppid=master.pid) 62 | >>> len(workers) 63 | 4 64 | # Nginx don't eat memory 65 | >>> sum([w.pmem for w in workers]) 66 | 0.8 67 | # But php does ! 68 | >>> sum([p.pmem for p in host.process.filter(comm="php5-fpm")]) 69 | 19.2 70 | 71 | """ 72 | 73 | def filter(self, **filters): 74 | """Get a list of matching process 75 | 76 | >>> host.process.filter(user="root", comm="zsh") 77 | [, , ...] 78 | """ 79 | match = [] 80 | for attrs in self._get_processes(**filters): 81 | for key, value in filters.items(): 82 | if str(attrs[key]) != str(value): 83 | break 84 | else: 85 | attrs["_get_process_attribute_by_pid"] = ( 86 | self._get_process_attribute_by_pid 87 | ) 88 | match.append(_Process(attrs)) 89 | return match 90 | 91 | def get(self, **filters): 92 | """Get one matching process 93 | 94 | Raise ``RuntimeError`` if no process found or multiple process 95 | matching filters. 96 | """ 97 | matches = self.filter(**filters) 98 | if not matches: 99 | raise RuntimeError("No process found") 100 | if len(matches) > 1: 101 | raise RuntimeError(f"Multiple process found: {matches}") 102 | return matches[0] 103 | 104 | def _get_processes(self, **filters): 105 | raise NotImplementedError 106 | 107 | def _get_process_attribute_by_pid(self, pid, name): 108 | raise NotImplementedError 109 | 110 | @classmethod 111 | def get_module_class(cls, host): 112 | if host.file("/bin/ps").linked_to == "/bin/busybox": 113 | return BusyboxProcess 114 | if ( 115 | host.file("/bin/busybox").exists 116 | and host.file("/bin/ps").inode == host.file("/bin/busybox").inode 117 | ): 118 | return BusyboxProcess 119 | if host.system_info.type == "linux" or host.system_info.type.endswith("bsd"): 120 | return PosixProcess 121 | raise NotImplementedError 122 | 123 | def __repr__(self): 124 | return "" 125 | 126 | 127 | class PosixProcess(Process): 128 | # Should be portable on both Linux and BSD 129 | 130 | def _get_processes(self, **filters): 131 | cmd = "ps -Aww -o %s" 132 | # "lstart" and "args" attributes contains spaces. Put them at the 133 | # end of the list. 134 | attributes = sorted( 135 | ({"pid", "comm", "pcpu", "pmem"} | set(filters)) - {"lstart", "args"} 136 | ) + ["lstart", "args"] 137 | arg = ":50,".join(attributes) 138 | 139 | procs = [] 140 | # skip first line (header) 141 | for line in self.check_output(cmd, arg).splitlines()[1:]: 142 | splitted = line.split() 143 | attrs = {} 144 | i = 0 145 | for i, key in enumerate(attributes[:-2]): 146 | attrs[key] = int_or_float(splitted[i]) 147 | attrs["lstart"] = " ".join(splitted[i + 1 : i + 6]) 148 | attrs["args"] = " ".join(splitted[i + 6 :]) 149 | procs.append(attrs) 150 | return procs 151 | 152 | def _get_process_attribute_by_pid(self, pid, name): 153 | out = self.check_output("ps -ww -p %s -o lstart,%s", str(pid), name) 154 | splitted = out.splitlines()[1].split() 155 | return { 156 | "lstart": " ".join(splitted[:5]), 157 | name: int_or_float(splitted[5]), 158 | } 159 | 160 | 161 | class BusyboxProcess(Process): 162 | def _get_processes(self, **filters): 163 | cmd = "ps -A -o %s" 164 | # "args" attribute contains spaces. Put them at the end of the list 165 | attributes = sorted(({"pid", "comm", "time"} | set(filters)) - {"args"}) + [ 166 | "args" 167 | ] 168 | arg = ",".join(attributes) 169 | 170 | procs = [] 171 | # skip first line (header) 172 | for line in self.check_output(cmd, arg).splitlines()[1:]: 173 | splitted = line.split() 174 | attrs = {} 175 | i = 0 176 | for i, key in enumerate(attributes[:-1]): 177 | attrs[key] = int_or_float(splitted[i]) 178 | 179 | attrs["lstart"] = attrs["time"] 180 | attrs["args"] = " ".join(splitted[i + 1 :]) 181 | procs.append(attrs) 182 | 183 | return procs 184 | 185 | def _get_process_attribute_by_pid(self, pid, name): 186 | out = self.check_output("ps -o pid,time,%s", name) 187 | 188 | # skip first line (header) 189 | for line in out.splitlines()[1:]: 190 | splitted = line.split() 191 | if int(splitted[0]) == pid: 192 | return { 193 | "lstart": splitted[1], 194 | name: int_or_float(splitted[2]), 195 | } 196 | -------------------------------------------------------------------------------- /testinfra/modules/puppet.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License, Version 2.0 (the "License"); 2 | # you may not use this file except in compliance with the License. 3 | # You may obtain a copy of the License at 4 | # 5 | # http://www.apache.org/licenses/LICENSE-2.0 6 | # 7 | # Unless required by applicable law or agreed to in writing, software 8 | # distributed under the License is distributed on an "AS IS" BASIS, 9 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | # See the License for the specific language governing permissions and 11 | # limitations under the License. 12 | 13 | import json 14 | 15 | from testinfra.modules.base import InstanceModule 16 | 17 | 18 | def parse_puppet_resource(data: str) -> dict[str, dict[str, str]]: 19 | """Parse data returned by 'puppet resource' 20 | 21 | $ puppet resource user 22 | user { 'root': 23 | ensure => 'present', 24 | comment => 'root', 25 | gid => '0', 26 | home => '/root', 27 | shell => '/usr/bin/zsh', 28 | uid => '0', 29 | } 30 | user { 'sshd': 31 | ensure => 'present', 32 | gid => '65534', 33 | home => '/var/run/sshd', 34 | shell => '/usr/sbin/nologin', 35 | uid => '106', 36 | } 37 | [...] 38 | """ 39 | 40 | state: dict[str, dict[str, str]] = {} 41 | current = None 42 | for line in data.splitlines(): 43 | if not current: 44 | current = line.split("'")[1] 45 | state[current] = {} 46 | elif current and line == "}": 47 | current = None 48 | elif current: 49 | key, value = line.split(" => ") 50 | key = key.strip() 51 | value = value.split("'")[1] 52 | state[current][key] = value 53 | return state 54 | 55 | 56 | class PuppetResource(InstanceModule): 57 | """Get puppet resources 58 | 59 | Run ``puppet resource --types`` to get a list of available types. 60 | 61 | >>> host.puppet_resource("user", "www-data") 62 | { 63 | 'www-data': { 64 | 'ensure': 'present', 65 | 'comment': 'www-data', 66 | 'gid': '33', 67 | 'home': '/var/www', 68 | 'shell': '/usr/sbin/nologin', 69 | 'uid': '33', 70 | }, 71 | } 72 | """ 73 | 74 | def __call__(self, resource_type, name=None): 75 | cmd = "puppet resource %s" 76 | args = [resource_type] 77 | if name is not None: 78 | cmd += " %s" 79 | args.append(name) 80 | # TODO(phil): Since puppet 4.0.0 puppet resource has a --to_yaml option 81 | return parse_puppet_resource(self.check_output(cmd, *args)) 82 | 83 | def __repr__(self): 84 | return "" 85 | 86 | 87 | class Facter(InstanceModule): 88 | """Get facts with `facter `_ 89 | 90 | >>> host.facter() 91 | { 92 | "operatingsystem": "Debian", 93 | "kernel": "linux", 94 | [...] 95 | } 96 | >>> host.facter("kernelversion", "is_virtual") 97 | { 98 | "kernelversion": "3.16.0", 99 | "is_virtual": "false" 100 | } 101 | """ 102 | 103 | def __call__(self, *facts): 104 | cmd = "facter --json --puppet " + " ".join(facts) 105 | return json.loads(self.check_output(cmd)) 106 | 107 | def __repr__(self): 108 | return "" 109 | -------------------------------------------------------------------------------- /testinfra/modules/salt.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License, Version 2.0 (the "License"); 2 | # you may not use this file except in compliance with the License. 3 | # You may obtain a copy of the License at 4 | # 5 | # http://www.apache.org/licenses/LICENSE-2.0 6 | # 7 | # Unless required by applicable law or agreed to in writing, software 8 | # distributed under the License is distributed on an "AS IS" BASIS, 9 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | # See the License for the specific language governing permissions and 11 | # limitations under the License. 12 | 13 | import json 14 | 15 | from testinfra.modules.base import InstanceModule 16 | 17 | 18 | class Salt(InstanceModule): 19 | """Run salt module functions 20 | 21 | 22 | >>> host.salt("pkg.version", "nginx") 23 | '1.6.2-5' 24 | >>> host.salt("pkg.version", ["nginx", "php5-fpm"]) 25 | {'nginx': '1.6.2-5', 'php5-fpm': '5.6.7+dfsg-1'} 26 | >>> host.salt("grains.item", ["osarch", "mem_total", "num_cpus"]) 27 | {'osarch': 'amd64', 'num_cpus': 4, 'mem_total': 15520} 28 | 29 | Run ``salt-call sys.doc`` to get a complete list of functions 30 | """ 31 | 32 | def __call__(self, function, args=None, local=False, config=None): 33 | args = args or [] 34 | if isinstance(args, str): 35 | args = [args] 36 | if self._host.backend.HAS_RUN_SALT: 37 | return self._host.backend.run_salt(function, args) 38 | cmd_args = [] 39 | cmd = "salt-call --out=json" 40 | if local: 41 | cmd += " --local" 42 | if config is not None: 43 | cmd += " -c %s" 44 | cmd_args.append(config) 45 | cmd += " %s" + len(args) * " %s" 46 | cmd_args += [function] + args 47 | return json.loads(self.check_output(cmd, *cmd_args))["local"] 48 | 49 | def __repr__(self): 50 | return "" 51 | -------------------------------------------------------------------------------- /testinfra/modules/sudo.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License, Version 2.0 (the "License"); 2 | # you may not use this file except in compliance with the License. 3 | # You may obtain a copy of the License at 4 | # 5 | # http://www.apache.org/licenses/LICENSE-2.0 6 | # 7 | # Unless required by applicable law or agreed to in writing, software 8 | # distributed under the License is distributed on an "AS IS" BASIS, 9 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | # See the License for the specific language governing permissions and 11 | # limitations under the License. 12 | 13 | import contextlib 14 | 15 | from testinfra.modules.base import InstanceModule 16 | 17 | 18 | class Sudo(InstanceModule): 19 | """Sudo module allows to run certain portion of code under another user. 20 | 21 | It is used as a context manager and can be nested. 22 | 23 | >>> Command.check_output("whoami") 24 | 'phil' 25 | >>> with host.sudo(): 26 | ... host.check_output("whoami") 27 | ... with host.sudo("www-data"): 28 | ... host.check_output("whoami") 29 | ... 30 | 'root' 31 | 'www-data' 32 | 33 | """ 34 | 35 | @contextlib.contextmanager 36 | def __call__(self, user=None): 37 | old_get_command = self._host.backend.get_command 38 | quote = self._host.backend.quote 39 | get_sudo_command = self._host.backend.get_sudo_command 40 | 41 | def get_command(command, *args): 42 | return old_get_command(get_sudo_command(quote(command, *args), user)) 43 | 44 | self._host.backend.get_command = get_command 45 | try: 46 | yield 47 | finally: 48 | self._host.backend.get_command = old_get_command 49 | 50 | def __repr__(self): 51 | return "" 52 | -------------------------------------------------------------------------------- /testinfra/modules/supervisor.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License, Version 2.0 (the "License"); 2 | # you may not use this file except in compliance with the License. 3 | # You may obtain a copy of the License at 4 | # 5 | # http://www.apache.org/licenses/LICENSE-2.0 6 | # 7 | # Unless required by applicable law or agreed to in writing, software 8 | # distributed under the License is distributed on an "AS IS" BASIS, 9 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | # See the License for the specific language governing permissions and 11 | # limitations under the License. 12 | 13 | from typing import NoReturn 14 | 15 | from testinfra.modules.base import Module 16 | 17 | STATUS = [ 18 | "STOPPED", 19 | "STARTING", 20 | "RUNNING", 21 | "BACKOFF", 22 | "STOPPING", 23 | "EXITED", 24 | "FATAL", 25 | "UNKNOWN", 26 | ] 27 | 28 | 29 | def supervisor_not_running() -> NoReturn: 30 | raise RuntimeError("Cannot get supervisor status. Is supervisor running ?") 31 | 32 | 33 | class Supervisor(Module): 34 | """Test supervisor managed services 35 | 36 | >>> gunicorn = host.supervisor("gunicorn") 37 | >>> gunicorn.status 38 | 'RUNNING' 39 | >>> gunicorn.is_running 40 | True 41 | >>> gunicorn.pid 42 | 4242 43 | 44 | The path where supervisorctl and its configuration file reside can be specified. 45 | 46 | >>> gunicorn = host.supervisor("gunicorn", "/usr/bin/supervisorctl", "/etc/supervisor/supervisord.conf") 47 | >>> gunicorn.status 48 | 'RUNNING' 49 | """ 50 | 51 | def __init__( 52 | self, 53 | name, 54 | supervisorctl_path="supervisorctl", 55 | supervisorctl_conf=None, 56 | _attrs_cache=None, 57 | ): 58 | self.name = name 59 | self.supervisorctl_path = supervisorctl_path 60 | self.supervisorctl_conf = supervisorctl_conf 61 | self._attrs_cache = _attrs_cache 62 | super().__init__() 63 | 64 | @staticmethod 65 | def _parse_status(line): 66 | splitted = line.split() 67 | name = splitted[0] 68 | status = splitted[1] 69 | # Some old supervisorctl versions exit status is 0 even if it cannot 70 | # connect to supervisord socket and output the error to stdout. So we 71 | # check that parsed status is a known status. 72 | if status not in STATUS: 73 | supervisor_not_running() 74 | pid = int(splitted[3].removesuffix(",")) if status == "RUNNING" else None 75 | return {"name": name, "status": status, "pid": pid} 76 | 77 | @property 78 | def _attrs(self): 79 | if self._attrs_cache is None: 80 | if self.supervisorctl_conf: 81 | out = self.run_expect( 82 | [0, 3, 4], 83 | "%s -c %s status %s", 84 | self.supervisorctl_path, 85 | self.supervisorctl_conf, 86 | self.name, 87 | ) 88 | else: 89 | out = self.run_expect( 90 | [0, 3, 4], "%s status %s", self.supervisorctl_path, self.name 91 | ) 92 | if out.rc == 4: 93 | supervisor_not_running() 94 | line = out.stdout.rstrip("\r\n") 95 | attrs = self._parse_status(line) 96 | assert attrs["name"] == self.name 97 | self._attrs_cache = attrs 98 | return self._attrs_cache 99 | 100 | @property 101 | def is_running(self): 102 | """Return True if managed service is in status RUNNING""" 103 | return self.status == "RUNNING" 104 | 105 | @property 106 | def status(self): 107 | """Return the status of the managed service 108 | 109 | Status can be STOPPED, STARTING, RUNNING, BACKOFF, STOPPING, 110 | EXITED, FATAL, UNKNOWN. 111 | 112 | See http://supervisord.org/subprocess.html#process-states 113 | """ 114 | return self._attrs["status"] 115 | 116 | @property 117 | def pid(self): 118 | """Return the pid (as int) of the managed service""" 119 | return self._attrs["pid"] 120 | 121 | @classmethod 122 | def get_services( 123 | cls, 124 | supervisorctl_path="supervisorctl", 125 | supervisorctl_conf=None, 126 | ): 127 | """Get a list of services running under supervisor 128 | 129 | >>> host.supervisor.get_services() 130 | [ 131 | ] 132 | 133 | The path where supervisorctl and its configuration file reside can be specified. 134 | 135 | >>> host.supervisor.get_services("/usr/bin/supervisorctl", "/etc/supervisor/supervisord.conf") 136 | [ 137 | ] 138 | """ 139 | services = [] 140 | if supervisorctl_conf: 141 | out = cls.check_output( 142 | "%s -c %s status", supervisorctl_path, supervisorctl_conf 143 | ) 144 | else: 145 | out = cls.check_output("%s status", supervisorctl_path) 146 | for line in out.splitlines(): 147 | attrs = cls._parse_status(line) 148 | service = cls( 149 | attrs["name"], 150 | supervisorctl_path=supervisorctl_path, 151 | supervisorctl_conf=supervisorctl_conf, 152 | _attrs_cache=attrs, 153 | ) 154 | services.append(service) 155 | return services 156 | 157 | def __repr__(self): 158 | return f"" 159 | -------------------------------------------------------------------------------- /testinfra/modules/sysctl.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License, Version 2.0 (the "License"); 2 | # you may not use this file except in compliance with the License. 3 | # You may obtain a copy of the License at 4 | # 5 | # http://www.apache.org/licenses/LICENSE-2.0 6 | # 7 | # Unless required by applicable law or agreed to in writing, software 8 | # distributed under the License is distributed on an "AS IS" BASIS, 9 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | # See the License for the specific language governing permissions and 11 | # limitations under the License. 12 | 13 | import functools 14 | 15 | from testinfra.modules.base import InstanceModule 16 | 17 | 18 | class Sysctl(InstanceModule): 19 | """Test kernel parameters 20 | 21 | >>> host.sysctl("kernel.osrelease") 22 | "3.16.0-4-amd64" 23 | >>> host.sysctl("vm.dirty_ratio") 24 | 20 25 | """ 26 | 27 | @functools.cached_property 28 | def _sysctl_command(self): 29 | return self.find_command("sysctl") 30 | 31 | def __call__(self, name): 32 | value = self.check_output("%s -n %s", self._sysctl_command, name) 33 | try: 34 | return int(value) 35 | except ValueError: 36 | return value 37 | 38 | def __repr__(self): 39 | return "" 40 | -------------------------------------------------------------------------------- /testinfra/modules/systeminfo.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License, Version 2.0 (the "License"); 2 | # you may not use this file except in compliance with the License. 3 | # You may obtain a copy of the License at 4 | # 5 | # http://www.apache.org/licenses/LICENSE-2.0 6 | # 7 | # Unless required by applicable law or agreed to in writing, software 8 | # distributed under the License is distributed on an "AS IS" BASIS, 9 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | # See the License for the specific language governing permissions and 11 | # limitations under the License. 12 | 13 | import functools 14 | import re 15 | 16 | from testinfra.modules.base import InstanceModule 17 | 18 | 19 | class SystemInfo(InstanceModule): 20 | """Return system information""" 21 | 22 | @functools.cached_property 23 | def sysinfo(self): 24 | sysinfo = { 25 | "type": None, 26 | "distribution": None, 27 | "codename": None, 28 | "release": None, 29 | "arch": None, 30 | } 31 | uname = self.run_expect([0, 1], "uname -s") 32 | if uname.rc == 1 or uname.stdout.lower().startswith("msys"): 33 | # FIXME: find a better way to detect windows here 34 | sysinfo.update(**self._get_windows_sysinfo()) 35 | return sysinfo 36 | sysinfo["type"] = uname.stdout.rstrip("\r\n").lower() 37 | if sysinfo["type"] == "linux": 38 | sysinfo.update(**self._get_linux_sysinfo()) 39 | elif sysinfo["type"] == "darwin": 40 | sysinfo.update(**self._get_darwin_sysinfo()) 41 | else: 42 | # BSD 43 | sysinfo["release"] = self.check_output("uname -r") 44 | sysinfo["distribution"] = sysinfo["type"] 45 | sysinfo["codename"] = None 46 | 47 | sysinfo["arch"] = self.check_output("uname -m") 48 | return sysinfo 49 | 50 | def _get_linux_sysinfo(self): 51 | sysinfo = {} 52 | 53 | # https://www.freedesktop.org/software/systemd/man/os-release.html 54 | os_release = self.run("cat /etc/os-release") 55 | if os_release.rc == 0: 56 | for line in os_release.stdout.splitlines(): 57 | for key, attname in ( 58 | ("ID=", "distribution"), 59 | ("VERSION_ID=", "release"), 60 | ("VERSION_CODENAME=", "codename"), 61 | ): 62 | if line.startswith(key): 63 | sysinfo[attname] = ( 64 | line[len(key) :].replace('"', "").replace("'", "").strip() 65 | ) 66 | # Arch doesn't have releases 67 | if "distribution" in sysinfo and sysinfo["distribution"] == "arch": 68 | sysinfo["release"] = "rolling" 69 | return sysinfo 70 | 71 | # RedHat / CentOS 6 haven't /etc/os-release 72 | redhat_release = self.run("cat /etc/redhat-release") 73 | if redhat_release.rc == 0: 74 | match = re.match( 75 | r"^(.+) release ([^ ]+) .*$", redhat_release.stdout.strip() 76 | ) 77 | if match: 78 | sysinfo["distribution"], sysinfo["release"] = match.groups() 79 | return sysinfo 80 | 81 | # Alpine doesn't have /etc/os-release 82 | alpine_release = self.run("cat /etc/alpine-release") 83 | if alpine_release.rc == 0: 84 | sysinfo["distribution"] = "alpine" 85 | sysinfo["release"] = alpine_release.stdout.strip() 86 | return sysinfo 87 | 88 | # LSB 89 | lsb = self.run("lsb_release -a") 90 | if lsb.rc == 0: 91 | for line in lsb.stdout.splitlines(): 92 | key, value = line.split(":", 1) 93 | key = key.strip().lower() 94 | value = value.strip().lower() 95 | if key == "distributor id": 96 | sysinfo["distribution"] = value 97 | elif key == "release": 98 | sysinfo["release"] = value 99 | elif key == "codename": 100 | sysinfo["codename"] = value 101 | return sysinfo 102 | 103 | return sysinfo 104 | 105 | def _get_darwin_sysinfo(self): 106 | sysinfo = {} 107 | 108 | sw_vers = self.run("sw_vers") 109 | if sw_vers.rc == 0: 110 | for line in sw_vers.stdout.splitlines(): 111 | key, value = line.split(":", 1) 112 | key = key.strip().lower() 113 | value = value.strip() 114 | if key == "productname": 115 | sysinfo["distribution"] = value 116 | elif key == "productversion": 117 | sysinfo["release"] = value 118 | 119 | return sysinfo 120 | 121 | def _get_windows_sysinfo(self): 122 | sysinfo = {} 123 | for line in self.check_output('systeminfo | findstr /B /C:"OS"').splitlines(): 124 | key, value = line.split(":", 1) 125 | key = key.strip().replace(" ", "_").lower() 126 | value = value.strip() 127 | if key == "os_name": 128 | sysinfo["distribution"] = value 129 | sysinfo["type"] = value.split(" ")[1].lower() 130 | elif key == "os_version": 131 | sysinfo["release"] = value 132 | sysinfo["arch"] = self.check_output("echo %PROCESSOR_ARCHITECTURE%") 133 | return sysinfo 134 | 135 | @property 136 | def type(self): 137 | """OS type 138 | 139 | >>> host.system_info.type 140 | 'linux' 141 | """ 142 | return self.sysinfo["type"] 143 | 144 | @property 145 | def distribution(self): 146 | """Distribution name 147 | 148 | >>> host.system_info.distribution 149 | 'debian' 150 | """ 151 | return self.sysinfo["distribution"] 152 | 153 | @property 154 | def release(self): 155 | """Distribution release number 156 | 157 | >>> host.system_info.release 158 | '10.2' 159 | """ 160 | return self.sysinfo["release"] 161 | 162 | @property 163 | def codename(self): 164 | """Release code name 165 | 166 | >>> host.system_info.codename 167 | 'bullseye' 168 | """ 169 | return self.sysinfo["codename"] 170 | 171 | @property 172 | def arch(self): 173 | """Host architecture 174 | 175 | >>> host.system_info.arch 176 | 'x86_64' 177 | """ 178 | return self.sysinfo["arch"] 179 | -------------------------------------------------------------------------------- /testinfra/modules/user.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License, Version 2.0 (the "License"); 2 | # you may not use this file except in compliance with the License. 3 | # You may obtain a copy of the License at 4 | # 5 | # http://www.apache.org/licenses/LICENSE-2.0 6 | # 7 | # Unless required by applicable law or agreed to in writing, software 8 | # distributed under the License is distributed on an "AS IS" BASIS, 9 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | # See the License for the specific language governing permissions and 11 | # limitations under the License. 12 | 13 | import datetime 14 | 15 | from testinfra.modules.base import Module 16 | 17 | 18 | class User(Module): 19 | """Test unix users 20 | 21 | If name is not supplied, test the current user 22 | """ 23 | 24 | def __init__(self, name=None): 25 | self._name = name 26 | super().__init__() 27 | 28 | @property 29 | def name(self): 30 | """Return the username""" 31 | if self._name is None: 32 | self._name = self.check_output("id -nu") 33 | return self._name 34 | 35 | @property 36 | def exists(self): 37 | """Test if user exists 38 | 39 | >>> host.user("root").exists 40 | True 41 | >>> host.user("nosuchuser").exists 42 | False 43 | 44 | """ 45 | 46 | return self.run_test("id %s", self.name).rc == 0 47 | 48 | @property 49 | def uid(self): 50 | """Return user ID""" 51 | return int(self.check_output("id -u %s", self.name)) 52 | 53 | @property 54 | def gid(self): 55 | """Return effective group ID""" 56 | return int(self.check_output("id -g %s", self.name)) 57 | 58 | @property 59 | def group(self): 60 | """Return effective group name""" 61 | return self.check_output("id -ng %s", self.name) 62 | 63 | @property 64 | def gids(self): 65 | """Return the list of user group IDs""" 66 | return [ 67 | int(gid) 68 | for gid in self.check_output( 69 | "id -G %s", 70 | self.name, 71 | ).split(" ") 72 | ] 73 | 74 | @property 75 | def groups(self): 76 | """Return the list of user group names""" 77 | return self.check_output("id -nG %s", self.name).split(" ") 78 | 79 | @property 80 | def home(self): 81 | """Return the user home directory""" 82 | return self.check_output("getent passwd %s", self.name).split(":")[5] 83 | 84 | @property 85 | def shell(self): 86 | """Return the user login shell""" 87 | return self.check_output("getent passwd %s", self.name).split(":")[6] 88 | 89 | @property 90 | def password(self): 91 | """Return the encrypted user password""" 92 | return self.check_output("getent shadow %s", self.name).split(":")[1] 93 | 94 | @property 95 | def password_max_days(self): 96 | """Return the maximum number of days between password changes""" 97 | days = self.check_output("getent shadow %s", self.name).split(":")[4] 98 | try: 99 | return int(days) 100 | except ValueError: 101 | return None 102 | 103 | @property 104 | def password_min_days(self): 105 | """Return the minimum number of days between password changes""" 106 | days = self.check_output("getent shadow %s", self.name).split(":")[3] 107 | try: 108 | return int(days) 109 | except ValueError: 110 | return None 111 | 112 | @property 113 | def gecos(self): 114 | """Return the user comment/gecos field""" 115 | return self.check_output("getent passwd %s", self.name).split(":")[4] 116 | 117 | @property 118 | def expiration_date(self): 119 | """Return the account expiration date 120 | 121 | >>> host.user("phil").expiration_date 122 | datetime.datetime(2020, 1, 1, 0, 0) 123 | >>> host.user("root").expiration_date 124 | None 125 | """ 126 | days = self.check_output("getent shadow %s", self.name).split(":")[7] 127 | try: 128 | days = int(days) 129 | except ValueError: 130 | return None 131 | 132 | if days > 0: 133 | epoch = datetime.datetime.utcfromtimestamp(0) 134 | return epoch + datetime.timedelta(days=int(days)) 135 | 136 | @property 137 | def get_all_users(self): 138 | """Returns a list of local and remote usernames 139 | 140 | >>> host.user().get_all_users 141 | ["root", "bin", "daemon", "lp", <...>] 142 | """ 143 | all_users = [ 144 | line.split(":")[0] 145 | for line in self.check_output("getent passwd").splitlines() 146 | ] 147 | return all_users 148 | 149 | @property 150 | def get_local_users(self): 151 | """Returns a list of local usernames 152 | 153 | >>> host.user().get_local_users 154 | ["root", "bin", "daemon", "lp", <...>] 155 | """ 156 | local_users = [ 157 | line.split(":")[0] 158 | for line in self.check_output("cat /etc/passwd").splitlines() 159 | ] 160 | # strip NIS compat mode entries 161 | local_users = [i for i in local_users if not i.startswith("+")] 162 | return local_users 163 | 164 | @classmethod 165 | def get_module_class(cls, host): 166 | if host.system_info.type.endswith("bsd"): 167 | return BSDUser 168 | if host.system_info.type == "windows": 169 | return WindowsUser 170 | return super().get_module_class(host) 171 | 172 | def __repr__(self): 173 | return f"" 174 | 175 | 176 | class BSDUser(User): 177 | @property 178 | def password(self): 179 | return self.check_output("getent passwd %s", self.name).split(":")[1] 180 | 181 | @property 182 | def expiration_date(self): 183 | seconds = self.check_output("getent passwd %s", self.name).split(":")[6] 184 | try: 185 | seconds = int(seconds) 186 | except ValueError: 187 | return None 188 | 189 | if seconds > 0: 190 | epoch = datetime.datetime.utcfromtimestamp(0) 191 | return epoch + datetime.timedelta(seconds=int(seconds)) 192 | 193 | 194 | class WindowsUser(User): 195 | @property 196 | def name(self): 197 | """Return the username""" 198 | if self._name is None: 199 | self._name = self.check_output("echo %username%") 200 | return self._name 201 | 202 | @property 203 | def exists(self): 204 | """Test if user exists 205 | 206 | >>> host.user("Administrator").exists 207 | True 208 | >>> host.user("nosuchuser").exists 209 | False 210 | 211 | """ 212 | 213 | return self.run_test("net user %s", self.name).rc == 0 214 | 215 | @property 216 | def uid(self): 217 | raise NotImplementedError 218 | 219 | @property 220 | def gid(self): 221 | raise NotImplementedError 222 | 223 | @property 224 | def group(self): 225 | raise NotImplementedError 226 | 227 | @property 228 | def gids(self): 229 | raise NotImplementedError 230 | 231 | @property 232 | def groups(self): 233 | """Return the list of user local group names""" 234 | local_groups = self.check_output( 235 | 'net user %s | findstr /B /C:"Local Group Memberships"', self.name 236 | ) 237 | local_groups = local_groups.split()[3:] 238 | return [g.replace("*", "") for g in local_groups] 239 | 240 | @property 241 | def home(self): 242 | raise NotImplementedError 243 | 244 | @property 245 | def shell(self): 246 | raise NotImplementedError 247 | 248 | @property 249 | def gecos(self): 250 | comment = self.check_output('net user %s | find /B /C:"Comment"', self.name) 251 | return comment.split().strip()[1] 252 | 253 | @property 254 | def password(self): 255 | raise NotImplementedError 256 | 257 | @property 258 | def expiration_date(self): 259 | expiration = self.check_output( 260 | 'net user %s | findstr /B /C:"Password \ 261 | expires"', 262 | self.name, 263 | ) 264 | expiration = expiration.split().strip()[1] 265 | if expiration == "Never": 266 | return None 267 | return datetime.datetime.strptime(expiration, "%m/%d/%Y %H:%M%S %p") 268 | -------------------------------------------------------------------------------- /testinfra/plugin.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License, Version 2.0 (the "License"); 2 | # you may not use this file except in compliance with the License. 3 | # You may obtain a copy of the License at 4 | # 5 | # http://www.apache.org/licenses/LICENSE-2.0 6 | # 7 | # Unless required by applicable law or agreed to in writing, software 8 | # distributed under the License is distributed on an "AS IS" BASIS, 9 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | # See the License for the specific language governing permissions and 11 | # limitations under the License. 12 | 13 | from __future__ import annotations 14 | 15 | import logging 16 | import shutil 17 | import sys 18 | import tempfile 19 | import time 20 | from typing import AnyStr, cast 21 | 22 | import pytest 23 | 24 | import testinfra 25 | import testinfra.host 26 | import testinfra.modules 27 | 28 | 29 | @pytest.fixture(scope="module") 30 | def _testinfra_host(request: pytest.FixtureRequest) -> testinfra.host.Host: 31 | return cast(testinfra.host.Host, request.param) 32 | 33 | 34 | @pytest.fixture(scope="module") 35 | def host(_testinfra_host: testinfra.host.Host) -> testinfra.host.Host: 36 | return _testinfra_host 37 | 38 | 39 | host.__doc__ = testinfra.host.Host.__doc__ 40 | 41 | 42 | def pytest_addoption(parser: pytest.Parser) -> None: 43 | group = parser.getgroup("testinfra") 44 | group.addoption( 45 | "--connection", 46 | action="store", 47 | dest="connection", 48 | help=( 49 | "Remote connection backend (paramiko, ssh, safe-ssh, " 50 | "salt, docker, ansible, podman)" 51 | ), 52 | ) 53 | group.addoption( 54 | "--hosts", 55 | action="store", 56 | dest="hosts", 57 | help="Hosts list (comma separated)", 58 | ) 59 | group.addoption( 60 | "--ssh-config", 61 | action="store", 62 | dest="ssh_config", 63 | help="SSH config file", 64 | ) 65 | group.addoption( 66 | "--ssh-extra-args", 67 | action="store", 68 | dest="ssh_extra_args", 69 | help="SSH extra args", 70 | ) 71 | group.addoption( 72 | "--ssh-identity-file", 73 | action="store", 74 | dest="ssh_identity_file", 75 | help="SSH identify file", 76 | ) 77 | group.addoption( 78 | "--sudo", 79 | action="store_true", 80 | dest="sudo", 81 | help="Use sudo", 82 | ) 83 | group.addoption( 84 | "--sudo-user", 85 | action="store", 86 | dest="sudo_user", 87 | help="sudo user", 88 | ) 89 | group.addoption( 90 | "--ansible-inventory", 91 | action="store", 92 | dest="ansible_inventory", 93 | help="Ansible inventory file", 94 | ) 95 | group.addoption( 96 | "--force-ansible", 97 | action="store_true", 98 | dest="force_ansible", 99 | help=( 100 | "Force use of ansible connection backend only (slower but all " 101 | "ansible connection options are handled)" 102 | ), 103 | ) 104 | group.addoption( 105 | "--nagios", 106 | action="store_true", 107 | dest="nagios", 108 | help="Nagios plugin", 109 | ) 110 | 111 | 112 | def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: 113 | if "_testinfra_host" in metafunc.fixturenames: 114 | if metafunc.config.option.hosts is not None: 115 | hosts = metafunc.config.option.hosts.split(",") 116 | elif hasattr(metafunc.module, "testinfra_hosts"): 117 | hosts = metafunc.module.testinfra_hosts 118 | else: 119 | hosts = [None] 120 | params = testinfra.get_hosts( 121 | hosts, 122 | connection=metafunc.config.option.connection, 123 | ssh_config=metafunc.config.option.ssh_config, 124 | ssh_identity_file=metafunc.config.option.ssh_identity_file, 125 | sudo=metafunc.config.option.sudo, 126 | sudo_user=metafunc.config.option.sudo_user, 127 | ansible_inventory=metafunc.config.option.ansible_inventory, 128 | force_ansible=metafunc.config.option.force_ansible, 129 | ) 130 | params = sorted(params, key=lambda x: x.backend.get_pytest_id()) 131 | ids = [e.backend.get_pytest_id() for e in params] 132 | metafunc.parametrize( 133 | "_testinfra_host", params, ids=ids, scope="module", indirect=True 134 | ) 135 | 136 | 137 | class NagiosReporter: 138 | def __init__(self, out): 139 | self.passed = 0 140 | self.failed = 0 141 | self.skipped = 0 142 | self.start_time = time.time() 143 | self.total_time = None 144 | self.out = out 145 | 146 | def pytest_runtest_logreport(self, report: pytest.TestReport) -> None: 147 | if report.passed: 148 | if report.when == "call": # ignore setup/teardown 149 | self.passed += 1 150 | elif report.failed: 151 | self.failed += 1 152 | elif report.skipped: 153 | self.skipped += 1 154 | 155 | def report(self) -> int: 156 | if self.failed: 157 | status = b"CRITICAL" 158 | ret = 2 159 | else: 160 | status = b"OK" 161 | ret = 0 162 | 163 | out = sys.stdout.buffer 164 | out.write( 165 | (b"TESTINFRA %s - %d passed, %d failed, %d skipped in %.2f seconds\n") 166 | % ( 167 | status, 168 | self.passed, 169 | self.failed, 170 | self.skipped, 171 | time.time() - self.start_time, 172 | ) 173 | ) 174 | self.out.seek(0) 175 | shutil.copyfileobj(self.out, out) 176 | return ret 177 | 178 | 179 | class SpooledTemporaryFile(tempfile.SpooledTemporaryFile[AnyStr]): 180 | def __init__(self, *args, **kwargs): 181 | if "b" in kwargs.get("mode", "b"): 182 | self._out_encoding = kwargs.pop("encoding") 183 | else: 184 | self._out_encoding = kwargs.get("encoding") 185 | super().__init__(*args, **kwargs) 186 | 187 | def write(self, s): 188 | # avoid traceback in py.io.terminalwriter.write_out 189 | # TypeError: a bytes-like object is required, not 'str' 190 | if isinstance(s, str): 191 | s = s.encode(self._out_encoding) 192 | return super().write(s) 193 | 194 | 195 | @pytest.hookimpl(trylast=True) 196 | def pytest_configure(config): 197 | if config.getoption("--verbose", 0) > 1: 198 | root = logging.getLogger() 199 | if not root.handlers: 200 | root.addHandler(logging.NullHandler()) 201 | logging.getLogger("testinfra").setLevel(logging.DEBUG) 202 | if config.getoption("--nagios"): 203 | # disable and re-enable terminalreporter to write in a tempfile 204 | reporter = config.pluginmanager.getplugin("terminalreporter") 205 | if reporter: 206 | out = SpooledTemporaryFile(encoding=sys.stdout.encoding) 207 | config.pluginmanager.unregister(reporter) 208 | reporter = reporter.__class__(config, out) 209 | config.pluginmanager.register(reporter, "terminalreporter") 210 | config.pluginmanager.register(NagiosReporter(out), "nagiosreporter") 211 | 212 | 213 | @pytest.hookimpl(trylast=True) 214 | def pytest_sessionfinish(session, exitstatus): 215 | reporter = session.config.pluginmanager.getplugin("nagiosreporter") 216 | if reporter: 217 | session.exitstatus = reporter.report() 218 | -------------------------------------------------------------------------------- /testinfra/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pytest-dev/pytest-testinfra/dca7b056607bcbc9f8b4b7e9051cbc86f8cb956a/testinfra/utils/__init__.py -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | minversion = 4.0.16 3 | envlist= 4 | lint 5 | mypy 6 | py 7 | docs 8 | packaging 9 | 10 | [testenv] 11 | description = Runs unittests 12 | extras = 13 | test 14 | typing 15 | commands= 16 | pytest {posargs:-v -n 4 --cov testinfra --cov-report xml --cov-report term test} 17 | usedevelop=True 18 | passenv= 19 | HOME 20 | TRAVIS 21 | DOCKER_CERT_PATH 22 | DOCKER_HOST 23 | DOCKER_TLS_VERIFY 24 | WSL_DISTRO_NAME 25 | 26 | [testenv:lint] 27 | description = Performs linting tasks 28 | skip_install = true 29 | deps = 30 | pre-commit>=2.6.0 31 | ruff 32 | commands= 33 | pre-commit run -a 34 | 35 | [testenv:mypy] 36 | description = Performs typing check 37 | extras = 38 | typing 39 | usedevelop=True 40 | commands= 41 | mypy 42 | 43 | [testenv:docs] 44 | extras = 45 | doc 46 | commands= 47 | sphinx-build -W -b html doc/source doc/build 48 | 49 | [testenv:packaging] 50 | description = Validate project packaging 51 | skip_install = true 52 | deps= 53 | build 54 | commands= 55 | {envpython} -m build 56 | --------------------------------------------------------------------------------