diff options
453 files changed, 9237 insertions, 2438 deletions
diff --git a/.ansible-lint b/.ansible-lint index 3530086..4e92c01 100644 --- a/.ansible-lint +++ b/.ansible-lint @@ -76,7 +76,7 @@ warn_list: # - yaml[document-start] # you can also use sub-rule matches # Some rules can transform files to fix (or make it easier to fix) identified -# errors. `ansible-lint --write` will reformat YAML files and run these transforms. +# errors. `ansible-lint --fix` will reformat YAML files and run these transforms. # By default it will run all transforms (effectively `write_list: ["all"]`). # You can disable running transforms by setting `write_list: ["none"]`. # Or only enable a subset of rule transforms by listing rules/tags here. @@ -118,3 +118,11 @@ kinds: # Allow setting custom prefix for name[prefix] rule task_name_prefix: "{stem} | " +# Complexity related settings + +# Limit the depth of the nested blocks: +# max_block_depth: 20 + +# Also recognize these versions of Ansible as supported: +# supported_ansible_also: +# - "2.14" diff --git a/.ansible-lint-ignore b/.ansible-lint-ignore index dae2afe..ca26ee7 100644 --- a/.ansible-lint-ignore +++ b/.ansible-lint-ignore @@ -1,3 +1,3 @@ -# See https://ansible-lint.readthedocs.io/configuring/#ignoring-rules-for-entire-files +# See https://ansible.readthedocs.io/projects/lint/configuring/#ignoring-rules-for-entire-files playbook2.yml package-latest # comment playbook2.yml foo-bar diff --git a/.config/constraints.txt b/.config/constraints.txt new file mode 100644 index 0000000..ae0bf17 --- /dev/null +++ b/.config/constraints.txt @@ -0,0 +1,125 @@ +# +# This file is autogenerated by pip-compile with Python 3.10 +# by the following command: +# +# pip-compile --all-extras --no-annotate --output-file=.config/constraints.txt --strip-extras --unsafe-package=resolvelib --unsafe-package=ruamel-yaml-clib --unsafe-package=wcmatch pyproject.toml +# +ansible-compat==24.6.1 +ansible-core==2.17.0 +astroid==3.2.2 +attrs==23.2.0 +babel==2.15.0 +beautifulsoup4==4.12.3 +black==24.4.2 +boolean-py==4.0 +bracex==2.4 +cachetools==5.3.3 +cairocffi==1.7.0 +cairosvg==2.7.1 +certifi==2024.6.2 +cffi==1.16.0 +chardet==5.2.0 +charset-normalizer==3.3.2 +click==8.1.7 +colorama==0.4.6 +coverage==7.5.3 +coverage-enable-subprocess==1.0 +cryptography==42.0.8 +csscompressor==0.9.5 +cssselect2==0.7.0 +defusedxml==0.7.1 +dill==0.3.8 +distlib==0.3.8 +dnspython==2.6.1 +exceptiongroup==1.2.1 +execnet==2.1.1 +filelock==3.15.1 +ghp-import==2.1.0 +griffe==0.45.3 +htmlmin2==0.1.13 +idna==3.7 +importlib-metadata==7.1.0 +iniconfig==2.0.0 +isort==5.13.2 +jinja2==3.1.4 +jmespath==1.0.1 +jsmin==3.0.1 +jsonschema==4.22.0 +jsonschema-specifications==2023.12.1 +license-expression==30.3.0 +linkchecker==10.4.0 +markdown==3.6 +markdown-exec==1.9.1 +markdown-include==0.8.1 +markdown-it-py==3.0.0 +markupsafe==2.1.5 +mccabe==0.7.0 +mdurl==0.1.2 +mergedeep==1.3.4 +mkdocs==1.6.0 +mkdocs-ansible==24.3.1 +mkdocs-autorefs==1.0.1 +mkdocs-gen-files==0.5.0 +mkdocs-get-deps==0.2.0 +mkdocs-htmlproofer-plugin==1.2.1 +mkdocs-macros-plugin==1.0.5 +mkdocs-material==9.5.26 +mkdocs-material-extensions==1.3.1 +mkdocs-minify-plugin==0.8.0 +mkdocs-monorepo-plugin==1.1.0 +mkdocstrings==0.25.1 +mkdocstrings-python==1.10.3 +mypy==1.10.0 +mypy-extensions==1.0.0 +netaddr==1.3.0 +packaging==24.1 +paginate==0.5.6 +pathspec==0.12.1 +pillow==10.3.0 +pip==24.0 +pipdeptree==2.22.0 +platformdirs==4.2.2 +pluggy==1.5.0 +psutil==5.9.8 +pycparser==2.22 +pygments==2.18.0 +pylint==3.2.3 +pymdown-extensions==10.8.1 +pyproject-api==1.6.1 +pytest==8.2.2 +pytest-mock==3.14.0 +pytest-plus==0.7.0 +pytest-xdist==3.6.1 +python-dateutil==2.9.0.post0 +python-slugify==8.0.4 +pyyaml==6.0.1 +pyyaml-env-tag==0.1 +referencing==0.35.1 +regex==2024.5.15 +requests==2.32.3 +rich==13.7.1 +rpds-py==0.18.1 +ruamel-yaml==0.18.6 +six==1.16.0 +soupsieve==2.5 +subprocess-tee==0.4.1 +termcolor==2.4.0 +text-unidecode==1.3 +tinycss2==1.3.0 +tomli==2.0.1 +tomlkit==0.12.5 +tox==4.15.1 +types-jsonschema==4.22.0.20240610 +types-pyyaml==6.0.12.20240311 +typing-extensions==4.12.2 +urllib3==2.2.1 +virtualenv==20.26.2 +watchdog==4.0.1 +webencodings==0.5.1 +yamllint==1.35.1 +zipp==3.19.2 + +# The following packages are considered to be unsafe in a requirements file: +# resolvelib +# ruamel-yaml-clib +# wcmatch diff --git a/.config/dictionary.txt b/.config/dictionary.txt index 9f48d06..6065fc0 100644 --- a/.config/dictionary.txt +++ b/.config/dictionary.txt @@ -9,6 +9,7 @@ Chamoulaud DISTRO DOTGLOB ENVVAR +EPEL EPIPE # linux Fimport Jython @@ -43,6 +44,7 @@ apport argparsing argspecs arxcruz +audgirka auditd autobuild autoclass @@ -50,6 +52,7 @@ autodetected autodiscovery autodoc autofix +autohide autorefs autoupdate awcrosby @@ -160,6 +163,7 @@ hwcksum idempotency ignorelist importlib +indentless iniconfig inlinehilite insertafter @@ -188,6 +192,7 @@ levelname libbzip libera libyaml +licensedb lineinfile linenums linkcheck @@ -206,6 +211,7 @@ matchtasks matchvar matchyaml maxdepth +maxsplit minversion mkdir mkdocs @@ -258,6 +264,7 @@ pipx pkgcache # linux pkgs placefolder +plainexamples pluggy pluginmanager pmrun @@ -289,6 +296,7 @@ pyupgrade pyyaml redirections reexec +reformatter regexes releasenotes relpath @@ -311,6 +319,7 @@ ruleset runas sarif scalarint +scancode schemafile sdist sdists @@ -357,6 +366,7 @@ taskincludes taskshandlers templatevars templating +testcollection testinfra testmon testns @@ -370,6 +380,7 @@ tmpfs toctree toidentifier tomli +tomlsort toolset tripleo tuco @@ -384,6 +395,7 @@ unindented uninstallation unjinja unlex +unloadable unnormalized unskippable unspaced diff --git a/.config/requirements-docs.in b/.config/requirements-docs.in new file mode 100644 index 0000000..d6bef78 --- /dev/null +++ b/.config/requirements-docs.in @@ -0,0 +1,2 @@ +mkdocs-ansible>=0.2.0 # do not use lock extra because it would break dependabot updates +pipdeptree>=2.7.1 diff --git a/.config/requirements-docs.txt b/.config/requirements-docs.txt deleted file mode 100644 index 79ab067..0000000 --- a/.config/requirements-docs.txt +++ /dev/null @@ -1,2 +0,0 @@ -mkdocs-ansible[lock]>=0.1.6 -pipdeptree>=2.4.0 diff --git a/.config/requirements-lock.txt b/.config/requirements-lock.txt index 2249663..f545ecd 100644 --- a/.config/requirements-lock.txt +++ b/.config/requirements-lock.txt @@ -1,44 +1,42 @@ # -# This file is autogenerated by pip-compile with Python 3.9 +# This file is autogenerated by pip-compile with Python 3.10 # by the following command: # -# pip-compile --no-annotate --output-file=.config/requirements-lock.txt --resolver=backtracking --strip-extras --unsafe-package=resolvelib --unsafe-package=ruamel-yaml-clib pyproject.toml +# pip-compile --no-annotate --output-file=.config/requirements-lock.txt --strip-extras --unsafe-package=resolvelib --unsafe-package=ruamel-yaml-clib pyproject.toml # -ansible-compat==4.1.2 -ansible-core==2.15.1 -attrs==23.1.0 -black==23.3.0 -bracex==2.3.post1 -certifi==2023.5.7 -cffi==1.15.1 -charset-normalizer==3.1.0 -click==8.1.3 -cryptography==41.0.1 -filelock==3.12.2 -idna==3.4 -importlib-resources==5.0.7 -jinja2==3.1.2 -jsonschema==4.17.3 +ansible-compat==24.6.1 +ansible-core==2.17.0 +attrs==23.2.0 +black==24.4.2 +bracex==2.4 +cffi==1.16.0 +click==8.1.7 +cryptography==42.0.8 +filelock==3.15.1 +importlib-metadata==7.1.0 +jinja2==3.1.4 +jsonschema==4.22.0 +jsonschema-specifications==2023.12.1 markdown-it-py==3.0.0 -markupsafe==2.1.3 +markupsafe==2.1.5 mdurl==0.1.2 mypy-extensions==1.0.0 -packaging==23.1 -pathspec==0.11.1 -platformdirs==3.7.0 -pycparser==2.21 -pygments==2.15.1 -pyrsistent==0.19.3 -pyyaml==6.0 -requests==2.31.0 -rich==13.4.2 -ruamel-yaml==0.17.32 +packaging==24.1 +pathspec==0.12.1 +platformdirs==4.2.2 +pycparser==2.22 +pygments==2.18.0 +pyyaml==6.0.1 +referencing==0.35.1 +rich==13.7.1 +rpds-py==0.18.1 +ruamel-yaml==0.18.6 subprocess-tee==0.4.1 tomli==2.0.1 -typing-extensions==4.6.3 -urllib3==2.0.3 -wcmatch==8.4.1 -yamllint==1.32.0 +typing-extensions==4.12.2 +wcmatch==8.5.2 ; python_version < "3.12" +yamllint==1.35.1 +zipp==3.19.2 # The following packages are considered to be unsafe in a requirements file: # resolvelib diff --git a/.config/requirements-test.txt b/.config/requirements-test.in index 3838713..87b4dc0 100644 --- a/.config/requirements-test.txt +++ b/.config/requirements-test.in @@ -2,16 +2,17 @@ black # IDE support coverage-enable-subprocess # see https://github.com/nedbat/coveragepy/issues/1341#issuecomment-1228942657 coverage[toml] >= 6.4.4 jmespath +license-expression >= 30.3.0 # Apache 2.0 mypy # IDE support netaddr # needed by ipwrap filter psutil # soft-dep of pytest-xdist pylint # IDE support pytest >= 7.2.2 pytest-mock -pytest-plus >= 0.2 # for PYTEST_REQPASS +pytest-plus >= 0.6 # for PYTEST_REQPASS pytest-xdist >= 2.1.0 -ruamel.yaml>=0.17.31,<0.18 # only the latest is expected to pass our tests +ruamel.yaml>=0.17.31 ruamel-yaml-clib # needed for mypy -spdx-tools >= 0.7.1 # Apache +tox >= 4.0.0 types-jsonschema # IDE support types-pyyaml # IDE support diff --git a/.config/requirements.in b/.config/requirements.in index a8a24fb..3b3ca52 100644 --- a/.config/requirements.in +++ b/.config/requirements.in @@ -1,17 +1,18 @@ # Special order section for helping pip: will-not-work-on-windows-try-from-wsl-instead; platform_system=='Windows' -ansible-core>=2.12.0 # GPLv3 -ansible-compat>=4.0.5 # GPLv3 +ansible-core>=2.13.0 # GPLv3 +ansible-compat>=24.5.0dev0 # GPLv3 # alphabetically sorted: -black>=22.8.0 # MIT +black>=24.3.0 # MIT (security) filelock>=3.3.0 # The Unlicense +importlib-metadata # Apache jsonschema>=4.10.0 # MIT, version needed for improved errors packaging>=21.3 # Apache-2.0,BSD-2-Clause pathspec>=0.10.3 # Mozilla Public License 2.0 (MPL 2.0) pyyaml>=5.4.1 # MIT (centos 9 has 5.3.1) rich>=12.0.0 # MIT -ruamel.yaml>=0.17.0,<0.18,!=0.17.29,!=0.17.30 # MIT, next version is planned to have breaking changes -requests>=2.31.0 # Apache-2.0 (indirect, but we want newer version for security reasons) +ruamel.yaml>=0.18.5 # MIT subprocess-tee>=0.4.1 # MIT, used by ansible-compat yamllint >= 1.30.0 # GPLv3 -wcmatch>=8.1.2 # MIT +wcmatch>=8.1.2; python_version < '3.12' # MIT +wcmatch>=8.5.0; python_version >= '3.12' # MIT diff --git a/.config/requirements.txt b/.config/requirements.txt deleted file mode 100644 index 48edc14..0000000 --- a/.config/requirements.txt +++ /dev/null @@ -1,117 +0,0 @@ -# -# This file is autogenerated by pip-compile with Python 3.9 -# by the following command: -# -# pip-compile --extra=docs --extra=test --no-annotate --output-file=.config/requirements.txt --resolver=backtracking --strip-extras --unsafe-package=resolvelib --unsafe-package=ruamel-yaml-clib pyproject.toml -# -ansible-compat==4.1.2 -ansible-core==2.15.1 -astroid==2.15.5 -attrs==23.1.0 -beautifulsoup4==4.12.2 -black==23.3.0 -bracex==2.3.post1 -cairocffi==1.5.1 -cairosvg==2.7.0 -certifi==2023.5.7 -cffi==1.15.1 -charset-normalizer==3.1.0 -click==8.1.3 -colorama==0.4.6 -coverage==7.2.7 -coverage-enable-subprocess==1.0 -cryptography==41.0.1 -csscompressor==0.9.5 -cssselect2==0.7.0 -defusedxml==0.7.1 -dill==0.3.6 -exceptiongroup==1.1.1 -execnet==1.9.0 -filelock==3.12.2 -ghp-import==2.1.0 -griffe==0.29.0 -htmlmin2==0.1.13 -idna==3.4 -importlib-metadata==6.6.0 -importlib-resources==5.0.7 -iniconfig==2.0.0 -isodate==0.6.1 -isort==5.12.0 -jinja2==3.1.2 -jmespath==1.0.1 -jsmin==3.0.1 -jsonschema==4.17.3 -lazy-object-proxy==1.9.0 -markdown==3.3.7 -markdown-exec==1.6.0 -markdown-include==0.8.1 -markdown-it-py==3.0.0 -markupsafe==2.1.2 -mccabe==0.7.0 -mdurl==0.1.2 -mergedeep==1.3.4 -mkdocs==1.4.3 -mkdocs-ansible==0.1.6 -mkdocs-autorefs==0.4.1 -mkdocs-gen-files==0.5.0 -mkdocs-htmlproofer-plugin==0.13.1 -mkdocs-material==9.1.15 -mkdocs-material-extensions==1.1.1 -mkdocs-minify-plugin==0.6.4 -mkdocs-monorepo-plugin==1.0.5 -mkdocstrings==0.22.0 -mkdocstrings-python==1.1.0 -mypy==1.4.0 -mypy-extensions==1.0.0 -netaddr==0.8.0 -packaging==23.1 -pathspec==0.11.1 -pillow==9.5.0 -pipdeptree==2.7.1 -platformdirs==3.7.0 -pluggy==1.2.0 -ply==3.11 -psutil==5.9.5 -pycparser==2.21 -pygments==2.15.1 -pylint==2.17.4 -pymdown-extensions==10.0.1 -pyparsing==3.1.0 -pyrsistent==0.19.3 -pytest==7.3.2 -pytest-mock==3.11.1 -pytest-plus==0.4.0 -pytest-xdist==3.3.1 -python-dateutil==2.8.2 -python-slugify==8.0.1 -pyyaml==6.0 -pyyaml-env-tag==0.1 -rdflib==6.3.2 -regex==2023.5.5 -requests==2.31.0 -rich==13.4.2 -ruamel-yaml==0.17.32 -six==1.16.0 -soupsieve==2.4.1 -spdx-tools==0.7.1 -subprocess-tee==0.4.1 -text-unidecode==1.3 -tinycss2==1.2.1 -tomli==2.0.1 -tomlkit==0.11.8 -types-jsonschema==4.17.0.8 -types-pyyaml==6.0.12.10 -typing-extensions==4.6.2 -uritools==4.0.1 -urllib3==2.0.2 -watchdog==3.0.0 -wcmatch==8.4.1 -webencodings==0.5.1 -wrapt==1.15.0 -xmltodict==0.13.0 -yamllint==1.32.0 -zipp==3.15.0 - -# The following packages are considered to be unsafe in a requirements file: -# resolvelib -# ruamel-yaml-clib diff --git a/.git_archival.txt b/.git_archival.txt index 865f10b..72b3073 100644 --- a/.git_archival.txt +++ b/.git_archival.txt @@ -1,4 +1,4 @@ -node: 3293b64b939c0de16ef8cb81dd49255e475bf89a -node-date: 2023-06-22T14:08:20+01:00 -describe-name: v6.17.2 -ref-names: tag: v6.17.2 +node: b4018c22f8fe8371bd6845d0cd62cebea54ce012 +node-date: 2024-06-21T16:26:15+01:00 +describe-name: v24 +ref-names: HEAD -> main, tag: v24.6.1, tag: v24 diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 3d3aa8e..d1f5d6b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @ansible/devtools @ansible/ansible-lint-external-contributors +* @ansible/devtools diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 041a61a..8413eed 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -2,7 +2,7 @@ name: Bug report about: > Create a bug report. Ensure that it does reproduce on the main branch with - python >=3.9. For anything else, please use the discussion link below. + python >=3.10. For anything else, please use the discussion link below. labels: bug, new --- @@ -53,8 +53,8 @@ Possible security bugs should be reported via email to `security@ansible.com` <!--- Describe what happened. If possible run with extra verbosity (-vvvv) --> -Please give some details of what is happening. -Include a [minimum complete verifiable example] with: +Please give some details of what is happening. Include a [minimum complete +verifiable example] with: - minimized playbook to reproduce the error - the output of running ansible-lint including the command line used diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 6a4dae2..e371e48 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -9,7 +9,10 @@ updates: labels: - dependabot-deps-updates - skip-changelog - versioning-strategy: lockfile-only + groups: + dependencies: + patterns: + - "*" - package-ecosystem: "github-actions" directory: "/" schedule: diff --git a/.github/lower-constraints.txt b/.github/lower-constraints.txt new file mode 100644 index 0000000..e87af5c --- /dev/null +++ b/.github/lower-constraints.txt @@ -0,0 +1,19 @@ +# This file is kept in a different directory than .config in order to not be +# automatically updated by dependabot. This should be kept in sync with +# minimal requirements configured inside .config/requirements.in +ansible-core==2.13.0 +ansible-compat==24.5.1 # GPLv3 +black==24.3.0 # MIT (security) +filelock==3.3.0 # The Unlicense +jsonschema==4.10.0 # MIT, version needed for improved errors +packaging==21.3 +pathspec==0.10.3 +pyyaml==5.4.1 +rich==12.0.0 +ruamel.yaml==0.18.5 # MIT +subprocess-tee==0.4.1 # MIT, used by ansible-compat +# https://packages.ubuntu.com/noble/python3-wcmatch +# https://packages.fedoraproject.org/pkgs/python-wcmatch/python3-wcmatch/ +wcmatch==8.1.2; python_version < '3.12' # EPEL 8 +wcmatch==8.5.0; python_version >= '3.12' +yamllint == 1.30.0 diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml index 11fa614..b2c18a9 100644 --- a/.github/release-drafter.yml +++ b/.github/release-drafter.yml @@ -1,3 +1,3 @@ --- -# see https://github.com/ansible/devtools -_extends: ansible/devtools +# see https://github.com/ansible/team-devtools +_extends: ansible/team-devtools diff --git a/.github/workflows/ack.yml b/.github/workflows/ack.yml index 291eb88..60853af 100644 --- a/.github/workflows/ack.yml +++ b/.github/workflows/ack.yml @@ -7,4 +7,5 @@ name: ack jobs: ack: - uses: ansible/devtools/.github/workflows/ack.yml@main + uses: ansible/team-devtools/.github/workflows/ack.yml@main + secrets: inherit diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index 1debf04..751e431 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -10,4 +10,4 @@ name: push jobs: ack: - uses: ansible/devtools/.github/workflows/push.yml@main + uses: ansible/team-devtools/.github/workflows/push.yml@main diff --git a/.github/workflows/redirects.yml b/.github/workflows/redirects.yml index fcc5eea..a988f68 100644 --- a/.github/workflows/redirects.yml +++ b/.github/workflows/redirects.yml @@ -18,8 +18,8 @@ jobs: environment: release runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v4 + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 - name: Upgrade Python toolchain run: python3 -m pip install --upgrade pip setuptools wheel diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 317b5e1..d9adfb0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,5 +1,4 @@ --- -# cspell:ignore mislav name: release "on": @@ -10,11 +9,18 @@ name: release jobs: # https://github.com/marketplace/actions/actions-tagger actions-tagger: + needs: pypi # do not move the mobile tag until we publish runs-on: windows-latest + permissions: + # Give the default GITHUB_TOKEN write permission. + # https://github.blog/changelog/2023-02-02-github-actions-updating-the-default-github_token-permissions-to-read-only/ + contents: write steps: - uses: Actions-R-Us/actions-tagger@latest - env: - GITHUB_TOKEN: "${{ github.token }}" + with: + token: "${{ github.token }}" + # Do not activate latest tag because it seems to affect RTD builds + # publish_latest_tag: true pypi: name: Publish to PyPI registry environment: release @@ -28,50 +34,24 @@ jobs: TOXENV: pkg steps: - - name: Switch to using Python 3.9 by default - uses: actions/setup-python@v4 + - name: Switch to using Python 3.10 by default + uses: actions/setup-python@v5 with: - python-version: 3.9 + python-version: "3.10" - name: Install tox run: python3 -m pip install --user "tox>=4.0.0" - name: Check out src from Git - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 0 # needed by setuptools-scm submodules: true - name: Build dists - run: python -m tox + run: python3 -m tox - name: Publish to pypi.org if: >- # "create" workflows run separately from "push" & "pull_request" github.event_name == 'release' uses: pypa/gh-action-pypi-publish@release/v1 - - homebrew: - name: Bump homebrew formula - environment: release - runs-on: ubuntu-22.04 - needs: pypi - - env: - FORCE_COLOR: 1 - PY_COLORS: 1 - TOXENV: pkg - - steps: - - name: Check out src from Git - uses: actions/checkout@v3 - with: - fetch-depth: 0 # needed by setuptools-scm - submodules: true - - - name: Bump homebrew formula - uses: mislav/bump-homebrew-formula-action@v2.2 - with: - # A PR will be sent to github.com/Homebrew/homebrew-core to update this formula: - formula-name: ansible-lint - env: - COMMITTER_TOKEN: ${{ secrets.COMMITTER_TOKEN }} diff --git a/.github/workflows/tox.yml b/.github/workflows/tox.yml index 3220155..3321e37 100644 --- a/.github/workflows/tox.yml +++ b/.github/workflows/tox.yml @@ -18,8 +18,8 @@ env: PY_COLORS: 1 jobs: - pre: - name: pre + prepare: + name: prepare runs-on: ubuntu-22.04 outputs: matrix: ${{ steps.generate_matrix.outputs.matrix }} @@ -28,8 +28,9 @@ jobs: id: generate_matrix uses: coactions/dynamic-matrix@v1 with: - min_python: "3.9" - max_python: "3.11" + min_python: "3.10" + max_python: "3.12" + default_python: "3.10" other_names: | lint pkg @@ -37,12 +38,15 @@ jobs: docs schemas eco - py-devel + pre + py311-devel + py310-lower + py312-lower platforms: linux,macos test-action: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Self test for ansible-lint@${{ github.action_ref || 'main' }} uses: ./ with: @@ -52,14 +56,13 @@ jobs: name: ${{ matrix.name }} runs-on: ${{ matrix.os || 'ubuntu-22.04' }} needs: - - pre - - test-action + - prepare defaults: run: shell: ${{ matrix.shell || 'bash'}} strategy: fail-fast: false - matrix: ${{ fromJson(needs.pre.outputs.matrix) }} + matrix: ${{ fromJson(needs.prepare.outputs.matrix) }} # max-parallel: 5 # The matrix testing goal is to cover the *most likely* environments # which are expected to be used by users in production. Avoid adding a @@ -67,29 +70,17 @@ jobs: # proof that we failed to catch a bug by not running it. Using # distribution should be preferred instead of custom builds. env: - # vars safe to be passed to wsl: - WSLENV: FORCE_COLOR:PYTEST_REQPASS:TOXENV:GITHUB_STEP_SUMMARY # Number of expected test passes, safety measure for accidental skip of # tests. Update value if you add/remove tests. - PYTEST_REQPASS: 805 + PYTEST_REQPASS: 884 steps: - - name: Activate WSL1 - if: "contains(matrix.shell, 'wsl')" - uses: Vampire/setup-wsl@v2 - - - name: MacOS workaround for https://github.com/actions/virtual-environments/issues/1187 - if: ${{ matrix.os == 'macOS-latest' }} - run: | - sudo sysctl -w net.link.generic.system.hwcksum_tx=0 - sudo sysctl -w net.link.generic.system.hwcksum_rx=0 - - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 # needed by setuptools-scm submodules: true - name: Set pre-commit cache - uses: actions/cache@v3 + uses: actions/cache@v4 if: ${{ matrix.passed_name == 'lint' }} with: path: | @@ -97,7 +88,7 @@ jobs: key: pre-commit-${{ matrix.name || matrix.passed_name }}-${{ hashFiles('.pre-commit-config.yaml') }} - name: Set ansible cache(s) - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: | .cache/eco @@ -107,16 +98,16 @@ jobs: ~/.ansible/roles key: ${{ matrix.name || matrix.passed_name }}-${{ hashFiles('tools/test-eco.sh', 'requirements.yml', 'examples/playbooks/collections/requirements.yml') }} - - name: Set up Python ${{ matrix.python_version || '3.9' }} + - name: Set up Python ${{ matrix.python_version || '3.10' }} if: "!contains(matrix.shell, 'wsl')" - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: cache: pip - python-version: ${{ matrix.python_version || '3.9' }} + python-version: ${{ matrix.python_version || '3.10' }} - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v4 with: - node-version: 18 + node-version: 20 cache: "npm" cache-dependency-path: test/schemas/package-lock.json @@ -139,27 +130,14 @@ jobs: - name: tox -e ${{ matrix.passed_name }} run: python3 -m tox -e ${{ matrix.passed_name }} - - name: Combine coverage data - if: ${{ startsWith(matrix.passed_name, 'py') }} - # produce a single .coverage file at repo root - run: tox -e coverage - - - name: Upload coverage data - if: ${{ startsWith(matrix.passed_name, 'py') }} - uses: codecov/codecov-action@v3 - with: - name: ${{ matrix.passed_name }} - fail_ci_if_error: false # see https://github.com/codecov/codecov-action/issues/598 - token: ${{ secrets.CODECOV_TOKEN }} - verbose: true # optional (default = false) - - name: Archive logs - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: - name: logs.zip - path: .tox/**/log/ - # https://github.com/actions/upload-artifact/issues/123 - continue-on-error: true + name: logs-${{ matrix.name }}.zip + path: | + .tox/**/log/ + .tox/**/.coverage* + .tox/**/coverage.xml - name: Report failure if git reports dirty status run: | @@ -186,11 +164,11 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -201,17 +179,18 @@ jobs: # queries: security-extended,security-and-quality - name: Autobuild - uses: github/codeql-action/autobuild@v2 + uses: github/codeql-action/autobuild@v3 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + uses: github/codeql-action/analyze@v3 with: category: "/language:${{matrix.language}}" - check: # This job does nothing and is only used for the branch protection + check: if: always() permissions: - pull-requests: write # allow codenotify to comment on pull-request + id-token: write + checks: read needs: - build @@ -220,17 +199,56 @@ jobs: runs-on: ubuntu-latest steps: + # checkout needed for codecov action which needs codecov.yml file + - uses: actions/checkout@v4 + + - name: Set up Python # likely needed for coverage + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - run: pip3 install 'coverage>=7.5.1' + + - name: Merge logs into a single archive + uses: actions/upload-artifact/merge@v4 + with: + name: logs.zip + pattern: logs-*.zip + # artifacts like py312.zip and py312-macos do have overlapping files + separate-directories: true + + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + name: logs.zip + path: . + + - name: Check for expected number of coverage.xml reports + run: | + JOBS_PRODUCING_COVERAGE=8 + if [ "$(find . -name coverage.xml | wc -l | bc)" -ne "${JOBS_PRODUCING_COVERAGE}" ]; then + echo "::error::Number of coverage.xml files was not the expected one (${JOBS_PRODUCING_COVERAGE}): $(find . -name coverage.xml |xargs echo)" + exit 1 + fi + + - name: Upload coverage data + uses: codecov/codecov-action@v4 + with: + name: ${{ matrix.passed_name }} + # verbose: true # optional (default = false) + fail_ci_if_error: true + use_oidc: true # cspell:ignore oidc + + - name: Check codecov.io status + if: github.event_name == 'pull_request' + uses: coactions/codecov-status@main + - name: Decide whether the needed jobs succeeded or failed uses: re-actors/alls-green@release/v1 with: jobs: ${{ toJSON(needs) }} - - name: Check out src from Git - uses: actions/checkout@v3 - - - name: Notify repository owners about lint change affecting them - uses: sourcegraph/codenotify@v0.6.4 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # https://github.com/sourcegraph/codenotify/issues/19 - continue-on-error: true + - name: Delete Merged Artifacts + uses: actions/upload-artifact/merge@v4 + with: + delete-merged: true @@ -8,7 +8,6 @@ __pycache__ # Packages .Python -env/ build/ develop-eggs/ dist/ @@ -20,7 +19,6 @@ parts/ pip-wheel-metadata sdist/ var/ -venv/ *.egg-info/ .installed.cfg *.egg @@ -37,8 +35,14 @@ pip-log.txt # pyenv .python-version +# Environments +.env +.venv +env/ +venv/ + # Coverage artifacts -.coverage +.coverage* coverage*.xml pip-wheel-metadata .test-results/ @@ -54,6 +58,8 @@ src/ansiblelint/_version.py test/fixtures/formatting-before/ # prettier should not edit this due to forcibly extra-long lines examples/playbooks/vars/strings.transformed.yml +# prettier should not edit this due to intentionally spaced nesting +examples/playbooks/vars/transform_nested_data.yml # other .cache @@ -66,7 +72,11 @@ src/ansiblelint/_version.py test/eco/CODENOTIFY.html test/eco test/schemas/node_modules +test/local-content .envrc -collections +# collections +# !/collections site _readthedocs +*.tmp.* +coverage.lcov diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1174880..fa6297f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,7 +14,8 @@ ci: submodules: true exclude: > (?x)^( - .config/requirements.*| + .config/constraints.txt| + .config/.*requirements.*| .vscode/extensions.json| .vscode/settings.json| examples/broken/encoding.yml| @@ -32,9 +33,14 @@ repos: - repo: meta hooks: - id: check-useless-excludes + # https://github.com/pappasam/toml-sort/issues/69 + # - repo: https://github.com/pappasam/toml-sort + # rev: v0.23.1 + # hooks: + # - id: toml-sort-fix - repo: https://github.com/pre-commit/mirrors-prettier # keep it before yamllint - rev: v3.0.0-alpha.9-for-vscode + rev: v4.0.0-alpha.8 hooks: - id: prettier # Temporary excludes so we can gradually normalize the formatting @@ -44,6 +50,7 @@ repos: examples/other/some.j2.yaml| examples/playbooks/collections/.*| examples/playbooks/example.yml| + examples/playbooks/invalid-transform.yml| examples/playbooks/multiline-brackets.*| examples/playbooks/templates/not-valid.yaml| examples/playbooks/vars/empty.transformed.yml| @@ -59,22 +66,22 @@ repos: )$ always_run: true additional_dependencies: - - prettier - - prettier-plugin-toml - - prettier-plugin-sort-json + - prettier@3.2.4 + - prettier-plugin-toml@2.0.1 + - prettier-plugin-sort-json@3.1.0 - repo: https://github.com/streetsidesoftware/cspell-cli - rev: v6.31.0 + rev: v8.8.2 hooks: - id: cspell # entry: codespell --relative args: [--relative, --no-progress, --no-summary] name: Spell check with cspell - repo: https://github.com/python-jsonschema/check-jsonschema - rev: 0.23.2 + rev: 0.28.4 hooks: - id: check-github-workflows - repo: https://github.com/pre-commit/pre-commit-hooks.git - rev: v4.4.0 + rev: v4.6.0 hooks: - id: end-of-file-fixer # ignore formatting-prettier to have an accurate prettier comparison @@ -101,7 +108,7 @@ repos: - id: debug-statements language_version: python3 - repo: https://github.com/codespell-project/codespell - rev: v2.2.5 + rev: v2.3.0 hooks: - id: codespell exclude: > @@ -115,7 +122,7 @@ repos: additional_dependencies: - tomli - repo: https://github.com/adrienverge/yamllint.git - rev: v1.32.0 + rev: v1.35.1 hooks: - id: yamllint exclude: > @@ -129,79 +136,83 @@ repos: files: \.(yaml|yml)$ types: [file, yaml] entry: yamllint --strict - - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: "v0.0.274" + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: "v0.4.7" hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] - repo: https://github.com/psf/black - rev: 23.3.0 + rev: 24.4.2 hooks: - id: black language_version: python3 - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.4.0 + rev: v1.10.0 hooks: - id: mypy # empty args needed in order to match mypy cli behavior args: [--strict] additional_dependencies: - - ansible-compat>=4.1.2 + - ansible-compat>=24.5.1 - black>=22.10.0 - cryptography>=39.0.1 - filelock>=3.12.2 + - importlib_metadata - jinja2 + - license-expression >= 30.3.0 - pytest-mock - pytest>=7.2.2 - rich>=13.2.0 - - ruamel-yaml>=0.17.31 - - ruamel-yaml-clib>=0.2.7 - - spdx-tools>=0.7.1 + - ruamel-yaml-clib>=0.2.8 + - ruamel-yaml>=0.18.6 - subprocess-tee - types-PyYAML - - types-jsonschema>=4.4.2 + - types-jsonschema>=4.20.0.0 - types-pkg_resources - types-setuptools - wcmatch exclude: > (?x)^( + collections/.*| test/local-content/.*| plugins/.* )$ - repo: https://github.com/pycqa/pylint - rev: v3.0.0a6 + rev: v3.2.2 hooks: - id: pylint args: - --output-format=colorized additional_dependencies: - - ansible-compat>=4.1.2 + - ansible-compat>=24.5.1 - ansible-core>=2.14.0 - black>=22.10.0 - docutils - filelock>=3.12.2 - - jsonschema>=4.9.0 + - importlib_metadata + - jsonschema>=4.20.0 + - license-expression >= 30.3.0 - pytest-mock - pytest>=7.2.2 - pyyaml - rich>=13.2.0 - - ruamel-yaml>=0.17.31 - ruamel-yaml-clib>=0.2.7 - - spdx-tools>=0.7.1 + - ruamel-yaml>=0.18.2 + - setuptools # needed for pkg_resources import - typing_extensions - wcmatch - yamllint - repo: https://github.com/jazzband/pip-tools - rev: 6.13.0 + rev: 7.4.1 hooks: - id: pip-compile name: lock alias: lock always_run: true - entry: pip-compile --upgrade --resolver=backtracking -q --no-annotate --output-file=.config/requirements-lock.txt pyproject.toml --strip-extras --unsafe-package ruamel-yaml-clib --unsafe-package resolvelib - files: ^.config\/requirements.*$ + entry: pip-compile --upgrade --no-annotate --output-file=.config/requirements-lock.txt pyproject.toml --strip-extras --unsafe-package ruamel-yaml-clib --unsafe-package resolvelib + files: ^.config\/.*requirements.*$ language: python - language_version: "3.9" # minimal we support officially + language_version: "3.10" # minimal we support officially pass_filenames: false stages: [manual] additional_dependencies: @@ -210,22 +221,22 @@ repos: name: deps alias: deps always_run: true - entry: pip-compile --resolver=backtracking -q --no-annotate --output-file=.config/requirements.txt pyproject.toml --extra docs --extra test --strip-extras --unsafe-package ruamel-yaml-clib --unsafe-package resolvelib - files: ^.config\/requirements.*$ + entry: pip-compile --no-annotate --output-file=.config/constraints.txt pyproject.toml --all-extras --strip-extras --unsafe-package wcmatch --unsafe-package ruamel-yaml-clib --unsafe-package resolvelib + files: ^.config\/.*requirements.*$ language: python - language_version: "3.9" # minimal we support officially + language_version: "3.10" # minimal we support officially pass_filenames: false additional_dependencies: - pip>=22.3.1 - id: pip-compile - entry: pip-compile --resolver=backtracking -q --no-annotate --output-file=.config/requirements.txt pyproject.toml --extra docs --extra test --strip-extras --unsafe-package ruamel-yaml-clib --unsafe-package resolvelib --upgrade + entry: pip-compile -v --no-annotate --output-file=.config/constraints.txt pyproject.toml --all-extras --strip-extras --unsafe-package wcmatch --unsafe-package ruamel-yaml-clib --unsafe-package resolvelib --upgrade language: python always_run: true pass_filenames: false - files: ^.config\/requirements.*$ + files: ^.config\/.*requirements.*$ alias: up stages: [manual] - language_version: "3.9" # minimal we support officially + language_version: "3.10" # minimal we support officially additional_dependencies: - pip>=22.3.1 - # keep at bottom as these are slower diff --git a/.readthedocs.yml b/.readthedocs.yml index 8262c4e..6592c91 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -11,9 +11,8 @@ build: python: "3.11" commands: - pip install --user tox - - python3 -m tox -e docs -- --strict --site-dir=_readthedocs/html/ + - python3 -m tox -e docs python: - system_packages: false install: - method: pip path: tox diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 578d905..7955e45 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -4,7 +4,10 @@ "charliermarsh.ruff", "esbenp.prettier-vscode", "hbenl.vscode-test-explorer", + "ms-python.black-formatter", "ms-python.isort", + "ms-python.mypy-type-checker", + "ms-python.pylint", "ms-python.python", "ms-python.vscode-pylance", "ms-vscode.live-server", diff --git a/.vscode/settings.json b/.vscode/settings.json index d17cb5b..bbfb53d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -12,27 +12,20 @@ "build": true }, "git.ignoreLimitWarning": true, - "grammarly.domain": "technical", + "grammarly.config.documentDomain": "academic", "grammarly.files.include": [ "**/*.txt", "**/*.md" ], - "grammarly.hideUnavailablePremiumAlerts": true, - "grammarly.showExamples": true, "python.analysis.exclude": [ "build" ], - "python.formatting.provider": "black", - "python.linting.flake8Args": [ - "--ignore=E501,W503" - ], - "python.linting.flake8Enabled": false, - "python.linting.mypyCategorySeverity.error": "Warning", - "python.linting.mypyEnabled": true, - "python.linting.pylintEnabled": true, "python.terminal.activateEnvironment": true, "python.testing.pytestEnabled": true, "python.testing.unittestEnabled": false, + "mypy-type-checker.severity": { + "error": "Warning", + }, "sortLines.filterBlankLines": true, "yaml.completion": true, "yaml.customTags": [ @@ -44,8 +37,10 @@ "evenBetterToml.formatter.alignComments": false, "[python]": { "editor.codeActionsOnSave": { - "source.organizeImports": true, - "source.fixAll": true - } + "source.organizeImports": "explicit", + "source.fixAll": "explicit" + }, + "editor.defaultFormatter": "ms-python.black-formatter", + "editor.formatOnSave": true } } @@ -1,16 +1,25 @@ --- rules: + braces: + min-spaces-inside: 0 + max-spaces-inside: 1 comments: # prettier compatibility min-spaces-from-content: 1 + comments-indentation: false document-start: present: true + key-duplicates: + forbid-duplicated-merge-keys: true indentation: level: error indent-sequences: consistent octal-values: forbid-implicit-octal: true forbid-explicit-octal: true + # quoted-strings: + # quote-type: double + # required: only-when-needed ignore: | .tox examples/playbooks/example.yml @@ -1,7 +1,6 @@ [![PyPI version](https://img.shields.io/pypi/v/ansible-lint.svg)](https://pypi.org/project/ansible-lint) -[![Ansible-lint rules explanation](https://img.shields.io/badge/Ansible--lint-rules-blue.svg)](https://ansible-lint.readthedocs.io/rules/) +[![Ansible-lint rules explanation](https://img.shields.io/badge/Ansible--lint-rules-blue.svg)](https://ansible.readthedocs.io/projects/lint/rules/) [![Discussions](https://img.shields.io/badge/Discussions-gray.svg)](https://github.com/ansible/ansible-lint/discussions) -[![GitHub Actions CI/CD](https://github.com/ansible/ansible-lint/workflows/gh/badge.svg)](https://github.com/ansible/ansible-lint/actions?query=workflow%3Agh+branch%3Amain+event%3Apush) [![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white)](https://github.com/pre-commit/pre-commit) # Ansible-lint @@ -10,7 +9,7 @@ potentially be improved. As a community-backed project ansible-lint supports only the last two major versions of Ansible. -[Visit the Ansible Lint docs site](https://ansible-lint.readthedocs.io/) +[Visit the Ansible Lint docs site](https://ansible.readthedocs.io/projects/lint/) # Using ansible-lint as a GitHub Action @@ -22,14 +21,15 @@ install it yourself. name: ansible-lint on: pull_request: - branches: ["stable", "release/v*"] + branches: ["main", "stable", "release/v*"] jobs: build: name: Ansible Lint # Naming the build is important to use it as a status check runs-on: ubuntu-latest steps: + - uses: actions/checkout@v4 - name: Run ansible-lint - uses: ansible/ansible-lint-action@v6 + uses: ansible/ansible-lint@main # or version tag instead of 'main' ``` For more details, see [ansible-lint-action]. @@ -53,11 +53,11 @@ ansible-lint was created by [Will Thames] and is now maintained as part of the [Ansible] by [Red Hat] project. [ansible]: https://ansible.com -[contribution guidelines]: https://ansible-lint.readthedocs.io/contributing +[contribution guidelines]: https://ansible.readthedocs.io/projects/lint/contributing [gplv3]: https://github.com/ansible/ansible-lint/blob/main/COPYING [mit]: https://github.com/ansible/ansible-lint/blob/main/docs/licenses/LICENSE.mit.txt [red hat]: https://redhat.com [will thames]: https://github.com/willthames [ansible-lint-action]: - https://ansible-lint.readthedocs.io/installing/#installing-from-source-code + https://ansible.readthedocs.io/projects/lint/installing/#installing-from-source-code @@ -7,40 +7,65 @@ branding: color: red inputs: args: - description: Arguments to be passed to ansible-lint command + description: Arguments to be passed to ansible-lint command. + required: false + default: "" + setup_python: + description: If false, this action will not setup python and will instead rely on the already installed python. + required: false + default: true + working_directory: + description: The directory where to run ansible-lint from. Default is `github.workspace`. required: false default: "" runs: using: composite steps: - - uses: actions/checkout@v3 - with: - fetch-depth: 0 # needed by setuptools-scm - submodules: true + - name: Process inputs + id: inputs + shell: bash + run: | + if [[ -n "${{ inputs.working_directory }}" ]]; then + echo "working_directory=${{ inputs.working_directory }}" >> $GITHUB_OUTPUT + else + echo "working_directory=${{ github.workspace }}" >> $GITHUB_OUTPUT + fi - - name: Generate ansible-lint-requirements.txt + # Due to GHA limitation, caching works only for files within GITHUB_WORKSPACE + # folder, so we are forced to stick this temporary file inside .git, so it + # will not affect the linted repository. + # https://github.com/actions/toolkit/issues/1035 + # https://github.com/actions/setup-python/issues/361 + - name: Generate .git/ansible-lint-requirements.txt shell: bash + env: + GH_ACTION_REF: ${{ github.action_ref || 'main' }} + working-directory: ${{ steps.inputs.outputs.working_directory }} run: | - wget --output-file=$HOME/requirements.txt https://raw.githubusercontent.com/ansible/ansible-lint/${{ github.action_ref || 'main' }}/.config/requirements-lock.txt + wget --output-document=${{ steps.inputs.outputs.working_directory }}/.git/ansible-lint-requirements.txt https://raw.githubusercontent.com/ansible/ansible-lint/$GH_ACTION_REF/.config/requirements-lock.txt - name: Set up Python - uses: actions/setup-python@v4 + if: inputs.setup_python == 'true' + uses: actions/setup-python@v5 with: cache: pip - cache-dependency-path: ~/requirements.txt + cache-dependency-path: ${{ steps.inputs.outputs.working_directory }}/.git/ansible-lint-requirements.txt python-version: "3.11" - name: Install ansible-lint shell: bash + env: + GH_ACTION_REF: ${{ github.action_ref || 'main' }} # We need to set the version manually because $GITHUB_ACTION_PATH is not # a git clone and setuptools-scm would not be able to determine the version. # git+https://github.com/ansible/ansible-lint@${{ github.action_ref || 'main' }} # SETUPTOOLS_SCM_PRETEND_VERSION=${{ github.action_ref || 'main' }} run: | cd $GITHUB_ACTION_PATH - pip install "ansible-lint[lock] @ git+https://github.com/ansible/ansible-lint@${{ github.action_ref || 'main' }}" + pip install "ansible-lint[lock] @ git+https://github.com/ansible/ansible-lint@$GH_ACTION_REF" ansible-lint --version - name: Run ansible-lint shell: bash + working-directory: ${{ steps.inputs.outputs.working_directory }} run: ansible-lint ${{ inputs.args }} diff --git a/ansible.cfg b/ansible.cfg index b341954..3b5eeca 100644 --- a/ansible.cfg +++ b/ansible.cfg @@ -1,2 +1,2 @@ [defaults] -collections_path = examples/playbooks/collections +collections_path = collections:examples/playbooks/collections diff --git a/codecov.yml b/codecov.yml index fa66b52..ccb3a37 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1,10 +1,8 @@ --- -codecov: - require_ci_to_pass: true comment: false coverage: status: - patch: false + patch: true project: default: threshold: 0.5% diff --git a/collections/ansible_collections/local/testcollection/README.md b/collections/ansible_collections/local/testcollection/README.md new file mode 100644 index 0000000..6c38018 --- /dev/null +++ b/collections/ansible_collections/local/testcollection/README.md @@ -0,0 +1,3 @@ +# Ansible Collection - local.testcollection + +Documentation for the collection. diff --git a/collections/ansible_collections/local/testcollection/galaxy.yml b/collections/ansible_collections/local/testcollection/galaxy.yml new file mode 100644 index 0000000..ac9bb7e --- /dev/null +++ b/collections/ansible_collections/local/testcollection/galaxy.yml @@ -0,0 +1,7 @@ +--- +namespace: local +name: testcollection +version: 1.0.0 +readme: README.md +authors: + - your name <example@domain.com> diff --git a/collections/ansible_collections/local/testcollection/plugins/module_utils/__init__.py b/collections/ansible_collections/local/testcollection/plugins/module_utils/__init__.py new file mode 100644 index 0000000..9854911 --- /dev/null +++ b/collections/ansible_collections/local/testcollection/plugins/module_utils/__init__.py @@ -0,0 +1,4 @@ +"""module_utils package.""" + +# Some value that can be imported from a module +MY_STRING: str = "foo" diff --git a/examples/collection/CHANGELOG.rst b/collections/ansible_collections/local/testcollection/plugins/module_utils/py.typed index e69de29..e69de29 100644 --- a/examples/collection/CHANGELOG.rst +++ b/collections/ansible_collections/local/testcollection/plugins/module_utils/py.typed diff --git a/collections/ansible_collections/local/testcollection/plugins/modules/__init__.py b/collections/ansible_collections/local/testcollection/plugins/modules/__init__.py new file mode 100644 index 0000000..0d3a56a --- /dev/null +++ b/collections/ansible_collections/local/testcollection/plugins/modules/__init__.py @@ -0,0 +1 @@ +"""modules package.""" diff --git a/collections/ansible_collections/local/testcollection/plugins/modules/module_with_relative_import.py b/collections/ansible_collections/local/testcollection/plugins/modules/module_with_relative_import.py new file mode 100644 index 0000000..147a8d3 --- /dev/null +++ b/collections/ansible_collections/local/testcollection/plugins/modules/module_with_relative_import.py @@ -0,0 +1,25 @@ +"""module_with_relative_import module.""" + +from ansible.module_utils.basic import AnsibleModule + +# pylint: disable=E0402 +from ..module_utils import MY_STRING # noqa: TID252 # type: ignore[import-untyped] + +DOCUMENTATION = r""" +options: + name: + required: True +""" + + +def main() -> AnsibleModule: + """The main function.""" + return AnsibleModule( + argument_spec={ + "name": {"required": True, "aliases": [MY_STRING]}, + }, + ) + + +if __name__ == "__main__": + main() diff --git a/conftest.py b/conftest.py index 1704e46..e7eee8f 100644 --- a/conftest.py +++ b/conftest.py @@ -1,4 +1,5 @@ """PyTest Fixtures.""" + import importlib import os import platform @@ -14,10 +15,11 @@ if Path.cwd() != Path(__file__).parent: os.chdir(Path(__file__).parent) # checking if user is running pytest without installing test dependencies: -missing = [] -for module in ["ansible", "black", "mypy", "pylint"]: - if not importlib.util.find_spec(module): - missing.append(module) +missing = [ + module + for module in ["ansible", "black", "mypy", "pylint"] + if not importlib.util.find_spec(module) +] if missing: pytest.exit( reason=f"FATAL: Missing modules: {', '.join(missing)} -- probably you missed installing test requirements with: pip install -e '.[test]'", diff --git a/cspell.config.yaml b/cspell.config.yaml index fce0237..c80fd0b 100644 --- a/cspell.config.yaml +++ b/cspell.config.yaml @@ -12,7 +12,7 @@ dictionaries: ignorePaths: - cspell.config.yaml # The requirements file - - .config/requirements.txt + - .config/constraints.txt - docs/requirements.txt - docs/requirements.in # Test fixtures generated from outside diff --git a/docs/_autofix_rules.md b/docs/_autofix_rules.md new file mode 100644 index 0000000..15de3bb --- /dev/null +++ b/docs/_autofix_rules.md @@ -0,0 +1,11 @@ +- [command-instead-of-shell](rules/command-instead-of-shell.md) +- [deprecated-local-action](rules/deprecated-local-action.md) +- [fqcn](rules/fqcn.md) +- [jinja](rules/jinja.md) +- [key-order](rules/key-order.md) +- [name](rules/name.md) +- [no-free-form](rules/no-free-form.md) +- [no-jinja-when](rules/no-jinja-when.md) +- [no-log-password](rules/no-log-password.md) +- [partial-become](rules/partial-become.md) +- [yaml](rules/yaml.md) diff --git a/docs/autofix.md b/docs/autofix.md new file mode 100644 index 0000000..8daef8d --- /dev/null +++ b/docs/autofix.md @@ -0,0 +1,11 @@ +# Autofix + +Ansible-lint autofix can fix or simplify fixing issues identified by that rule. `ansible-lint --fix` will reformat YAML files and run transform for the given +rules. You can limit the effective rule transforms (the 'write_list') by passing +a keywords 'all' or 'none' or a comma separated list of rule ids or rule tags. +By default it will run all transforms (effectively `write_list: ["all"]`). +You can disable running transforms by setting `write_list: ["none"]`. Or only enable a subset of rule transforms by listing rules/tags here. + +Following is the list of supported rules covered under autofix functionality. + +{!_autofix_rules.md!} diff --git a/docs/configuring.md b/docs/configuring.md index e67bbbc..f61f1f7 100644 --- a/docs/configuring.md +++ b/docs/configuring.md @@ -5,10 +5,10 @@ You can ignore certain rules, enable `opt-in` rules, and control various other settings. Ansible-lint loads configuration from a file in the current working directory or -from a file that you specify in the command line. If you provide configuration -on both via a config file and on the command line, list values are merged (for -example `exclude_paths`) and **True** is preferred for boolean values like -`quiet`. +from a file that you specify in the command line. + +Any configuration option that is passed from the command line will override +the one specified inside the configuration file. ## Using local configuration files @@ -57,17 +57,54 @@ Keep in mind that this will override any existing file content. ## Pre-commit setup -To use Ansible-lint with [pre-commit], add the following to the +To use Ansible-lint with the [pre-commit] tool, add the following to the `.pre-commit-config.yaml` file in your local repository. +Do not confuse the [pre-commit] tool with the git hook feature that has the same name. +While the [pre-commit] tool can also make use of git hooks, it does not require +them and it does not install them by default. + +[pre-commit.ci] is a hosted service that can run pre-commit for you +on each change but you can also run the tool yourself using the CI of your choice. + Change **rev:** to either a commit sha or tag of Ansible-lint that contains `.pre-commit-hooks.yaml`. ```yaml +--- +ci: + # This section is specific to pre-commit.ci, telling it to create a pull request + # to update the linter version tag every month. + autoupdate_schedule: monthly + # If you have other Ansible collection dependencies (requirements.yml) + # `pre-commit.ci` will not be able to install them because it runs in offline mode, + # and you will need to tell it to skip the hook. + # skip: + # - ansible-lint +repos: - repo: https://github.com/ansible/ansible-lint rev: ... # put latest release tag from https://github.com/ansible/ansible-lint/releases/ hooks: - id: ansible-lint + # Uncomment if you need the full Ansible community bundle instead of ansible-core: + # additional_dependencies: + # - ansible ``` +!!! warning + + [pre-commit] always uses python virtual environments. If you happen to + use the [Ansible package] instead of just [ansible-core] you might be surprised + to see that pre-commit is not able to find these collections, even if + your local Ansible does. This is because they are installed inside a location + that is not available to the virtual environment in which pre-commit hook + is installed. In this case, you might want to uncomment the commented lines + from the hook definition above, so the bundle will be installed. + + You should note that collection installed into `~/.ansible` are found by + the hook. + [pre-commit]: https://pre-commit.com/ +[Ansible package]: https://pypi.org/project/ansible/ +[ansible-core]: https://pypi.org/project/ansible-core/ +[pre-commit.ci]: https://pre-commit.ci/ diff --git a/docs/contributing.md b/docs/contributing.md index 3e8e41d..fc7cd15 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -8,8 +8,13 @@ After [creating your fork on GitHub], you can do: ```shell-session $ git clone --recursive git@github.com:your-name/ansible-lint $ cd ansible-lint +$ # Recommended: Initialize and activate a Python virtual environment +$ pip install --upgrade pip +$ pip install -e .[test] # Install testing dependencies +$ tox run -e lint,pkg,docs,py # Ensure subset of tox tests work in clean checkout $ git checkout -b your-branch-name # DO SOME CODING HERE +$ tox run -e lint,pkg,docs,py # Ensure subset of tox tests work with your changes $ git add your new files $ git commit -v $ git push origin your-branch-name @@ -31,8 +36,6 @@ released. Automated tests will be run against all PRs, to run checks locally before pushing commits, just use [tox](https://tox.wiki/en/latest/). -% DO-NOT-REMOVE-deps-snippet-PLACEHOLDER - ## Talk to us Use Github [discussions] forum or for a live chat experience try diff --git a/docs/installing.md b/docs/installing.md index 6008de7..d8d6586 100644 --- a/docs/installing.md +++ b/docs/installing.md @@ -1,3 +1,8 @@ +--- +# YAML header +render_macros: true +--- + # Installing Install Ansible-lint to apply rules and follow best practices with your @@ -13,9 +18,9 @@ automation content. this document. Before raising any bugs related to installation, review all of the following details: - - You should use installation methods outlined in this document only. + - You should use the installation methods outlined in this document only. - You should upgrade the Python installer (`pip` or `pipx`) to the latest - version available from pypi.org. If you used a system package manager, you + version available from pypi.org. If you use a system package manager, you will need to upgrade the installer to a newer version. - If you are installing from a git zip archive, which is not supported but should work, ensure you use the main branch and the latest version of pip and @@ -27,20 +32,23 @@ automation content. [discussion](https://github.com/ansible/ansible-lint/discussions/2820#discussioncomment-4400380). Pull requests to improve installation instructions are welcome. Any new issues - related to installation will be closed and locked. + related to the installation will be closed and locked. For a container image, we recommend using -[creator-ee](https://github.com/ansible/creator-ee/), which includes -Ansible-lint. If you have a use case that the `creator-ee` container doesn't -satisfy, please contact the team through the -[discussions](https://github.com/ansible/ansible-lint/discussions) forum. +[creator-ee](https://github.com/ansible/creator-ee/) which includes +`ansible-dev-tools` (it combines critical Ansible development packages into a +unified Python package). If you have a use case that the `creator-ee` container +doesn't satisfy, please contact the team through the +[discussion](https://github.com/ansible/ansible-lint/discussions) forum. You can also run Ansible-lint on your source code with the -[Ansible-lint GitHub action](https://github.com/marketplace/actions/ansible-lint) +[Ansible-lint GitHub action](https://github.com/marketplace/actions/run-ansible-lint) instead of installing it directly. ## Installing the latest version +{{ install_from_adt("ansible-lint") }} + You can install the most recent version of Ansible-lint with the [pip3] or [pipx] Python package manager. Use [pipx] to isolate Ansible-lint from your current Python environment as an alternative to creating a virtual environment. @@ -50,6 +58,16 @@ current Python environment as an alternative to creating a virtual environment. pip3 install ansible-lint ``` +!!! note + + If you want to install the exact versions of all dependencies that were used to + test a specific version of ansible-lint, you can add `lock` extra. This will + only work with Python 3.10 or newer. Do this only inside a virtual environment. + + ```bash + pip3 install "ansible-lint[lock]" + ``` + ## Installing on Fedora and RHEL You can install Ansible-lint on Fedora, or Red Hat Enterprise Linux (RHEL) with @@ -95,16 +113,17 @@ jobs: name: Ansible Lint # Naming the build is important to use it as a status check runs-on: ubuntu-latest steps: + - uses: actions/checkout@v4 - name: Run ansible-lint uses: ansible/ansible-lint@v6 ``` Due to limitations on how GitHub Actions are processing arguments, we do not plan to provide extra options. You will have to make use of -[ansible-lint own configuration file](https://ansible-lint.readthedocs.io/configuring/) -for altering its behavior. +[ansible-lint own configuration file](https://ansible.readthedocs.io/projects/lint/configuring/) +to alter its behavior. -To also enable [dependabot][dependabot] automatic updates the newer versions of +To also enable [dependabot][dependabot] automatic updates, the newer versions of ansible-lint action you should create a file similar to [.github/dependabot.yml][.github/dependabot.yml] diff --git a/docs/philosophy.md b/docs/philosophy.md index 2ef698d..da85bba 100644 --- a/docs/philosophy.md +++ b/docs/philosophy.md @@ -93,7 +93,7 @@ only when a major version is released. Not really. The certification process is likely to use only a subset of rules. At this time, we are working on building that list. -### Why lots of official Ansible docs examples are not passing linting? +### Why do many official Ansible docs examples fail to pass linting? Most of the official examples are written to exemplify specific features, and some might conflict with our rules. Still, we plan to include linting of diff --git a/docs/rules/complexity.md b/docs/rules/complexity.md new file mode 120000 index 0000000..6ab4e69 --- /dev/null +++ b/docs/rules/complexity.md @@ -0,0 +1 @@ +../../src/ansiblelint/rules/complexity.md
\ No newline at end of file diff --git a/docs/rules/index.md b/docs/rules/index.md index 4f4dc3d..43ac967 100644 --- a/docs/rules/index.md +++ b/docs/rules/index.md @@ -2,6 +2,7 @@ - [args][] - [avoid-implicit][] +- [complexity][] - [command-instead-of-module][] - [command-instead-of-shell][] - [deprecated-bare-vars][] diff --git a/docs/usage.md b/docs/usage.md index dbe115d..da059a1 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -43,11 +43,11 @@ repositories. ## Gradual adoption For an easier gradual adoption, adopters should consider [ignore -file][configuring.md#ignoring-rules-for-entire-files] feature. This allows the -quick introduction of a linter pipeline for preventing addition of new -violations, while known violations are ignored. Some people can work on -addressing these historical violations while others may continue to work on -other maintenance tasks. +file][ignoring-rules-for-entire-files] feature. This allows the quick +introduction of a linter pipeline for preventing the addition of new violations, +while known violations are ignored. Some people can work on addressing these +historical violations while others may continue to work on other maintenance +tasks. The deprecated `--progressive` mode was removed in v6.16.0 as it added code complexity and performance overhead. It also presented several corner cases @@ -124,7 +124,7 @@ ansible-lint --offline -q -f codeclimate examples/playbooks/norole.yml ``` Historically `-f json` was used to generate Code Climate JSON reports but in -never versions we switched its meaning point SARIF JSON format instead. +newer versions we switched its meaning point SARIF JSON format instead. !!! warning @@ -222,18 +222,12 @@ argument or add it to `skip_list` in your configuration. The least preferred method of skipping rules is to skip all task-based rules for a task, which does not skip line-based rules. You can use the -`skip_ansible_lint` tag with all tasks or the `warn` parameter with the -_command_ or _shell_ modules, for example: +`skip_ansible_lint` tag with all tasks, for example: ```yaml - name: This would typically fire no-free-form command: warn=no chmod 644 X -- name: This would typically fire command-instead-of-module - command: git pull --rebase - args: - warn: false - - name: This would typically fire git-latest git: src=/path/to/git/repo dest=checkout tags: diff --git a/examples/collection/meta/runtime.yml b/examples/.collection/CHANGELOG.rst index e69de29..e69de29 100644 --- a/examples/collection/meta/runtime.yml +++ b/examples/.collection/CHANGELOG.rst diff --git a/examples/collection/galaxy.yml b/examples/.collection/galaxy.yml index d21efb2..d21efb2 100644 --- a/examples/collection/galaxy.yml +++ b/examples/.collection/galaxy.yml diff --git a/examples/no_changelog/meta/runtime.yml b/examples/.collection/meta/runtime.yml index e69de29..e69de29 100644 --- a/examples/no_changelog/meta/runtime.yml +++ b/examples/.collection/meta/runtime.yml diff --git a/examples/collection/plugins/modules/alpha.py b/examples/.collection/plugins/modules/alpha.py index c806cad..a508a05 100644 --- a/examples/collection/plugins/modules/alpha.py +++ b/examples/.collection/plugins/modules/alpha.py @@ -1,6 +1,5 @@ """An ansible test module.""" - DOCUMENTATION = """ module: mod_1 author: diff --git a/examples/collection/plugins/modules/deep/beta.py b/examples/.collection/plugins/modules/deep/beta.py index ffd9ff8..3b60edc 100644 --- a/examples/collection/plugins/modules/deep/beta.py +++ b/examples/.collection/plugins/modules/deep/beta.py @@ -1,6 +1,5 @@ """An ansible test module.""" - DOCUMENTATION = """ module: mod_2 author: diff --git a/examples/.github/workflows/sample.yml b/examples/.github/workflows/sample.yml new file mode 100644 index 0000000..71b2975 --- /dev/null +++ b/examples/.github/workflows/sample.yml @@ -0,0 +1,9 @@ +--- +name: ack +on: # <-- ansible-lint should not complain or touch about this 'on' + pull_request_target: + types: [opened] + +jobs: + ack: + uses: ansible/team-devtools/.github/workflows/ack.yml@main diff --git a/examples/.invalid_dependencies/CHANGELOG.rst b/examples/.invalid_dependencies/CHANGELOG.rst new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/examples/.invalid_dependencies/CHANGELOG.rst diff --git a/examples/.invalid_dependencies/galaxy.yml b/examples/.invalid_dependencies/galaxy.yml new file mode 100644 index 0000000..c3cbeda --- /dev/null +++ b/examples/.invalid_dependencies/galaxy.yml @@ -0,0 +1,18 @@ +--- +name: foo +namespace: bar +version: 0.0.0 # noqa: galaxy[version-incorrect] +authors: + - John +readme: ../README.md +description: "..." +dependencies: + other_namespace.collection1: ">=1.0.0" + other_namespace.collection2: ">=2.0.0,<3.0.0" + anderson55.my_collection: "*" # note: "*" selects the highest version available + foo.my_collection1: " " # note: this should error out because of invalid dependency version + foo.my_collection2: "" # note: this should error out because of invalid dependency version +license: + - Apache-2.0 +repository: some-url +tags: [networking, test_tag] diff --git a/examples/.invalid_dependencies/meta/runtime.yml b/examples/.invalid_dependencies/meta/runtime.yml new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/examples/.invalid_dependencies/meta/runtime.yml diff --git a/examples/no_changelog/galaxy.yml b/examples/.no_changelog/galaxy.yml index 2c35693..2c35693 100644 --- a/examples/no_changelog/galaxy.yml +++ b/examples/.no_changelog/galaxy.yml diff --git a/examples/.no_changelog/meta/runtime.yml b/examples/.no_changelog/meta/runtime.yml new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/examples/.no_changelog/meta/runtime.yml diff --git a/examples/no_collection_version/changelogs/changelog.yaml b/examples/.no_collection_version/changelogs/changelog.yaml index 52e7f38..52e7f38 100644 --- a/examples/no_collection_version/changelogs/changelog.yaml +++ b/examples/.no_collection_version/changelogs/changelog.yaml diff --git a/examples/no_collection_version/galaxy.yml b/examples/.no_collection_version/galaxy.yml index 95d9d18..95d9d18 100644 --- a/examples/no_collection_version/galaxy.yml +++ b/examples/.no_collection_version/galaxy.yml diff --git a/examples/.test_collection/.ansible-lint b/examples/.test_collection/.ansible-lint new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/examples/.test_collection/.ansible-lint @@ -0,0 +1 @@ +{} diff --git a/examples/test_collection/README.md b/examples/.test_collection/README.md index b4aaea4..b4aaea4 100644 --- a/examples/test_collection/README.md +++ b/examples/.test_collection/README.md diff --git a/examples/test_collection/galaxy.yml b/examples/.test_collection/galaxy.yml index 633719b..633719b 100644 --- a/examples/test_collection/galaxy.yml +++ b/examples/.test_collection/galaxy.yml diff --git a/examples/test_collection/roles/my_role/tasks/main.yml b/examples/.test_collection/roles/my_role/tasks/main.yml index 784a814..784a814 100644 --- a/examples/test_collection/roles/my_role/tasks/main.yml +++ b/examples/.test_collection/roles/my_role/tasks/main.yml diff --git a/examples/test_collection/roles/my_role2/tasks/main.yml b/examples/.test_collection/roles/my_role2/tasks/main.yml index 27954a5..27954a5 100644 --- a/examples/test_collection/roles/my_role2/tasks/main.yml +++ b/examples/.test_collection/roles/my_role2/tasks/main.yml diff --git a/examples/broken_supported_ansible_also/.ansible-lint b/examples/broken_supported_ansible_also/.ansible-lint new file mode 100644 index 0000000..7897948 --- /dev/null +++ b/examples/broken_supported_ansible_also/.ansible-lint @@ -0,0 +1,2 @@ +# Invalid supported_ansible_also type +supported_ansible_also: True diff --git a/examples/meta/changelogs/changelog.yml b/examples/meta/changelogs/changelog.yml new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/examples/meta/changelogs/changelog.yml diff --git a/examples/meta_runtime_version_checks/fail_2/meta/runtime.yml b/examples/meta_runtime_version_checks/fail_2/meta/runtime.yml index 92db835..a574d0a 100644 --- a/examples/meta_runtime_version_checks/fail_2/meta/runtime.yml +++ b/examples/meta_runtime_version_checks/fail_2/meta/runtime.yml @@ -1,2 +1,2 @@ --- -requires_ansible: "2.13.0,<2.15" +requires_ansible: "2.15.0,<2.16" diff --git a/examples/meta_runtime_version_checks/pass/meta/runtime.yml b/examples/meta_runtime_version_checks/pass/meta/runtime.yml deleted file mode 100644 index 2b69758..0000000 --- a/examples/meta_runtime_version_checks/pass/meta/runtime.yml +++ /dev/null @@ -1,2 +0,0 @@ ---- -requires_ansible: ">=2.13.0,<2.15" diff --git a/examples/meta_runtime_version_checks/pass_0/meta/runtime.yml b/examples/meta_runtime_version_checks/pass_0/meta/runtime.yml new file mode 100644 index 0000000..9257a8c --- /dev/null +++ b/examples/meta_runtime_version_checks/pass_0/meta/runtime.yml @@ -0,0 +1,2 @@ +--- +requires_ansible: ">=2.15.0,<2.17.0" diff --git a/examples/meta_runtime_version_checks/pass_1/meta/runtime.yml b/examples/meta_runtime_version_checks/pass_1/meta/runtime.yml new file mode 100644 index 0000000..460bbaf --- /dev/null +++ b/examples/meta_runtime_version_checks/pass_1/meta/runtime.yml @@ -0,0 +1,2 @@ +--- +requires_ansible: ">=2.9.10" diff --git a/examples/playbooks/4114/transform-with-missing-role-and-modules.transformed.yml b/examples/playbooks/4114/transform-with-missing-role-and-modules.transformed.yml new file mode 100644 index 0000000..10ae898 --- /dev/null +++ b/examples/playbooks/4114/transform-with-missing-role-and-modules.transformed.yml @@ -0,0 +1,13 @@ +--- +- name: Reproducer for bug 4114 + hosts: localhost + roles: + - this_role_is_missing + tasks: + - name: Task referring to a missing module + this_module_does_not_exist: + foo: bar + + - name: Use raw to echo + ansible.builtin.debug: # <-- this should be converted to fqcn + msg: some message! diff --git a/examples/playbooks/4114/transform-with-missing-role-and-modules.yml b/examples/playbooks/4114/transform-with-missing-role-and-modules.yml new file mode 100644 index 0000000..c166dd5 --- /dev/null +++ b/examples/playbooks/4114/transform-with-missing-role-and-modules.yml @@ -0,0 +1,13 @@ +--- +- name: Reproducer for bug 4114 + hosts: localhost + roles: + - this_role_is_missing + tasks: + - name: Task referring to a missing module + this_module_does_not_exist: + foo: bar + + - name: Use raw to echo + debug: # <-- this should be converted to fqcn + msg: some message! diff --git a/examples/playbooks/action_plugins/some_action.py b/examples/playbooks/action_plugins/some_action.py new file mode 100644 index 0000000..1dc01aa --- /dev/null +++ b/examples/playbooks/action_plugins/some_action.py @@ -0,0 +1,13 @@ +"""Sample action_plugin.""" + +from ansible.plugins.action import ActionBase + + +class ActionModule(ActionBase): # type: ignore[misc] + """Sample module.""" + + def run(self, tmp=None, task_vars=None): # type: ignore[no-untyped-def] + """.""" + super().run(tmp, task_vars) + ret = {"foo": "bar"} + return {"ansible_facts": ret} diff --git a/examples/playbooks/adj_action.yml b/examples/playbooks/adj_action.yml new file mode 100644 index 0000000..4c78a2b --- /dev/null +++ b/examples/playbooks/adj_action.yml @@ -0,0 +1,10 @@ +--- +- name: Fixture for testing adjacent plugins + hosts: localhost + tasks: + - name: Call adjacent action plugin + some_action: {} + + - name: Call adjacent filter plugin + ansible.builtin.debug: + msg: "{{ 'foo' | some_filter }}" diff --git a/examples/playbooks/blockincludes.yml b/examples/playbooks/blockincludes.yml index b8387a8..31317a7 100644 --- a/examples/playbooks/blockincludes.yml +++ b/examples/playbooks/blockincludes.yml @@ -14,7 +14,7 @@ - name: Block level 3 block: - name: Include under block level 3 # noqa: deprecated-module - ansible.builtin.include: "{{ varset }}.yml" + ansible.builtin.include_tasks: "{{ varset }}.yml" - name: Block level 4 block: - name: INCLUDE under block level 4 diff --git a/examples/playbooks/common-include-1.yml b/examples/playbooks/common-include-1.yml index 3a4691f..9885d61 100644 --- a/examples/playbooks/common-include-1.yml +++ b/examples/playbooks/common-include-1.yml @@ -8,3 +8,5 @@ - name: Some include_tasks with file and jinja2 ansible.builtin.include_tasks: file: "{{ 'tasks/included-with-lint.yml' }}" + - name: Some include 3 + ansible.builtin.include_tasks: file=tasks/included-with-lint.yml diff --git a/examples/playbooks/common-include-wrong-syntax.yml b/examples/playbooks/common-include-wrong-syntax.yml new file mode 100644 index 0000000..c59b41b --- /dev/null +++ b/examples/playbooks/common-include-wrong-syntax.yml @@ -0,0 +1,9 @@ +--- +- name: Fixture for test coverage + hosts: localhost + gather_facts: false + tasks: + - name: Some include with invalid syntax + ansible.builtin.include_tasks: "file=" + - name: Some include with invalid syntax + ansible.builtin.include_tasks: other=tasks/included-with-lint.yml diff --git a/examples/playbooks/common-include-wrong-syntax2.yml b/examples/playbooks/common-include-wrong-syntax2.yml new file mode 100644 index 0000000..a4891c8 --- /dev/null +++ b/examples/playbooks/common-include-wrong-syntax2.yml @@ -0,0 +1,8 @@ +--- +- name: Fixture for test coverage + hosts: localhost + gather_facts: false + tasks: + - name: Some include with invalid syntax + ansible.builtin.include_tasks: + file: null diff --git a/examples/playbooks/common-include-wrong-syntax3.yml b/examples/playbooks/common-include-wrong-syntax3.yml new file mode 100644 index 0000000..21bba1e --- /dev/null +++ b/examples/playbooks/common-include-wrong-syntax3.yml @@ -0,0 +1,7 @@ +--- +- name: Fixture + hosts: localhost + tasks: + - name: Fixture + ansible.builtin.include_role: + name: include_wrong_syntax diff --git a/examples/playbooks/conflicting_action2.yml b/examples/playbooks/conflicting_action2.yml new file mode 100644 index 0000000..380857d --- /dev/null +++ b/examples/playbooks/conflicting_action2.yml @@ -0,0 +1,9 @@ +--- +- hosts: localhost + gather_facts: false + tasks: + - block: + include_role: + tasks_from: ghe-config-apply.yml + tags: + - github diff --git a/examples/playbooks/example.yml b/examples/playbooks/example.yml index fa1a635..14f7927 100644 --- a/examples/playbooks/example.yml +++ b/examples/playbooks/example.yml @@ -36,8 +36,8 @@ - git # yamllint wrong indentation - bobbins - - name: Yum latest - ansible.builtin.yum: state=latest name=httpd + - name: Dnf latest + ansible.builtin.dnf: state=latest name=httpd - ansible.builtin.debug: msg="debug task without a name" diff --git a/examples/playbooks/filter_plugins/some_filter.py b/examples/playbooks/filter_plugins/some_filter.py new file mode 100644 index 0000000..86ebda8 --- /dev/null +++ b/examples/playbooks/filter_plugins/some_filter.py @@ -0,0 +1,13 @@ +"""Sample adjacent filter plugin.""" + +from __future__ import annotations + + +class FilterModule: # pylint: disable=too-few-public-methods + """Ansible filters.""" + + def filters(self): # type: ignore[no-untyped-def] + """Return list of exposed filters.""" + return { + "some_filter": str, + } diff --git a/examples/playbooks/handlers/empty.yml b/examples/playbooks/handlers/empty.yml new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/examples/playbooks/handlers/empty.yml diff --git a/examples/playbooks/include.yml b/examples/playbooks/include.yml index 5596728..57fe58e 100644 --- a/examples/playbooks/include.yml +++ b/examples/playbooks/include.yml @@ -11,6 +11,7 @@ tasks: - ansible.builtin.include_tasks: tasks/x.yml - ansible.builtin.include_tasks: tasks/x.yml y=z + - ansible.builtin.include_tasks: file=tasks/x.yml handlers: - ansible.builtin.include_tasks: handlers/y.yml diff --git a/examples/playbooks/incorrect_module_args.yml b/examples/playbooks/incorrect_module_args.yml new file mode 100644 index 0000000..9e4dde6 --- /dev/null +++ b/examples/playbooks/incorrect_module_args.yml @@ -0,0 +1,7 @@ +--- +- name: Demonstrate linting issue. + hosts: all + tasks: + - name: Include a role with the wrong syntax + ansible.builtin.include_role: + role: foo diff --git a/examples/playbooks/invalid-transform.yml b/examples/playbooks/invalid-transform.yml new file mode 100644 index 0000000..3a1d50a --- /dev/null +++ b/examples/playbooks/invalid-transform.yml @@ -0,0 +1,11 @@ +# yamllint disable-file +--- +- name: Test + hosts: localhost + gather_facts: false + + tasks: + - name: Print hello message + ansible.builtin.debug: + msg: "Hello!" + register: vm_output diff --git a/examples/playbooks/module_relative_import.yml b/examples/playbooks/module_relative_import.yml new file mode 100644 index 0000000..8857966 --- /dev/null +++ b/examples/playbooks/module_relative_import.yml @@ -0,0 +1,6 @@ +--- +- name: Module relative import + hosts: localhost + tasks: + - name: Module with relative import + local.testcollection.module_with_relative_import: {} diff --git a/examples/playbooks/multi_yaml_doc.transformed.yml b/examples/playbooks/multi_yaml_doc.transformed.yml new file mode 100644 index 0000000..ab1e02f --- /dev/null +++ b/examples/playbooks/multi_yaml_doc.transformed.yml @@ -0,0 +1,23 @@ +--- +- name: First problematic play + hosts: localhost + tasks: + - name: Echo a message + ansible.builtin.shell: echo hello # <-- command-instead-of-shell + changed_when: false +--- +- name: second problematic play # <-- name[casing] + hosts: localhost + tasks: + - name: Remove file (delete file) + ansible.builtin.file: + path: /etc/foo.txt + state: absent +--- +- name: Third problematic play + hosts: localhost + tasks: + - name: Remove file (delete file) + file: # <-- fqcn[action-core] + path: /etc/foo.txt + state: absent diff --git a/examples/playbooks/multi_yaml_doc.yml b/examples/playbooks/multi_yaml_doc.yml new file mode 100644 index 0000000..ab1e02f --- /dev/null +++ b/examples/playbooks/multi_yaml_doc.yml @@ -0,0 +1,23 @@ +--- +- name: First problematic play + hosts: localhost + tasks: + - name: Echo a message + ansible.builtin.shell: echo hello # <-- command-instead-of-shell + changed_when: false +--- +- name: second problematic play # <-- name[casing] + hosts: localhost + tasks: + - name: Remove file (delete file) + ansible.builtin.file: + path: /etc/foo.txt + state: absent +--- +- name: Third problematic play + hosts: localhost + tasks: + - name: Remove file (delete file) + file: # <-- fqcn[action-core] + path: /etc/foo.txt + state: absent diff --git a/examples/playbooks/name-case.transformed.yml b/examples/playbooks/name-case.transformed.yml index 03b8c46..906a237 100644 --- a/examples/playbooks/name-case.transformed.yml +++ b/examples/playbooks/name-case.transformed.yml @@ -1,4 +1,33 @@ --- - name: This lacks a capitalization hosts: localhost - tasks: [] + tasks: + - name: Task that always changes + ansible.builtin.debug: + msg: I always change! + changed_when: true + notify: My handler + + - name: Task with notify as list + ansible.builtin.debug: + msg: I always change! + changed_when: true + notify: + - my handler 1 + - My handler + - my handler 2 + + - name: Task without notify + ansible.builtin.debug: + msg: I always change! + changed_when: true + + handlers: + - name: My handler + ansible.builtin.debug: + msg: I never run :( + + - name: Test task for listen + ansible.builtin.debug: + msg: I never run :( + listen: My handler diff --git a/examples/playbooks/name-case.yml b/examples/playbooks/name-case.yml index 5480d2c..62d7b56 100644 --- a/examples/playbooks/name-case.yml +++ b/examples/playbooks/name-case.yml @@ -1,4 +1,33 @@ --- - name: this lacks a capitalization hosts: localhost - tasks: [] + tasks: + - name: Task that always changes + ansible.builtin.debug: + msg: I always change! + changed_when: true + notify: my handler + + - name: Task with notify as list + ansible.builtin.debug: + msg: I always change! + changed_when: true + notify: + - my handler 1 + - my handler + - my handler 2 + + - name: Task without notify + ansible.builtin.debug: + msg: I always change! + changed_when: true + + handlers: + - name: my handler + ansible.builtin.debug: + msg: I never run :( + + - name: Test task for listen + ansible.builtin.debug: + msg: I never run :( + listen: "my handler" diff --git a/examples/playbooks/no_handler_pass.yml b/examples/playbooks/no_handler_pass.yml index 5c44891..ea6d61d 100644 --- a/examples/playbooks/no_handler_pass.yml +++ b/examples/playbooks/no_handler_pass.yml @@ -82,3 +82,14 @@ ansible.builtin.debug: msg: why isn't this a handler when: result | changed + + handlers: + # If this task would have being under 'tasks:' it should have triggered + # the rule, but under 'handlers:' it should not. + - name: Reproduce bug 3646 + loop: "{{ _something_done.results }}" + loop_control: + label: "{{ item.item.name }}" + when: item.changed + ansible.builtin.debug: + msg: "{{ item.item.name }} changed" diff --git a/examples/playbooks/nodeps.yml b/examples/playbooks/nodeps.yml new file mode 100644 index 0000000..0ca1aa3 --- /dev/null +++ b/examples/playbooks/nodeps.yml @@ -0,0 +1,6 @@ +--- +- name: Example + hosts: localhost + tasks: + - name: Calling a module that is not installed + a.b.c: {} diff --git a/examples/playbooks/nodeps2.yml b/examples/playbooks/nodeps2.yml new file mode 100644 index 0000000..fc784d0 --- /dev/null +++ b/examples/playbooks/nodeps2.yml @@ -0,0 +1,7 @@ +--- +- name: Fixture for nodeps with missing filter + hosts: localhost + tasks: + - name: Calling a module that is not installed + ansible.builtin.debug: + msg: "{{ foo | missing_filter }}" diff --git a/examples/playbooks/package-check-failure.yml b/examples/playbooks/package-check-failure.yml index 393b52b..69182f3 100644 --- a/examples/playbooks/package-check-failure.yml +++ b/examples/playbooks/package-check-failure.yml @@ -19,3 +19,10 @@ name: sudo state: latest update_only: false + + - name: Install ansible with only_upgrade to false + ansible.builtin.apt: + name: sudo + state: latest + upgrade: true + only_upgrade: false diff --git a/examples/playbooks/package-check-success.yml b/examples/playbooks/package-check-success.yml index a513d5d..a9e8435 100644 --- a/examples/playbooks/package-check-success.yml +++ b/examples/playbooks/package-check-success.yml @@ -20,3 +20,10 @@ name: sudo state: latest update_only: true + + - name: Upgrade ansible + ansible.builtin.apt: + name: sudo + state: latest + upgrade: true + only_upgrade: true diff --git a/examples/playbooks/removed-include.yml b/examples/playbooks/removed-include.yml new file mode 100644 index 0000000..4f0ba58 --- /dev/null +++ b/examples/playbooks/removed-include.yml @@ -0,0 +1,6 @@ +--- +- name: Invalid playbook + hosts: localhost + tasks: + - name: Foo + include: tasks/simple_task.yml # <-- include was removed in 2.16 diff --git a/examples/playbooks/role_vars_prefix_detection.yml b/examples/playbooks/role_vars_prefix_detection.yml new file mode 100644 index 0000000..fee163f --- /dev/null +++ b/examples/playbooks/role_vars_prefix_detection.yml @@ -0,0 +1,53 @@ +--- +- name: Test role-prefix + hosts: localhost + connection: local + roles: + - role_vars_prefix_detection + + - role: role_vars_prefix_detection + var1: val1 + + - role: role_vars_prefix_detection + var1: val1 + become: true + vars: + var2: val2 + + - role: role_vars_prefix_detection + become: true + environment: + FOO: /bar/barr + role_vars_prefix_detection_var1: val1 + + - role: role_vars_prefix_detection + vars: + var1: val1 + + - role: role_vars_prefix_detection + become: true + environment: + BAR: /baz + vars: + var1: val1 + + - role: role_vars_prefix_detection + become: true + environment: + BAR: /baz + vars: + role_vars_prefix_detection_var1: val1 + tasks: + - name: Include1 + ansible.builtin.include_role: + name: role_vars_prefix_detection + vars: + var1: val1 + + - name: Include2 + ansible.builtin.include_role: + name: role_vars_prefix_detection + vars: + role_vars_prefix_detection_var1: val1 + _role_vars_prefix_detection_var2: val2 + __role_vars_prefix_detection_var3: val3 diff --git a/examples/playbooks/rule-command-instead-of-module-pass.yml b/examples/playbooks/rule-command-instead-of-module-pass.yml index c0a26e9..2fbc5c2 100644 --- a/examples/playbooks/rule-command-instead-of-module-pass.yml +++ b/examples/playbooks/rule-command-instead-of-module-pass.yml @@ -5,9 +5,11 @@ - name: Print current git branch ansible.builtin.command: git branch changed_when: false + - name: Print git log ansible.builtin.command: git log changed_when: false + - name: Install git lfs support ansible.builtin.command: git lfs install changed_when: false @@ -20,6 +22,10 @@ ansible.builtin.command: systemctl show-environment changed_when: false + - name: Get systemd runlevel + ansible.builtin.command: systemctl get-default + changed_when: false + - name: Set systemd runlevel ansible.builtin.command: systemctl set-default multi-user.target changed_when: false @@ -35,3 +41,11 @@ - name: Clear yum cache ansible.builtin.command: "" changed_when: false + + - name: Print yum history + ansible.builtin.command: yum history + changed_when: false + + - name: Print yum info + ansible.builtin.command: yum info bash + changed_when: false diff --git a/examples/playbooks/rule-complexity-fail.yml b/examples/playbooks/rule-complexity-fail.yml new file mode 100644 index 0000000..0eb68bf --- /dev/null +++ b/examples/playbooks/rule-complexity-fail.yml @@ -0,0 +1,42 @@ +--- +# no of tasks required are 5 and since there are 6 tasks it will give an error +- name: Test Fixture complexity rule + hosts: all + tasks: + - name: Task 1 + ansible.builtin.debug: + msg: "This is task 1" + + - name: Task 2 + ansible.builtin.debug: + msg: "This is task 2" + + - name: Task 3 + ansible.builtin.debug: + msg: "This is task 3" + + - name: Task 4 + ansible.builtin.debug: + msg: "This is task 4" + + - name: Task 5 + ansible.builtin.debug: + msg: "This is task 5" + + - name: Task 6 + ansible.builtin.debug: + msg: "This is task 6" + + - name: Block Task 7 + block: + - name: 2nd level block + block: + - name: 3rd level block + block: + - name: 4th level block + block: + - name: 5th level block + block: + - name: Nested Task 1 + ansible.builtin.debug: + msg: "This is nested task 1" diff --git a/examples/playbooks/rule-complexity-pass.yml b/examples/playbooks/rule-complexity-pass.yml new file mode 100644 index 0000000..27ec7f4 --- /dev/null +++ b/examples/playbooks/rule-complexity-pass.yml @@ -0,0 +1,35 @@ +--- +- name: Test fixture complexity rule + hosts: all + tasks: + - name: Task 1 + ansible.builtin.debug: + msg: "This is task 1" + + - name: Task 2 + ansible.builtin.debug: + msg: "This is task 2" + + - name: Task 3 + ansible.builtin.debug: + msg: "This is task 3" + + - name: Task 4 + ansible.builtin.debug: + msg: "This is task 4" + + - name: Task 5 + block: + - name: Include under block level 1 + ansible.builtin.debug: + msg: "This is nested block" + - name: Block level 2 + block: + - name: Include under block level 2 + ansible.builtin.debug: + msg: "This is block 2" + - name: Block level 3 + block: + - name: INCLUDE under block level 3 + ansible.builtin.debug: + msg: "This is block 3" diff --git a/examples/playbooks/rule-deprecated-bare-vars-fail.yml b/examples/playbooks/rule-deprecated-bare-vars-fail.yml index 7091f46..a7efeea 100644 --- a/examples/playbooks/rule-deprecated-bare-vars-fail.yml +++ b/examples/playbooks/rule-deprecated-bare-vars-fail.yml @@ -39,12 +39,6 @@ msg: "{{ item }}" with_dict: my_dict - ### Testing with_dict with a default empty dictionary - - name: Use with_dict loop using variable and default - ansible.builtin.debug: - msg: "{{ item.key }} - {{ item.value }}" - with_dict: uwsgi_ini | default({}) - - name: Use with_nested loop using bare variable ansible.builtin.debug: msg: "{{ item.0 }} {{ item.1 }}" diff --git a/examples/playbooks/rule-deprecated-bare-vars-pass.yml b/examples/playbooks/rule-deprecated-bare-vars-pass.yml index c7e6521..fe3ca1d 100644 --- a/examples/playbooks/rule-deprecated-bare-vars-pass.yml +++ b/examples/playbooks/rule-deprecated-bare-vars-pass.yml @@ -166,3 +166,12 @@ with_items: >- {%- set ns = [1, 1, 2] -%} {{- ns.keys | unique -}} + + - name: Reproduce bug 3646 + ansible.builtin.file: + path: "{{ item.path }}" + state: directory + mode: "{{ item.mode }}" + with_community.general.filetree: + - "../templates/SpaceVim.d/" + when: item.state == "directory" and ".git" not in item.path diff --git a/examples/playbooks/rule-jinja-before.transformed.yml b/examples/playbooks/rule-jinja-before.transformed.yml new file mode 100644 index 0000000..ac6a81c --- /dev/null +++ b/examples/playbooks/rule-jinja-before.transformed.yml @@ -0,0 +1,9 @@ +--- +# https://github.com/ansible/ansible-lint/issues/3739 +- name: Reproducer bug 3739 + hosts: all + tasks: + - name: Generate keypair + community.crypto.openssh_keypair: + path: "{{ env.path }}" + when: ( env.path is not none ) diff --git a/examples/playbooks/rule-jinja-before.yml b/examples/playbooks/rule-jinja-before.yml new file mode 100644 index 0000000..355de8a --- /dev/null +++ b/examples/playbooks/rule-jinja-before.yml @@ -0,0 +1,9 @@ +--- +# https://github.com/ansible/ansible-lint/issues/3739 +- name: Reproducer bug 3739 + hosts: all + tasks: + - name: Generate keypair + community.crypto.openssh_keypair: + path: "{{env.path}}" + when: ( env.path is not none ) diff --git a/examples/playbooks/rule-jinja-pass.yml b/examples/playbooks/rule-jinja-pass.yml index cbdfee6..6944611 100644 --- a/examples/playbooks/rule-jinja-pass.yml +++ b/examples/playbooks/rule-jinja-pass.yml @@ -29,6 +29,9 @@ - name: Bug https://github.com/ansible/ansible-lint/issues/3048 ansible.builtin.set_fact: x: "{{ y.json | community.general.json_query(edition.version) }}" + - name: Bug https://github.com/ansible/ansible-lint/issues/3769 + ansible.builtin.debug: + msg: "{{ 65534 | ansible.builtin.random(seed=inventory_hostname) }}" # https://github.com/ansible/ansible-lint/issues/2697 - name: Test linter @@ -81,3 +84,7 @@ - name: "Bug https://github.com/ansible/ansible-lint/issues/3155" ansible.builtin.debug: msg: "Is changed:{{ date_cmd is changed }}" + + - name: Bug https://github.com/ansible/ansible-lint/issues/3908 + ansible.builtin.debug: + msg: "{{ foo | ansible.builtin.mandatory(msg='My message') }}" diff --git a/examples/playbooks/rule-no-free-form-fail.yml b/examples/playbooks/rule-no-free-form-fail.yml index 8360608..dea98b8 100644 --- a/examples/playbooks/rule-no-free-form-fail.yml +++ b/examples/playbooks/rule-no-free-form-fail.yml @@ -5,9 +5,11 @@ - name: Create a placefolder file ansible.builtin.command: chdir=/tmp touch foo # <-- don't use shorthand changed_when: false + - name: Use raw to echo ansible.builtin.raw: executable=/bin/bash echo foo # <-- don't use executable= changed_when: false + - name: Testing anything else passed to raw except for string ansible.builtin.raw: args: "123" diff --git a/examples/playbooks/rule-no-tabs.yml b/examples/playbooks/rule-no-tabs.yml index 4621096..3078e22 100644 --- a/examples/playbooks/rule-no-tabs.yml +++ b/examples/playbooks/rule-no-tabs.yml @@ -16,5 +16,22 @@ - name: Should not trigger no-tabs rules # noqa fqcn lineinfile: path: some.txt - regexp: ^\t$ + regexp: "^\t$" line: string with \t inside + # Disabled as attempt to mock it would trigger an error validating its arguments + # - name: Should not trigger no-tabs rules # noqa fqcn + # win_lineinfile: + # path: some.txt + # regexp: "^\t$" + # line: string with \t inside + - name: Should not trigger no-tabs rules + community.windows.win_lineinfile: + path: some.txt + regexp: "^\t$" + line: string with \t inside + - name: Should not trigger inside jinja + vars: + deep: + "some{{ '\t' }}stuff": true + ansible.builtin.debug: + msg: "{{ 'foo' + '\t' + 'bar' }}" diff --git a/examples/playbooks/rule-partial-become-without-become-fail.yml b/examples/playbooks/rule-partial-become-without-become-fail.yml index da48b2f..80b633d 100644 --- a/examples/playbooks/rule-partial-become-without-become-fail.yml +++ b/examples/playbooks/rule-partial-become-without-become-fail.yml @@ -1,28 +1,27 @@ --- -- hosts: localhost - name: Use of become_user without become play +- name: Use of become_user without become at play level + hosts: localhost become_user: root tasks: - - ansible.builtin.debug: + - name: A task without issues + ansible.builtin.debug: msg: hello -- hosts: localhost - +- name: Use of become_user without become at task level + hosts: localhost tasks: - name: Use of become_user without become task ansible.builtin.command: whoami become_user: postgres changed_when: false -- hosts: localhost - +- name: Use of become_user without become at task level + hosts: localhost tasks: - name: A block with become and become_user on different tasks block: - name: Sample become - become: true - ansible.builtin.command: whoami - - name: Sample become_user - become_user: postgres ansible.builtin.command: whoami + become_user: true + changed_when: false diff --git a/examples/playbooks/rule-partial-become-without-become-pass.yml b/examples/playbooks/rule-partial-become-without-become-pass.yml index e1ae189..c01b141 100644 --- a/examples/playbooks/rule-partial-become-without-become-pass.yml +++ b/examples/playbooks/rule-partial-become-without-become-pass.yml @@ -1,14 +1,16 @@ --- -- hosts: localhost +- name: Test play + hosts: localhost become_user: root become: true tasks: - - ansible.builtin.debug: + - name: Debug + ansible.builtin.debug: msg: hello -- hosts: localhost - +- name: Test play + hosts: localhost tasks: - name: Foo ansible.builtin.command: whoami @@ -16,20 +18,22 @@ become: true changed_when: false -- hosts: localhost - become: true +- name: Test play + hosts: localhost tasks: - name: Accepts a become from higher scope ansible.builtin.command: whoami - become_user: postgres changed_when: false -- hosts: localhost +- name: Test play + hosts: localhost become_user: postgres + become: true tasks: - name: Accepts a become from a lower scope ansible.builtin.command: whoami become: true + become_user: root changed_when: false diff --git a/examples/playbooks/skiptasks.yml b/examples/playbooks/skiptasks.yml index e105ed3..004eb07 100644 --- a/examples/playbooks/skiptasks.yml +++ b/examples/playbooks/skiptasks.yml @@ -37,20 +37,16 @@ - name: Test latest[git] (don't warn) ansible.builtin.command: git log - args: - warn: false changed_when: false - name: Test latest[hg] (don't warn) ansible.builtin.command: chmod 644 A args: - warn: false creates: B - name: Test latest[hg] (warn) ansible.builtin.command: chmod 644 A args: - warn: true creates: B - name: Test latest[git] (don't warn single line) diff --git a/examples/playbooks/tasks/local_action.transformed.yml b/examples/playbooks/tasks/local_action.transformed.yml new file mode 100644 index 0000000..51e2ec1 --- /dev/null +++ b/examples/playbooks/tasks/local_action.transformed.yml @@ -0,0 +1,4 @@ +--- +- name: Sample + ansible.builtin.command: echo 123 + delegate_to: localhost diff --git a/examples/playbooks/tasks/local_action.yml b/examples/playbooks/tasks/local_action.yml new file mode 100644 index 0000000..a4f7a99 --- /dev/null +++ b/examples/playbooks/tasks/local_action.yml @@ -0,0 +1,3 @@ +--- +- name: Sample + local_action: command echo 123 diff --git a/examples/playbooks/tasks/main.yml b/examples/playbooks/tasks/main.yml new file mode 100644 index 0000000..b44604b --- /dev/null +++ b/examples/playbooks/tasks/main.yml @@ -0,0 +1,7 @@ +--- +- name: This is correct + ansible.builtin.assert: + that: true +- name: A phony prefix | This is also correct + ansible.builtin.assert: + that: true diff --git a/examples/playbooks/tasks/partial_become.yml/main.yml b/examples/playbooks/tasks/partial_become.yml/main.yml new file mode 100644 index 0000000..c7f1980 --- /dev/null +++ b/examples/playbooks/tasks/partial_become.yml/main.yml @@ -0,0 +1,4 @@ +--- +- name: Included with partial become + ansible.builtin.debug: + msg: Included with partial become diff --git a/examples/playbooks/tasks/partial_prefix/foo.yml b/examples/playbooks/tasks/partial_prefix/foo.yml new file mode 100644 index 0000000..5dfb8e9 --- /dev/null +++ b/examples/playbooks/tasks/partial_prefix/foo.yml @@ -0,0 +1,10 @@ +--- +- name: foo | This prefix is incomplete + ansible.builtin.assert: + that: true +- name: partial_prefix | This prefix is incomplete + ansible.builtin.assert: + that: true +- name: partial_prefix | foo | This is correct + ansible.builtin.assert: + that: true diff --git a/examples/playbooks/tasks/partial_prefix/main.yml b/examples/playbooks/tasks/partial_prefix/main.yml new file mode 100644 index 0000000..5c141a8 --- /dev/null +++ b/examples/playbooks/tasks/partial_prefix/main.yml @@ -0,0 +1,10 @@ +--- +- name: partial_prefix | main | This is correct + ansible.builtin.assert: + that: true +- name: main | This prefix is incomplete + ansible.builtin.assert: + that: true +- name: partial_prefix | This prefix is incomplete + ansible.builtin.assert: + that: true diff --git a/examples/playbooks/test-include.yml b/examples/playbooks/test-include.yml new file mode 100644 index 0000000..952e820 --- /dev/null +++ b/examples/playbooks/test-include.yml @@ -0,0 +1,31 @@ +--- +- name: Fixture for testing various includes/imports + hosts: localhost + gather_facts: false + + pre_tasks: + - name: Include 1 + ansible.builtin.include_tasks: tasks/main.yml + + roles: + - test_nop + - { role: test_nop, test_nop_arg1: true } + + tasks: + - name: Include 2 + ansible.builtin.include_tasks: tasks/main.yml + - name: Include 3 + ansible.builtin.include_tasks: tasks/main.yml + - name: Include 4 + ansible.builtin.include_tasks: file=tasks/main.yml + - name: Include 4 + ansible.builtin.import_tasks: file=tasks/main.yml + + handlers: + - name: Include 5 + ansible.builtin.include_tasks: handlers/empty.yml + - name: Include 5 + ansible.builtin.import_tasks: handlers/empty.yml + +- name: Include 6 + ansible.builtin.import_playbook: valid.yml diff --git a/examples/playbooks/test_import_playbook.yml b/examples/playbooks/test_import_playbook.yml new file mode 100644 index 0000000..690950a --- /dev/null +++ b/examples/playbooks/test_import_playbook.yml @@ -0,0 +1,5 @@ +--- +- name: Fixture 1 for bug 4024 + import_playbook: community.molecule.validate.yml +- name: Fixture 2 for bug 4024 + ansible.builtin.import_playbook: community.molecule.validate.yml diff --git a/examples/playbooks/test_import_playbook_invalid.yml b/examples/playbooks/test_import_playbook_invalid.yml new file mode 100644 index 0000000..7bac521 --- /dev/null +++ b/examples/playbooks/test_import_playbook_invalid.yml @@ -0,0 +1,7 @@ +--- +- name: Fixture 3 - not supported (invalid syntax) + ansible.builtin.import_playbook: + file: community.molecule.validate.yml +- name: Fixture 4 - not supported (invalid syntax) + ansible.builtin.import_playbook: + other: community.molecule.validate.yml diff --git a/examples/playbooks/test_skip_inside_yaml.yml b/examples/playbooks/test_skip_inside_yaml.yml index 1f72954..88c396a 100644 --- a/examples/playbooks/test_skip_inside_yaml.yml +++ b/examples/playbooks/test_skip_inside_yaml.yml @@ -44,9 +44,9 @@ - name: Test no-free-form # <-- 3 no-free-form ansible.builtin.command: creates=B chmod 644 A # noqa: no-free-form - name: Test no-free-form # <-- 4 no-free-form - ansible.builtin.command: warn=yes creates=B chmod 644 A # noqa: no-free-form + ansible.builtin.command: creates=B chmod 644 A # noqa: no-free-form - name: Test no-free-form (skipped via no warn) - ansible.builtin.command: warn=no creates=B chmod 644 A # noqa: no-free-form + ansible.builtin.command: creates=B chmod 644 A # noqa: no-free-form - name: Test no-free-form (skipped via skip_ansible_lint) ansible.builtin.command: creates=B chmod 644 A # noqa: no-free-form tags: diff --git a/examples/playbooks/transform-block-indentation-indicator.transformed.yml b/examples/playbooks/transform-block-indentation-indicator.transformed.yml new file mode 100644 index 0000000..e74beff --- /dev/null +++ b/examples/playbooks/transform-block-indentation-indicator.transformed.yml @@ -0,0 +1,10 @@ +--- +- name: Demo + hosts: all + tasks: + - name: Demo + ansible.builtin.debug: + msg: |2 + multi + line + message diff --git a/examples/playbooks/transform-block-indentation-indicator.yml b/examples/playbooks/transform-block-indentation-indicator.yml new file mode 100644 index 0000000..7e9c817 --- /dev/null +++ b/examples/playbooks/transform-block-indentation-indicator.yml @@ -0,0 +1,10 @@ +--- +- name: Demo + hosts: all + tasks: + - name: Demo + ansible.builtin.debug: + msg: |3 + multi + line + message diff --git a/examples/playbooks/transform-deprecated-local-action.transformed.yml b/examples/playbooks/transform-deprecated-local-action.transformed.yml new file mode 100644 index 0000000..5ea7747 --- /dev/null +++ b/examples/playbooks/transform-deprecated-local-action.transformed.yml @@ -0,0 +1,7 @@ +--- +- name: Fixture for deprecated-local-action + hosts: localhost + tasks: + - name: Task example + ansible.builtin.debug: + delegate_to: localhost diff --git a/examples/playbooks/transform-deprecated-local-action.yml b/examples/playbooks/transform-deprecated-local-action.yml new file mode 100644 index 0000000..c8eeb11 --- /dev/null +++ b/examples/playbooks/transform-deprecated-local-action.yml @@ -0,0 +1,7 @@ +--- +- name: Fixture for deprecated-local-action + hosts: localhost + tasks: + - name: Task example + local_action: + module: ansible.builtin.debug diff --git a/examples/playbooks/transform-jinja.transformed.yml b/examples/playbooks/transform-jinja.transformed.yml new file mode 100644 index 0000000..a89dad0 --- /dev/null +++ b/examples/playbooks/transform-jinja.transformed.yml @@ -0,0 +1,40 @@ +--- +- name: Fixture + hosts: localhost + vars: + my_list: + - foo + - bar + tasks: + - name: A block used to check that we do not identify error at correct level + block: + - name: Foo # <-- this is valid jinja2 + ansible.builtin.debug: + foo: "{{ 1 }}" # <-- jinja2[spacing] + msg: "{{ 'a' b }}" # <-- jinja2[invalid] + + - name: A block used to check that we do not identify error at correct level + block: + - name: Foo # <-- this is valid jinja2 + ansible.builtin.debug: + msg: "{{ item }}" # <-- jinja2[spacing] + with_items: + - "{{ items }}" + + - name: Confirm a deeply nested duplicate error is corrected + ansible.builtin.set_fact: + fact: + dict: + dict: + list: + - one + - two + - dict: + fix: "{{ 'VALUE_1' | lower }}" # <-- jinja2[spacing] + - dict: + fix: "{{ 'VALUE_1' | lower }}" # <-- jinja2[spacing] + - dict: + fix: "{{ 'VALUE_2' | lower }}" # <-- jinja2[spacing] + +# It should be noted that even ansible --syntax-check fails to spot the jinja +# error above, but ansible will throw a runtime error when running diff --git a/examples/playbooks/transform-jinja.yml b/examples/playbooks/transform-jinja.yml new file mode 100644 index 0000000..4a4cd32 --- /dev/null +++ b/examples/playbooks/transform-jinja.yml @@ -0,0 +1,40 @@ +--- +- name: Fixture + hosts: localhost + vars: + my_list: + - foo + - bar + tasks: + - name: A block used to check that we do not identify error at correct level + block: + - name: Foo # <-- this is valid jinja2 + ansible.builtin.debug: + foo: "{{ 1 }}" # <-- jinja2[spacing] + msg: "{{ 'a' b }}" # <-- jinja2[invalid] + + - name: A block used to check that we do not identify error at correct level + block: + - name: Foo # <-- this is valid jinja2 + ansible.builtin.debug: + msg: "{{ item }}" # <-- jinja2[spacing] + with_items: + - "{{ items }}" + + - name: Confirm a deeply nested duplicate error is corrected + ansible.builtin.set_fact: + fact: + dict: + dict: + list: + - one + - two + - dict: + fix: "{{'VALUE_1'|lower}}" # <-- jinja2[spacing] + - dict: + fix: "{{'VALUE_1'|lower}}" # <-- jinja2[spacing] + - dict: + fix: "{{'VALUE_2'|lower}}" # <-- jinja2[spacing] + +# It should be noted that even ansible --syntax-check fails to spot the jinja +# error above, but ansible will throw a runtime error when running diff --git a/examples/playbooks/transform-key-order-block.transformed.yml b/examples/playbooks/transform-key-order-block.transformed.yml new file mode 100644 index 0000000..0f1ca12 --- /dev/null +++ b/examples/playbooks/transform-key-order-block.transformed.yml @@ -0,0 +1,20 @@ +--- +- name: Testing multiple plays in a playbook + hosts: localhost + tasks: + - name: First block + when: true + block: + - name: Display a message + ansible.builtin.debug: + msg: Hello world! + +- name: A second play + hosts: localhost + tasks: + - name: Second block + when: true # <-- name key should be the second one + block: + - name: Display a message + ansible.builtin.debug: + msg: Hello world! diff --git a/examples/playbooks/transform-key-order-block.yml b/examples/playbooks/transform-key-order-block.yml new file mode 100644 index 0000000..12a171e --- /dev/null +++ b/examples/playbooks/transform-key-order-block.yml @@ -0,0 +1,20 @@ +--- +- name: Testing multiple plays in a playbook + hosts: localhost + tasks: + - name: First block + when: true + block: + - name: Display a message + ansible.builtin.debug: + msg: Hello world! + +- name: A second play + hosts: localhost + tasks: + - name: Second block + block: + - name: Display a message + ansible.builtin.debug: + msg: Hello world! + when: true # <-- name key should be the second one diff --git a/examples/playbooks/transform-key-order-play.transformed.yml b/examples/playbooks/transform-key-order-play.transformed.yml new file mode 100644 index 0000000..030364d --- /dev/null +++ b/examples/playbooks/transform-key-order-play.transformed.yml @@ -0,0 +1,10 @@ +--- +- name: This is a playbook # <-- name key should be the first one + hosts: localhost + tasks: + - name: A block + when: true + block: + - name: Display a message + ansible.builtin.debug: + msg: Hello world! diff --git a/examples/playbooks/transform-key-order-play.yml b/examples/playbooks/transform-key-order-play.yml new file mode 100644 index 0000000..e61920d --- /dev/null +++ b/examples/playbooks/transform-key-order-play.yml @@ -0,0 +1,10 @@ +--- +- hosts: localhost + name: This is a playbook # <-- name key should be the first one + tasks: + - name: A block + when: true + block: + - name: Display a message + ansible.builtin.debug: + msg: Hello world! diff --git a/examples/playbooks/transform-key-order.transformed.yml b/examples/playbooks/transform-key-order.transformed.yml new file mode 100644 index 0000000..82b62d2 --- /dev/null +++ b/examples/playbooks/transform-key-order.transformed.yml @@ -0,0 +1,32 @@ +--- +- name: Fixture + hosts: localhost + tasks: + # comment before keys + - name: Task with no_log on top # name comment + no_log: true # no_log comment + ansible.builtin.command: echo hello # command comment + changed_when: false # changed_when comment + # comment after keys + - name: Task with when on top + when: true + ansible.builtin.command: echo hello + changed_when: false + - name: Delegate_to on top + delegate_to: localhost + ansible.builtin.command: echo hello + changed_when: false + - name: Loopy + loop: + - 1 + - 2 + ansible.builtin.command: echo {{ item }} + changed_when: false + - name: Become first + become: true + ansible.builtin.command: echo hello + changed_when: false + - name: Register first + register: test + ansible.builtin.command: echo hello + changed_when: false diff --git a/examples/playbooks/transform-key-order.yml b/examples/playbooks/transform-key-order.yml new file mode 100644 index 0000000..71712d1 --- /dev/null +++ b/examples/playbooks/transform-key-order.yml @@ -0,0 +1,32 @@ +--- +- name: Fixture + hosts: localhost + tasks: + - # comment before keys + no_log: true # no_log comment + ansible.builtin.command: echo hello # command comment + name: Task with no_log on top # name comment + changed_when: false # changed_when comment + # comment after keys + - when: true + name: Task with when on top + ansible.builtin.command: echo hello + changed_when: false + - delegate_to: localhost + name: Delegate_to on top + ansible.builtin.command: echo hello + changed_when: false + - loop: + - 1 + - 2 + name: Loopy + ansible.builtin.command: echo {{ item }} + changed_when: false + - become: true + name: Become first + ansible.builtin.command: echo hello + changed_when: false + - register: test + ansible.builtin.command: echo hello + name: Register first + changed_when: false diff --git a/examples/playbooks/transform-no-free-form.transformed.yml b/examples/playbooks/transform-no-free-form.transformed.yml new file mode 100644 index 0000000..e947c34 --- /dev/null +++ b/examples/playbooks/transform-no-free-form.transformed.yml @@ -0,0 +1,30 @@ +--- +- name: Example with discouraged free-form syntax + hosts: localhost + tasks: + - name: Create a placefolder file + ansible.builtin.command: # <-- don't use shorthand + chdir: /tmp + cmd: touch foo + changed_when: false + + - name: Create a placefolder file + ansible.builtin.command: # <-- command can also go first + chdir: /tmp + cmd: touch bar + changed_when: false + + - name: Use raw to echo + ansible.builtin.raw: echo foo # <-- don't use executable= + args: + executable: /bin/bash + changed_when: false + + - name: Example task with usage for '=' as module params + ansible.builtin.debug: + msg: "'Hello there world'" + changed_when: false + + - name: Task that has a non-debug string with spaces + ansible.builtin.set_fact: + foo: '"String with spaces"' diff --git a/examples/playbooks/transform-no-free-form.yml b/examples/playbooks/transform-no-free-form.yml new file mode 100644 index 0000000..c57da0c --- /dev/null +++ b/examples/playbooks/transform-no-free-form.yml @@ -0,0 +1,22 @@ +--- +- name: Example with discouraged free-form syntax + hosts: localhost + tasks: + - name: Create a placefolder file + ansible.builtin.command: chdir=/tmp touch foo # <-- don't use shorthand + changed_when: false + + - name: Create a placefolder file + ansible.builtin.command: touch bar chdir=/tmp # <-- command can also go first + changed_when: false + + - name: Use raw to echo + ansible.builtin.raw: executable=/bin/bash echo foo # <-- don't use executable= + changed_when: false + + - name: Example task with usage for '=' as module params + ansible.builtin.debug: msg='Hello there world' + changed_when: false + + - name: Task that has a non-debug string with spaces + ansible.builtin.set_fact: foo="String with spaces" diff --git a/examples/playbooks/transform-no-jinja-when.transformed.yml b/examples/playbooks/transform-no-jinja-when.transformed.yml new file mode 100644 index 0000000..da93ec5 --- /dev/null +++ b/examples/playbooks/transform-no-jinja-when.transformed.yml @@ -0,0 +1,21 @@ +--- +- name: One + hosts: all + tasks: + - name: Test when with jinja2 # noqa: jinja[spacing] + ansible.builtin.debug: + msg: text + when: "false" + +- name: Two + hosts: all + roles: + - role: hello + when: "'1' = '1'" + +- name: Three + hosts: all + roles: + - role: hello + when: + - "'1' = '1'" diff --git a/examples/playbooks/transform-no-jinja-when.yml b/examples/playbooks/transform-no-jinja-when.yml new file mode 100644 index 0000000..be8dd05 --- /dev/null +++ b/examples/playbooks/transform-no-jinja-when.yml @@ -0,0 +1,21 @@ +--- +- name: One + hosts: all + tasks: + - name: Test when with jinja2 # noqa: jinja[spacing] + ansible.builtin.debug: + msg: text + when: "{{ false }}" + +- name: Two + hosts: all + roles: + - role: hello + when: "{{ '1' = '1' }}" + +- name: Three + hosts: all + roles: + - role: hello + when: + - "{{ '1' = '1' }}" diff --git a/examples/playbooks/transform-no-log-password.transformed.yml b/examples/playbooks/transform-no-log-password.transformed.yml new file mode 100644 index 0000000..791c074 --- /dev/null +++ b/examples/playbooks/transform-no-log-password.transformed.yml @@ -0,0 +1,23 @@ +--- +- name: Fixture for no log password + hosts: all + tasks: + - name: Fail when no_log is set to False + ansible.builtin.user: + name: john_doe + password: "{{ item }}" + state: absent + with_items: + - wow + - now + no_log: true + + - name: Fail when no_log is absent + ansible.builtin.user: + name: john_doe + password: "{{ item }}" + state: absent + with_items: + - wow + - now + no_log: true diff --git a/examples/playbooks/transform-no-log-password.yml b/examples/playbooks/transform-no-log-password.yml new file mode 100644 index 0000000..467883a --- /dev/null +++ b/examples/playbooks/transform-no-log-password.yml @@ -0,0 +1,22 @@ +--- +- name: Fixture for no log password + hosts: all + tasks: + - name: Fail when no_log is set to False + ansible.builtin.user: + name: john_doe + password: "{{ item }}" + state: absent + with_items: + - wow + - now + no_log: false + + - name: Fail when no_log is absent + ansible.builtin.user: + name: john_doe + password: "{{ item }}" + state: absent + with_items: + - wow + - now diff --git a/examples/playbooks/transform-partial-become.transformed.yml b/examples/playbooks/transform-partial-become.transformed.yml new file mode 100644 index 0000000..31d2a15 --- /dev/null +++ b/examples/playbooks/transform-partial-become.transformed.yml @@ -0,0 +1,56 @@ +--- +# The play has become_user and the task has become +# this is fixable, copy the become_user to the task +# and remove from the play +- name: Play 1 + hosts: localhost + tasks: + - name: A block + block: + - name: Debug + ansible.builtin.debug: + msg: hello + become: true + become_user: root + +# The task has become_user but the play does not +# this is fixable, remove the become_user from the task +- name: Play 2 + hosts: localhost + tasks: + - name: A block + block: + - name: Debug + ansible.builtin.debug: + msg: hello + +# The task has become_user and the play has become +# this is fixable, add become to the task +- name: Play 3 + hosts: localhost + become: true + tasks: + - name: A block + block: + - name: Debug + ansible.builtin.debug: + msg: hello + become: true + become_user: root + +# The play has become_user but has an include +# this is not fixable, the include could be called from multiple playbooks +- name: Play 4 + hosts: localhost + become_user: root + tasks: + - name: A block + block: + - name: Debug + ansible.builtin.debug: + msg: hello + become: true + + - name: Include + ansible.builtin.include_tasks: + file: ../tasks/partial_become/main.yml diff --git a/examples/playbooks/transform-partial-become.yml b/examples/playbooks/transform-partial-become.yml new file mode 100644 index 0000000..079d1a0 --- /dev/null +++ b/examples/playbooks/transform-partial-become.yml @@ -0,0 +1,56 @@ +--- +# The play has become_user and the task has become +# this is fixable, copy the become_user to the task +# and remove from the play +- name: Play 1 + hosts: localhost + become_user: root + tasks: + - name: A block + block: + - name: Debug + ansible.builtin.debug: + msg: hello + become: true + +# The task has become_user but the play does not +# this is fixable, remove the become_user from the task +- name: Play 2 + hosts: localhost + tasks: + - name: A block + block: + - name: Debug + ansible.builtin.debug: + msg: hello + become_user: root + +# The task has become_user and the play has become +# this is fixable, add become to the task +- name: Play 3 + hosts: localhost + become: true + tasks: + - name: A block + block: + - name: Debug + ansible.builtin.debug: + msg: hello + become_user: root + +# The play has become_user but has an include +# this is not fixable, the include could be called from multiple playbooks +- name: Play 4 + hosts: localhost + become_user: root + tasks: + - name: A block + block: + - name: Debug + ansible.builtin.debug: + msg: hello + become: true + + - name: Include + ansible.builtin.include_tasks: + file: ../tasks/partial_become/main.yml diff --git a/examples/playbooks/transform_command_instead_of_shell.transformed.yml b/examples/playbooks/transform_command_instead_of_shell.transformed.yml new file mode 100644 index 0000000..f2477a5 --- /dev/null +++ b/examples/playbooks/transform_command_instead_of_shell.transformed.yml @@ -0,0 +1,25 @@ +--- +- name: Fixture + hosts: localhost + tasks: + - name: Shell no pipe + ansible.builtin.command: + cmd: echo hello + changed_when: false + + - name: Shell with jinja filter + ansible.builtin.command: + cmd: echo {{ "hello" | upper }} + changed_when: false + + - name: Shell with jinja filter (fqcn) + ansible.builtin.command: + cmd: echo {{ "hello" | upper }} + changed_when: false + + - name: Command with executable parameter + ansible.builtin.shell: + cmd: clear + args: + executable: /bin/bash + changed_when: false diff --git a/examples/playbooks/transform_command_instead_of_shell.yml b/examples/playbooks/transform_command_instead_of_shell.yml new file mode 100644 index 0000000..278f5d7 --- /dev/null +++ b/examples/playbooks/transform_command_instead_of_shell.yml @@ -0,0 +1,25 @@ +--- +- name: Fixture + hosts: localhost + tasks: + - name: Shell no pipe + ansible.builtin.shell: + cmd: echo hello + changed_when: false + + - name: Shell with jinja filter + ansible.builtin.shell: + cmd: echo {{ "hello" | upper }} + changed_when: false + + - name: Shell with jinja filter (fqcn) + ansible.builtin.shell: + cmd: echo {{ "hello" | upper }} + changed_when: false + + - name: Command with executable parameter + ansible.builtin.shell: + cmd: clear + args: + executable: /bin/bash + changed_when: false diff --git a/examples/playbooks/rule-var-naming-fail.yml b/examples/playbooks/var-naming/rule-var-naming-fail.yml index 888ed72..3861cd9 100644 --- a/examples/playbooks/rule-var-naming-fail.yml +++ b/examples/playbooks/var-naming/rule-var-naming-fail.yml @@ -30,3 +30,15 @@ ansible.builtin.debug: var: test_var register: CamelCaseIsBad # invalid 7 + + - name: This should not trigger due to role name being dynamic (jinja) + ansible.builtin.include_role: + name: "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') | basename }}" + vars: + nginx_logrotate_conf_enable: true + + - name: This should not trigger due to containing a dot in role name + ansible.builtin.include_role: + name: "foo.bar" + vars: + bar_foo: true diff --git a/examples/playbooks/vars/transform_nested_data.transformed.yml b/examples/playbooks/vars/transform_nested_data.transformed.yml new file mode 100644 index 0000000..c0479fc --- /dev/null +++ b/examples/playbooks/vars/transform_nested_data.transformed.yml @@ -0,0 +1,7 @@ +--- +sequence: + - - - 111 + - 112 + - 12 + - - 21 + - - 221 diff --git a/examples/playbooks/vars/transform_nested_data.yml b/examples/playbooks/vars/transform_nested_data.yml new file mode 100644 index 0000000..9f5aeb8 --- /dev/null +++ b/examples/playbooks/vars/transform_nested_data.yml @@ -0,0 +1,7 @@ +--- +sequence: + - - - 111 + - 112 + - 12 + - - 21 + - - 221 diff --git a/examples/playbooks_globs/a.yml b/examples/playbooks_globs/a.yml new file mode 100644 index 0000000..37b6d9e --- /dev/null +++ b/examples/playbooks_globs/a.yml @@ -0,0 +1,4 @@ +--- +- name: A + hosts: localhost + tasks: [] diff --git a/examples/playbooks_globs/b.yml b/examples/playbooks_globs/b.yml new file mode 100644 index 0000000..19f17c5 --- /dev/null +++ b/examples/playbooks_globs/b.yml @@ -0,0 +1,4 @@ +--- +- # missing name + hosts: localhost + tasks: [] diff --git a/examples/roles/include_wrong_syntax/tasks/main.yml b/examples/roles/include_wrong_syntax/tasks/main.yml new file mode 100644 index 0000000..be269e5 --- /dev/null +++ b/examples/roles/include_wrong_syntax/tasks/main.yml @@ -0,0 +1,3 @@ +--- +- name: Invalid syntax for import (coverage) + ansible.builtin.import_tasks: wrong=imported_tasks.yml diff --git a/examples/roles/name_casing/tasks/main.transformed.yml b/examples/roles/name_casing/tasks/main.transformed.yml new file mode 100644 index 0000000..74c18ae --- /dev/null +++ b/examples/roles/name_casing/tasks/main.transformed.yml @@ -0,0 +1,15 @@ +--- +- name: Test nested tasks within block and always + block: + - name: Test1 + ansible.builtin.debug: + msg: Foo + + - name: Test2 + ansible.builtin.debug: + msg: Bar + + always: + - name: From always block to be auto fixed as name[casing] scenario + ansible.builtin.debug: + msg: Baz diff --git a/examples/roles/name_casing/tasks/main.yml b/examples/roles/name_casing/tasks/main.yml new file mode 100644 index 0000000..7bf73ff --- /dev/null +++ b/examples/roles/name_casing/tasks/main.yml @@ -0,0 +1,15 @@ +--- +- name: Test nested tasks within block and always + block: + - name: test1 + ansible.builtin.debug: + msg: Foo + + - name: Test2 + ansible.builtin.debug: + msg: Bar + + always: + - name: from always block to be auto fixed as name[casing] scenario + ansible.builtin.debug: + msg: Baz diff --git a/examples/roles/name_prefix/tasks/test.transformed.yml b/examples/roles/name_prefix/tasks/test.transformed.yml new file mode 100644 index 0000000..eb6e116 --- /dev/null +++ b/examples/roles/name_prefix/tasks/test.transformed.yml @@ -0,0 +1,8 @@ +--- +- name: test | Not cap + ansible.builtin.debug: + msg: not cap + +- name: test | Cap + ansible.builtin.debug: + msg: Cap diff --git a/examples/roles/name_prefix/tasks/test.yml b/examples/roles/name_prefix/tasks/test.yml new file mode 100644 index 0000000..679332a --- /dev/null +++ b/examples/roles/name_prefix/tasks/test.yml @@ -0,0 +1,8 @@ +--- +- name: test | not cap + ansible.builtin.debug: + msg: not cap + +- name: test | Cap + ansible.builtin.debug: + msg: Cap diff --git a/examples/roles/role_detection/base/bar/defaults/main.yml b/examples/roles/role_detection/base/bar/defaults/main.yml new file mode 100644 index 0000000..faa4ea1 --- /dev/null +++ b/examples/roles/role_detection/base/bar/defaults/main.yml @@ -0,0 +1,3 @@ +--- +base_var_1: foo +base_var_2: foo diff --git a/examples/roles/role_detection/foo/defaults/main.yml b/examples/roles/role_detection/foo/defaults/main.yml new file mode 100644 index 0000000..1a5b54b --- /dev/null +++ b/examples/roles/role_detection/foo/defaults/main.yml @@ -0,0 +1,3 @@ +--- +foo_var_1: bar +foo_var_2: bar diff --git a/examples/roles/role_vars_prefix_detection/defaults/main.yml b/examples/roles/role_vars_prefix_detection/defaults/main.yml new file mode 100644 index 0000000..23809fe --- /dev/null +++ b/examples/roles/role_vars_prefix_detection/defaults/main.yml @@ -0,0 +1,2 @@ +--- +foo: bar diff --git a/examples/roles/role_vars_prefix_detection/vars/main.yml b/examples/roles/role_vars_prefix_detection/vars/main.yml new file mode 100644 index 0000000..143ee72 --- /dev/null +++ b/examples/roles/role_vars_prefix_detection/vars/main.yml @@ -0,0 +1,3 @@ +--- +role_vars_prefix_detection_bar: baz +bar: baz diff --git a/examples/roles/role_with_deps_paths/meta/main.yml b/examples/roles/role_with_deps_paths/meta/main.yml new file mode 100644 index 0000000..51efd72 --- /dev/null +++ b/examples/roles/role_with_deps_paths/meta/main.yml @@ -0,0 +1,10 @@ +--- +dependencies: + - role: subfolder/1st_role + vars: + param: baz + - role: subfolder + vars: + param: baz + - role: subfolder/2nd_role + - subfolder/3rd_role diff --git a/examples/roles/role_with_handler/handlers/main.yml b/examples/roles/role_with_handler/handlers/main.yml new file mode 100644 index 0000000..9ca1ab1 --- /dev/null +++ b/examples/roles/role_with_handler/handlers/main.yml @@ -0,0 +1,8 @@ +--- +- name: Debug + loop: "{{ _something_done.results }}" + loop_control: + label: "{{ item.item.name }}" + when: item.changed + ansible.builtin.debug: + msg: "{{ item.item.name }} changed" diff --git a/examples/roles/role_with_handler/tasks/main.yml b/examples/roles/role_with_handler/tasks/main.yml new file mode 100644 index 0000000..362dc78 --- /dev/null +++ b/examples/roles/role_with_handler/tasks/main.yml @@ -0,0 +1,17 @@ +--- +- name: Get info + delegate_to: localhost + register: collected_info + ansible.builtin.debug: + msg: test + +- name: Do something + delegate_to: localhost + loop: "{{ collected_info['some_list'] }}" + loop_control: + label: "{{ item.name }}" + notify: + - Debug + register: _something_done + ansible.builtin.debug: + msg: test2 diff --git a/examples/roles/test-no-deps-role/meta/main.yml b/examples/roles/test-no-deps-role/meta/main.yml new file mode 100644 index 0000000..1d80cc3 --- /dev/null +++ b/examples/roles/test-no-deps-role/meta/main.yml @@ -0,0 +1,55 @@ +--- +galaxy_info: + author: audgirka + description: your role description + company: Red Hat + role_name: test_no_deps_role # if absent directory name hosting role is used instead + namespace: foo # if absent, author is used instead + + # If the issue tracker for your role is not on github, uncomment the + # next line and provide a value + # issue_tracker_url: http://example.com/issue/tracker + + # Choose a valid license ID from https://spdx.org - some suggested licenses: + # - BSD-3-Clause (default) + # - MIT + # - GPL-2.0-or-later + # - GPL-3.0-only + # - Apache-2.0 + # - CC-BY-4.0 + license: GPL-2.0-or-later + + min_ansible_version: "2.1" + + # If this a Container Enabled role, provide the minimum Ansible Container version. + # min_ansible_container_version: + + # + # Provide a list of supported platforms, and for each platform a list of versions. + # If you don't wish to enumerate all versions for a particular platform, use 'all'. + # To view available platforms and versions (or releases), visit: + # https://galaxy.ansible.com/api/v1/platforms/ + # + # platforms: + # - name: Fedora + # versions: + # - all + # - 25 + # - name: SomePlatform + # versions: + # - all + # - 1.0 + # - 7 + # - 99.99 + + galaxy_tags: [] + # List tags for your role here, one per line. A tag is a keyword that describes + # and categorizes the role. Users find roles by searching for tags. Be sure to + # remove the '[]' above, if you add tags to this list. + # + # NOTE: A tag is limited to a single word comprised of alphanumeric characters. + # Maximum 20 tags per role. +# Skipping deps for testing scenario when no role deps are present +# dependencies: [] +# List your role dependencies here, one per line. Be sure to remove the '[]' above, +# if you add dependencies to this list. diff --git a/examples/roles/var_naming_pattern/tasks/include_task_with_vars.yml b/examples/roles/var_naming_pattern/tasks/include_task_with_vars.yml index 5151cd3..40b6729 100644 --- a/examples/roles/var_naming_pattern/tasks/include_task_with_vars.yml +++ b/examples/roles/var_naming_pattern/tasks/include_task_with_vars.yml @@ -1,10 +1,15 @@ --- -- name: include_task_with_vars | Foo +- name: include_task_with_vars | Var1 + ansible.builtin.include_tasks: file=../tasks/included-task-with-vars.yml + +- name: include_task_with_vars | Var2 ansible.builtin.include_tasks: ../tasks/included-task-with-vars.yml vars: - var_naming_pattern_foo: bar + var_naming_pattern_1: bar + _var_naming_pattern_2: ... # we allow _ before the prefix + __var_naming_pattern_3: ... # we allow __ before the prefix -- name: include_task_with_vars | Foo +- name: include_task_with_vars | Var3 ansible.builtin.include_role: name: bobbins vars: diff --git a/examples/rulebooks/rulebook-fail.yml b/examples/rulebooks/rulebook-fail.yml index 11472b4..b08cd03 100644 --- a/examples/rulebooks/rulebook-fail.yml +++ b/examples/rulebooks/rulebook-fail.yml @@ -1,8 +1,8 @@ --- - name: Sample rulebooks hosts: all - that_should_not_be_here: foo - sources: # should be "sources" + that_should_not_be_here: foo # <-- this is not supported + sources: # <-- should be "sources" - name: listen for alerts ansible.eda.alertmanager: host: 0.0.0.0 @@ -18,4 +18,4 @@ - name: debug condition: event.alert.labels.job == "fastapi" action: - debug: sss + debug: sss # <-- this should be an object diff --git a/examples/rules/task_has_tag.py b/examples/rules/task_has_tag.py index a4b927c..a96d123 100644 --- a/examples/rules/task_has_tag.py +++ b/examples/rules/task_has_tag.py @@ -1,4 +1,5 @@ """Example implementation of a rule requiring tasks to have tags set.""" + from __future__ import annotations from typing import TYPE_CHECKING diff --git a/examples/sanity_ignores/tests/sanity/ignore-2.13.txt b/examples/sanity_ignores/tests/sanity/ignore-2.13.txt index 2b95cf5..fedd39e 100644 --- a/examples/sanity_ignores/tests/sanity/ignore-2.13.txt +++ b/examples/sanity_ignores/tests/sanity/ignore-2.13.txt @@ -1 +1,2 @@ plugins/module_utils/ansible_example_module.py validate-modules:deprecation-mismatch # comment +tests/unit/file.py import-3.6!skip diff --git a/examples/sanity_ignores/tests/sanity/ignore-2.15.txt b/examples/sanity_ignores/tests/sanity/ignore-2.15.txt index 069ef15..08d21a2 100644 --- a/examples/sanity_ignores/tests/sanity/ignore-2.15.txt +++ b/examples/sanity_ignores/tests/sanity/ignore-2.15.txt @@ -1,2 +1,3 @@ plugins/module_utils/ansible_example_module.incorrect-3.6!skip #plugins/module_utils/ansible_example_module.py import-3.6!skip +other_dir/module_utils/ansible_example_module incorrect-3.6!skip diff --git a/examples/sanity_ignores/tests/sanity/ignore-2.9.txt b/examples/sanity_ignores/tests/sanity/ignore-2.9.txt index bfee509..4727a66 100644 --- a/examples/sanity_ignores/tests/sanity/ignore-2.9.txt +++ b/examples/sanity_ignores/tests/sanity/ignore-2.9.txt @@ -1,2 +1,4 @@ +# Should be fully skipped plugins/module_utils/ansible_example_module.py validate-modules:deprecation-mismatch plugins/module_utils/ansible_example_module.py import-2.6!skip +plugins/module_utils/ansible_example_module.py validate-modules diff --git a/examples/yamllint/.github/workflows/ci.yml b/examples/yamllint/.github/workflows/ci.yml new file mode 100644 index 0000000..fac4072 --- /dev/null +++ b/examples/yamllint/.github/workflows/ci.yml @@ -0,0 +1,9 @@ +--- +name: ack +on: # <-- this is invalid by YAML 1.2 spec as it loads as true boolean. + pull_request_target: + types: [opened, labeled, unlabeled, synchronize] + +jobs: + ack: + uses: ansible/team-devtools/.github/workflows/ack.yml@main diff --git a/examples/yamllint/incompatible-config/.yamllint b/examples/yamllint/incompatible-config/.yamllint new file mode 100644 index 0000000..7639dd5 --- /dev/null +++ b/examples/yamllint/incompatible-config/.yamllint @@ -0,0 +1,14 @@ +# This config file is full of yamllint configuration settings that are +# incompatible with ansible-lint. It used for testing their detection. +rules: + comments: + min-spaces-from-content: 2 + comments-indentation: false + braces: + min-spaces-inside: 1 + max-spaces-inside: 2 + key-duplicates: + forbid-duplicated-merge-keys: false + octal-values: + forbid-implicit-octal: false + forbid-explicit-octal: false @@ -1,6 +1,6 @@ --- site_name: Ansible Lint Documentation -site_url: https://ansible-lint.readthedocs.io/ +site_url: https://ansible.readthedocs.io/projects/lint/ repo_url: https://github.com/ansible/ansible-lint edit_uri: blob/main/docs/ copyright: Copyright © 2023 Red Hat, Inc. @@ -13,13 +13,28 @@ extra_css: theme: name: ansible features: - - content.code.copy + - announce.dismiss - content.action.edit + - content.action.view + - content.code.annotate + - content.code.copy + - content.tabs.link + - content.tooltips + - header.autohide - navigation.expand - - navigation.sections - - navigation.instant + - navigation.footer - navigation.indexes + - navigation.instant + - navigation.path + - navigation.prune + - navigation.sections + - navigation.tabs + - navigation.tabs.sticky + - navigation.top - navigation.tracking + - search.highlight + - search.share + - search.suggest - toc.integrate extra: social: @@ -46,17 +61,20 @@ extra: name: GitHub nav: - - User Guide: + - Home: - home: index.md - - philosophy.md - - installing.md + - Philosophy: philosophy.md - usage.md + - Setup: + - installing.md - configuring.md - profiles.md + - autofix.md - Rules: - index: rules/index.md - rules/args.md - rules/avoid-implicit.md + - rules/complexity.md - rules/command-instead-of-module.md - rules/command-instead-of-shell.md - rules/deprecated-bare-vars.md @@ -108,12 +126,18 @@ nav: - Contributing: contributing.md - custom-rules.md +exclude_docs: | + _autofix_rules.md + plugins: - autorefs + - macros: + modules: [mkdocs-ansible:mkdocs_ansible] + render_by_default: false - markdown-exec - gen-files: scripts: - - src/ansiblelint/generate_docs.py + - tools/generate_docs.py - material/search: separator: '[\s\-,:!=\[\]()"`/]+|\.(?!\d)|&[lg]t;|(?!\b)(?=[A-Z][a-z])' - material/social diff --git a/playbook.yml b/playbook.yml index f55677e..ea510a4 100644 --- a/playbook.yml +++ b/playbook.yml @@ -1,6 +1,7 @@ --- - name: Example hosts: localhost + gather_facts: false tasks: - name: include extra tasks ansible.builtin.include_tasks: diff --git a/plugins/modules/fake_module.py b/plugins/modules/fake_module.py index bdff5c7..1c42b8f 100644 --- a/plugins/modules/fake_module.py +++ b/plugins/modules/fake_module.py @@ -2,8 +2,17 @@ This is used to test ability to detect and use custom modules. """ + from ansible.module_utils.basic import AnsibleModule +EXAMPLES = r""" +- name: "playbook" + tasks: + - name: Hello + debug: + msg: 'world' +""" + def main() -> None: """Return the module instance.""" diff --git a/pyproject.toml b/pyproject.toml index e182c5b..b1ff701 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,13 +2,12 @@ requires = [ "setuptools >= 65.3.0", # required by pyproject+setuptools_scm integration and editable installs "setuptools_scm[toml] >= 7.0.5", # required for "no-local-version" scheme - ] build-backend = "setuptools.build_meta" [project] # https://peps.python.org/pep-0621/#readme -requires-python = ">=3.9" +requires-python = ">=3.10" dynamic = ["version", "dependencies", "optional-dependencies"] name = "ansible-lint" description = "Checks playbooks for practices and behavior that could potentially be improved" @@ -27,9 +26,9 @@ classifiers = [ "Operating System :: POSIX", "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python", "Topic :: System :: Systems Administration", @@ -39,47 +38,47 @@ classifiers = [ ] keywords = ["ansible", "lint"] +[project.scripts] +ansible-lint = "ansiblelint.__main__:_run_cli_entrypoint" + [project.urls] homepage = "https://github.com/ansible/ansible-lint" -documentation = "https://ansible-lint.readthedocs.io/" +documentation = "https://ansible.readthedocs.io/projects/lint/" repository = "https://github.com/ansible/ansible-lint" changelog = "https://github.com/ansible/ansible-lint/releases" -[project.scripts] -ansible-lint = "ansiblelint.__main__:_run_cli_entrypoint" - [tool.black] -target-version = ["py39"] +target-version = ["py310"] [tool.codespell] skip = ".tox,.mypy_cache,build,.git,.eggs,pip-wheel-metadata" # indention is a typo in ruamel.yaml's API ignore-words-list = "indention" -[tool.coverage.run] -source = ["src"] -# Do not use branch until bug is fixes: -# https://github.com/nedbat/coveragepy/issues/605 -# branch = true -parallel = true -concurrency = ["multiprocessing", "thread"] - # Keep this default because xml/report do not know to use load it from config file: # data_file = ".coverage" [tool.coverage.paths] source = ["src", ".tox/*/site-packages"] [tool.coverage.report] -exclude_lines = ["pragma: no cover", "if TYPE_CHECKING:"] +exclude_also = ["pragma: no cover", "if TYPE_CHECKING:"] omit = ["test/*"] # Increase it just so it would pass on any single-python run -fail_under = 93 +fail_under = 92 skip_covered = true skip_empty = true # During development we might remove code (files) with coverage data, and we dont want to fail: ignore_errors = true show_missing = true +[tool.coverage.run] +source = ["src"] +# Do not use branch until bug is fixes: +# https://github.com/nedbat/coveragepy/issues/605 +# branch = true +parallel = true +concurrency = ["multiprocessing", "thread"] + [tool.isort] profile = "black" # add_imports = "from __future__ import annotations" @@ -94,7 +93,7 @@ ensure_newline_before_comments = true line_length = 88 [tool.mypy] -python_version = 3.9 +python_version = "3.10" strict = true color_output = true error_summary = true @@ -108,31 +107,30 @@ disallow_any_generics = true # site-packages is here to help vscode mypy integration getting confused exclude = "(build|dist|test/local-content|site-packages|~/.pyenv|examples/playbooks/collections|plugins/modules)" # https://github.com/python/mypy/issues/12664 -no_incremental = true +incremental = false [[tool.mypy.overrides]] module = [ "ansible.*", - "yamllint.*", "ansiblelint._version", # generated + "license_expression", "ruamel.yaml", - "spdx.*", + "yamllint.*", ] ignore_missing_imports = true ignore_errors = true -[tool.pylint.MAIN] -extension-pkg-allow-list = ["black.parsing"] - [tool.pylint.IMPORTS] preferred-modules = ["py:pathlib", "unittest:pytest"] +[tool.pylint.MAIN] +extension-pkg-allow-list = ["black.parsing"] + [tool.pylint.MASTER] bad-names = [ # spell-checker:ignore linenumber "linenumber", # use lineno instead "line_number", # use lineno instead - ] # pylint defaults + f,fh,v,id good-names = ["i", "j", "k", "Run", "_", "f", "fh", "v", "id", "T"] @@ -145,6 +143,7 @@ max-statements = 60 disable = [ # Disabled on purpose: "line-too-long", # covered by black + "protected-access", # covered by ruff SLF001 "too-many-branches", # covered by ruff C901 # TODO(ssbarnea): remove temporary skips adding during initial adoption: "duplicate-code", @@ -154,10 +153,12 @@ disable = [ # https://github.com/PyCQA/pylint/issues/8453 "preferred-module", ] +enable = [ + "useless-suppression", # Identify unneeded pylint disable statements +] -[tool.pylint.TYPECHECK] -# pylint is unable to detect Namespace attributes and will throw a E1101 -generated-members = "options.*" +[tool.pylint.REPORTING] +output-format = "colorized" [tool.pylint.SUMMARY] # We don't need the score spamming console, as we either pass or fail @@ -165,7 +166,7 @@ score = "n" [tool.pyright] # https://github.com/microsoft/pyright/blob/main/docs/configuration.md#sample-pyprojecttoml-file -pythonVersion = "3.9" +pythonVersion = "3.10" include = ["src"] # https://github.com/microsoft/pyright/issues/777 "stubPath" = "" @@ -188,6 +189,11 @@ filterwarnings = [ "ignore:pkg_resources is deprecated as an API:DeprecationWarning", # We raise one non critical warning from our own conftest.py: "always::pytest.PytestWarning", + # py312 ansible-core + # https://github.com/ansible/ansible/issues/81906 + "ignore:'importlib.abc.TraversableResources' is deprecated and slated for removal in Python 3.14:DeprecationWarning", + # https://github.com/ansible/ansible/pull/80968 + "ignore:Attribute s is deprecated and will be removed in Python 3.14; use value instead:DeprecationWarning", ] junit_duration_report = "call" # Our github annotation parser from .github/workflows/tox.yml requires xunit1 format. Ref: @@ -196,18 +202,19 @@ junit_family = "xunit1" junit_suite_name = "ansible_lint_test_suite" minversion = "4.6.6" norecursedirs = [ - "build", - "collections", - "dist", - "docs", - "src/ansible_lint.egg-info", + "*.egg", ".cache", ".eggs", ".git", ".github", - ".tox", - "*.egg", + ".mypy_cache", ".projects", + ".tox", + "build", + "collections", + "dist", + "docs", + "src/ansible_lint.egg-info", ] python_files = [ "test_*.py", @@ -223,8 +230,12 @@ python_files = [ xfail_strict = true [tool.ruff] -required-version = "0.0.274" -ignore = [ +target-version = "py310" +# Same as Black. +line-length = 88 +lint.ignore = [ + "D203", # incompatible with D211 + "D213", # incompatible with D212 "E501", # we use black "ERA001", # auto-removal of commented out code affects development and vscode integration "INP001", # "is part of an implicit namespace package", all false positives @@ -238,27 +249,26 @@ ignore = [ "FBT003", "PLR", "RUF012", # Mutable class attributes should be annotated with `typing.ClassVar` - "TRY", + "PERF203", + "PD011", # We are not using pandas, any .values attributes are unrelated + "PLW0603", # global lock file in cache dir ] -select = ["ALL"] -target-version = "py39" -# Same as Black. -line-length = 88 - -[tool.ruff.mccabe] -# Implicit 10 is too low for our codebase, even black uses 18 as default. -max-complexity = 20 +lint.select = ["ALL"] -[tool.ruff.flake8-builtins] +[tool.ruff.lint.flake8-builtins] builtins-ignorelist = ["id"] -[tool.ruff.flake8-pytest-style] +[tool.ruff.lint.flake8-pytest-style] parametrize-values-type = "tuple" -[tool.ruff.isort] +[tool.ruff.lint.isort] known-first-party = ["ansiblelint"] -[tool.ruff.per-file-ignores] +[tool.ruff.lint.mccabe] +# Implicit 10 is too low for our codebase, even black uses 18 as default. +max-complexity = 20 + +[tool.ruff.lint.per-file-ignores] "test/**/*.py" = ["S"] "src/ansiblelint/rules/*.py" = ["S"] "src/ansiblelint/testing/*.py" = ["S"] @@ -267,12 +277,25 @@ known-first-party = ["ansiblelint"] "PTH", ] +[tool.ruff.lint.pydocstyle] +convention = "google" + [tool.setuptools.dynamic] -optional-dependencies.docs = { file = [".config/requirements-docs.txt"] } -optional-dependencies.test = { file = [".config/requirements-test.txt"] } -optional-dependencies.lock = { file = [".config/requirements-lock.txt"] } dependencies = { file = [".config/requirements.in"] } +optional-dependencies.docs = { file = [".config/requirements-docs.in"] } +optional-dependencies.test = { file = [".config/requirements-test.in"] } [tool.setuptools_scm] local_scheme = "no-local-version" +tag_regex = "^(?P<prefix>v)?(?P<version>[0-9.]+)(?P<suffix>.*)?$" write_to = "src/ansiblelint/_version.py" +# To prevent accidental pick of mobile version tags such 'v6' +git_describe_command = [ + "git", + "describe", + "--dirty", + "--tags", + "--long", + "--match", + "v*.*", +] diff --git a/requirements.yml b/requirements.yml index 40b9b57..1291b29 100644 --- a/requirements.yml +++ b/requirements.yml @@ -7,3 +7,4 @@ collections: - community.docker - community.general - community.molecule + - community.windows diff --git a/src/ansiblelint/__main__.py b/src/ansiblelint/__main__.py index af434d0..ca4a33b 100755 --- a/src/ansiblelint/__main__.py +++ b/src/ansiblelint/__main__.py @@ -30,12 +30,23 @@ import shutil import site import sys from pathlib import Path -from typing import TYPE_CHECKING, Any, Callable, TextIO +from typing import TYPE_CHECKING, Any, TextIO from ansible_compat.prerun import get_cache_dir from filelock import FileLock, Timeout from rich.markup import escape +from ansiblelint.constants import RC, SKIP_SCHEMA_UPDATE + +# safety check for broken ansible core, needs to happen first +try: + # pylint: disable=unused-import + from ansible.parsing.dataloader import DataLoader # noqa: F401 + +except Exception as _exc: # pylint: disable=broad-exception-caught # noqa: BLE001 + logging.fatal(_exc) + sys.exit(RC.INVALID_CONFIG) +# pylint: disable=ungrouped-imports from ansiblelint import cli from ansiblelint._mockings import _perform_mockings_cleanup from ansiblelint.app import get_app @@ -53,20 +64,21 @@ from ansiblelint.config import ( log_entries, options, ) -from ansiblelint.constants import RC from ansiblelint.loaders import load_ignore_txt +from ansiblelint.runner import get_matches from ansiblelint.skip_utils import normalize_tag from ansiblelint.version import __version__ if TYPE_CHECKING: # RulesCollection must be imported lazily or ansible gets imported too early. + from collections.abc import Callable + from ansiblelint.rules import RulesCollection from ansiblelint.runner import LintResult _logger = logging.getLogger(__name__) -cache_dir_lock: None | FileLock = None class LintLogHandler(logging.Handler): @@ -107,8 +119,9 @@ def initialize_logger(level: int = 0) -> None: _logger.debug("Logging initialized to level %s", logging_level) -def initialize_options(arguments: list[str] | None = None) -> None: +def initialize_options(arguments: list[str] | None = None) -> None | FileLock: """Load config options and store them inside options module.""" + cache_dir_lock = None new_options = cli.get_config(arguments or []) new_options.cwd = pathlib.Path.cwd() @@ -132,13 +145,13 @@ def initialize_options(arguments: list[str] | None = None) -> None: options.cache_dir.mkdir(parents=True, exist_ok=True) if not options.offline: # pragma: no cover - cache_dir_lock = FileLock( # pylint: disable=redefined-outer-name + cache_dir_lock = FileLock( f"{options.cache_dir}/.lock", ) try: cache_dir_lock.acquire(timeout=180) except Timeout: # pragma: no cover - _logger.error( + _logger.error( # noqa: TRY400 "Timeout waiting for another instance of ansible-lint to release the lock.", ) sys.exit(RC.LOCK_TIMEOUT) @@ -147,6 +160,8 @@ def initialize_options(arguments: list[str] | None = None) -> None: if "ANSIBLE_DEVEL_WARNING" not in os.environ: # pragma: no branch os.environ["ANSIBLE_DEVEL_WARNING"] = "false" + return cache_dir_lock + def _do_list(rules: RulesCollection) -> int: # On purpose lazy-imports to avoid pre-loading Ansible @@ -194,23 +209,85 @@ def _do_transform(result: LintResult, opts: Options) -> None: def support_banner() -> None: """Display support banner when running on unsupported platform.""" - if sys.version_info < (3, 9, 0): # pragma: no cover - prefix = "::warning::" if "GITHUB_ACTION" in os.environ else "WARNING: " - console_stderr.print( - f"{prefix}ansible-lint is no longer tested under Python {sys.version_info.major}.{sys.version_info.minor} and will soon require 3.9. Do not report bugs for this version.", - style="bold red", - ) -# pylint: disable=too-many-statements,too-many-locals +def fix(runtime_options: Options, result: LintResult, rules: RulesCollection) -> None: + """Fix the linting errors. + + :param options: Options object + :param result: LintResult object + """ + match_count = len(result.matches) + _logger.debug("Begin fixing: %s matches", match_count) + ruamel_safe_version = "0.17.26" + + # pylint: disable=import-outside-toplevel + from packaging.version import Version + from ruamel.yaml import __version__ as ruamel_yaml_version_str + + # pylint: enable=import-outside-toplevel + + if Version(ruamel_safe_version) > Version(ruamel_yaml_version_str): + _logger.warning( + "We detected use of `--fix` feature with a buggy ruamel-yaml %s library instead of >=%s, upgrade it before reporting any bugs like dropped comments.", + ruamel_yaml_version_str, + ruamel_safe_version, + ) + acceptable_tags = {"all", "none", *rules.known_tags()} + unknown_tags = set(options.write_list).difference(acceptable_tags) + + if unknown_tags: + _logger.error( + "Found invalid value(s) (%s) for --fix arguments, must be one of: %s", + ", ".join(unknown_tags), + ", ".join(acceptable_tags), + ) + sys.exit(RC.INVALID_CONFIG) + _do_transform(result, options) + + rerun = ["yaml"] + resolved = [] + for idx, match in reversed(list(enumerate(result.matches))): + _logger.debug("Fixing: (%s of %s) %s", match_count - idx, match_count, match) + if match.fixed: + _logger.debug("Fixed, removed: %s", match) + result.matches.pop(idx) + continue + if match.rule.id not in rerun: + _logger.debug("Not rerun eligible: %s", match) + continue + + uid = (match.rule.id, match.filename) + if uid in resolved: + _logger.debug("Previously resolved: %s", match) + result.matches.pop(idx) + continue + _logger.debug("Rerunning: %s", match) + runtime_options.tags = [match.rule.id] + runtime_options.lintables = [match.filename] + runtime_options._skip_ansible_syntax_check = True # noqa: SLF001 + new_results = get_matches(rules, runtime_options) + if not new_results.matches: + _logger.debug("Newly resolved: %s", match) + result.matches.pop(idx) + resolved.append(uid) + continue + if match in new_results.matches: + _logger.debug("Still found: %s", match) + continue + _logger.debug("Fixed, removed: %s", match) + result.matches.pop(idx) + + +# pylint: disable=too-many-locals def main(argv: list[str] | None = None) -> int: """Linter CLI entry point.""" # alter PATH if needed (venv support) - path_inject() + path_inject(argv[0] if argv and argv[0] else "") if argv is None: # pragma: no cover argv = sys.argv - initialize_options(argv[1:]) + cache_dir_lock = initialize_options(argv[1:]) console_options["force_terminal"] = options.colored reconfigure(console_options) @@ -236,7 +313,23 @@ def main(argv: list[str] | None = None) -> int: _logger.debug("Options: %s", options) _logger.debug("CWD: %s", Path.cwd()) - if not options.offline: + # checks if we have `ANSIBLE_LINT_SKIP_SCHEMA_UPDATE` set to bypass schema + # update. Also skip if in offline mode. + # env var set to skip schema refresh + skip_schema_update = ( + bool( + int( + os.environ.get( + SKIP_SCHEMA_UPDATE, + "0", + ), + ), + ) + or options.offline + or options.nodeps + ) + + if not skip_schema_update: # pylint: disable=import-outside-toplevel from ansiblelint.schemas.__main__ import refresh_schemas @@ -244,7 +337,6 @@ def main(argv: list[str] | None = None) -> int: # pylint: disable=import-outside-toplevel from ansiblelint.rules import RulesCollection - from ansiblelint.runner import _get_matches if options.list_profiles: from ansiblelint.generate_docs import profiles_as_rich @@ -265,20 +357,7 @@ def main(argv: list[str] | None = None) -> int: if isinstance(options.tags, str): options.tags = options.tags.split(",") # pragma: no cover - result = _get_matches(rules, options) - - if options.write_list: - ruamel_safe_version = "0.17.26" - from packaging.version import Version - from ruamel.yaml import __version__ as ruamel_yaml_version_str - - if Version(ruamel_safe_version) > Version(ruamel_yaml_version_str): - _logger.warning( - "We detected use of `--write` feature with a buggy ruamel-yaml %s library instead of >=%s, upgrade it before reporting any bugs like dropped comments.", - ruamel_yaml_version_str, - ruamel_safe_version, - ) - _do_transform(result, options) + result = get_matches(rules, options) mark_as_success = True @@ -292,6 +371,18 @@ def main(argv: list[str] | None = None) -> int: for match in result.matches: if match.tag in ignore_map[match.filename]: match.ignored = True + _logger.debug("Ignored: %s", match) + + if app.yamllint_config.incompatible: + logging.log( + level=logging.ERROR if options.write_list else logging.WARNING, + msg=app.yamllint_config.incompatible, + ) + + if options.write_list: + if app.yamllint_config.incompatible: + sys.exit(RC.INVALID_CONFIG) + fix(runtime_options=options, result=result, rules=rules) app.render_matches(result.matches) @@ -325,7 +416,7 @@ def _run_cli_entrypoint() -> None: raise SystemExit(exc) from exc -def path_inject() -> None: +def path_inject(own_location: str = "") -> None: """Add python interpreter path to top of PATH to fix outside venv calling.""" # This make it possible to call ansible-lint that was installed inside a # virtualenv without having to pre-activate it. Otherwise subprocess will @@ -350,6 +441,7 @@ def path_inject() -> None: inject_paths = [] userbase_bin_path = Path(site.getuserbase()) / "bin" + if ( str(userbase_bin_path) not in paths and (userbase_bin_path / "bin" / "ansible").exists() @@ -357,11 +449,23 @@ def path_inject() -> None: inject_paths.append(str(userbase_bin_path)) py_path = Path(sys.executable).parent - if str(py_path) not in paths and (py_path / "ansible").exists(): + pipx_path = os.environ.get("PIPX_HOME", "pipx") + if ( + str(py_path) not in paths + and (py_path / "ansible").exists() + and pipx_path not in str(py_path) + ): inject_paths.append(str(py_path)) + # last option, if nothing else is found, just look next to ourselves... + if own_location: + own_location = os.path.realpath(own_location) + parent = Path(own_location).parent + if (parent / "ansible").exists() and str(parent) not in paths: + inject_paths.append(str(parent)) + if not os.environ.get("PYENV_VIRTUAL_ENV", None): - if inject_paths: + if inject_paths and not all("pipx" in p for p in inject_paths): print( # noqa: T201 f"WARNING: PATH altered to include {', '.join(inject_paths)} :: This is usually a sign of broken local setup, which can cause unexpected behaviors.", file=sys.stderr, diff --git a/src/ansiblelint/_internal/rules.py b/src/ansiblelint/_internal/rules.py index acaf0f3..38cb835 100644 --- a/src/ansiblelint/_internal/rules.py +++ b/src/ansiblelint/_internal/rules.py @@ -1,4 +1,5 @@ """Internally used rule classes.""" + from __future__ import annotations import inspect @@ -9,6 +10,7 @@ from typing import TYPE_CHECKING, Any from ansiblelint.constants import RULE_DOC_URL if TYPE_CHECKING: + from ansiblelint.config import Options from ansiblelint.errors import MatchError from ansiblelint.file_utils import Lintable from ansiblelint.rules import RulesCollection @@ -44,6 +46,8 @@ class BaseRule: link: str = "" has_dynamic_tags: bool = False needs_raw_task: bool = False + # Used to mark rules that we will never unload (internal ones) + unloadable: bool = False # We use _order to sort rules and to ensure that some run before others, # _order 0 for internal rules # _order 1 for rules that check that data can be loaded @@ -54,7 +58,7 @@ class BaseRule: _collection: RulesCollection | None = None @property - def help(self) -> str: # noqa: A003 + def help(self) -> str: """Return a help markdown string for the rule.""" if self._help is None: self._help = "" @@ -92,10 +96,11 @@ class BaseRule: _logger.warning( "Ignored exception from %s.%s while processing %s: %s", self.__class__.__name__, - method, + method.__name__, str(file), exc, ) + _logger.debug("Ignored exception details", exc_info=True) else: matches.extend(self.matchdir(file)) return matches @@ -157,6 +162,26 @@ class BaseRule: """ return getattr(cls, "_ids", {cls.id: cls.shortdesc}) + @property + def rule_config(self) -> dict[str, Any]: + """Retrieve rule specific configuration.""" + rule_config = {} + if self.options: + rule_config = self.options.rules.get(self.id, {}) + if not isinstance(rule_config, dict): # pragma: no branch + msg = f"Invalid rule config for {self.id}: {rule_config}" + raise RuntimeError(msg) # noqa: TRY004 + return rule_config + + @property + def options(self) -> Options | None: + """Used to access linter configuration.""" + if self._collection is None: + msg = f"A rule ({self.id}) that is not part of a collection cannot access its configuration." + _logger.warning(msg) + return None + return self._collection.options + # pylint: enable=unused-argument @@ -170,6 +195,7 @@ class RuntimeErrorRule(BaseRule): tags = ["core"] version_added = "v5.0.0" _order = 0 + unloadable = True class AnsibleParserErrorRule(BaseRule): @@ -181,6 +207,7 @@ class AnsibleParserErrorRule(BaseRule): tags = ["core"] version_added = "v5.0.0" _order = 0 + unloadable = True class LoadingFailureRule(BaseRule): @@ -196,6 +223,7 @@ class LoadingFailureRule(BaseRule): _ids = { "load-failure[not-found]": "File not found", } + unloadable = True class WarningRule(BaseRule): @@ -207,3 +235,4 @@ class WarningRule(BaseRule): tags = ["core", "experimental"] version_added = "v6.8.0" _order = 0 + unloadable = True diff --git a/src/ansiblelint/_mockings.py b/src/ansiblelint/_mockings.py index e0482b7..5c2a9a7 100644 --- a/src/ansiblelint/_mockings.py +++ b/src/ansiblelint/_mockings.py @@ -1,4 +1,5 @@ """Utilities for mocking ansible modules and roles.""" + from __future__ import annotations import contextlib @@ -46,7 +47,7 @@ def _make_module_stub(module_name: str, options: Options) -> None: path.mkdir(exist_ok=True, parents=True) _write_module_stub( filename=module_file, - name=module_file, + name=module_name, namespace=namespace, collection=collection, ) @@ -122,4 +123,4 @@ def _perform_mockings_cleanup(options: Options) -> None: else: path = options.cache_dir / "roles" / role_name with contextlib.suppress(OSError): - path.unlink() + path.rmdir() diff --git a/src/ansiblelint/app.py b/src/ansiblelint/app.py index 52581b3..3568f53 100644 --- a/src/ansiblelint/app.py +++ b/src/ansiblelint/app.py @@ -1,10 +1,12 @@ """Application.""" + from __future__ import annotations import copy import itertools import logging import os +import sys from functools import lru_cache from pathlib import Path from typing import TYPE_CHECKING, Any @@ -20,6 +22,7 @@ from ansiblelint.config import PROFILES, Options, get_version_warning from ansiblelint.config import options as default_options from ansiblelint.constants import RC, RULE_DOC_URL from ansiblelint.loaders import IGNORE_FILE +from ansiblelint.requirements import Reqs from ansiblelint.stats import SummarizedResults, TagStats if TYPE_CHECKING: @@ -30,6 +33,7 @@ if TYPE_CHECKING: _logger = logging.getLogger(__package__) +_CACHED_APP = None class App: @@ -46,7 +50,25 @@ class App: self.formatter = formatter_factory(options.cwd, options.display_relative_path) # Without require_module, our _set_collections_basedir may fail - self.runtime = Runtime(isolated=True, require_module=True) + self.runtime = Runtime( + isolated=True, + require_module=True, + verbosity=options.verbosity, + ) + self.reqs = Reqs("ansible-lint") + package = "ansible-core" + if not self.reqs.matches( + package, + str(self.runtime.version), + ): # pragma: no cover + msg = f"ansible-lint requires {package}{','.join(str(x) for x in self.reqs[package])} and current version is {self.runtime.version}" + logging.error(msg) + sys.exit(RC.INVALID_CONFIG) + + # pylint: disable=import-outside-toplevel + from ansiblelint.yaml_utils import load_yamllint_config + + self.yamllint_config = load_yamllint_config() def render_matches(self, matches: list[MatchError]) -> None: """Display given matches (if they are not fixed).""" @@ -54,7 +76,7 @@ class App: if isinstance( self.formatter, - (formatters.CodeclimateJSONFormatter, formatters.SarifFormatter), + formatters.CodeclimateJSONFormatter | formatters.SarifFormatter, ): # If formatter CodeclimateJSONFormatter or SarifFormatter is chosen, # then print only the matches in JSON @@ -205,7 +227,7 @@ class App: ignore_file.writelines(sorted(lines)) elif matched_rules and not self.options.quiet: console_stderr.print( - "Read [link=https://ansible-lint.readthedocs.io/configuring/#ignoring-rules-for-entire-files]documentation[/link] for instructions on how to ignore specific rule violations.", + "Read [link=https://ansible.readthedocs.io/projects/lint/configuring/#ignoring-rules-for-entire-files]documentation[/link] for instructions on how to ignore specific rule violations.", ) # Do not deprecate the old tags just yet. Why? Because it is not currently feasible @@ -223,7 +245,7 @@ class App: if self.options.write_list and "yaml" in self.options.skip_list: _logger.warning( - "You specified '--write', but no files can be modified " + "You specified '--fix', but no files can be modified " "because 'yaml' is in 'skip_list'.", ) @@ -332,7 +354,10 @@ class App: if self.options.profile: msg += f" Profile '{self.options.profile}' was required" if summary.passed_profile: - msg += f", but only '{summary.passed_profile}' profile passed." + if summary.passed_profile == self.options.profile: + msg += ", and it passed." + else: + msg += f", but '{summary.passed_profile}' profile passed." else: msg += "." elif summary.passed_profile: @@ -378,8 +403,19 @@ def _sanitize_list_options(tag_list: list[str]) -> list[str]: @lru_cache -def get_app(*, offline: bool | None = None) -> App: +def get_app(*, offline: bool | None = None, cached: bool = False) -> App: """Return the application instance, caching the return value.""" + # Avoids ever running the app initialization twice if cached argument + # is mentioned. + if cached: + if offline is not None: + msg = ( + "get_app should never be called with other arguments when cached=True." + ) + raise RuntimeError(msg) + if cached and _CACHED_APP is not None: + return _CACHED_APP + if offline is None: offline = default_options.offline diff --git a/src/ansiblelint/cli.py b/src/ansiblelint/cli.py index c9178a7..ce8d9ec 100644 --- a/src/ansiblelint/cli.py +++ b/src/ansiblelint/cli.py @@ -1,4 +1,5 @@ """CLI parser setup and helpers.""" + from __future__ import annotations import argparse @@ -7,7 +8,7 @@ import os import sys from argparse import Namespace from pathlib import Path -from typing import TYPE_CHECKING, Any, Callable +from typing import TYPE_CHECKING, Any from ansiblelint.config import ( DEFAULT_KINDS, @@ -16,7 +17,7 @@ from ansiblelint.config import ( Options, log_entries, ) -from ansiblelint.constants import CUSTOM_RULESDIR_ENVVAR, DEFAULT_RULESDIR, RC +from ansiblelint.constants import CUSTOM_RULESDIR_ENVVAR, DEFAULT_RULESDIR, EPILOG, RC from ansiblelint.file_utils import ( Lintable, abspath, @@ -29,7 +30,7 @@ from ansiblelint.schemas.main import validate_file_schema from ansiblelint.yaml_utils import clean_json if TYPE_CHECKING: - from collections.abc import Sequence + from collections.abc import Callable, Sequence _logger = logging.getLogger(__name__) @@ -91,7 +92,7 @@ def load_config(config_file: str | None) -> tuple[dict[Any, Any], str | None]: config = clean_json(config_lintable.data) if not isinstance(config, dict): msg = "Schema failed to properly validate the config file." - raise RuntimeError(msg) + raise TypeError(msg) config["config_file"] = config_path config_dir = os.path.dirname(config_path) expand_to_normalized_paths(config, config_dir) @@ -134,7 +135,7 @@ class AbspathArgAction(argparse.Action): values: str | Sequence[Any] | None, option_string: str | None = None, ) -> None: - if isinstance(values, (str, Path)): + if isinstance(values, str | Path): values = [values] if values: normalized_values = [ @@ -145,7 +146,7 @@ class AbspathArgAction(argparse.Action): class WriteArgAction(argparse.Action): - """Argparse action to handle the --write flag with optional args.""" + """Argparse action to handle the --fix flag with optional args.""" _default = "__default__" @@ -174,8 +175,8 @@ class WriteArgAction(argparse.Action): super().__init__( option_strings=option_strings, dest=dest, - nargs="?", # either 0 (--write) or 1 (--write=a,b,c) argument - const=self._default, # --write (no option) implicitly stores this + nargs="?", # either 0 (--fix) or 1 (--fix=a,b,c) argument + const=self._default, # --fix (no option) implicitly stores this default=default, type=type, choices=choices, @@ -194,8 +195,8 @@ class WriteArgAction(argparse.Action): lintables = getattr(namespace, "lintables", None) if not lintables and isinstance(values, str): # args are processed in order. - # If --write is after lintables, then that is not ambiguous. - # But if --write comes first, then it might actually be a lintable. + # If --fix is after lintables, then that is not ambiguous. + # But if --fix comes first, then it might actually be a lintable. maybe_lintable = Path(values) if maybe_lintable.exists(): namespace.lintables = [values] @@ -211,26 +212,40 @@ class WriteArgAction(argparse.Action): setattr(namespace, self.dest, values) @classmethod - def merge_write_list_config( + def merge_fix_list_config( cls, from_file: list[str], from_cli: list[str], ) -> list[str]: - """Combine the write_list from file config with --write CLI arg. + """Determine the write_list value based on cli vs config. + + When --fix is not passed from command line the from_cli is an empty list, + so we use the file. - Handles the implicit "all" when "__default__" is present and file config is empty. + When from_cli is not an empty list, we ignore the from_file value. """ - if not from_file or "none" in from_cli: - # --write is the same as --write=all - return ["all" if value == cls._default else value for value in from_cli] - # --write means use the config from the config file - from_cli = [value for value in from_cli if value != cls._default] - return from_file + from_cli + if not from_file: + arguments = ["all"] if from_cli == [cls._default] else from_cli + else: + arguments = from_file + for magic_value in ("none", "all"): + if magic_value in arguments and len(arguments) > 1: + msg = f"When passing '{magic_value}' to '--fix', you cannot pass other values." + raise RuntimeError( + msg, + ) + if len(arguments) == 1 and arguments[0] == "none": + arguments = [] + return arguments def get_cli_parser() -> argparse.ArgumentParser: """Initialize an argument parser.""" - parser = argparse.ArgumentParser() + parser = argparse.ArgumentParser( + epilog=EPILOG, + # Avoid rewrapping description and epilog + formatter_class=argparse.RawTextHelpFormatter, + ) listing_group = parser.add_mutually_exclusive_group() listing_group.add_argument( @@ -338,22 +353,16 @@ def get_cli_parser() -> argparse.ArgumentParser: help="Return non-zero exit code on warnings as well as errors", ) parser.add_argument( - "--write", + "--fix", dest="write_list", # this is a tri-state argument that takes an optional comma separated list: action=WriteArgAction, - help="Allow ansible-lint to reformat YAML files and run rule transforms " - "(Reformatting YAML files standardizes spacing, quotes, etc. " - "A rule transform can fix or simplify fixing issues identified by that rule). " + help="Allow ansible-lint to perform auto-fixes, including YAML reformatting. " "You can limit the effective rule transforms (the 'write_list') by passing a " "keywords 'all' or 'none' or a comma separated list of rule ids or rule tags. " - "YAML reformatting happens whenever '--write' or '--write=' is used. " - "'--write' and '--write=all' are equivalent: they allow all transforms to run. " - "The effective list of transforms comes from 'write_list' in the config file, " - "followed whatever '--write' args are provided on the commandline. " - "'--write=none' resets the list of transforms to allow reformatting YAML " - "without running any of the transforms (ie '--write=none,rule-id' will " - "ignore write_list in the config file and only run the rule-id transform).", + "YAML reformatting happens whenever '--fix' or '--fix=' is used. " + "'--fix' and '--fix=all' are equivalent: they allow all transforms to run. " + "Presence of --fix in command overrides config file value.", ) parser.add_argument( "--show-relpath", @@ -490,6 +499,7 @@ def merge_config(file_config: dict[Any, Any], cli_config: Options) -> Options: "enable_list": [], "only_builtins_allow_collections": [], "only_builtins_allow_modules": [], + "supported_ansible_also": [], # do not include "write_list" here. See special logic below. } @@ -506,6 +516,10 @@ def merge_config(file_config: dict[Any, Any], cli_config: Options) -> Options: for entry, default in lists_map.items(): if not getattr(cli_config, entry, None): setattr(cli_config, entry, default) + if cli_config.write_list is None: + cli_config.write_list = [] + elif cli_config.write_list == [WriteArgAction._default]: # noqa: SLF001 + cli_config.write_list = ["all"] return cli_config for entry in bools: @@ -513,8 +527,8 @@ def merge_config(file_config: dict[Any, Any], cli_config: Options) -> Options: v = getattr(cli_config, entry) or file_value setattr(cli_config, entry, v) - for entry, default in scalar_map.items(): - file_value = file_config.pop(entry, default) + for entry, default_scalar in scalar_map.items(): + file_value = file_config.pop(entry, default_scalar) v = getattr(cli_config, entry, None) or file_value setattr(cli_config, entry, v) @@ -533,7 +547,7 @@ def merge_config(file_config: dict[Any, Any], cli_config: Options) -> Options: setattr( cli_config, entry, - WriteArgAction.merge_write_list_config( + WriteArgAction.merge_fix_list_config( from_file=file_config.pop(entry, []), from_cli=getattr(cli_config, entry, []) or [], ), @@ -557,6 +571,13 @@ def merge_config(file_config: dict[Any, Any], cli_config: Options) -> Options: def get_config(arguments: list[str]) -> Options: """Extract the config based on given args.""" parser = get_cli_parser() + # translate deprecated options + for i, value in enumerate(arguments): + if arguments[i].startswith("--write"): + arguments[i] = value.replace("--write", "--fix") + _logger.warning( + "Replaced deprecated '--write' option with '--fix', change you call to avoid future regressions when we remove old option.", + ) options = Options(**vars(parser.parse_args(arguments))) # docs is not document, being used for internal documentation building diff --git a/src/ansiblelint/color.py b/src/ansiblelint/color.py index 8f31e1c..d72d98d 100644 --- a/src/ansiblelint/color.py +++ b/src/ansiblelint/color.py @@ -1,4 +1,5 @@ """Console coloring and terminal support.""" + from __future__ import annotations from typing import Any diff --git a/src/ansiblelint/config.py b/src/ansiblelint/config.py index 6164b10..ee9dea0 100644 --- a/src/ansiblelint/config.py +++ b/src/ansiblelint/config.py @@ -1,4 +1,5 @@ """Store configuration options as a singleton.""" + from __future__ import annotations import json @@ -67,7 +68,7 @@ DEFAULT_KINDS = [ {"requirements": "**/requirements.{yaml,yml}"}, # v2 and v1 {"playbook": "**/molecule/*/*.{yaml,yml}"}, # molecule playbooks {"yaml": "**/{.ansible-lint,.yamllint}"}, - {"changelog": "**/changelogs/changelog.yaml"}, + {"changelog": "**/changelogs/changelog.{yaml,yml}"}, {"yaml": "**/*.{yaml,yml}"}, {"yaml": "**/.*.{yaml,yml}"}, {"sanity-ignore-file": "**/tests/sanity/ignore-*.txt"}, @@ -98,22 +99,41 @@ BASE_KINDS = [ {"text/python": "**/*.py"}, ] +# File kinds that are recognized by ansible, used internally to force use of +# YAML 1.1 instead of 1.2 due to ansible-core dependency on pyyaml. +ANSIBLE_OWNED_KINDS = { + "handlers", + "galaxy", + "meta", + "meta-runtime", + "playbook", + "requirements", + "role-arg-spec", + "rulebook", + "tasks", + "vars", +} + PROFILES = yaml_from_file(Path(__file__).parent / "data" / "profiles.yml") LOOP_VAR_PREFIX = "^(__|{role}_)" @dataclass -class Options: # pylint: disable=too-many-instance-attributes,too-few-public-methods +class Options: # pylint: disable=too-many-instance-attributes """Store ansible-lint effective configuration options.""" + # Private attributes + _skip_ansible_syntax_check: bool = False + + # Public attributes cache_dir: Path | None = None colored: bool = True configured: bool = False - cwd: Path = Path(".") + cwd: Path = Path() display_relative_path: bool = True exclude_paths: list[str] = field(default_factory=list) - format: str = "brief" # noqa: A003 + format: str = "brief" lintables: list[str] = field(default_factory=list) list_rules: bool = False list_tags: bool = False @@ -152,6 +172,27 @@ class Options: # pylint: disable=too-many-instance-attributes,too-few-public-me version: bool = False # display version command list_profiles: bool = False # display profiles command ignore_file: Path | None = None + max_tasks: int = 100 + max_block_depth: int = 20 + # Refer to https://docs.ansible.com/ansible/latest/reference_appendices/release_and_maintenance.html#ansible-core-support-matrix + _default_supported = ["2.15.", "2.16.", "2.17."] + supported_ansible_also: list[str] = field(default_factory=list) + + @property + def nodeps(self) -> bool: + """Returns value of nodeps feature.""" + # We do not want this to be cached as it would affect our testings. + return bool(int(os.environ.get("ANSIBLE_LINT_NODEPS", "0"))) + + def __post_init__(self) -> None: + """Extra initialization logic.""" + if self.nodeps: + self.offline = True + + @property + def supported_ansible(self) -> list[str]: + """Returns list of ansible versions that are considered supported.""" + return sorted([*self._default_supported, *self.supported_ansible_also]) options = Options() @@ -166,15 +207,6 @@ collection_list: list[str] = [] log_entries: list[tuple[int, str]] = [] -def get_rule_config(rule_id: str) -> dict[str, Any]: - """Get configurations for the rule ``rule_id``.""" - rule_config = options.rules.get(rule_id, {}) - if not isinstance(rule_config, dict): # pragma: no branch - msg = f"Invalid rule config for {rule_id}: {rule_config}" - raise RuntimeError(msg) - return rule_config - - @lru_cache def ansible_collections_path() -> str: """Return collection path variable for current version of Ansible.""" @@ -241,7 +273,6 @@ def guess_install_method() -> str: else: logging.debug("Skipping %s as it is not installed.", package_name) use_pip = False - # pylint: disable=broad-except except (AttributeError, ModuleNotFoundError) as exc: # On Fedora 36, we got a AttributeError exception from pip that we want to avoid # On NixOS, we got a ModuleNotFoundError exception from pip that we want to avoid @@ -269,6 +300,11 @@ def get_version_warning() -> str: # 0.1dev1 is special fallback version if __version__ == "0.1.dev1": # pragma: no cover return "" + pip = guess_install_method() + # If we do not know how to upgrade, we do not want to show any warnings + # about version. + if not pip: + return "" msg = "" data = {} @@ -309,9 +345,6 @@ def get_version_warning() -> str: msg = "[dim]You are using a pre-release version of ansible-lint.[/]" elif current_version < new_version: msg = f"""[warning]A new release of ansible-lint is available: [red]{current_version}[/] → [green][link={html_url}]{new_version}[/][/][/]""" - - pip = guess_install_method() - if pip: - msg += f" Upgrade by running: [info]{pip}[/]" + msg += f" Upgrade by running: [info]{pip}[/]" return msg diff --git a/src/ansiblelint/constants.py b/src/ansiblelint/constants.py index 6b8bd12..56cf71b 100644 --- a/src/ansiblelint/constants.py +++ b/src/ansiblelint/constants.py @@ -1,11 +1,26 @@ """Constants used by AnsibleLint.""" + from enum import Enum from pathlib import Path from typing import Literal DEFAULT_RULESDIR = Path(__file__).parent / "rules" CUSTOM_RULESDIR_ENVVAR = "ANSIBLE_LINT_CUSTOM_RULESDIR" -RULE_DOC_URL = "https://ansible-lint.readthedocs.io/rules/" +RULE_DOC_URL = "https://ansible.readthedocs.io/projects/lint/rules/" +SKIP_SCHEMA_UPDATE = "ANSIBLE_LINT_SKIP_SCHEMA_UPDATE" + +ENV_VARS_HELP = { + CUSTOM_RULESDIR_ENVVAR: "Used for adding another folder into the lookup path for new rules.", + "ANSIBLE_LINT_IGNORE_FILE": "Define it to override the name of the default ignore file `.ansible-lint-ignore`", + "ANSIBLE_LINT_WRITE_TMP": "Tells linter to dump fixes into different temp files instead of overriding original. Used internally for testing.", + SKIP_SCHEMA_UPDATE: "Tells ansible-lint to skip schema refresh.", + "ANSIBLE_LINT_NODEPS": "Avoids installing content dependencies and avoids performing checks that would fail when modules are not installed. Far less violations will be reported.", +} + +EPILOG = ( + "The following environment variables are also recognized but there is no guarantee that they will work in future versions:\n\n" + + "\n".join(f"{key}: {value}\n" for key, value in ENV_VARS_HELP.items()) +) # Not using an IntEnum because only starting with py3.11 it will evaluate it @@ -126,6 +141,36 @@ PLAYBOOK_TASK_KEYWORDS = [ "pre_tasks", "post_tasks", ] +PLAYBOOK_ROLE_KEYWORDS = [ + "any_errors_fatal", + "become", + "become_exe", + "become_flags", + "become_method", + "become_user", + "check_mode", + "collections", + "connection", + "debugger", + "delegate_facts", + "delegate_to", + "diff", + "environment", + "ignore_errors", + "ignore_unreachable", + "module_defaults", + "name", + "role", + "no_log", + "port", + "remote_user", + "run_once", + "tags", + "throttle", + "timeout", + "vars", + "when", +] NESTED_TASK_KEYS = [ "block", "always", diff --git a/src/ansiblelint/data/.yamllint b/src/ansiblelint/data/.yamllint new file mode 100644 index 0000000..6ff09f0 --- /dev/null +++ b/src/ansiblelint/data/.yamllint @@ -0,0 +1,25 @@ +extends: default +rules: + comments: + # https://github.com/prettier/prettier/issues/6780 + min-spaces-from-content: 1 + # https://github.com/adrienverge/yamllint/issues/384 + comments-indentation: false + document-start: disable + # 160 chars was the default used by old E204 rule, but + # you can easily change it or disable in your .yamllint file. + line-length: + max: 160 + # We are adding an extra space inside braces as that's how prettier does it + # and we are trying not to fight other linters. + braces: + min-spaces-inside: 0 # yamllint defaults to 0 + max-spaces-inside: 1 # yamllint defaults to 0 + # key-duplicates: + # forbid-duplicated-merge-keys: true # not enabled by default + octal-values: + forbid-implicit-octal: true # yamllint defaults to false + forbid-explicit-octal: true # yamllint defaults to false + # quoted-strings: + # quote-type: double + # required: only-when-needed diff --git a/src/ansiblelint/errors.py b/src/ansiblelint/errors.py index c8458b8..5ee2d6f 100644 --- a/src/ansiblelint/errors.py +++ b/src/ansiblelint/errors.py @@ -1,4 +1,5 @@ """Exceptions and error representations.""" + from __future__ import annotations import functools @@ -6,7 +7,6 @@ from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any from ansiblelint._internal.rules import BaseRule, RuntimeErrorRule -from ansiblelint.config import options from ansiblelint.file_utils import Lintable if TYPE_CHECKING: @@ -27,15 +27,9 @@ class WarnSource: message: str | None = None -class StrictModeError(RuntimeError): - """Raise when we encounter a warning in strict mode.""" - - def __init__( - self, - message: str = "Warning treated as error due to --strict option.", - ): - """Initialize a StrictModeError instance.""" - super().__init__(message) +@dataclass(frozen=True) +class RuleMatchTransformMeta: + """Additional metadata about a match error to be used during transformation.""" # pylint: disable=too-many-instance-attributes @@ -54,7 +48,6 @@ class MatchError(ValueError): # order matters for these: message: str = field(init=True, repr=False, default="") lintable: Lintable = field(init=True, repr=False, default=Lintable(name="")) - filename: str = field(init=True, repr=False, default="") tag: str = field(init=True, repr=False, default="") lineno: int = 1 @@ -65,13 +58,11 @@ class MatchError(ValueError): rule: BaseRule = field(hash=False, default=RuntimeErrorRule()) ignored: bool = False fixed: bool = False # True when a transform has resolved this MatchError + transform_meta: RuleMatchTransformMeta | None = None def __post_init__(self) -> None: """Can be use by rules that can report multiple errors type, so we can still filter by them.""" - if not self.lintable and self.filename: - self.lintable = Lintable(self.filename) - elif self.lintable and not self.filename: - self.filename = self.lintable.name + self.filename = self.lintable.name # We want to catch accidental MatchError() which contains no useful # information. When no arguments are passed, the '_message' field is @@ -104,11 +95,22 @@ class MatchError(ValueError): msg = "MatchError called incorrectly as column numbers start with 1" raise RuntimeError(msg) + self.lineno += self.lintable.line_offset + + # We make the lintable aware that we found a match inside it, as this + # can be used to skip running other rules that do require current one + # to pass. + self.lintable.matches.append(self) + @functools.cached_property def level(self) -> str: """Return the level of the rule: error, warning or notice.""" - if not self.ignored and {self.tag, self.rule.id, *self.rule.tags}.isdisjoint( - options.warn_list, + if ( + not self.ignored + and self.rule.options + and {self.tag, self.rule.id, *self.rule.tags}.isdisjoint( + self.rule.options.warn_list, + ) ): return "error" return "warning" @@ -128,6 +130,10 @@ class MatchError(ValueError): self.details, ) + def __str__(self) -> str: + """Return a MatchError instance string representation.""" + return self.__repr__() + @property def position(self) -> str: """Return error positioning, with column number if available.""" diff --git a/src/ansiblelint/file_utils.py b/src/ansiblelint/file_utils.py index 15c92d2..04ce3cd 100644 --- a/src/ansiblelint/file_utils.py +++ b/src/ansiblelint/file_utils.py @@ -1,4 +1,5 @@ """Utility functions related to file operations.""" + from __future__ import annotations import copy @@ -16,12 +17,15 @@ import wcmatch.pathlib import wcmatch.wcmatch from yaml.error import YAMLError -from ansiblelint.config import BASE_KINDS, Options, options +from ansiblelint.app import get_app +from ansiblelint.config import ANSIBLE_OWNED_KINDS, BASE_KINDS, Options, options from ansiblelint.constants import CONFIG_FILENAMES, FileType, States if TYPE_CHECKING: from collections.abc import Iterator, Sequence + from ansiblelint.errors import MatchError + _logger = logging.getLogger(__package__) @@ -69,9 +73,9 @@ def is_relative_to(path: Path, *other: Any) -> bool: """Return True if the path is relative to another path or False.""" try: path.resolve().absolute().relative_to(*other) - return True except ValueError: return False + return True def normpath_path(path: str | Path) -> Path: @@ -197,6 +201,10 @@ class Lintable: self.exc: Exception | None = None # Stores data loading exceptions self.parent = parent self.explicit = False # Indicates if the file was explicitly provided or was indirectly included. + self.line_offset = ( + 0 # Amount to offset line numbers by to get accurate position + ) + self.matches: list[MatchError] = [] if isinstance(name, str): name = Path(name) @@ -219,7 +227,12 @@ class Lintable: parts = self.path.parent.parts if "roles" in parts: role = self.path - while role.parent.name != "roles" and role.name: + roles_path = get_app(cached=True).runtime.config.default_roles_path + while ( + str(role.parent.absolute()) not in roles_path + and role.parent.name != "roles" + and role.name + ): role = role.parent if role.exists(): self.role = role.name @@ -252,7 +265,12 @@ class Lintable: self.parent = _guess_parent(self) if self.kind == "yaml": - _ = self.data # pylint: disable=pointless-statement + _ = self.data + + def __del__(self) -> None: + """Clean up temporary files when the instance is cleaned up.""" + if hasattr(self, "file"): + self.file.close() def _guess_kind(self) -> None: if self.kind == "yaml": @@ -350,10 +368,16 @@ class Lintable: lintable.write(force=True) """ - if not force and not self.updated: + dump_filename = self.path.expanduser().resolve() + if os.environ.get("ANSIBLE_LINT_WRITE_TMP", "0") == "1": + dump_filename = dump_filename.with_suffix( + f".tmp{dump_filename.suffix}", + ) + elif not force and not self.updated: # No changes to write. return - self.path.expanduser().resolve().write_text( + + dump_filename.write_text( self._content or "", encoding="utf-8", ) @@ -372,6 +396,16 @@ class Lintable: """Return user friendly representation of a lintable.""" return f"{self.name} ({self.kind})" + def is_owned_by_ansible(self) -> bool: + """Return true for YAML files that are managed by Ansible.""" + return self.kind in ANSIBLE_OWNED_KINDS + + def failed(self) -> bool: + """Return true if we already found syntax-check errors on this file.""" + return any( + match.rule.id in ("syntax-check", "load-failure") for match in self.matches + ) + @property def data(self) -> Any: """Return loaded data representation for current file, if possible.""" @@ -396,7 +430,11 @@ class Lintable: # pylint: disable=import-outside-toplevel from ansiblelint.skip_utils import append_skipped_rules - self.state = append_skipped_rules(self.state, self) + # pylint: disable=possibly-used-before-assignment + self.state = append_skipped_rules( + self.state, + self, + ) else: logging.debug( "data set to None for %s due to being '%s' (%s) kind.", @@ -513,7 +551,7 @@ def expand_dirs_in_lintables(lintables: set[Lintable]) -> None: for item in copy.copy(lintables): if item.path.is_dir(): for filename in all_files: - if filename.startswith(str(item.path)): + if filename.startswith((str(item.path), str(item.path.absolute()))): lintables.add(Lintable(filename)) diff --git a/src/ansiblelint/formatters/__init__.py b/src/ansiblelint/formatters/__init__.py index 9ddca00..187d803 100644 --- a/src/ansiblelint/formatters/__init__.py +++ b/src/ansiblelint/formatters/__init__.py @@ -1,4 +1,5 @@ """Output formatters.""" + from __future__ import annotations import hashlib @@ -14,6 +15,7 @@ from ansiblelint.version import __version__ if TYPE_CHECKING: from ansiblelint.errors import MatchError + from ansiblelint.rules import BaseRule # type: ignore[attr-defined] T = TypeVar("T", bound="BaseFormatter") # type: ignore[type-arg] @@ -27,6 +29,7 @@ class BaseFormatter(Generic[T]): ---- base_dir (str|Path): reference directory against which display relative path. display_relative_path (bool): whether to show path as relative or absolute + """ def __init__(self, base_dir: str | Path, display_relative_path: bool) -> None: @@ -143,7 +146,7 @@ class CodeclimateJSONFormatter(BaseFormatter[Any]): """Format a list of match errors as a JSON string.""" if not isinstance(matches, list): msg = f"The {self.__class__} was expecting a list of MatchError." - raise RuntimeError(msg) + raise TypeError(msg) result = [] for match in matches: @@ -210,7 +213,7 @@ class SarifFormatter(BaseFormatter[Any]): """Format a list of match errors as a JSON string.""" if not isinstance(matches, list): msg = f"The {self.__class__} was expecting a list of MatchError." - raise RuntimeError(msg) + raise TypeError(msg) root_path = Path(str(self.base_dir)).as_uri() root_path = root_path + "/" if not root_path.endswith("/") else root_path @@ -264,7 +267,7 @@ class SarifFormatter(BaseFormatter[Any]): "text": str(match.message), }, "defaultConfiguration": { - "level": self._to_sarif_level(match), + "level": self.get_sarif_rule_severity_level(match.rule), }, "help": { "text": str(match.rule.description), @@ -275,12 +278,21 @@ class SarifFormatter(BaseFormatter[Any]): return rule def _to_sarif_result(self, match: MatchError) -> dict[str, Any]: + # https://docs.oasis-open.org/sarif/sarif/v2.1.0/errata01/os/sarif-v2.1.0-errata01-os-complete.html#_Toc141790898 + if match.level not in ("warning", "error", "note", "none"): + msg = "Unexpected failure to map '%s' level to SARIF." + raise RuntimeError( + msg, + match.level, + ) + result: dict[str, Any] = { "ruleId": match.tag, + "level": self.get_sarif_result_severity_level(match), "message": { - "text": str(match.details) - if str(match.details) - else str(match.message), + "text": ( + str(match.details) if str(match.details) else str(match.message) + ), }, "locations": [ { @@ -303,6 +315,37 @@ class SarifFormatter(BaseFormatter[Any]): return result @staticmethod - def _to_sarif_level(match: MatchError) -> str: - # sarif accepts only 4 levels: error, warning, note, none - return match.level + def get_sarif_rule_severity_level(rule: BaseRule) -> str: + """General SARIF severity level for a rule. + + Note: Can differ from an actual result/match severity. + Possible values: "none", "note", "warning", "error" + + see: https://github.com/oasis-tcs/sarif-spec/blob/123e95847b13fbdd4cbe2120fa5e33355d4a042b/Schemata/sarif-schema-2.1.0.json#L1934-L1939 + """ + if rule.severity in ["VERY_HIGH", "HIGH"]: + return "error" + + if rule.severity in ["MEDIUM", "LOW", "VERY_LOW"]: + return "warning" + + if rule.severity == "INFO": + return "note" + + return "none" + + @staticmethod + def get_sarif_result_severity_level(match: MatchError) -> str: + """SARIF severity level for an actual result/match. + + Possible values: "none", "note", "warning", "error" + + see: https://github.com/oasis-tcs/sarif-spec/blob/123e95847b13fbdd4cbe2120fa5e33355d4a042b/Schemata/sarif-schema-2.1.0.json#L2066-L2071 + """ + if not match.level: + return "none" + + if match.level in ["warning", "error"]: + return match.level + + return "note" diff --git a/src/ansiblelint/generate_docs.py b/src/ansiblelint/generate_docs.py index 1498a67..6e319fb 100644 --- a/src/ansiblelint/generate_docs.py +++ b/src/ansiblelint/generate_docs.py @@ -1,4 +1,5 @@ """Utils to generate rules documentation.""" + import logging from collections.abc import Iterable @@ -9,7 +10,7 @@ from rich.table import Table from ansiblelint.config import PROFILES from ansiblelint.constants import RULE_DOC_URL -from ansiblelint.rules import RulesCollection +from ansiblelint.rules import RulesCollection, TransformMixin DOC_HEADER = """ # Default Rules @@ -27,6 +28,8 @@ def rules_as_str(rules: RulesCollection) -> RenderableType: """Return rules as string.""" table = Table(show_header=False, header_style="title", box=box.SIMPLE) for rule in rules.alphabetical(): + if issubclass(rule.__class__, TransformMixin): + rule.tags.insert(0, "autofix") tag = f"[dim] ({', '.join(rule.tags)})[/dim]" if rule.tags else "" table.add_row( f"[link={RULE_DOC_URL}{rule.id}/]{rule.id}[/link]", @@ -56,6 +59,12 @@ def rules_as_md(rules: RulesCollection) -> str: result += f"\n\n## {title}\n\n**{rule.shortdesc}**\n\n{description}" + # Safety net for preventing us from adding autofix to rules and + # forgetting to mention it inside their documentation. + if "autofix" in rule.tags and "autofix" not in rule.description: + msg = f"Rule {rule.id} is invalid because it has 'autofix' tag but this ability is not documented in its description." + raise RuntimeError(msg) + return result diff --git a/src/ansiblelint/loaders.py b/src/ansiblelint/loaders.py index 49e38f1..c369c89 100644 --- a/src/ansiblelint/loaders.py +++ b/src/ansiblelint/loaders.py @@ -1,11 +1,12 @@ """Utilities for loading various files.""" + from __future__ import annotations import logging import os -from collections import defaultdict, namedtuple +from collections import defaultdict from functools import partial -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, NamedTuple import yaml from yaml import YAMLError @@ -19,7 +20,14 @@ except (ImportError, AttributeError): if TYPE_CHECKING: from pathlib import Path -IgnoreFile = namedtuple("IgnoreFile", "default alternative") + +class IgnoreFile(NamedTuple): + """IgnoreFile n.""" + + default: str + alternative: str + + IGNORE_FILE = IgnoreFile(".ansible-lint-ignore", ".config/ansible-lint-ignore.txt") yaml_load = partial(yaml.load, Loader=FullLoader) diff --git a/src/ansiblelint/logger.py b/src/ansiblelint/logger.py index f0477cd..cb3bb19 100644 --- a/src/ansiblelint/logger.py +++ b/src/ansiblelint/logger.py @@ -1,4 +1,5 @@ """Utils related to logging.""" + import logging import time from collections.abc import Iterator @@ -17,15 +18,3 @@ def timed_info(msg: Any, *args: Any) -> Iterator[None]: finally: elapsed = time.time() - start _logger.info(msg + " (%.2fs)", *(*args, elapsed)) # noqa: G003 - - -def warn_or_fail(message: str) -> None: - """Warn or fail depending on the strictness level.""" - # pylint: disable=import-outside-toplevel - from ansiblelint.config import options - from ansiblelint.errors import StrictModeError - - if options.strict: - raise StrictModeError(message) - - _logger.warning(message) diff --git a/src/ansiblelint/requirements.py b/src/ansiblelint/requirements.py new file mode 100644 index 0000000..96381b9 --- /dev/null +++ b/src/ansiblelint/requirements.py @@ -0,0 +1,28 @@ +"""Utilities for checking python packages requirements.""" + +import importlib_metadata +from packaging.requirements import Requirement +from packaging.specifiers import SpecifierSet +from packaging.version import Version + + +class Reqs(dict[str, SpecifierSet]): + """Utility class for working with package dependencies.""" + + reqs: dict[str, SpecifierSet] + + def __init__(self, name: str = "ansible-lint") -> None: + """Load linter metadata requirements.""" + for req_str in importlib_metadata.metadata(name).json["requires_dist"]: + req = Requirement(req_str) + if req.name: + self[req.name] = req.specifier + + def matches(self, req_name: str, req_version: str | Version) -> bool: + """Verify if given version is matching current metadata dependencies.""" + if req_name not in self: + return False + return all( + specifier.contains(str(req_version), prereleases=True) + for specifier in self[req_name] + ) diff --git a/src/ansiblelint/rules/__init__.py b/src/ansiblelint/rules/__init__.py index acb7df1..a1743a0 100644 --- a/src/ansiblelint/rules/__init__.py +++ b/src/ansiblelint/rules/__init__.py @@ -1,4 +1,5 @@ """All internal ansible-lint rules.""" + from __future__ import annotations import copy @@ -23,7 +24,7 @@ from ansiblelint._internal.rules import ( WarningRule, ) from ansiblelint.app import App, get_app -from ansiblelint.config import PROFILES, Options, get_rule_config +from ansiblelint.config import PROFILES, Options from ansiblelint.config import options as default_options from ansiblelint.constants import LINE_NUMBER_KEY, RULE_DOC_URL, SKIPPED_RULES_KEY from ansiblelint.errors import MatchError @@ -32,6 +33,8 @@ from ansiblelint.file_utils import Lintable, expand_paths_vars if TYPE_CHECKING: from ruamel.yaml.comments import CommentedMap, CommentedSeq + from ansiblelint.errors import RuleMatchTransformMeta + _logger = logging.getLogger(__name__) match_types = { @@ -53,11 +56,6 @@ class AnsibleLintRule(BaseRule): """Return rule documentation url.""" return RULE_DOC_URL + self.id + "/" - @property - def rule_config(self) -> dict[str, Any]: - """Retrieve rule specific configuration.""" - return get_rule_config(self.id) - def get_config(self, key: str) -> Any: """Return a configured value for given key string.""" return self.rule_config.get(key, None) @@ -78,6 +76,7 @@ class AnsibleLintRule(BaseRule): details: str = "", filename: Lintable | None = None, tag: str = "", + transform_meta: RuleMatchTransformMeta | None = None, ) -> MatchError: """Instantiate a new MatchError.""" match = MatchError( @@ -87,13 +86,14 @@ class AnsibleLintRule(BaseRule): lintable=filename or Lintable(""), rule=copy.copy(self), tag=tag, + transform_meta=transform_meta, ) # search through callers to find one of the match* methods frame = inspect.currentframe() match_type: str | None = None while not match_type and frame is not None: func_name = frame.f_code.co_name - match_type = match_types.get(func_name, None) + match_type = match_types.get(func_name) if match_type: # add the match_type to the match match.match_type = match_type @@ -109,8 +109,8 @@ class AnsibleLintRule(BaseRule): match.task = task if not match.details: match.details = "Task/Handler: " + ansiblelint.utils.task_to_str(task) - if match.lineno < task[LINE_NUMBER_KEY]: - match.lineno = task[LINE_NUMBER_KEY] + + match.lineno = max(match.lineno, task[LINE_NUMBER_KEY]) def matchlines(self, file: Lintable) -> list[MatchError]: matches: list[MatchError] = [] @@ -224,7 +224,16 @@ class AnsibleLintRule(BaseRule): if isinstance(yaml, str): if yaml.startswith("$ANSIBLE_VAULT"): return [] - return [MatchError(lintable=file, rule=LoadingFailureRule())] + if self._collection is None: + msg = f"Rule {self.id} was not added to a collection." + raise RuntimeError(msg) + return [ + # pylint: disable=E1136 + MatchError( + lintable=file, + rule=self._collection["load-failure"], + ), + ] if not yaml: return matches @@ -250,7 +259,7 @@ class AnsibleLintRule(BaseRule): class TransformMixin: """A mixin for AnsibleLintRule to enable transforming files. - If ansible-lint is started with the ``--write`` option, then the ``Transformer`` + If ansible-lint is started with the ``--fix`` option, then the ``Transformer`` will call the ``transform()`` method for every MatchError identified if the rule that identified it subclasses this ``TransformMixin``. Only the rule that identified a MatchError can do transforms to fix that match. @@ -324,7 +333,6 @@ class TransformMixin: return target -# pylint: disable=too-many-nested-blocks def load_plugins( dirs: list[str], ) -> Iterator[AnsibleLintRule]: @@ -370,7 +378,7 @@ def load_plugins( class RulesCollection: """Container for a collection of rules.""" - def __init__( + def __init__( # pylint: disable=too-many-arguments self, rulesdirs: list[str] | list[Path] | None = None, options: Options | None = None, @@ -388,7 +396,7 @@ class RulesCollection: else: self.options = options self.profile = [] - self.app = app or get_app(offline=True) + self.app = app or get_app(cached=True) if profile_name: self.profile = PROFILES[profile_name] @@ -405,6 +413,8 @@ class RulesCollection: WarningRule(), ], ) + for rule in self.rules: + rule._collection = self # noqa: SLF001 for rule in load_plugins(rulesdirs_str): self.register(rule, conditional=conditional) self.rules = sorted(self.rules) @@ -443,6 +453,17 @@ class RulesCollection: """Return the length of the RulesCollection data.""" return len(self.rules) + def __getitem__(self, item: Any) -> BaseRule: + """Return a rule from inside the collection based on its id.""" + if not isinstance(item, str): + msg = f"Expected str but got {type(item)} when trying to access rule by it's id" + raise TypeError(msg) + for rule in self.rules: + if rule.id == item: + return rule + msg = f"Rule {item} is not present inside this collection." + raise ValueError(msg) + def extend(self, more: list[AnsibleLintRule]) -> None: """Combine rules.""" self.rules.extend(more) @@ -469,7 +490,7 @@ class RulesCollection: MatchError( message=str(exc), lintable=file, - rule=LoadingFailureRule(), + rule=self["load-failure"], tag=f"{LoadingFailureRule.id}[{exc.__class__.__name__.lower()}]", ), ] @@ -482,10 +503,18 @@ class RulesCollection: or rule.has_dynamic_tags or not set(rule.tags).union([rule.id]).isdisjoint(tags) ): - rule_definition = set(rule.tags) - rule_definition.add(rule.id) - if set(rule_definition).isdisjoint(skip_list): - matches.extend(rule.getmatches(file)) + if tags and set(rule.tags).union(list(rule.ids().keys())).isdisjoint( + tags, + ): + _logger.debug("Skipping rule %s", rule.id) + else: + _logger.debug("Running rule %s", rule.id) + rule_definition = set(rule.tags) + rule_definition.add(rule.id) + if set(rule_definition).isdisjoint(skip_list): + matches.extend(rule.getmatches(file)) + else: + _logger.debug("Skipping rule %s", rule.id) # some rules can produce matches with tags that are inside our # skip_list, so we need to cleanse the matches @@ -499,6 +528,15 @@ class RulesCollection: [rule.verbose() for rule in sorted(self.rules, key=lambda x: x.id)], ) + def known_tags(self) -> list[str]: + """Return a list of known tags, without returning no sub-tags.""" + tags = set() + for rule in self.rules: + tags.add(rule.id) + for tag in rule.tags: + tags.add(tag) + return sorted(tags) + def list_tags(self) -> str: """Return a string with all the tags in the RulesCollection.""" tag_desc = { @@ -525,11 +563,10 @@ class RulesCollection: msg = f"Rule {rule} does not have any of the required tags: {', '.join(tag_desc.keys())}" raise RuntimeError(msg) for tag in rule.tags: - for id_ in rule.ids(): - tags[tag].append(id_) + tags[tag] = list(rule.ids()) result = "# List of tags and rules they cover\n" for tag in sorted(tags): - desc = tag_desc.get(tag, None) + desc = tag_desc.get(tag) if desc: result += f"{tag}: # {desc}\n" else: @@ -550,6 +587,8 @@ def filter_rules_with_profile(rule_col: list[BaseRule], profile: str) -> None: included.add(rule) extends = PROFILES[extends].get("extends", None) for rule in rule_col.copy(): + if rule.unloadable: + continue if rule.id not in included: _logger.debug( "Unloading %s rule due to not being part of %s profile.", diff --git a/src/ansiblelint/rules/args.py b/src/ansiblelint/rules/args.py index 2acf32e..fb9f991 100644 --- a/src/ansiblelint/rules/args.py +++ b/src/ansiblelint/rules/args.py @@ -1,4 +1,5 @@ """Rule definition to validate task options.""" + from __future__ import annotations import contextlib @@ -8,7 +9,6 @@ import json import logging import re import sys -from functools import lru_cache from typing import TYPE_CHECKING, Any # pylint: disable=preferred-module @@ -18,11 +18,11 @@ from unittest.mock import patch # pylint: disable=reimported import ansible.module_utils.basic as mock_ansible_module from ansible.module_utils import basic -from ansible.plugins.loader import PluginLoadContext, module_loader from ansiblelint.constants import LINE_NUMBER_KEY from ansiblelint.rules import AnsibleLintRule, RulesCollection from ansiblelint.text import has_jinja +from ansiblelint.utils import load_plugin from ansiblelint.yaml_utils import clean_json if TYPE_CHECKING: @@ -66,12 +66,6 @@ workarounds_inject_map = { } -@lru_cache -def load_module(module_name: str) -> PluginLoadContext: - """Load plugin from module name and cache it.""" - return module_loader.find_plugin_with_context(module_name) - - class ValidationPassedError(Exception): """Exception to be raised when validation passes.""" @@ -103,7 +97,7 @@ class ArgsRule(AnsibleLintRule): task: Task, file: Lintable | None = None, ) -> list[MatchError]: - # pylint: disable=too-many-locals,too-many-return-statements + # pylint: disable=too-many-return-statements results: list[MatchError] = [] module_name = task["action"]["__ansible_module_original__"] failed_msg = None @@ -111,7 +105,7 @@ class ArgsRule(AnsibleLintRule): if module_name in self.module_aliases: return [] - loaded_module = load_module(module_name) + loaded_module = load_plugin(module_name) # https://github.com/ansible/ansible-lint/issues/3200 # since "ps1" modules cannot be executed on POSIX platforms, we will @@ -150,14 +144,10 @@ class ArgsRule(AnsibleLintRule): CustomAnsibleModule, ): spec = importlib.util.spec_from_file_location( - name=loaded_module.resolved_fqcn, + name=loaded_module.plugin_resolved_name, location=loaded_module.plugin_resolved_path, ) - if spec: - assert spec.loader is not None - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - else: + if not spec: assert file is not None _logger.warning( "Unable to load module %s at %s:%s for options validation", @@ -166,6 +156,9 @@ class ArgsRule(AnsibleLintRule): task[LINE_NUMBER_KEY], ) return [] + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) try: if not hasattr(module, "main"): @@ -196,9 +189,9 @@ class ArgsRule(AnsibleLintRule): ) sanitized_results = self._sanitize_results(results, module_name) - return sanitized_results except ValidationPassedError: return [] + return sanitized_results # pylint: disable=unused-argument def _sanitize_results( diff --git a/src/ansiblelint/rules/avoid_implicit.py b/src/ansiblelint/rules/avoid_implicit.py index 8d1fe26..d752ec7 100644 --- a/src/ansiblelint/rules/avoid_implicit.py +++ b/src/ansiblelint/rules/avoid_implicit.py @@ -1,4 +1,5 @@ """Implementation of avoid-implicit rule.""" + # https://github.com/ansible/ansible-lint/issues/2501 from __future__ import annotations @@ -40,8 +41,8 @@ class AvoidImplicitRule(AnsibleLintRule): # testing code to be loaded only with pytest or when executed the rule file if "pytest" in sys.modules: - from ansiblelint.rules import RulesCollection # pylint: disable=ungrouped-imports - from ansiblelint.runner import Runner # pylint: disable=ungrouped-imports + from ansiblelint.rules import RulesCollection + from ansiblelint.runner import Runner def test_template_instead_of_copy_positive() -> None: """Positive test for avoid-implicit.""" diff --git a/src/ansiblelint/rules/command_instead_of_module.py b/src/ansiblelint/rules/command_instead_of_module.py index 068e430..538141b 100644 --- a/src/ansiblelint/rules/command_instead_of_module.py +++ b/src/ansiblelint/rules/command_instead_of_module.py @@ -1,4 +1,5 @@ """Implementation of command-instead-of-module rule.""" + # Copyright (c) 2013-2014 Will Thames <will@thames.id.au> # # Permission is hereby granted, free of charge, to any person obtaining a copy @@ -25,7 +26,7 @@ from pathlib import Path from typing import TYPE_CHECKING from ansiblelint.rules import AnsibleLintRule -from ansiblelint.utils import convert_to_boolean, get_first_cmd_arg, get_second_cmd_arg +from ansiblelint.utils import get_first_cmd_arg, get_second_cmd_arg if TYPE_CHECKING: from ansiblelint.file_utils import Lintable @@ -68,9 +69,17 @@ class CommandsInsteadOfModulesRule(AnsibleLintRule): } _executable_options = { - "git": ["branch", "log", "lfs"], - "systemctl": ["--version", "kill", "set-default", "show-environment", "status"], - "yum": ["clean"], + "git": ["branch", "log", "lfs", "rev-parse"], + "systemctl": [ + "--version", + "get-default", + "kill", + "set-default", + "set-property", + "show-environment", + "status", + ], + "yum": ["clean", "history", "info"], "rpm": ["--nodeps"], } @@ -97,9 +106,7 @@ class CommandsInsteadOfModulesRule(AnsibleLintRule): ): return False - if executable in self._modules and convert_to_boolean( - task["action"].get("warn", True), - ): + if executable in self._modules: message = "{0} used in place of {1} module" return message.format(executable, self._modules[executable]) return False @@ -108,8 +115,9 @@ class CommandsInsteadOfModulesRule(AnsibleLintRule): if "pytest" in sys.modules: import pytest - from ansiblelint.rules import RulesCollection # pylint: disable=ungrouped-imports - from ansiblelint.runner import Runner # pylint: disable=ungrouped-imports + # pylint: disable=ungrouped-imports + from ansiblelint.rules import RulesCollection + from ansiblelint.runner import Runner @pytest.mark.parametrize( ("file", "expected"), diff --git a/src/ansiblelint/rules/command_instead_of_shell.md b/src/ansiblelint/rules/command_instead_of_shell.md index 0abf69d..1e64d2c 100644 --- a/src/ansiblelint/rules/command_instead_of_shell.md +++ b/src/ansiblelint/rules/command_instead_of_shell.md @@ -28,3 +28,7 @@ environment variable expansion or chaining multiple commands using pipes. ansible.builtin.command: echo hello changed_when: false ``` + +!!! note + + This rule can be automatically fixed using [`--fix`](../autofix.md) option. diff --git a/src/ansiblelint/rules/command_instead_of_shell.py b/src/ansiblelint/rules/command_instead_of_shell.py index 346a071..789adca 100644 --- a/src/ansiblelint/rules/command_instead_of_shell.py +++ b/src/ansiblelint/rules/command_instead_of_shell.py @@ -1,4 +1,5 @@ """Implementation of command-instead-of-shell rule.""" + # Copyright (c) 2016 Will Thames <will@thames.id.au> # # Permission is hereby granted, free of charge, to any person obtaining a copy @@ -23,15 +24,18 @@ from __future__ import annotations import sys from typing import TYPE_CHECKING -from ansiblelint.rules import AnsibleLintRule +from ansiblelint.rules import AnsibleLintRule, TransformMixin from ansiblelint.utils import get_cmd_args if TYPE_CHECKING: + from ruamel.yaml.comments import CommentedMap, CommentedSeq + + from ansiblelint.errors import MatchError from ansiblelint.file_utils import Lintable from ansiblelint.utils import Task -class UseCommandInsteadOfShellRule(AnsibleLintRule): +class UseCommandInsteadOfShellRule(AnsibleLintRule, TransformMixin): """Use shell only when shell functionality is required.""" id = "command-instead-of-shell" @@ -62,13 +66,27 @@ class UseCommandInsteadOfShellRule(AnsibleLintRule): return not any(ch in jinja_stripped_cmd for ch in "&|<>;$\n*[]{}?`") return False + def transform( + self, + match: MatchError, + lintable: Lintable, + data: CommentedMap | CommentedSeq | str, + ) -> None: + if match.tag == "command-instead-of-shell": + target_task = self.seek(match.yaml_path, data) + for _ in range(len(target_task)): + k, v = target_task.popitem(False) + target_task["ansible.builtin.command" if "shell" in k else k] = v + match.fixed = True + # testing code to be loaded only with pytest or when executed the rule file if "pytest" in sys.modules: import pytest - from ansiblelint.rules import RulesCollection # pylint: disable=ungrouped-imports - from ansiblelint.runner import Runner # pylint: disable=ungrouped-imports + # pylint: disable=ungrouped-imports + from ansiblelint.rules import RulesCollection + from ansiblelint.runner import Runner @pytest.mark.parametrize( ("file", "expected"), diff --git a/src/ansiblelint/rules/complexity.md b/src/ansiblelint/rules/complexity.md new file mode 100644 index 0000000..aa25a1e --- /dev/null +++ b/src/ansiblelint/rules/complexity.md @@ -0,0 +1,19 @@ +# complexity + +This rule aims to warn about Ansible content that seems to be overly complex, +suggesting refactoring for better readability and maintainability. + +## complexity[tasks] + +`complexity[tasks]` will be triggered if the total number of tasks inside a file +is above 100. If encountered, you should consider using +[`ansible.builtin.include_tasks`](https://docs.ansible.com/ansible/latest/collections/ansible/builtin/include_tasks_module.html) +to split your tasks into smaller files. + +## complexity[nesting] + +`complexity[nesting]` will appear when a block contains too many tasks, by +default that number is 20 but it can be changed inside the configuration file by +defining `max_block_depth` value. + + Replace nested block with an include_tasks to make code easier to maintain. Maximum block depth allowed is ... diff --git a/src/ansiblelint/rules/complexity.py b/src/ansiblelint/rules/complexity.py new file mode 100644 index 0000000..04d92d0 --- /dev/null +++ b/src/ansiblelint/rules/complexity.py @@ -0,0 +1,115 @@ +"""Implementation of limiting number of tasks.""" + +from __future__ import annotations + +import re +import sys +from typing import TYPE_CHECKING, Any + +from ansiblelint.constants import LINE_NUMBER_KEY +from ansiblelint.rules import AnsibleLintRule, RulesCollection + +if TYPE_CHECKING: + from ansiblelint.config import Options + from ansiblelint.errors import MatchError + from ansiblelint.file_utils import Lintable + from ansiblelint.utils import Task + + +class ComplexityRule(AnsibleLintRule): + """Rule for limiting number of tasks inside a file.""" + + id = "complexity" + description = "There should be limited tasks executed inside any file" + severity = "MEDIUM" + tags = ["experimental", "idiom"] + version_added = "v6.18.0 (last update)" + _re_templated_inside = re.compile(r".*\{\{.*\}\}.*\w.*$") + + def matchplay(self, file: Lintable, data: dict[str, Any]) -> list[MatchError]: + """Call matchplay for up to no_of_max_tasks inside file and return aggregate results.""" + results: list[MatchError] = [] + + if file.kind != "playbook": + return [] + tasks = data.get("tasks", []) + if not isinstance(self._collection, RulesCollection): + msg = "Rules cannot be run outside a rule collection." + raise TypeError(msg) + if len(tasks) > self._collection.options.max_tasks: + results.append( + self.create_matcherror( + message=f"Maximum tasks allowed in a play is {self._collection.options.max_tasks}.", + lineno=data[LINE_NUMBER_KEY], + tag=f"{self.id}[play]", + filename=file, + ), + ) + return results + + def matchtask(self, task: Task, file: Lintable | None = None) -> list[MatchError]: + """Check if the task is a block and count the number of items inside it.""" + results: list[MatchError] = [] + + if not isinstance(self._collection, RulesCollection): + msg = "Rules cannot be run outside a rule collection." + raise TypeError(msg) + + if task.action == "block/always/rescue": + block_depth = self.calculate_block_depth(task) + if block_depth > self._collection.options.max_block_depth: + results.append( + self.create_matcherror( + message=f"Replace nested block with an include_tasks to make code easier to maintain. Maximum block depth allowed is {self._collection.options.max_block_depth}.", + lineno=task[LINE_NUMBER_KEY], + tag=f"{self.id}[nesting]", + filename=file, + ), + ) + return results + + def calculate_block_depth(self, task: Task) -> int: + """Recursively calculate the block depth of a task.""" + if not isinstance(task.position, str): + raise NotImplementedError + return task.position.count(".block") + + +if "pytest" in sys.modules: + import pytest + + # pylint: disable=ungrouped-imports + from ansiblelint.runner import Runner + + @pytest.mark.parametrize( + ("file", "expected_results"), + ( + pytest.param( + "examples/playbooks/rule-complexity-pass.yml", + [], + id="pass", + ), + pytest.param( + "examples/playbooks/rule-complexity-fail.yml", + ["complexity[play]", "complexity[nesting]"], + id="fail", + ), + ), + ) + def test_complexity( + file: str, + expected_results: list[str], + monkeypatch: pytest.MonkeyPatch, + config_options: Options, + ) -> None: + """Test rule.""" + monkeypatch.setattr(config_options, "max_tasks", 5) + monkeypatch.setattr(config_options, "max_block_depth", 3) + collection = RulesCollection(options=config_options) + collection.register(ComplexityRule()) + results = Runner(file, rules=collection).run() + + assert len(results) == len(expected_results) + for i, result in enumerate(results): + assert result.rule.id == ComplexityRule.id, result + assert result.tag == expected_results[i] diff --git a/src/ansiblelint/rules/conftest.py b/src/ansiblelint/rules/conftest.py index f4df7a5..5a22ffd 100644 --- a/src/ansiblelint/rules/conftest.py +++ b/src/ansiblelint/rules/conftest.py @@ -1,3 +1,4 @@ """Makes pytest fixtures available.""" + # pylint: disable=wildcard-import,unused-wildcard-import from ansiblelint.testing.fixtures import * # noqa: F403 diff --git a/src/ansiblelint/rules/deprecated_bare_vars.py b/src/ansiblelint/rules/deprecated_bare_vars.py index 1756e92..7b1ab08 100644 --- a/src/ansiblelint/rules/deprecated_bare_vars.py +++ b/src/ansiblelint/rules/deprecated_bare_vars.py @@ -27,7 +27,7 @@ import sys from typing import TYPE_CHECKING, Any from ansiblelint.rules import AnsibleLintRule -from ansiblelint.text import has_glob, has_jinja +from ansiblelint.text import has_glob, has_jinja, is_fqcn_or_name if TYPE_CHECKING: from ansiblelint.file_utils import Lintable @@ -66,7 +66,7 @@ class UsingBareVariablesIsDeprecatedRule(AnsibleLintRule): # we just need to check that one variable, and not iterate over it like # it's a list. Otherwise, loop through and check all items. items = task[loop_type] - if not isinstance(items, (list, tuple)): + if not isinstance(items, list | tuple): items = [items] for var in items: return self._matchvar(var, task, loop_type) @@ -84,7 +84,11 @@ class UsingBareVariablesIsDeprecatedRule(AnsibleLintRule): task: dict[str, Any], loop_type: str, ) -> bool | str: - if isinstance(varstring, str) and not has_jinja(varstring): + if ( + isinstance(varstring, str) + and not has_jinja(varstring) + and is_fqcn_or_name(varstring) + ): valid = loop_type == "with_fileglob" and bool( has_jinja(varstring) or has_glob(varstring), ) @@ -121,4 +125,4 @@ if "pytest" in sys.modules: failure = "examples/playbooks/rule-deprecated-bare-vars-fail.yml" bad_runner = Runner(failure, rules=collection) errs = bad_runner.run() - assert len(errs) == 12 + assert len(errs) == 11 diff --git a/src/ansiblelint/rules/deprecated_local_action.md b/src/ansiblelint/rules/deprecated_local_action.md index c52eb9d..68f4345 100644 --- a/src/ansiblelint/rules/deprecated_local_action.md +++ b/src/ansiblelint/rules/deprecated_local_action.md @@ -19,3 +19,7 @@ This rule recommends using `delegate_to: localhost` instead of the ansible.builtin.debug: delegate_to: localhost # <-- recommended way to run on localhost ``` + +!!! note + + This rule can be automatically fixed using [`--fix`](../autofix.md) option. diff --git a/src/ansiblelint/rules/deprecated_local_action.py b/src/ansiblelint/rules/deprecated_local_action.py index fc3e4ff..4e09795 100644 --- a/src/ansiblelint/rules/deprecated_local_action.py +++ b/src/ansiblelint/rules/deprecated_local_action.py @@ -1,19 +1,33 @@ """Implementation for deprecated-local-action rule.""" + # Copyright (c) 2016, Tsukinowa Inc. <info@tsukinowa.jp> # Copyright (c) 2018, Ansible Project from __future__ import annotations +import copy +import logging +import os import sys +from pathlib import Path from typing import TYPE_CHECKING -from ansiblelint.rules import AnsibleLintRule +from ansiblelint.rules import AnsibleLintRule, TransformMixin +from ansiblelint.runner import get_matches +from ansiblelint.transformer import Transformer if TYPE_CHECKING: + from ruamel.yaml.comments import CommentedMap, CommentedSeq + + from ansiblelint.config import Options + from ansiblelint.errors import MatchError from ansiblelint.file_utils import Lintable from ansiblelint.utils import Task -class TaskNoLocalAction(AnsibleLintRule): +_logger = logging.getLogger(__name__) + + +class TaskNoLocalAction(AnsibleLintRule, TransformMixin): """Do not use 'local_action', use 'delegate_to: localhost'.""" id = "deprecated-local-action" @@ -35,11 +49,46 @@ class TaskNoLocalAction(AnsibleLintRule): return False + def transform( + self, + match: MatchError, + lintable: Lintable, + data: CommentedMap | CommentedSeq | str, + ) -> None: + if match.tag == self.id: + # we do not want perform a partial modification accidentally + original_target_task = self.seek(match.yaml_path, data) + target_task = copy.deepcopy(original_target_task) + for _ in range(len(target_task)): + k, v = target_task.popitem(False) + if k == "local_action": + if isinstance(v, dict): + module_name = v["module"] + target_task[module_name] = None + target_task["delegate_to"] = "localhost" + elif isinstance(v, str): + module_name, module_value = v.split(" ", 1) + target_task[module_name] = module_value + target_task["delegate_to"] = "localhost" + else: + _logger.debug( + "Ignored unexpected data inside %s transform.", + self.id, + ) + return + else: + target_task[k] = v + match.fixed = True + original_target_task.clear() + original_target_task.update(target_task) + # testing code to be loaded only with pytest or when executed the rule file if "pytest" in sys.modules: - from ansiblelint.rules import RulesCollection # pylint: disable=ungrouped-imports - from ansiblelint.runner import Runner # pylint: disable=ungrouped-imports + from unittest import mock + + from ansiblelint.rules import RulesCollection + from ansiblelint.runner import Runner def test_local_action(default_rules_collection: RulesCollection) -> None: """Positive test deprecated_local_action.""" @@ -50,3 +99,34 @@ if "pytest" in sys.modules: assert len(results) == 1 assert results[0].tag == "deprecated-local-action" + + @mock.patch.dict(os.environ, {"ANSIBLE_LINT_WRITE_TMP": "1"}, clear=True) + def test_local_action_transform( + config_options: Options, + default_rules_collection: RulesCollection, + ) -> None: + """Test transform functionality for no-log-password rule.""" + playbook = Path("examples/playbooks/tasks/local_action.yml") + config_options.write_list = ["all"] + + config_options.lintables = [str(playbook)] + runner_result = get_matches( + rules=default_rules_collection, + options=config_options, + ) + transformer = Transformer(result=runner_result, options=config_options) + transformer.run() + matches = runner_result.matches + assert len(matches) == 3 + + orig_content = playbook.read_text(encoding="utf-8") + expected_content = playbook.with_suffix( + f".transformed{playbook.suffix}", + ).read_text(encoding="utf-8") + transformed_content = playbook.with_suffix(f".tmp{playbook.suffix}").read_text( + encoding="utf-8", + ) + + assert orig_content != transformed_content + assert expected_content == transformed_content + playbook.with_suffix(f".tmp{playbook.suffix}").unlink() diff --git a/src/ansiblelint/rules/deprecated_module.py b/src/ansiblelint/rules/deprecated_module.py index 03c9361..72e328f 100644 --- a/src/ansiblelint/rules/deprecated_module.py +++ b/src/ansiblelint/rules/deprecated_module.py @@ -1,4 +1,5 @@ """Implementation of deprecated-module rule.""" + # Copyright (c) 2018, Ansible Project from __future__ import annotations diff --git a/src/ansiblelint/rules/empty_string_compare.py b/src/ansiblelint/rules/empty_string_compare.py index 5c7cafc..6870ed2 100644 --- a/src/ansiblelint/rules/empty_string_compare.py +++ b/src/ansiblelint/rules/empty_string_compare.py @@ -1,4 +1,5 @@ """Implementation of empty-string-compare rule.""" + # Copyright (c) 2016, Will Thames and contributors # Copyright (c) 2018, Ansible Project @@ -54,8 +55,8 @@ class ComparisonToEmptyStringRule(AnsibleLintRule): # testing code to be loaded only with pytest or when executed the rule file if "pytest" in sys.modules: - from ansiblelint.rules import RulesCollection # pylint: disable=ungrouped-imports - from ansiblelint.runner import Runner # pylint: disable=ungrouped-imports + from ansiblelint.rules import RulesCollection + from ansiblelint.runner import Runner def test_rule_empty_string_compare_fail() -> None: """Test rule matches.""" diff --git a/src/ansiblelint/rules/fqcn.md b/src/ansiblelint/rules/fqcn.md index 0165477..a64a324 100644 --- a/src/ansiblelint/rules/fqcn.md +++ b/src/ansiblelint/rules/fqcn.md @@ -87,3 +87,7 @@ structure in a backward-compatible way by adding redirects like in # Use the FQCN for the builtin shell module. ansible.builtin.shell: ssh ssh_user@{{ ansible_ssh_host }} ``` + +!!! note + + This rule can be automatically fixed using [`--fix`](../autofix.md) option. diff --git a/src/ansiblelint/rules/fqcn.py b/src/ansiblelint/rules/fqcn.py index 768fb9e..b571db3 100644 --- a/src/ansiblelint/rules/fqcn.py +++ b/src/ansiblelint/rules/fqcn.py @@ -1,17 +1,19 @@ """Rule definition for usage of fully qualified collection names for builtins.""" + from __future__ import annotations import logging import sys from typing import TYPE_CHECKING, Any -from ansible.plugins.loader import module_loader +from ruamel.yaml.comments import CommentedSeq from ansiblelint.constants import LINE_NUMBER_KEY from ansiblelint.rules import AnsibleLintRule, TransformMixin +from ansiblelint.utils import load_plugin if TYPE_CHECKING: - from ruamel.yaml.comments import CommentedMap, CommentedSeq + from ruamel.yaml.comments import CommentedMap from ansiblelint.errors import MatchError from ansiblelint.file_utils import Lintable @@ -114,11 +116,16 @@ class FQCNBuiltinsRule(AnsibleLintRule, TransformMixin): task: Task, file: Lintable | None = None, ) -> list[MatchError]: - result = [] + result: list[MatchError] = [] + if file and file.failed(): + return result module = task["action"]["__ansible_module_original__"] + if not isinstance(module, str): + msg = "Invalid data for module." + raise TypeError(msg) if module not in self.module_aliases: - loaded_module = module_loader.find_plugin_with_context(module) + loaded_module = load_plugin(module) target = loaded_module.resolved_fqcn self.module_aliases[module] = target if target is None: @@ -137,40 +144,45 @@ class FQCNBuiltinsRule(AnsibleLintRule, TransformMixin): 1, ) if module != legacy_module: + if module == "ansible.builtin.include": + message = f"Avoid deprecated module ({module})" + details = "Use `ansible.builtin.include_task` or `ansible.builtin.import_tasks` instead." + else: + message = f"Use FQCN for builtin module actions ({module})." + details = f"Use `{module_alias}` or `{legacy_module}` instead." result.append( self.create_matcherror( - message=f"Use FQCN for builtin module actions ({module}).", - details=f"Use `{module_alias}` or `{legacy_module}` instead.", + message=message, + details=details, filename=file, lineno=task["__line__"], tag="fqcn[action-core]", ), ) - else: - if module.count(".") < 2: - result.append( - self.create_matcherror( - message=f"Use FQCN for module actions, such `{self.module_aliases[module]}`.", - details=f"Action `{module}` is not FQCN.", - filename=file, - lineno=task["__line__"], - tag="fqcn[action]", - ), - ) - # TODO(ssbarnea): Remove the c.g. and c.n. exceptions from here once # noqa: FIX002 - # community team is flattening these. - # https://github.com/ansible-community/community-topics/issues/147 - elif not module.startswith("community.general.") or module.startswith( - "community.network.", - ): - result.append( - self.create_matcherror( - message=f"You should use canonical module name `{self.module_aliases[module]}` instead of `{module}`.", - filename=file, - lineno=task["__line__"], - tag="fqcn[canonical]", - ), - ) + elif module.count(".") < 2: + result.append( + self.create_matcherror( + message=f"Use FQCN for module actions, such `{self.module_aliases[module]}`.", + details=f"Action `{module}` is not FQCN.", + filename=file, + lineno=task["__line__"], + tag="fqcn[action]", + ), + ) + # TODO(ssbarnea): Remove the c.g. and c.n. exceptions from here once # noqa: FIX002 + # community team is flattening these. + # https://github.com/ansible-community/community-topics/issues/147 + elif not module.startswith("community.general.") or module.startswith( + "community.network.", + ): + result.append( + self.create_matcherror( + message=f"You should use canonical module name `{self.module_aliases[module]}` instead of `{module}`.", + filename=file, + lineno=task["__line__"], + tag="fqcn[canonical]", + ), + ) return result def matchyaml(self, file: Lintable) -> list[MatchError]: @@ -220,6 +232,8 @@ class FQCNBuiltinsRule(AnsibleLintRule, TransformMixin): target_task = self.seek(match.yaml_path, data) # Unfortunately, a lot of data about Ansible content gets lost here, you only get a simple dict. # For now, just parse the error messages for the data about action names etc. and fix this later. + current_action = "" + new_action = "" if match.tag == "fqcn[action-core]": # split at the first bracket, cut off the last bracket and dot current_action = match.message.split("(")[1][:-2] @@ -233,6 +247,8 @@ class FQCNBuiltinsRule(AnsibleLintRule, TransformMixin): current_action = match.message.split("`")[3] new_action = match.message.split("`")[1] for _ in range(len(target_task)): + if isinstance(target_task, CommentedSeq): + continue k, v = target_task.popitem(False) target_task[new_action if k == current_action else k] = v match.fixed = True @@ -241,7 +257,7 @@ class FQCNBuiltinsRule(AnsibleLintRule, TransformMixin): # testing code to be loaded only with pytest or when executed the rule file if "pytest" in sys.modules: from ansiblelint.rules import RulesCollection - from ansiblelint.runner import Runner # pylint: disable=ungrouped-imports + from ansiblelint.runner import Runner def test_fqcn_builtin_fail() -> None: """Test rule matches.""" @@ -269,7 +285,7 @@ if "pytest" in sys.modules: """Test rule matches.""" collection = RulesCollection() collection.register(FQCNBuiltinsRule()) - failure = "examples/collection/plugins/modules/deep/beta.py" + failure = "examples/.collection/plugins/modules/deep/beta.py" results = Runner(failure, rules=collection).run() assert len(results) == 1 assert results[0].tag == "fqcn[deep]" @@ -279,6 +295,6 @@ if "pytest" in sys.modules: """Test rule does not match.""" collection = RulesCollection() collection.register(FQCNBuiltinsRule()) - success = "examples/collection/plugins/modules/alpha.py" + success = "examples/.collection/plugins/modules/alpha.py" results = Runner(success, rules=collection).run() assert len(results) == 0 diff --git a/src/ansiblelint/rules/galaxy.md b/src/ansiblelint/rules/galaxy.md index 61fc5c5..d719e30 100644 --- a/src/ansiblelint/rules/galaxy.md +++ b/src/ansiblelint/rules/galaxy.md @@ -26,6 +26,8 @@ This rule can produce messages such: - `galaxy[tags]` - `galaxy.yaml` must have one of the required tags: `application`, `cloud`, `database`, `infrastructure`, `linux`, `monitoring`, `networking`, `security`, `storage`, `tools`, `windows`. +- `galaxy[invalid-dependency-version]` = Invalid collection metadata. Dependency + version spec range is invalid If you want to ignore some of the messages above, you can add any of them to the `ignore_list`. @@ -60,12 +62,14 @@ description: "..." # Changelog Details -This rule expects a `CHANGELOG.md` or `.rst` file in the collection root or a -`changelogs/changelog.yaml` file. +This rule expects a `CHANGELOG.md`, `CHANGELOG.rst`, +`changelogs/changelog.yaml`, or `changelogs/changelog.yml` file in the +collection root. -If a `changelogs/changelog.yaml` file exists, the schema will be checked. +If a `changelogs/changelog.yaml` or `changelogs/changelog.yml` file exists, the +schema will be checked. -## Minimum required changelog.yaml file +## Minimum required changelog.yaml/changelog.yml file ```yaml # changelog.yaml diff --git a/src/ansiblelint/rules/galaxy.py b/src/ansiblelint/rules/galaxy.py index 2f627f5..e9b21d3 100644 --- a/src/ansiblelint/rules/galaxy.py +++ b/src/ansiblelint/rules/galaxy.py @@ -1,11 +1,12 @@ """Implementation of GalaxyRule.""" + from __future__ import annotations import sys from functools import total_ordering from typing import TYPE_CHECKING, Any -from ansiblelint.constants import LINE_NUMBER_KEY +from ansiblelint.constants import FILENAME_KEY, LINE_NUMBER_KEY from ansiblelint.rules import AnsibleLintRule if TYPE_CHECKING: @@ -27,6 +28,7 @@ class GalaxyRule(AnsibleLintRule): "galaxy[version-missing]": "galaxy.yaml should have version tag.", "galaxy[version-incorrect]": "collection version should be greater than or equal to 1.0.0", "galaxy[no-runtime]": "meta/runtime.yml file not found.", + "galaxy[invalid-dependency-version]": "Invalid collection metadata. Dependency version spec range is invalid", } def matchplay(self, file: Lintable, data: dict[str, Any]) -> list[MatchError]: @@ -39,6 +41,7 @@ class GalaxyRule(AnsibleLintRule): "application", "cloud", "database", + "eda", "infrastructure", "linux", "monitoring", @@ -55,6 +58,7 @@ class GalaxyRule(AnsibleLintRule): changelog_found = 0 changelog_paths = [ base_path / "changelogs" / "changelog.yaml", + base_path / "changelogs" / "changelog.yml", base_path / "CHANGELOG.rst", base_path / "CHANGELOG.md", ] @@ -62,8 +66,21 @@ class GalaxyRule(AnsibleLintRule): for path in changelog_paths: if path.is_file(): changelog_found = 1 - - galaxy_tag_list = data.get("tags", None) + galaxy_tag_list = data.get("tags") + collection_deps = data.get("dependencies") + if collection_deps: + for dep, ver in collection_deps.items(): + if ( + dep not in [LINE_NUMBER_KEY, FILENAME_KEY] + and len(str(ver).strip()) == 0 + ): + results.append( + self.create_matcherror( + message=f"Invalid collection metadata. Dependency version spec range is invalid for '{dep}'.", + tag="galaxy[invalid-dependency-version]", + filename=file, + ), + ) # Changelog Check - building off Galaxy rule as there is no current way to check # for a nonexistent file @@ -108,7 +125,6 @@ class GalaxyRule(AnsibleLintRule): results.append( self.create_matcherror( message="collection version should be greater than or equal to 1.0.0", - # pylint: disable=protected-access lineno=version._line_number, # noqa: SLF001 tag="galaxy[version-incorrect]", filename=file, @@ -154,7 +170,7 @@ class Version: def _coerce(other: object) -> Version: if isinstance(other, str): other = Version(other) - if isinstance(other, (int, float)): + if isinstance(other, int | float): other = Version(str(other)) if isinstance(other, Version): return other @@ -172,7 +188,7 @@ if "pytest" in sys.modules: """Positive test for collection version in galaxy.""" collection = RulesCollection() collection.register(GalaxyRule()) - success = "examples/collection/galaxy.yml" + success = "examples/.collection/galaxy.yml" good_runner = Runner(success, rules=collection) assert [] == good_runner.run() @@ -189,7 +205,7 @@ if "pytest" in sys.modules: """Test for no collection version in galaxy.""" collection = RulesCollection() collection.register(GalaxyRule()) - failure = "examples/no_collection_version/galaxy.yml" + failure = "examples/.no_collection_version/galaxy.yml" bad_runner = Runner(failure, rules=collection) errs = bad_runner.run() assert len(errs) == 1 @@ -222,17 +238,25 @@ if "pytest" in sys.modules: id="pass", ), pytest.param( - "examples/collection/galaxy.yml", + "examples/.collection/galaxy.yml", ["schema[galaxy]"], id="schema", ), pytest.param( - "examples/no_changelog/galaxy.yml", + "examples/.invalid_dependencies/galaxy.yml", + [ + "galaxy[invalid-dependency-version]", + "galaxy[invalid-dependency-version]", + ], + id="invalid-dependency-version", + ), + pytest.param( + "examples/.no_changelog/galaxy.yml", ["galaxy[no-changelog]"], id="no-changelog", ), pytest.param( - "examples/no_collection_version/galaxy.yml", + "examples/.no_collection_version/galaxy.yml", ["schema[galaxy]", "galaxy[version-missing]"], id="no-collection-version", ), diff --git a/src/ansiblelint/rules/ignore_errors.py b/src/ansiblelint/rules/ignore_errors.py index 4144f2d..29f0408 100644 --- a/src/ansiblelint/rules/ignore_errors.py +++ b/src/ansiblelint/rules/ignore_errors.py @@ -1,4 +1,5 @@ """IgnoreErrorsRule used with ansible-lint.""" + from __future__ import annotations import sys @@ -44,7 +45,7 @@ if "pytest" in sys.modules: import pytest if TYPE_CHECKING: - from ansiblelint.testing import RunFromText # pylint: disable=ungrouped-imports + from ansiblelint.testing import RunFromText IGNORE_ERRORS_TRUE = """ - hosts: all diff --git a/src/ansiblelint/rules/inline_env_var.py b/src/ansiblelint/rules/inline_env_var.py index f578fb7..1f0747e 100644 --- a/src/ansiblelint/rules/inline_env_var.py +++ b/src/ansiblelint/rules/inline_env_var.py @@ -1,4 +1,5 @@ """Implementation of inside-env-var rule.""" + # Copyright (c) 2016 Will Thames <will@thames.id.au> # # Permission is hereby granted, free of charge, to any person obtaining a copy @@ -48,7 +49,6 @@ class EnvVarsInCommandRule(AnsibleLintRule): "executable", "removes", "stdin", - "warn", "stdin_add_newline", "strip_empty_ends", "cmd", diff --git a/src/ansiblelint/rules/jinja.md b/src/ansiblelint/rules/jinja.md index 8e1732e..e4720d7 100644 --- a/src/ansiblelint/rules/jinja.md +++ b/src/ansiblelint/rules/jinja.md @@ -12,7 +12,7 @@ version can report: As jinja2 syntax is closely following Python one we aim to follow [black](https://black.readthedocs.io/en/stable/) formatting rules. If you are -curious how black would reformat a small sniped feel free to visit +curious how black would reformat a small snippet feel free to visit [online black formatter](https://black.vercel.app/) site. Keep in mind to not include the entire jinja2 template, so instead of `{{ 1+2==3 }}`, do paste only `1+2==3`. @@ -53,3 +53,7 @@ In its current form, this rule presents the following limitations: does not support tilde as a binary operator. Example: `{{ a ~ b }}`. - Jinja2 blocks that use dot notation with numbers are ignored because python and black do not allow it. Example: `{{ foo.0.bar }}` + +!!! note + + This rule can be automatically fixed using [`--fix`](../autofix.md) option. diff --git a/src/ansiblelint/rules/jinja.py b/src/ansiblelint/rules/jinja.py index 08254bc..ff124a8 100644 --- a/src/ansiblelint/rules/jinja.py +++ b/src/ansiblelint/rules/jinja.py @@ -1,28 +1,35 @@ """Rule for checking content of jinja template strings.""" + from __future__ import annotations import logging +import os import re import sys -from collections import namedtuple +from dataclasses import dataclass from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, NamedTuple import black import jinja2 -from ansible.errors import AnsibleError, AnsibleParserError +from ansible.errors import AnsibleError, AnsibleFilterError, AnsibleParserError from ansible.parsing.yaml.objects import AnsibleUnicode from jinja2.exceptions import TemplateSyntaxError from ansiblelint.constants import LINE_NUMBER_KEY +from ansiblelint.errors import RuleMatchTransformMeta from ansiblelint.file_utils import Lintable -from ansiblelint.rules import AnsibleLintRule +from ansiblelint.rules import AnsibleLintRule, TransformMixin +from ansiblelint.runner import get_matches from ansiblelint.skip_utils import get_rule_skips_from_line from ansiblelint.text import has_jinja from ansiblelint.utils import parse_yaml_from_file, template from ansiblelint.yaml_utils import deannotate, nested_items_path if TYPE_CHECKING: + from ruamel.yaml.comments import CommentedMap, CommentedSeq + + from ansiblelint.config import Options from ansiblelint.errors import MatchError from ansiblelint.utils import Task @@ -30,7 +37,14 @@ if TYPE_CHECKING: _logger = logging.getLogger(__package__) KEYWORDS_WITH_IMPLICIT_TEMPLATE = ("changed_when", "failed_when", "until", "when") -Token = namedtuple("Token", "lineno token_type value") + +class Token(NamedTuple): + """Token.""" + + lineno: int + token_type: str + value: str + ignored_re = re.compile( "|".join( # noqa: FLY002 @@ -53,7 +67,27 @@ ignored_re = re.compile( ) -class JinjaRule(AnsibleLintRule): +@dataclass(frozen=True) +class JinjaRuleTMetaSpacing(RuleMatchTransformMeta): + """JinjaRule transform metadata. + + :param key: Key or index within the task + :param value: Value of the key + :param path: Path to the key + :param fixed: Value with spacing fixed + """ + + key: str | int + value: str | int + path: tuple[str | int, ...] + fixed: str + + def __str__(self) -> str: + """Return string representation.""" + return f"{self.key}={self.value} at {self.path} fixed to {self.fixed}" + + +class JinjaRule(AnsibleLintRule, TransformMixin): """Rule that looks inside jinja2 templates.""" id = "jinja" @@ -94,11 +128,13 @@ class JinjaRule(AnsibleLintRule): if isinstance(v, str): try: template( - basedir=file.path.parent if file else Path("."), + basedir=file.path.parent if file else Path(), value=v, variables=deannotate(task.get("vars", {})), fail_on_error=True, # we later decide which ones to ignore or not ) + except AnsibleFilterError: + bypass = True # ValueError RepresenterError except AnsibleError as exc: bypass = False @@ -111,7 +147,7 @@ class JinjaRule(AnsibleLintRule): ) if ignored_re.search(orig_exc_message) or isinstance( orig_exc, - AnsibleParserError, + AnsibleParserError | TypeError, ): # An unhandled exception occurred while running the lookup plugin 'template'. Error was a <class 'ansible.errors.AnsibleError'>, original message: the template file ... could not be found for the lookup. the template file ... could not be found for the lookup @@ -119,7 +155,7 @@ class JinjaRule(AnsibleLintRule): # AnsibleError(TemplateSyntaxError): template error while templating string: Could not load "ipwrap": 'Invalid plugin FQCN (ansible.netcommon.ipwrap): unable to locate collection ansible.netcommon'. String: Foo {{ buildset_registry.host | ipwrap }}. Could not load "ipwrap": 'Invalid plugin FQCN (ansible.netcommon.ipwrap): unable to locate collection ansible.netcommon' bypass = True elif ( - isinstance(orig_exc, (AnsibleError, TemplateSyntaxError)) + isinstance(orig_exc, AnsibleError | TemplateSyntaxError) and match ): error = match.group("error") @@ -166,6 +202,12 @@ class JinjaRule(AnsibleLintRule): details=details, filename=file, tag=f"{self.id}[{tag}]", + transform_meta=JinjaRuleTMetaSpacing( + key=key, + value=v, + path=tuple(path), + fixed=reformatted, + ), ), ) except Exception as exc: @@ -181,7 +223,6 @@ class JinjaRule(AnsibleLintRule): if str(file.kind) == "vars": data = parse_yaml_from_file(str(file.path)) - # pylint: disable=unused-variable for key, v, _path in nested_items_path(data): if isinstance(v, AnsibleUnicode): reformatted, details, tag = self.check_whitespace( @@ -249,7 +290,7 @@ class JinjaRule(AnsibleLintRule): last_value = value return result - # pylint: disable=too-many-statements,too-many-locals + # pylint: disable=too-many-locals def check_whitespace( self, text: str, @@ -327,7 +368,7 @@ class JinjaRule(AnsibleLintRule): # process expression # pylint: disable=unsupported-membership-test if isinstance(expr_str, str) and "\n" in expr_str: - raise NotImplementedError + raise NotImplementedError # noqa: TRY301 leading_spaces = " " * (len(expr_str) - len(expr_str.lstrip())) expr_str = leading_spaces + blacken(expr_str.lstrip()) if tokens[ @@ -348,7 +389,6 @@ class JinjaRule(AnsibleLintRule): except jinja2.exceptions.TemplateSyntaxError as exc: return "", str(exc.message), "invalid" - # https://github.com/PyCQA/pylint/issues/7433 - py311 only # pylint: disable=c-extension-no-member except (NotImplementedError, black.parsing.InvalidInput) as exc: # black is not able to recognize all valid jinja2 templates, so we @@ -370,6 +410,68 @@ class JinjaRule(AnsibleLintRule): ) return reformatted, details, "spacing" + def transform( + self: JinjaRule, + match: MatchError, + lintable: Lintable, + data: CommentedMap | CommentedSeq | str, + ) -> None: + """Transform jinja2 errors. + + :param match: MatchError instance + :param lintable: Lintable instance + :param data: data to transform + """ + if match.tag == "jinja[spacing]": + self._transform_spacing(match, data) + + def _transform_spacing( + self: JinjaRule, + match: MatchError, + data: CommentedMap | CommentedSeq | str, + ) -> None: + """Transform jinja2 spacing errors. + + The match error was found on a normalized task so we cannot compare the path + instead we only compare the key and value, if the task has 2 identical keys with the + exact same jinja spacing issue, we may transform them out of order + + :param match: MatchError instance + :param data: data to transform + """ + if not isinstance(match.transform_meta, JinjaRuleTMetaSpacing): + return + if isinstance(data, str): + return + + obj = self.seek(match.yaml_path, data) + if obj is None: + return + + ignored_keys = ("block", "ansible.builtin.block", "ansible.legacy.block") + for key, value, path in nested_items_path( + data_collection=obj, + ignored_keys=ignored_keys, + ): + if key == match.transform_meta.key and value == match.transform_meta.value: + if not path: + continue + for pth in path[:-1]: + try: + obj = obj[pth] + except (KeyError, TypeError) as exc: + err = f"Unable to transform {match.transform_meta}: {exc}" + _logger.error(err) # noqa: TRY400 + return + try: + obj[path[-1]][key] = match.transform_meta.fixed + match.fixed = True + + except (KeyError, TypeError) as exc: + err = f"Unable to transform {match.transform_meta}: {exc}" + _logger.error(err) # noqa: TRY400 + return + def blacken(text: str) -> str: """Format Jinja2 template using black.""" @@ -380,10 +482,14 @@ def blacken(text: str) -> str: if "pytest" in sys.modules: + from unittest import mock + import pytest - from ansiblelint.rules import RulesCollection # pylint: disable=ungrouped-imports - from ansiblelint.runner import Runner # pylint: disable=ungrouped-imports + # pylint: disable=ungrouped-imports + from ansiblelint.rules import RulesCollection + from ansiblelint.runner import Runner + from ansiblelint.transformer import Transformer @pytest.fixture(name="error_expected_lines") def fixture_error_expected_lines() -> list[int]: @@ -725,6 +831,38 @@ if "pytest" in sys.modules: errs = Runner(success, rules=collection).run() assert len(errs) == 0 + @mock.patch.dict(os.environ, {"ANSIBLE_LINT_WRITE_TMP": "1"}, clear=True) + def test_jinja_transform( + config_options: Options, + default_rules_collection: RulesCollection, + ) -> None: + """Test transform functionality for jinja rule.""" + playbook = Path("examples/playbooks/rule-jinja-before.yml") + config_options.write_list = ["all"] + + config_options.lintables = [str(playbook)] + runner_result = get_matches( + rules=default_rules_collection, + options=config_options, + ) + transformer = Transformer(result=runner_result, options=config_options) + transformer.run() + + matches = runner_result.matches + assert len(matches) == 2 + + orig_content = playbook.read_text(encoding="utf-8") + expected_content = playbook.with_suffix( + f".transformed{playbook.suffix}", + ).read_text(encoding="utf-8") + transformed_content = playbook.with_suffix(f".tmp{playbook.suffix}").read_text( + encoding="utf-8", + ) + + assert orig_content != transformed_content + assert expected_content == transformed_content + playbook.with_suffix(f".tmp{playbook.suffix}").unlink() + def _get_error_line(task: dict[str, Any], path: list[str | int]) -> int: """Return error line number.""" @@ -736,5 +874,5 @@ def _get_error_line(task: dict[str, Any], path: list[str | int]) -> int: line = ctx[LINE_NUMBER_KEY] if not isinstance(line, int): msg = "Line number is not an integer" - raise RuntimeError(msg) + raise TypeError(msg) return line diff --git a/src/ansiblelint/rules/key_order.md b/src/ansiblelint/rules/key_order.md index 378d8a5..bcef36a 100644 --- a/src/ansiblelint/rules/key_order.md +++ b/src/ansiblelint/rules/key_order.md @@ -61,3 +61,7 @@ we concluded that the block keys must be the last ones. Another common practice was to put `tags` as the last property. Still, for the same reasons, we decided that they should not be put after block keys either. + +!!! note + + This rule can be automatically fixed using [`--fix`](../autofix.md) option. diff --git a/src/ansiblelint/rules/key_order.py b/src/ansiblelint/rules/key_order.py index 897da64..0c0a2f1 100644 --- a/src/ansiblelint/rules/key_order.py +++ b/src/ansiblelint/rules/key_order.py @@ -1,14 +1,19 @@ """All tasks should be have name come first.""" + from __future__ import annotations import functools import sys -from typing import TYPE_CHECKING +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any -from ansiblelint.rules import AnsibleLintRule +from ansiblelint.constants import ANNOTATION_KEYS, LINE_NUMBER_KEY +from ansiblelint.errors import MatchError, RuleMatchTransformMeta +from ansiblelint.rules import AnsibleLintRule, TransformMixin if TYPE_CHECKING: - from ansiblelint.errors import MatchError + from ruamel.yaml.comments import CommentedMap, CommentedSeq + from ansiblelint.file_utils import Lintable from ansiblelint.utils import Task @@ -46,7 +51,21 @@ def task_property_sorter(property1: str, property2: str) -> int: return (v_1 > v_2) - (v_1 < v_2) -class KeyOrderRule(AnsibleLintRule): +@dataclass(frozen=True) +class KeyOrderTMeta(RuleMatchTransformMeta): + """Key Order transform metadata. + + :param fixed: tuple with updated key order + """ + + fixed: tuple[str | int, ...] + + def __str__(self) -> str: + """Return string representation.""" + return f"Fixed to {self.fixed}" + + +class KeyOrderRule(AnsibleLintRule, TransformMixin): """Ensure specific order of keys in mappings.""" id = "key-order" @@ -59,6 +78,25 @@ class KeyOrderRule(AnsibleLintRule): "key-order[task]": "You can improve the task key order", } + def matchplay(self, file: Lintable, data: dict[str, Any]) -> list[MatchError]: + """Return matches found for a specific play (entry in playbook).""" + result: list[MatchError] = [] + if file.kind != "playbook": + return result + keys = [str(key) for key, val in data.items() if key not in ANNOTATION_KEYS] + sorted_keys = sorted(keys, key=functools.cmp_to_key(task_property_sorter)) + if keys != sorted_keys: + result.append( + self.create_matcherror( + f"You can improve the play key order to: {', '.join(sorted_keys)}", + filename=file, + tag=f"{self.id}[play]", + lineno=data[LINE_NUMBER_KEY], + transform_meta=KeyOrderTMeta(fixed=tuple(sorted_keys)), + ), + ) + return result + def matchtask( self, task: Task, @@ -66,7 +104,7 @@ class KeyOrderRule(AnsibleLintRule): ) -> list[MatchError]: result = [] raw_task = task["__raw_task__"] - keys = [key for key in raw_task if not key.startswith("_")] + keys = [str(key) for key in raw_task if not key.startswith("_")] sorted_keys = sorted(keys, key=functools.cmp_to_key(task_property_sorter)) if keys != sorted_keys: result.append( @@ -74,17 +112,43 @@ class KeyOrderRule(AnsibleLintRule): f"You can improve the task key order to: {', '.join(sorted_keys)}", filename=file, tag="key-order[task]", + transform_meta=KeyOrderTMeta(fixed=tuple(sorted_keys)), ), ) return result + def transform( + self, + match: MatchError, + lintable: Lintable, + data: CommentedMap | CommentedSeq | str, + ) -> None: + if not isinstance(match.transform_meta, KeyOrderTMeta): + return + + if match.tag == f"{self.id}[play]": + play = self.seek(match.yaml_path, data) + for key in match.transform_meta.fixed: + # other transformation might change the key + if key in play: + play[key] = play.pop(key) + match.fixed = True + if match.tag == f"{self.id}[task]": + task = self.seek(match.yaml_path, data) + for key in match.transform_meta.fixed: + # other transformation might change the key + if key in task: + task[key] = task.pop(key) + match.fixed = True + # testing code to be loaded only with pytest or when executed the rule file if "pytest" in sys.modules: import pytest - from ansiblelint.rules import RulesCollection # pylint: disable=ungrouped-imports - from ansiblelint.runner import Runner # pylint: disable=ungrouped-imports + # pylint: disable=ungrouped-imports + from ansiblelint.rules import RulesCollection + from ansiblelint.runner import Runner @pytest.mark.parametrize( ("test_file", "failures"), diff --git a/src/ansiblelint/rules/latest.py b/src/ansiblelint/rules/latest.py index 0838feb..ef57b94 100644 --- a/src/ansiblelint/rules/latest.py +++ b/src/ansiblelint/rules/latest.py @@ -1,4 +1,5 @@ """Implementation of latest rule.""" + from __future__ import annotations from typing import TYPE_CHECKING diff --git a/src/ansiblelint/rules/literal_compare.py b/src/ansiblelint/rules/literal_compare.py index 1129d1d..151398a 100644 --- a/src/ansiblelint/rules/literal_compare.py +++ b/src/ansiblelint/rules/literal_compare.py @@ -1,4 +1,5 @@ """Implementation of the literal-compare rule.""" + # Copyright (c) 2016, Will Thames and contributors # Copyright (c) 2018-2021, Ansible Project @@ -55,8 +56,9 @@ class ComparisonToLiteralBoolRule(AnsibleLintRule): if "pytest" in sys.modules: import pytest - from ansiblelint.rules import RulesCollection # pylint: disable=ungrouped-imports - from ansiblelint.runner import Runner # pylint: disable=ungrouped-imports + # pylint: disable=ungrouped-imports + from ansiblelint.rules import RulesCollection + from ansiblelint.runner import Runner @pytest.mark.parametrize( ("test_file", "failures"), diff --git a/src/ansiblelint/rules/loop_var_prefix.md b/src/ansiblelint/rules/loop_var_prefix.md index 33adbd7..5d1b9b0 100644 --- a/src/ansiblelint/rules/loop_var_prefix.md +++ b/src/ansiblelint/rules/loop_var_prefix.md @@ -1,15 +1,15 @@ # loop-var-prefix -This rule avoids conflicts with nested looping tasks by configuring a variable -prefix with `loop_var`. Ansible sets `item` as the loop variable. You can use -`loop_var` to specify a prefix for loop variables and ensure they are unique to -each task. +This rule avoids conflicts with nested looping tasks by enforcing an individual +variable name in loops. Ansible defaults to `item` as the loop variable. You can +use `loop_var` to rename it. Optionally require a prefix on the variable name. +The prefix can be configured via the `<loop_var_prefix>` setting. This rule can produce the following messages: - `loop-var-prefix[missing]` - Replace any unsafe implicit `item` loop variable - by adding `loop_var: <loop_var_prefix>...`. -- `loop-var-prefix[wrong]` - Ensure loop variables start with + by adding `loop_var: <variable_name>...`. +- `loop-var-prefix[wrong]` - Ensure the loop variable starts with `<loop_var_prefix>`. This rule originates from the [Naming parameters section of Ansible Best @@ -41,20 +41,20 @@ enable_list: - name: Example playbook hosts: localhost tasks: - - name: Does not set a prefix for loop variables. + - name: Does not set a variable name for loop variables. ansible.builtin.debug: - var: item + var: item # <- When in a nested loop, "item" is ambiguous loop: - foo - - bar # <- These items do not have a unique prefix. - - name: Sets a prefix that is not unique. + - bar + - name: Sets a variable name that doesn't start with <loop_var_prefix>. ansible.builtin.debug: var: zz_item loop: - foo - bar loop_control: - loop_var: zz_item # <- This prefix is not unique. + loop_var: zz_item # <- zz is not the role name so the prefix is wrong ``` ## Correct Code @@ -64,14 +64,14 @@ enable_list: - name: Example playbook hosts: localhost tasks: - - name: Sets a unique prefix for loop variables. + - name: Sets a unique variable_name with role as prefix for loop variables. ansible.builtin.debug: - var: zz_item + var: myrole_item loop: - foo - bar loop_control: - loop_var: my_prefix # <- Specifies a unique prefix for loop variables. + loop_var: myrole_item # <- Unique variable name with role as prefix ``` [cop314]: diff --git a/src/ansiblelint/rules/loop_var_prefix.py b/src/ansiblelint/rules/loop_var_prefix.py index 8f1bb56..9f7a2ca 100644 --- a/src/ansiblelint/rules/loop_var_prefix.py +++ b/src/ansiblelint/rules/loop_var_prefix.py @@ -1,4 +1,5 @@ """Optional Ansible-lint rule to enforce use of prefix on role loop vars.""" + from __future__ import annotations import re @@ -81,8 +82,9 @@ Looping inside roles has the risk of clashing with loops from user-playbooks.\ if "pytest" in sys.modules: import pytest - from ansiblelint.rules import RulesCollection # pylint: disable=ungrouped-imports - from ansiblelint.runner import Runner # pylint: disable=ungrouped-imports + # pylint: disable=ungrouped-imports + from ansiblelint.rules import RulesCollection + from ansiblelint.runner import Runner @pytest.mark.parametrize( ("test_file", "failures"), diff --git a/src/ansiblelint/rules/meta_incorrect.py b/src/ansiblelint/rules/meta_incorrect.py index 4252254..ed8d8d9 100644 --- a/src/ansiblelint/rules/meta_incorrect.py +++ b/src/ansiblelint/rules/meta_incorrect.py @@ -1,4 +1,5 @@ """Implementation of meta-incorrect rule.""" + # Copyright (c) 2018, Ansible Project from __future__ import annotations @@ -56,8 +57,8 @@ class MetaChangeFromDefaultRule(AnsibleLintRule): if "pytest" in sys.modules: - from ansiblelint.rules import RulesCollection # pylint: disable=ungrouped-imports - from ansiblelint.runner import Runner # pylint: disable=ungrouped-imports + from ansiblelint.rules import RulesCollection + from ansiblelint.runner import Runner def test_default_galaxy_info( default_rules_collection: RulesCollection, diff --git a/src/ansiblelint/rules/meta_no_tags.py b/src/ansiblelint/rules/meta_no_tags.py index c27a30e..3e9b636 100644 --- a/src/ansiblelint/rules/meta_no_tags.py +++ b/src/ansiblelint/rules/meta_no_tags.py @@ -1,4 +1,5 @@ """Implementation of meta-no-tags rule.""" + from __future__ import annotations import re diff --git a/src/ansiblelint/rules/meta_runtime.md b/src/ansiblelint/rules/meta_runtime.md index 6ed6f17..3e05c59 100644 --- a/src/ansiblelint/rules/meta_runtime.md +++ b/src/ansiblelint/rules/meta_runtime.md @@ -1,26 +1,21 @@ # meta-runtime -This rule checks the meta/runtime.yml `requires_ansible` key against the list of currently supported versions of ansible-core. - -This rule can produce messages such: - -- `requires_ansible` key must be set to a supported version. - -Currently supported versions of ansible-core are: - -- `2.9.10` -- `2.11.x` -- `2.12.x` -- `2.13.x` -- `2.14.x` -- `2.15.x` -- `2.16.x` (in development) +This rule checks the meta/runtime.yml `requires_ansible` key against the list of +currently supported versions of ansible-core. This rule can produce messages such as: -- `meta-runtime[unsupported-version]` - `requires_ansible` key must contain a supported version, shown in the list above. -- `meta-runtime[invalid-version]` - `requires_ansible` key must be a valid version identifier. +- `meta-runtime[unsupported-version]` - `requires_ansible` key must refer to a + currently supported version such as: >=2.14.0, >=2.15.0, >=2.16.0 +- `meta-runtime[invalid-version]` - `requires_ansible` is not a valid + requirement specification +Please note that the linter will allow only a full version of Ansible such +`2.16.0` and not allow their short form, like `2.16`. This is a safety measure +for asking authors to mention an explicit version that they tested with. Over +the years we spotted multiple problems caused by the use of the short versions, +users ended up trying an outdated version that was never tested against by the +collection maintainer. ## Problematic code @@ -30,11 +25,10 @@ This rule can produce messages such as: requires_ansible: ">=2.9" ``` - ```yaml # runtime.yml --- -requires_ansible: "2.9" +requires_ansible: "2.15" ``` ## Correct code @@ -42,5 +36,17 @@ requires_ansible: "2.9" ```yaml # runtime.yml --- -requires_ansible: ">=2.9.10" +requires_ansible: ">=2.15.0" +``` + +## Configuration + +In addition to the internal list of supported Ansible versions, users can +configure additional values. This allows those that want to maintain content +that requires a version of ansible-core that is already out of support. + +```yaml +# Also recognize these versions of Ansible as supported: +supported_ansible_also: + - "2.14" ``` diff --git a/src/ansiblelint/rules/meta_runtime.py b/src/ansiblelint/rules/meta_runtime.py index fed7121..3df2826 100644 --- a/src/ansiblelint/rules/meta_runtime.py +++ b/src/ansiblelint/rules/meta_runtime.py @@ -1,4 +1,5 @@ """Implementation of meta-runtime rule.""" + from __future__ import annotations import sys @@ -22,17 +23,15 @@ class CheckRequiresAnsibleVersion(AnsibleLintRule): id = "meta-runtime" description = ( "The ``requires_ansible`` key in runtime.yml must specify " - "a supported platform version of ansible-core and be a valid version." + "a supported platform version of ansible-core and be a valid version value " + "in x.y.z format." ) severity = "VERY_HIGH" tags = ["metadata"] version_added = "v6.11.0 (last update)" - # Refer to https://access.redhat.com/support/policy/updates/ansible-automation-platform - # Also add devel to this list - supported_ansible = ["2.9.10", "2.11.", "2.12.", "2.13.", "2.14.", "2.15.", "2.16."] _ids = { - "meta-runtime[unsupported-version]": "requires_ansible key must be set to a supported version.", + "meta-runtime[unsupported-version]": "'requires_ansible' key must refer to a currently supported version", "meta-runtime[invalid-version]": "'requires_ansible' is not a valid requirement specification", } @@ -47,22 +46,26 @@ class CheckRequiresAnsibleVersion(AnsibleLintRule): if file.kind != "meta-runtime": return [] - version_required = file.data.get("requires_ansible", None) + requires_ansible = file.data.get("requires_ansible", None) - if version_required: - if not any( - version in version_required for version in self.supported_ansible + if requires_ansible: + if self.options and not any( + version in requires_ansible + for version in self.options.supported_ansible ): + supported_ansible = [f">={x}0" for x in self.options.supported_ansible] + msg = f"'requires_ansible' key must refer to a currently supported version such as: {', '.join(supported_ansible)}" + results.append( self.create_matcherror( - message="requires_ansible key must be set to a supported version.", + message=msg, tag="meta-runtime[unsupported-version]", filename=file, ), ) try: - SpecifierSet(version_required) + SpecifierSet(requires_ansible) except ValueError: results.append( self.create_matcherror( @@ -79,17 +82,18 @@ class CheckRequiresAnsibleVersion(AnsibleLintRule): if "pytest" in sys.modules: import pytest - from ansiblelint.rules import RulesCollection # pylint: disable=ungrouped-imports - from ansiblelint.runner import Runner # pylint: disable=ungrouped-imports + # pylint: disable=ungrouped-imports + from ansiblelint.rules import RulesCollection + from ansiblelint.runner import Runner @pytest.mark.parametrize( ("test_file", "failures", "tags"), ( pytest.param( - "examples/meta_runtime_version_checks/pass/meta/runtime.yml", + "examples/meta_runtime_version_checks/pass_0/meta/runtime.yml", 0, "meta-runtime[unsupported-version]", - id="pass", + id="pass0", ), pytest.param( "examples/meta_runtime_version_checks/fail_0/meta/runtime.yml", @@ -111,16 +115,37 @@ if "pytest" in sys.modules: ), ), ) - def test_meta_supported_version( + def test_default_meta_supported_version( default_rules_collection: RulesCollection, test_file: str, failures: int, tags: str, ) -> None: - """Test rule matches.""" + """Test for default supported ansible versions.""" default_rules_collection.register(CheckRequiresAnsibleVersion()) results = Runner(test_file, rules=default_rules_collection).run() for result in results: assert result.rule.id == CheckRequiresAnsibleVersion().id assert result.tag == tags assert len(results) == failures + + @pytest.mark.parametrize( + ("test_file", "failures"), + ( + pytest.param( + "examples/meta_runtime_version_checks/pass_1/meta/runtime.yml", + 0, + id="pass1", + ), + ), + ) + def test_added_meta_supported_version( + default_rules_collection: RulesCollection, + test_file: str, + failures: int, + ) -> None: + """Test for added supported ansible versions in the config.""" + default_rules_collection.register(CheckRequiresAnsibleVersion()) + default_rules_collection.options.supported_ansible_also = ["2.9"] + results = Runner(test_file, rules=default_rules_collection).run() + assert len(results) == failures diff --git a/src/ansiblelint/rules/meta_video_links.py b/src/ansiblelint/rules/meta_video_links.py index 5d4941a..fa19cc6 100644 --- a/src/ansiblelint/rules/meta_video_links.py +++ b/src/ansiblelint/rules/meta_video_links.py @@ -1,4 +1,5 @@ """Implementation of meta-video-links rule.""" + # Copyright (c) 2018, Ansible Project from __future__ import annotations @@ -86,8 +87,9 @@ class MetaVideoLinksRule(AnsibleLintRule): if "pytest" in sys.modules: import pytest - from ansiblelint.rules import RulesCollection # pylint: disable=ungrouped-imports - from ansiblelint.runner import Runner # pylint: disable=ungrouped-imports + # pylint: disable=ungrouped-imports + from ansiblelint.rules import RulesCollection + from ansiblelint.runner import Runner @pytest.mark.parametrize( ("test_file", "failures"), diff --git a/src/ansiblelint/rules/name.md b/src/ansiblelint/rules/name.md index 9df4213..0c5080a 100644 --- a/src/ansiblelint/rules/name.md +++ b/src/ansiblelint/rules/name.md @@ -21,12 +21,21 @@ If you want to ignore some of the messages above, you can add any of them to the ## name[prefix] -This rule applies only to included task files that are not named `main.yml`. It -suggests adding the stem of the file as a prefix to the task name. +This rule applies only to included task files that are not named `main.yml` or +are embedded within subdirectories. It suggests adding the stems of the file +path as a prefix to the task name. For example, if you have a task named `Restart server` inside a file named `tasks/deploy.yml`, this rule suggests renaming it to `deploy | Restart server`, -so it would be easier to identify where it comes from. +so it would be easier to identify where it comes from. If the file was named +`tasks/main.yml`, then the rule would have no effect. + +For task files that are embedded within subdirectories, these subdirectories +will also be appended as part of the prefix. For example, if you have a task +named `Terminate server` inside a file named `tasks/foo/destroy.yml`, this rule +suggests renaming it to `foo | destroy | Terminate server`. If the file was +named `tasks/foo/main.yml` then the rule would recommend renaming the task to +`foo | main | Terminate server`. For the moment, this sub-rule is just an **opt-in**, so you need to add it to your `enable_list` to activate it. @@ -59,3 +68,7 @@ your `enable_list` to activate it. - name: Create placeholder file ansible.builtin.command: touch /tmp/.placeholder ``` + +!!! note + + `name[casing]` can be automatically fixed using [`--fix`](../autofix.md) option. diff --git a/src/ansiblelint/rules/name.py b/src/ansiblelint/rules/name.py index 41ce5cb..b814a41 100644 --- a/src/ansiblelint/rules/name.py +++ b/src/ansiblelint/rules/name.py @@ -1,19 +1,23 @@ """Implementation of NameRule.""" + from __future__ import annotations import re import sys -from copy import deepcopy from typing import TYPE_CHECKING, Any +import wcmatch.pathlib +import wcmatch.wcmatch + from ansiblelint.constants import LINE_NUMBER_KEY +from ansiblelint.file_utils import Lintable from ansiblelint.rules import AnsibleLintRule, TransformMixin if TYPE_CHECKING: from ruamel.yaml.comments import CommentedMap, CommentedSeq + from ansiblelint.config import Options from ansiblelint.errors import MatchError - from ansiblelint.file_utils import Lintable from ansiblelint.utils import Task @@ -39,9 +43,11 @@ class NameRule(AnsibleLintRule, TransformMixin): def matchplay(self, file: Lintable, data: dict[str, Any]) -> list[MatchError]: """Return matches found for a specific play (entry in playbook).""" - results = [] + results: list[MatchError] = [] if file.kind != "playbook": return [] + if file.failed(): + return results if "name" not in data: return [ self.create_matcherror( @@ -65,7 +71,9 @@ class NameRule(AnsibleLintRule, TransformMixin): task: Task, file: Lintable | None = None, ) -> list[MatchError]: - results = [] + results: list[MatchError] = [] + if file and file.failed(): + return results name = task.get("name") if not name: results.append( @@ -84,6 +92,7 @@ class NameRule(AnsibleLintRule, TransformMixin): lineno=task[LINE_NUMBER_KEY], ), ) + return results def _prefix_check( @@ -120,10 +129,15 @@ class NameRule(AnsibleLintRule, TransformMixin): # stage one check prefix effective_name = name if self._collection and lintable: - prefix = self._collection.options.task_name_prefix.format( - stem=lintable.path.stem, - ) - if lintable.kind == "tasks" and lintable.path.stem != "main": + full_stem = self._find_full_stem(lintable) + stems = [ + self._collection.options.task_name_prefix.format(stem=stem) + for stem in wcmatch.pathlib.PurePath( + full_stem, + ).parts + ] + prefix = "".join(stems) + if lintable.kind == "tasks" and full_stem != "main": if not name.startswith(prefix): # For the moment in order to raise errors this rule needs to be # enabled manually. Still, we do allow use of prefixes even without @@ -165,6 +179,38 @@ class NameRule(AnsibleLintRule, TransformMixin): ) return results + def _find_full_stem(self, lintable: Lintable) -> str: + lintable_dir = wcmatch.pathlib.PurePath(lintable.dir) + stem = lintable.path.stem + kind = str(lintable.kind) + + stems = [lintable_dir.name] + lintable_dir = lintable_dir.parent + pathex = lintable_dir / stem + glob = "" + + if self.options: + for entry in self.options.kinds: + for key, value in entry.items(): + if kind == key: + glob = value + + while pathex.globmatch( + glob, + flags=( + wcmatch.pathlib.GLOBSTAR + | wcmatch.pathlib.BRACE + | wcmatch.pathlib.DOTGLOB + ), + ): + stems.insert(0, lintable_dir.name) + lintable_dir = lintable_dir.parent + pathex = lintable_dir / stem + + if stems[0].startswith(kind): + del stems[0] + return str(wcmatch.pathlib.PurePath(*stems, stem)) + def transform( self, match: MatchError, @@ -172,17 +218,44 @@ class NameRule(AnsibleLintRule, TransformMixin): data: CommentedMap | CommentedSeq | str, ) -> None: if match.tag == "name[casing]": + + def update_task_name(task_name: str) -> str: + """Capitalize the first work of the task name.""" + # Not using capitalize(), since that rewrites the rest of the name to lower case + if "|" in task_name: # if using prefix + [file_name, update_task_name] = task_name.split("|") + return f"{file_name.strip()} | {update_task_name.strip()[:1].upper()}{update_task_name.strip()[1:]}" + + return f"{task_name[:1].upper()}{task_name[1:]}" + target_task = self.seek(match.yaml_path, data) - # Not using capitalize(), since that rewrites the rest of the name to lower case - target_task[ - "name" - ] = f"{target_task['name'][:1].upper()}{target_task['name'][1:]}" - match.fixed = True + orig_task_name = target_task.get("name", None) + # pylint: disable=too-many-nested-blocks + if orig_task_name: + updated_task_name = update_task_name(orig_task_name) + for item in data: + if isinstance(item, dict) and "tasks" in item: + for task in item["tasks"]: + # We want to rewrite task names in the notify keyword, but + # if there isn't a notify section, there's nothing to do. + if "notify" not in task: + continue + + if ( + isinstance(task["notify"], str) + and orig_task_name == task["notify"] + ): + task["notify"] = updated_task_name + elif isinstance(task["notify"], list): + for idx in range(len(task["notify"])): + if orig_task_name == task["notify"][idx]: + task["notify"][idx] = updated_task_name + + target_task["name"] = updated_task_name + match.fixed = True if "pytest" in sys.modules: - from ansiblelint.config import options - from ansiblelint.file_utils import Lintable # noqa: F811 from ansiblelint.rules import RulesCollection from ansiblelint.runner import Runner @@ -203,11 +276,23 @@ if "pytest" in sys.modules: errs = bad_runner.run() assert len(errs) == 5 - def test_name_prefix_negative() -> None: + def test_name_prefix_positive(config_options: Options) -> None: + """Positive test for name[prefix].""" + config_options.enable_list = ["name[prefix]"] + collection = RulesCollection(options=config_options) + collection.register(NameRule()) + success = Lintable( + "examples/playbooks/tasks/main.yml", + kind="tasks", + ) + good_runner = Runner(success, rules=collection) + results = good_runner.run() + assert len(results) == 0 + + def test_name_prefix_negative(config_options: Options) -> None: """Negative test for name[missing].""" - custom_options = deepcopy(options) - custom_options.enable_list = ["name[prefix]"] - collection = RulesCollection(options=custom_options) + config_options.enable_list = ["name[prefix]"] + collection = RulesCollection(options=config_options) collection.register(NameRule()) failure = Lintable( "examples/playbooks/tasks/rule-name-prefix-fail.yml", @@ -221,6 +306,36 @@ if "pytest" in sys.modules: assert results[1].tag == "name[prefix]" assert results[2].tag == "name[prefix]" + def test_name_prefix_negative_2(config_options: Options) -> None: + """Negative test for name[prefix].""" + config_options.enable_list = ["name[prefix]"] + collection = RulesCollection(options=config_options) + collection.register(NameRule()) + failure = Lintable( + "examples/playbooks/tasks/partial_prefix/foo.yml", + kind="tasks", + ) + bad_runner = Runner(failure, rules=collection) + results = bad_runner.run() + assert len(results) == 2 + assert results[0].tag == "name[prefix]" + assert results[1].tag == "name[prefix]" + + def test_name_prefix_negative_3(config_options: Options) -> None: + """Negative test for name[prefix].""" + config_options.enable_list = ["name[prefix]"] + collection = RulesCollection(options=config_options) + collection.register(NameRule()) + failure = Lintable( + "examples/playbooks/tasks/partial_prefix/main.yml", + kind="tasks", + ) + bad_runner = Runner(failure, rules=collection) + results = bad_runner.run() + assert len(results) == 2 + assert results[0].tag == "name[prefix]" + assert results[1].tag == "name[prefix]" + def test_rule_name_lowercase() -> None: """Negative test for a task that starts with lowercase.""" collection = RulesCollection() @@ -255,6 +370,5 @@ if "pytest" in sys.modules: def test_when_no_lintable() -> None: """Test when lintable is None.""" name_rule = NameRule() - # pylint: disable=protected-access result = name_rule._prefix_check("Foo", None, 1) # noqa: SLF001 assert len(result) == 0 diff --git a/src/ansiblelint/rules/no_changed_when.py b/src/ansiblelint/rules/no_changed_when.py index 28ba427..e71934d 100644 --- a/src/ansiblelint/rules/no_changed_when.py +++ b/src/ansiblelint/rules/no_changed_when.py @@ -1,4 +1,5 @@ """Implementation of the no-changed-when rule.""" + # Copyright (c) 2016 Will Thames <will@thames.id.au> # # Permission is hereby granted, free of charge, to any person obtaining a copy @@ -75,8 +76,9 @@ class CommandHasChangesCheckRule(AnsibleLintRule): if "pytest" in sys.modules: import pytest - from ansiblelint.rules import RulesCollection # pylint: disable=ungrouped-imports - from ansiblelint.runner import Runner # pylint: disable=ungrouped-imports + # pylint: disable=ungrouped-imports + from ansiblelint.rules import RulesCollection + from ansiblelint.runner import Runner @pytest.mark.parametrize( ("file", "expected"), diff --git a/src/ansiblelint/rules/no_free_form.md b/src/ansiblelint/rules/no_free_form.md index 0ffc0ac..ae05d0f 100644 --- a/src/ansiblelint/rules/no_free_form.md +++ b/src/ansiblelint/rules/no_free_form.md @@ -56,3 +56,7 @@ This rule can produce messages as: executable: /bin/bash # <-- explicit is better changed_when: false ``` + +!!! note + + This rule can be automatically fixed using [`--fix`](../autofix.md) option. diff --git a/src/ansiblelint/rules/no_free_form.py b/src/ansiblelint/rules/no_free_form.py index e89333b..13489ef 100644 --- a/src/ansiblelint/rules/no_free_form.py +++ b/src/ansiblelint/rules/no_free_form.py @@ -1,20 +1,25 @@ """Implementation of NoFreeFormRule.""" + from __future__ import annotations +import functools import re import sys -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from ansiblelint.constants import INCLUSION_ACTION_NAMES, LINE_NUMBER_KEY -from ansiblelint.rules import AnsibleLintRule +from ansiblelint.rules import AnsibleLintRule, TransformMixin +from ansiblelint.rules.key_order import task_property_sorter if TYPE_CHECKING: + from ruamel.yaml.comments import CommentedMap, CommentedSeq + from ansiblelint.errors import MatchError from ansiblelint.file_utils import Lintable from ansiblelint.utils import Task -class NoFreeFormRule(AnsibleLintRule): +class NoFreeFormRule(AnsibleLintRule, TransformMixin): """Rule for detecting discouraged free-form syntax for action modules.""" id = "no-free-form" @@ -75,7 +80,7 @@ class NoFreeFormRule(AnsibleLintRule): "win_command", "win_shell", ): - if self.cmd_shell_re.match(action_value): + if self.cmd_shell_re.search(action_value): fail = True else: fail = True @@ -89,12 +94,97 @@ class NoFreeFormRule(AnsibleLintRule): ) return results + def transform( + self, + match: MatchError, + lintable: Lintable, + data: CommentedMap | CommentedSeq | str, + ) -> None: + if "no-free-form" in match.tag: + task = self.seek(match.yaml_path, data) + + def filter_values( + val: str, + filter_key: str, + filter_dict: dict[str, Any], + ) -> str: + """Pull out key=value pairs from a string and set them in filter_dict. + + Returns unmatched strings. + """ + if filter_key not in val: + return val + + extra = "" + [k, v] = val.split(filter_key, 1) + if " " in k: + extra, k = k.rsplit(" ", 1) + + if v[0] in "\"'": + # Keep quoted strings together + quote = v[0] + _, v, remainder = v.split(quote, 2) + v = f"{quote}{v}{quote}" + else: + try: + v, remainder = v.split(" ", 1) + except ValueError: + remainder = "" + + filter_dict[k] = v + + extra = " ".join( + (extra, filter_values(remainder, filter_key, filter_dict)), + ) + return extra.strip() + + if match.tag == "no-free-form": + module_opts: dict[str, Any] = {} + for _ in range(len(task)): + k, v = task.popitem(False) + # identify module as key and process its value + if len(k.split(".")) == 3 and isinstance(v, str): + cmd = filter_values(v, "=", module_opts) + if cmd: + module_opts["cmd"] = cmd + + sorted_module_opts = {} + for key in sorted( + module_opts.keys(), + key=functools.cmp_to_key(task_property_sorter), + ): + sorted_module_opts[key] = module_opts[key] + + task[k] = sorted_module_opts + else: + task[k] = v + + match.fixed = True + elif match.tag == "no-free-form[raw]": + exec_key_val: dict[str, Any] = {} + for _ in range(len(task)): + k, v = task.popitem(False) + if isinstance(v, str) and "executable" in v: + # Filter the executable and other parts from the string + task[k] = " ".join( + [ + item + for item in v.split(" ") + if filter_values(item, "=", exec_key_val) + ], + ) + task["args"] = exec_key_val + else: + task[k] = v + match.fixed = True + if "pytest" in sys.modules: import pytest - from ansiblelint.rules import RulesCollection # pylint: disable=ungrouped-imports - from ansiblelint.runner import Runner # pylint: disable=ungrouped-imports + # pylint: disable=ungrouped-imports + from ansiblelint.rules import RulesCollection + from ansiblelint.runner import Runner @pytest.mark.parametrize( ("file", "expected"), diff --git a/src/ansiblelint/rules/no_handler.py b/src/ansiblelint/rules/no_handler.py index 380fd61..ae8f820 100644 --- a/src/ansiblelint/rules/no_handler.py +++ b/src/ansiblelint/rules/no_handler.py @@ -69,25 +69,27 @@ class UseHandlerRatherThanWhenChangedRule(AnsibleLintRule): task: Task, file: Lintable | None = None, ) -> bool | str: - if task["__ansible_action_type__"] != "task": + if task["__ansible_action_type__"] != "task" or task.is_handler(): return False when = task.get("when") + result = False if isinstance(when, list): - if len(when) > 1: - return False - return _changed_in_when(when[0]) - if isinstance(when, str): - return _changed_in_when(when) - return False + if len(when) <= 1: + result = _changed_in_when(when[0]) + elif isinstance(when, str): + result = _changed_in_when(when) + return result if "pytest" in sys.modules: import pytest - from ansiblelint.rules import RulesCollection # pylint: disable=ungrouped-imports - from ansiblelint.runner import Runner # pylint: disable=ungrouped-imports + # pylint: disable=ungrouped-imports + from ansiblelint.rules import RulesCollection + from ansiblelint.runner import Runner + from ansiblelint.testing import run_ansible_lint @pytest.mark.parametrize( ("test_file", "failures"), @@ -106,3 +108,10 @@ if "pytest" in sys.modules: assert len(results) == failures for result in results: assert result.tag == "no-handler" + + def test_role_with_handler() -> None: + """Test role with handler.""" + role_path = "examples/roles/role_with_handler" + + results = run_ansible_lint("-v", role_path) + assert "no-handler" not in results.stdout diff --git a/src/ansiblelint/rules/no_jinja_when.md b/src/ansiblelint/rules/no_jinja_when.md index 702e807..5a2c736 100644 --- a/src/ansiblelint/rules/no_jinja_when.md +++ b/src/ansiblelint/rules/no_jinja_when.md @@ -30,3 +30,7 @@ anti-pattern and does not produce expected results. ansible.builtin.command: /sbin/shutdown -t now when: ansible_facts['os_family'] == "Debian" # <- Uses facts in a conditional statement. ``` + +!!! note + + This rule can be automatically fixed using [`--fix`](../autofix.md) option. diff --git a/src/ansiblelint/rules/no_jinja_when.py b/src/ansiblelint/rules/no_jinja_when.py index 807081d..a5fc030 100644 --- a/src/ansiblelint/rules/no_jinja_when.py +++ b/src/ansiblelint/rules/no_jinja_when.py @@ -1,19 +1,23 @@ """Implementation of no-jinja-when rule.""" + from __future__ import annotations +import re import sys from typing import TYPE_CHECKING, Any from ansiblelint.constants import LINE_NUMBER_KEY -from ansiblelint.rules import AnsibleLintRule +from ansiblelint.rules import AnsibleLintRule, TransformMixin if TYPE_CHECKING: + from ruamel.yaml.comments import CommentedMap, CommentedSeq + from ansiblelint.errors import MatchError from ansiblelint.file_utils import Lintable from ansiblelint.utils import Task -class NoFormattingInWhenRule(AnsibleLintRule): +class NoFormattingInWhenRule(AnsibleLintRule, TransformMixin): """No Jinja2 in when.""" id = "no-jinja-when" @@ -44,19 +48,19 @@ class NoFormattingInWhenRule(AnsibleLintRule): if isinstance(data, dict): if "roles" not in data or data["roles"] is None: return errors - for role in data["roles"]: + errors = [ + self.create_matcherror( + details=str({"when": role}), + filename=file, + lineno=role[LINE_NUMBER_KEY], + ) + for role in data["roles"] if ( isinstance(role, dict) and "when" in role and not self._is_valid(role["when"]) - ): - errors.append( - self.create_matcherror( - details=str({"when": role}), - filename=file, - lineno=role[LINE_NUMBER_KEY], - ), - ) + ) + ] return errors def matchtask( @@ -66,6 +70,37 @@ class NoFormattingInWhenRule(AnsibleLintRule): ) -> bool | str: return "when" in task.raw_task and not self._is_valid(task.raw_task["when"]) + def transform( + self, + match: MatchError, + lintable: Lintable, + data: CommentedMap | CommentedSeq | str, + ) -> None: + if match.tag == self.id: + task = self.seek(match.yaml_path, data) + key_to_check = ("when", "changed_when", "failed_when") + for _ in range(len(task)): + k, v = task.popitem(False) + if k == "roles" and isinstance(v, list): + transform_for_roles(v, key_to_check=key_to_check) + elif k in key_to_check: + v = re.sub(r"{{ (.*?) }}", r"\1", v) + task[k] = v + match.fixed = True + + +def transform_for_roles(v: list[Any], key_to_check: tuple[str, ...]) -> None: + """Additional transform logic in case of roles.""" + for idx, new_dict in enumerate(v): + for new_key, new_value in new_dict.items(): + if new_key in key_to_check: + if isinstance(new_value, list): + for index, nested_value in enumerate(new_value): + new_value[index] = re.sub(r"{{ (.*?) }}", r"\1", nested_value) + v[idx][new_key] = new_value + if isinstance(new_value, str): + v[idx][new_key] = re.sub(r"{{ (.*?) }}", r"\1", new_value) + if "pytest" in sys.modules: # Tests for no-jinja-when rule. diff --git a/src/ansiblelint/rules/no_log_password.md b/src/ansiblelint/rules/no_log_password.md index 579dd11..3629ef6 100644 --- a/src/ansiblelint/rules/no_log_password.md +++ b/src/ansiblelint/rules/no_log_password.md @@ -43,3 +43,7 @@ Explicitly adding `no_log: true` prevents accidentally exposing secrets. - wow no_log: true # <- Sets the no_log attribute to a non-false value. ``` + +!!! note + + This rule can be automatically fixed using [`--fix`](../autofix.md) option. diff --git a/src/ansiblelint/rules/no_log_password.py b/src/ansiblelint/rules/no_log_password.py index 7cc7439..c3f6d34 100644 --- a/src/ansiblelint/rules/no_log_password.py +++ b/src/ansiblelint/rules/no_log_password.py @@ -15,17 +15,25 @@ """NoLogPasswordsRule used with ansible-lint.""" from __future__ import annotations +import os import sys +from pathlib import Path from typing import TYPE_CHECKING -from ansiblelint.rules import AnsibleLintRule +from ansiblelint.rules import AnsibleLintRule, RulesCollection, TransformMixin +from ansiblelint.runner import get_matches +from ansiblelint.transformer import Transformer from ansiblelint.utils import Task, convert_to_boolean if TYPE_CHECKING: + from ruamel.yaml.comments import CommentedMap, CommentedSeq + + from ansiblelint.config import Options + from ansiblelint.errors import MatchError from ansiblelint.file_utils import Lintable -class NoLogPasswordsRule(AnsibleLintRule): +class NoLogPasswordsRule(AnsibleLintRule, TransformMixin): """Password should not be logged.""" id = "no-log-password" @@ -72,12 +80,26 @@ class NoLogPasswordsRule(AnsibleLintRule): has_password and not convert_to_boolean(no_log) and len(has_loop) > 0, ) + def transform( + self, + match: MatchError, + lintable: Lintable, + data: CommentedMap | CommentedSeq | str, + ) -> None: + if match.tag == self.id: + task = self.seek(match.yaml_path, data) + task["no_log"] = True + + match.fixed = True + if "pytest" in sys.modules: + from unittest import mock + import pytest if TYPE_CHECKING: - from ansiblelint.testing import RunFromText # pylint: disable=ungrouped-imports + from ansiblelint.testing import RunFromText NO_LOG_UNUSED = """ - name: Test @@ -304,3 +326,33 @@ if "pytest" in sys.modules: """The task does not actually lock the user.""" results = rule_runner.run_playbook(PASSWORD_LOCK_FALSE) assert len(results) == 0 + + @mock.patch.dict(os.environ, {"ANSIBLE_LINT_WRITE_TMP": "1"}, clear=True) + def test_no_log_password_transform( + config_options: Options, + ) -> None: + """Test transform functionality for no-log-password rule.""" + playbook = Path("examples/playbooks/transform-no-log-password.yml") + config_options.write_list = ["all"] + rules = RulesCollection(options=config_options) + rules.register(NoLogPasswordsRule()) + + config_options.lintables = [str(playbook)] + runner_result = get_matches(rules=rules, options=config_options) + transformer = Transformer(result=runner_result, options=config_options) + transformer.run() + + matches = runner_result.matches + assert len(matches) == 2 + + orig_content = playbook.read_text(encoding="utf-8") + expected_content = playbook.with_suffix( + f".transformed{playbook.suffix}", + ).read_text(encoding="utf-8") + transformed_content = playbook.with_suffix(f".tmp{playbook.suffix}").read_text( + encoding="utf-8", + ) + + assert orig_content != transformed_content + assert expected_content == transformed_content + playbook.with_suffix(f".tmp{playbook.suffix}").unlink() diff --git a/src/ansiblelint/rules/no_prompting.py b/src/ansiblelint/rules/no_prompting.py index 6622771..c5d11d8 100644 --- a/src/ansiblelint/rules/no_prompting.py +++ b/src/ansiblelint/rules/no_prompting.py @@ -1,4 +1,5 @@ """Implementation of no-prompting rule.""" + from __future__ import annotations import sys @@ -8,6 +9,7 @@ from ansiblelint.constants import LINE_NUMBER_KEY from ansiblelint.rules import AnsibleLintRule if TYPE_CHECKING: + from ansiblelint.config import Options from ansiblelint.errors import MatchError from ansiblelint.file_utils import Lintable from ansiblelint.utils import Task @@ -32,7 +34,7 @@ class NoPromptingRule(AnsibleLintRule): if file.kind != "playbook": # pragma: no cover return [] - vars_prompt = data.get("vars_prompt", None) + vars_prompt = data.get("vars_prompt") if not vars_prompt: return [] return [ @@ -60,15 +62,14 @@ class NoPromptingRule(AnsibleLintRule): if "pytest" in sys.modules: - from ansiblelint.config import options - from ansiblelint.rules import RulesCollection # pylint: disable=ungrouped-imports - from ansiblelint.runner import Runner # pylint: disable=ungrouped-imports + from ansiblelint.rules import RulesCollection + from ansiblelint.runner import Runner - def test_no_prompting_fail() -> None: + def test_no_prompting_fail(config_options: Options) -> None: """Negative test for no-prompting.""" # For testing we want to manually enable opt-in rules - options.enable_list = ["no-prompting"] - rules = RulesCollection(options=options) + config_options.enable_list = ["no-prompting"] + rules = RulesCollection(options=config_options) rules.register(NoPromptingRule()) results = Runner("examples/playbooks/rule-no-prompting.yml", rules=rules).run() assert len(results) == 2 diff --git a/src/ansiblelint/rules/no_relative_paths.py b/src/ansiblelint/rules/no_relative_paths.py index 470b1b8..de22641 100644 --- a/src/ansiblelint/rules/no_relative_paths.py +++ b/src/ansiblelint/rules/no_relative_paths.py @@ -1,4 +1,5 @@ """Implementation of no-relative-paths rule.""" + # Copyright (c) 2016, Tsukinowa Inc. <info@tsukinowa.jp> # Copyright (c) 2018, Ansible Project @@ -53,8 +54,9 @@ class RoleRelativePath(AnsibleLintRule): if "pytest" in sys.modules: import pytest - from ansiblelint.rules import RulesCollection # pylint: disable=ungrouped-imports - from ansiblelint.runner import Runner # pylint: disable=ungrouped-imports + # pylint: disable=ungrouped-imports + from ansiblelint.rules import RulesCollection + from ansiblelint.runner import Runner @pytest.mark.parametrize( ("test_file", "failures"), diff --git a/src/ansiblelint/rules/no_same_owner.py b/src/ansiblelint/rules/no_same_owner.py index 021900e..23290e0 100644 --- a/src/ansiblelint/rules/no_same_owner.py +++ b/src/ansiblelint/rules/no_same_owner.py @@ -1,4 +1,5 @@ """Optional rule for avoiding keeping owner/group when transferring files.""" + from __future__ import annotations import re @@ -84,8 +85,9 @@ should not be preserved when transferring files between them. if "pytest" in sys.modules: import pytest - from ansiblelint.rules import RulesCollection # pylint: disable=ungrouped-imports - from ansiblelint.runner import Runner # pylint: disable=ungrouped-imports + # pylint: disable=ungrouped-imports + from ansiblelint.rules import RulesCollection + from ansiblelint.runner import Runner @pytest.mark.parametrize( ("test_file", "failures"), diff --git a/src/ansiblelint/rules/no_tabs.py b/src/ansiblelint/rules/no_tabs.py index c53f1bb..2614a1a 100644 --- a/src/ansiblelint/rules/no_tabs.py +++ b/src/ansiblelint/rules/no_tabs.py @@ -1,4 +1,5 @@ """Implementation of no-tabs rule.""" + # Copyright (c) 2016, Will Thames and contributors # Copyright (c) 2018, Ansible Project from __future__ import annotations @@ -7,6 +8,7 @@ import sys from typing import TYPE_CHECKING from ansiblelint.rules import AnsibleLintRule +from ansiblelint.text import has_jinja from ansiblelint.yaml_utils import nested_items_path if TYPE_CHECKING: @@ -27,6 +29,10 @@ class NoTabsRule(AnsibleLintRule): ("lineinfile", "insertbefore"), ("lineinfile", "regexp"), ("lineinfile", "line"), + ("win_lineinfile", "insertafter"), + ("win_lineinfile", "insertbefore"), + ("win_lineinfile", "regexp"), + ("win_lineinfile", "line"), ("ansible.builtin.lineinfile", "insertafter"), ("ansible.builtin.lineinfile", "insertbefore"), ("ansible.builtin.lineinfile", "regexp"), @@ -35,6 +41,10 @@ class NoTabsRule(AnsibleLintRule): ("ansible.legacy.lineinfile", "insertbefore"), ("ansible.legacy.lineinfile", "regexp"), ("ansible.legacy.lineinfile", "line"), + ("community.windows.win_lineinfile", "insertafter"), + ("community.windows.win_lineinfile", "insertbefore"), + ("community.windows.win_lineinfile", "regexp"), + ("community.windows.win_lineinfile", "line"), ] def matchtask( @@ -44,17 +54,22 @@ class NoTabsRule(AnsibleLintRule): ) -> bool | str: action = task["action"]["__ansible_module__"] for k, v, _ in nested_items_path(task): - if isinstance(k, str) and "\t" in k: + if isinstance(k, str) and "\t" in k and not has_jinja(k): return True - if isinstance(v, str) and "\t" in v and (action, k) not in self.allow_list: + if ( + isinstance(v, str) + and "\t" in v + and (action, k) not in self.allow_list + and not has_jinja(v) + ): return True return False # testing code to be loaded only with pytest or when executed the rule file if "pytest" in sys.modules: - from ansiblelint.rules import RulesCollection # pylint: disable=ungrouped-imports - from ansiblelint.runner import Runner # pylint: disable=ungrouped-imports + from ansiblelint.rules import RulesCollection + from ansiblelint.runner import Runner def test_no_tabs_rule(default_rules_collection: RulesCollection) -> None: """Test rule matches.""" @@ -62,6 +77,12 @@ if "pytest" in sys.modules: "examples/playbooks/rule-no-tabs.yml", rules=default_rules_collection, ).run() - assert results[0].lineno == 10 - assert results[0].message == NoTabsRule().shortdesc - assert len(results) == 2 + expected_results = [ + (10, NoTabsRule().shortdesc), + (13, NoTabsRule().shortdesc), + ] + for i, expected in enumerate(expected_results): + assert len(results) >= i + 1 + assert results[i].lineno == expected[0] + assert results[i].message == expected[1] + assert len(results) == len(expected), results diff --git a/src/ansiblelint/rules/only_builtins.py b/src/ansiblelint/rules/only_builtins.py index 78ad93a..3757af8 100644 --- a/src/ansiblelint/rules/only_builtins.py +++ b/src/ansiblelint/rules/only_builtins.py @@ -1,11 +1,11 @@ """Rule definition for usage of builtin actions only.""" + from __future__ import annotations import os import sys from typing import TYPE_CHECKING -from ansiblelint.config import options from ansiblelint.rules import AnsibleLintRule from ansiblelint.rules.fqcn import builtins from ansiblelint.skip_utils import is_nested_task @@ -33,9 +33,11 @@ class OnlyBuiltinsRule(AnsibleLintRule): allowed_collections = [ "ansible.builtin", "ansible.legacy", - *options.only_builtins_allow_collections, ] - allowed_modules = builtins + options.only_builtins_allow_modules + allowed_modules = builtins + if self.options: + allowed_collections += self.options.only_builtins_allow_collections + allowed_modules += self.options.only_builtins_allow_modules is_allowed = ( any(module.startswith(f"{prefix}.") for prefix in allowed_collections) diff --git a/src/ansiblelint/rules/package_latest.md b/src/ansiblelint/rules/package_latest.md index c7e0d82..c965548 100644 --- a/src/ansiblelint/rules/package_latest.md +++ b/src/ansiblelint/rules/package_latest.md @@ -7,7 +7,7 @@ In production environments, you should set `state` to `present` and specify a ta Setting `state` to `latest` not only installs software, it performs an update and installs additional packages. This can result in performance degradation or loss of service. -If you do want to update packages to the latest version, you should also set the `update_only` parameter to `true` to avoid installing additional packages. +If you do want to update packages to the latest version, you should also set the `update_only` or `only_upgrade` parameter to `true` based on package manager to avoid installing additional packages. ## Problematic Code @@ -32,11 +32,17 @@ If you do want to update packages to the latest version, you should also set the name: some-package state: latest # <- Installs the latest package. - - name: Install Ansible with update_only to false + - name: Install sudo with update_only to false ansible.builtin.yum: name: sudo state: latest update_only: false # <- Updates and installs packages. + + - name: Install sudo with only_upgrade to false + ansible.builtin.apt: + name: sudo + state: latest + only_upgrade: false # <- Upgrades and installs packages ``` ## Correct Code @@ -63,9 +69,15 @@ If you do want to update packages to the latest version, you should also set the name: some-package state: present # <- Ensures the package is installed. - - name: Update Ansible with update_only to true + - name: Update sudo with update_only to true ansible.builtin.yum: name: sudo state: latest update_only: true # <- Updates but does not install additional packages. + + - name: Install sudo with only_upgrade to true + ansible.builtin.apt: + name: sudo + state: latest + only_upgrade: true # <- Upgrades but does not install additional packages. ``` diff --git a/src/ansiblelint/rules/package_latest.py b/src/ansiblelint/rules/package_latest.py index a00a540..9c8ce3c 100644 --- a/src/ansiblelint/rules/package_latest.py +++ b/src/ansiblelint/rules/package_latest.py @@ -1,4 +1,5 @@ """Implementations of the package-latest rule.""" + # Copyright (c) 2016 Will Thames <will@thames.id.au> # # Permission is hereby granted, free of charge, to any person obtaining a copy @@ -79,5 +80,6 @@ class PackageIsNotLatestRule(AnsibleLintRule): task["action"]["__ansible_module__"] in self._package_managers and not task["action"].get("version") and not task["action"].get("update_only") + and not task["action"].get("only_upgrade") and task["action"].get("state") == "latest" ) diff --git a/src/ansiblelint/rules/partial_become.md b/src/ansiblelint/rules/partial_become.md index 01f9dae..672ef96 100644 --- a/src/ansiblelint/rules/partial_become.md +++ b/src/ansiblelint/rules/partial_become.md @@ -5,6 +5,13 @@ This rule checks that privilege escalation is activated when changing users. To perform an action as a different user with the `become_user` directive, you must set `become: true`. +This rule can produce the following messages: + +- `partial-become[play]`: become_user requires become to work as expected, at + play level. +- `partial-become[task]`: become_user requires become to work as expected, at + task level. + !!! warning While Ansible inherits have of `become` and `become_user` from upper levels, @@ -19,12 +26,13 @@ must set `become: true`. --- - name: Example playbook hosts: localhost + become: true # <- Activates privilege escalation. tasks: - name: Start the httpd service as the apache user ansible.builtin.service: name: httpd state: started - become_user: apache # <- Does not change the user because "become: true" is not set. + become_user: apache # <- Does not change the user because "become: true" is not set. ``` ## Correct Code @@ -37,6 +45,82 @@ must set `become: true`. ansible.builtin.service: name: httpd state: started - become: true # <- Activates privilege escalation. - become_user: apache # <- Changes the user with the desired privileges. + become: true # <- Activates privilege escalation. + become_user: apache # <- Changes the user with the desired privileges. + +# Stand alone playbook alternative, applies to all tasks + +- name: Example playbook + hosts: localhost + become: true # <- Activates privilege escalation. + become_user: apache # <- Changes the user with the desired privileges. + tasks: + - name: Start the httpd service as the apache user + ansible.builtin.service: + name: httpd + state: started +``` + +## Problematic Code + +```yaml +--- +- name: Example playbook 1 + hosts: localhost + become: true # <- Activates privilege escalation. + tasks: + - name: Include a task file + ansible.builtin.include_tasks: tasks.yml ``` + +```yaml +--- +- name: Example playbook 2 + hosts: localhost + tasks: + - name: Include a task file + ansible.builtin.include_tasks: tasks.yml +``` + +```yaml +# tasks.yml +- name: Start the httpd service as the apache user + ansible.builtin.service: + name: httpd + state: started + become_user: apache # <- Does not change the user because "become: true" is not set. +``` + +## Correct Code + +```yaml +--- +- name: Example playbook 1 + hosts: localhost + tasks: + - name: Include a task file + ansible.builtin.include_tasks: tasks.yml +``` + +```yaml +--- +- name: Example playbook 2 + hosts: localhost + tasks: + - name: Include a task file + ansible.builtin.include_tasks: tasks.yml +``` + +```yaml +# tasks.yml +- name: Start the httpd service as the apache user + ansible.builtin.service: + name: httpd + state: started + become: true # <- Activates privilege escalation. + become_user: apache # <- Does not change the user because "become: true" is not set. +``` + +!!! note + + This rule can be automatically fixed using [`--fix`](../autofix.md) option. diff --git a/src/ansiblelint/rules/partial_become.py b/src/ansiblelint/rules/partial_become.py index d14c06f..879b186 100644 --- a/src/ansiblelint/rules/partial_become.py +++ b/src/ansiblelint/rules/partial_become.py @@ -1,4 +1,5 @@ """Implementation of partial-become rule.""" + # Copyright (c) 2016 Will Thames <will@thames.id.au> # # Permission is hereby granted, free of charge, to any person obtaining a copy @@ -21,115 +22,231 @@ from __future__ import annotations import sys -from functools import reduce from typing import TYPE_CHECKING, Any +from ruamel.yaml.comments import CommentedMap, CommentedSeq + from ansiblelint.constants import LINE_NUMBER_KEY -from ansiblelint.rules import AnsibleLintRule +from ansiblelint.rules import AnsibleLintRule, TransformMixin if TYPE_CHECKING: + from collections.abc import Iterator + from ansiblelint.errors import MatchError from ansiblelint.file_utils import Lintable + from ansiblelint.utils import Task -def _get_subtasks(data: dict[str, Any]) -> list[Any]: - result: list[Any] = [] - block_names = [ - "tasks", - "pre_tasks", - "post_tasks", - "handlers", - "block", - "always", - "rescue", - ] - for name in block_names: - if data and name in data: - result += data[name] or [] - return result - - -def _nested_search(term: str, data: dict[str, Any]) -> Any: - if data and term in data: - return True - return reduce( - (lambda x, y: x or _nested_search(term, y)), - _get_subtasks(data), - False, - ) - - -def _become_user_without_become(becomeuserabove: bool, data: dict[str, Any]) -> Any: - if "become" in data: - # If become is in lineage of tree then correct - return False - if "become_user" in data and _nested_search("become", data): - # If 'become_user' on tree and become somewhere below - # we must check for a case of a second 'become_user' without a - # 'become' in its lineage - subtasks = _get_subtasks(data) - return reduce( - (lambda x, y: x or _become_user_without_become(False, y)), - subtasks, - False, - ) - if _nested_search("become_user", data): - # Keep searching down if 'become_user' exists in the tree below current task - subtasks = _get_subtasks(data) - return len(subtasks) == 0 or reduce( - ( - lambda x, y: x - or _become_user_without_become( - becomeuserabove or "become_user" in data, - y, - ) - ), - subtasks, - False, - ) - # If at bottom of tree, flag up if 'become_user' existed in the lineage of the tree and - # 'become' was not. This is an error if any lineage has a 'become_user' but no become - return becomeuserabove - - -class BecomeUserWithoutBecomeRule(AnsibleLintRule): - """become_user requires become to work as expected.""" +class BecomeUserWithoutBecomeRule(AnsibleLintRule, TransformMixin): + """``become_user`` should have a corresponding ``become`` at the play or task level.""" id = "partial-become" - description = "``become_user`` without ``become`` will not actually change user" + description = "``become_user`` should have a corresponding ``become`` at the play or task level." severity = "VERY_HIGH" tags = ["unpredictability"] version_added = "historic" - def matchplay(self, file: Lintable, data: dict[str, Any]) -> list[MatchError]: - if file.kind == "playbook": - result = _become_user_without_become(False, data) - if result: - return [ - self.create_matcherror( - message=self.shortdesc, - filename=file, - lineno=data[LINE_NUMBER_KEY], - ), - ] - return [] + def matchplay( + self: BecomeUserWithoutBecomeRule, + file: Lintable, + data: dict[str, Any], + ) -> list[MatchError]: + """Match become_user without become in play. + + :param file: The file to lint. + :param data: The data to lint (play) + :returns: A list of errors. + """ + if file.kind != "playbook": + return [] + errors = [] + partial = "become_user" in data and "become" not in data + if partial: + error = self.create_matcherror( + message=self.shortdesc, + filename=file, + tag=f"{self.id}[play]", + lineno=data[LINE_NUMBER_KEY], + ) + errors.append(error) + return errors + + def matchtask( + self: BecomeUserWithoutBecomeRule, + task: Task, + file: Lintable | None = None, + ) -> list[MatchError]: + """Match become_user without become in task. + + :param task: The task to lint. + :param file: The file to lint. + :returns: A list of errors. + """ + data = task.normalized_task + errors = [] + partial = "become_user" in data and "become" not in data + if partial: + error = self.create_matcherror( + message=self.shortdesc, + filename=file, + tag=f"{self.id}[task]", + lineno=task[LINE_NUMBER_KEY], + ) + errors.append(error) + return errors + + def _dive(self: BecomeUserWithoutBecomeRule, data: CommentedSeq) -> Iterator[Any]: + """Dive into the data and yield each item. + + :param data: The data to dive into. + :yield: Each item in the data. + """ + for item in data: + for nested in ("block", "rescue", "always"): + if nested in item: + yield from self._dive(item[nested]) + yield item + + def transform( + self: BecomeUserWithoutBecomeRule, + match: MatchError, + lintable: Lintable, + data: CommentedMap | CommentedSeq | str, + ) -> None: + """Transform the data. + + :param match: The match to transform. + :param lintable: The file to transform. + :param data: The data to transform. + """ + if not isinstance(data, CommentedSeq): + return + + obj = self.seek(match.yaml_path, data) + if "become" in obj and "become_user" in obj: + match.fixed = True + return + if "become" not in obj and "become_user" not in obj: + match.fixed = True + return + + self._transform_plays(plays=data) + + if "become" in obj and "become_user" in obj: + match.fixed = True + return + if "become" not in obj and "become_user" not in obj: + match.fixed = True + return + + def is_ineligible_for_transform( + self: BecomeUserWithoutBecomeRule, + data: CommentedMap, + ) -> bool: + """Check if the data is eligible for transformation. + + :param data: The data to check. + :returns: True if ineligible, False otherwise. + """ + if any("include" in key for key in data): + return True + if "notify" in data: + return True + return False + + def _transform_plays(self, plays: CommentedSeq) -> None: + """Transform the plays. + + :param plays: The plays to transform. + """ + for play in plays: + self._transform_play(play=play) + + def _transform_play(self, play: CommentedMap) -> None: + """Transform the play. + + :param play: The play to transform. + """ + # Ensure we have no includes in this play + task_groups = ("tasks", "pre_tasks", "post_tasks", "handlers") + for task_group in task_groups: + tasks = self._dive(play.get(task_group, [])) + for task in tasks: + if self.is_ineligible_for_transform(task): + return + remove_play_become_user = False + for task_group in task_groups: + tasks = self._dive(play.get(task_group, [])) + for task in tasks: + b_in_t = "become" in task + bu_in_t = "become_user" in task + b_in_p = "become" in play + bu_in_p = "become_user" in play + if b_in_t and not bu_in_t and bu_in_p: + # Preserve the end comment if become is the last key + comment = None + if list(task.keys())[-1] == "become" and "become" in task.ca.items: + comment = task.ca.items.pop("become") + become_index = list(task.keys()).index("become") + task.insert(become_index + 1, "become_user", play["become_user"]) + if comment: + self._attach_comment_end(task, comment) + remove_play_become_user = True + if bu_in_t and not b_in_t and b_in_p: + become_user_index = list(task.keys()).index("become_user") + task.insert(become_user_index, "become", play["become"]) + if bu_in_t and not b_in_t and not b_in_p: + # Preserve the end comment if become_user is the last key + comment = None + if ( + list(task.keys())[-1] == "become_user" + and "become_user" in task.ca.items + ): + comment = task.ca.items.pop("become_user") + task.pop("become_user") + if comment: + self._attach_comment_end(task, comment) + if remove_play_become_user: + del play["become_user"] + + def _attach_comment_end( + self, + obj: CommentedMap | CommentedSeq, + comment: Any, + ) -> None: + """Attach a comment to the end of the object. + + :param obj: The object to attach the comment to. + :param comment: The comment to attach. + """ + if isinstance(obj, CommentedMap): + last = list(obj.keys())[-1] + if not isinstance(obj[last], CommentedSeq | CommentedMap): + obj.ca.items[last] = comment + return + self._attach_comment_end(obj[last], comment) + elif isinstance(obj, CommentedSeq): + if not isinstance(obj[-1], CommentedSeq | CommentedMap): + obj.ca.items[len(obj)] = comment + return + self._attach_comment_end(obj[-1], comment) # testing code to be loaded only with pytest or when executed the rule file if "pytest" in sys.modules: - from ansiblelint.rules import RulesCollection # pylint: disable=ungrouped-imports - from ansiblelint.runner import Runner # pylint: disable=ungrouped-imports + from ansiblelint.rules import RulesCollection + from ansiblelint.runner import Runner - def test_partial_become_positive() -> None: - """Positive test for partial-become.""" + def test_partial_become_pass() -> None: + """No errors found for partial-become.""" collection = RulesCollection() collection.register(BecomeUserWithoutBecomeRule()) success = "examples/playbooks/rule-partial-become-without-become-pass.yml" good_runner = Runner(success, rules=collection) assert [] == good_runner.run() - def test_partial_become_negative() -> None: - """Negative test for partial-become.""" + def test_partial_become_fail() -> None: + """Errors found for partial-become.""" collection = RulesCollection() collection.register(BecomeUserWithoutBecomeRule()) failure = "examples/playbooks/rule-partial-become-without-become-fail.yml" diff --git a/src/ansiblelint/rules/playbook_extension.py b/src/ansiblelint/rules/playbook_extension.py index b4ca41c..a08c984 100644 --- a/src/ansiblelint/rules/playbook_extension.py +++ b/src/ansiblelint/rules/playbook_extension.py @@ -1,4 +1,5 @@ """Implementation of playbook-extension rule.""" + # Copyright (c) 2016, Tsukinowa Inc. <info@tsukinowa.jp> # Copyright (c) 2018, Ansible Project from __future__ import annotations @@ -39,7 +40,8 @@ class PlaybookExtensionRule(AnsibleLintRule): if "pytest" in sys.modules: import pytest - from ansiblelint.rules import RulesCollection # pylint: disable=ungrouped-imports + # pylint: disable=ungrouped-imports + from ansiblelint.rules import RulesCollection @pytest.mark.parametrize( ("file", "expected"), diff --git a/src/ansiblelint/rules/risky_file_permissions.md b/src/ansiblelint/rules/risky_file_permissions.md index 2a62a6d..ad46871 100644 --- a/src/ansiblelint/rules/risky_file_permissions.md +++ b/src/ansiblelint/rules/risky_file_permissions.md @@ -50,7 +50,7 @@ Modules that are checked: - name: Safe example of using ini_file (2nd solution) community.general.ini_file: path: foo - mode: 0600 # explicitly sets the desired permissions, to make the results predictable + mode: "0600" # explicitly sets the desired permissions, to make the results predictable - name: Safe example of using copy (3rd solution) ansible.builtin.copy: diff --git a/src/ansiblelint/rules/risky_file_permissions.py b/src/ansiblelint/rules/risky_file_permissions.py index f4494eb..7fe3870 100644 --- a/src/ansiblelint/rules/risky_file_permissions.py +++ b/src/ansiblelint/rules/risky_file_permissions.py @@ -137,8 +137,9 @@ class MissingFilePermissionsRule(AnsibleLintRule): if "pytest" in sys.modules: import pytest - from ansiblelint.rules import RulesCollection # pylint: disable=ungrouped-imports - from ansiblelint.testing import RunFromText # pylint: disable=ungrouped-imports + # pylint: disable=ungrouped-imports + from ansiblelint.rules import RulesCollection + from ansiblelint.testing import RunFromText @pytest.mark.parametrize( ("file", "expected"), diff --git a/src/ansiblelint/rules/risky_octal.py b/src/ansiblelint/rules/risky_octal.py index e3651ea..e3dad38 100644 --- a/src/ansiblelint/rules/risky_octal.py +++ b/src/ansiblelint/rules/risky_octal.py @@ -1,4 +1,5 @@ """Implementation of risky-octal rule.""" + # Copyright (c) 2013-2014 Will Thames <will@thames.id.au> # # Permission is hereby granted, free of charge, to any person obtaining a copy diff --git a/src/ansiblelint/rules/risky_shell_pipe.md b/src/ansiblelint/rules/risky_shell_pipe.md index 302d0d9..dfede8e 100644 --- a/src/ansiblelint/rules/risky_shell_pipe.md +++ b/src/ansiblelint/rules/risky_shell_pipe.md @@ -7,7 +7,7 @@ The return status of a pipeline is the exit status of the command. The `pipefail` option ensures that tasks fail as expected if the first command fails. -As this requirement does apply to PowerShell, for shell commands that have +As this requirement does not apply to PowerShell, for shell commands that have `pwsh` inside `executable` attribute, this rule will not trigger. ## Problematic Code @@ -30,10 +30,14 @@ As this requirement does apply to PowerShell, for shell commands that have become: false tasks: - name: Pipeline with pipefail - ansible.builtin.shell: set -o pipefail && false | cat + ansible.builtin.shell: + cmd: set -o pipefail && false | cat + executable: /bin/bash - name: Pipeline with pipefail, multi-line - ansible.builtin.shell: | - set -o pipefail # <-- adding this will prevent surprises - false | cat + ansible.builtin.shell: + cmd: | + set -o pipefail # <-- adding this will prevent surprises + false | cat + executable: /bin/bash ``` diff --git a/src/ansiblelint/rules/risky_shell_pipe.py b/src/ansiblelint/rules/risky_shell_pipe.py index 58a6f5f..b0c6063 100644 --- a/src/ansiblelint/rules/risky_shell_pipe.py +++ b/src/ansiblelint/rules/risky_shell_pipe.py @@ -1,4 +1,5 @@ """Implementation of risky-shell-pipe rule.""" + from __future__ import annotations import re @@ -62,8 +63,9 @@ class ShellWithoutPipefail(AnsibleLintRule): if "pytest" in sys.modules: import pytest - from ansiblelint.rules import RulesCollection # pylint: disable=ungrouped-imports - from ansiblelint.runner import Runner # pylint: disable=ungrouped-imports + # pylint: disable=ungrouped-imports + from ansiblelint.rules import RulesCollection + from ansiblelint.runner import Runner @pytest.mark.parametrize( ("file", "expected"), diff --git a/src/ansiblelint/rules/role_name.py b/src/ansiblelint/rules/role_name.py index 499c086..ebe0b1a 100644 --- a/src/ansiblelint/rules/role_name.py +++ b/src/ansiblelint/rules/role_name.py @@ -1,4 +1,5 @@ """Implementation of role-name rule.""" + # Copyright (c) 2020 Gael Chamoulaud <gchamoul@redhat.com> # Copyright (c) 2020 Sorin Sbarnea <ssbarnea@redhat.com> # @@ -94,6 +95,26 @@ class RoleNames(AnsibleLintRule): if file.kind not in ("meta", "role", "playbook"): return result + if file.kind == "meta": + for role in file.data.get("dependencies", []): + if isinstance(role, dict): + role_name = role["role"] + elif isinstance(role, str): + role_name = role + else: + msg = "Role dependency has unexpected type." + raise TypeError(msg) + if "/" in role_name: + result.append( + self.create_matcherror( + f"Avoid using paths when importing roles. ({role_name})", + filename=file, + lineno=role_name.ansible_pos[1], + tag=f"{self.id}[path]", + ), + ) + return result + if file.kind == "playbook": for play in file.data: if "roles" in play: @@ -143,7 +164,7 @@ class RoleNames(AnsibleLintRule): if meta_data: try: return str(meta_data["galaxy_info"]["role_name"]) - except KeyError: + except (KeyError, TypeError): pass return default @@ -151,8 +172,9 @@ class RoleNames(AnsibleLintRule): if "pytest" in sys.modules: import pytest - from ansiblelint.rules import RulesCollection # pylint: disable=ungrouped-imports - from ansiblelint.runner import Runner # pylint: disable=ungrouped-imports + # pylint: disable=ungrouped-imports + from ansiblelint.rules import RulesCollection + from ansiblelint.runner import Runner @pytest.mark.parametrize( ("test_file", "failure"), @@ -168,3 +190,44 @@ if "pytest" in sys.modules: for result in results: assert result.tag == "role-name[path]" assert len(results) == failure + + @pytest.mark.parametrize( + ("test_file", "failure"), + (pytest.param("examples/roles/role_with_deps_paths", 3, id="fail"),), + ) + def test_role_deps_path_names( + default_rules_collection: RulesCollection, + test_file: str, + failure: int, + ) -> None: + """Test rule matches.""" + results = Runner( + test_file, + rules=default_rules_collection, + ).run() + expected_errors = ( + ("role-name[path]", 3), + ("role-name[path]", 9), + ("role-name[path]", 10), + ) + assert len(expected_errors) == failure + for idx, result in enumerate(results): + assert result.tag == expected_errors[idx][0] + assert result.lineno == expected_errors[idx][1] + assert len(results) == failure + + @pytest.mark.parametrize( + ("test_file", "failure"), + (pytest.param("examples/roles/test-no-deps-role", 0, id="no_deps"),), + ) + def test_role_no_deps( + default_rules_collection: RulesCollection, + test_file: str, + failure: int, + ) -> None: + """Test role if no dependencies are present in meta/main.yml.""" + results = Runner( + test_file, + rules=default_rules_collection, + ).run() + assert len(results) == failure diff --git a/src/ansiblelint/rules/run_once.py b/src/ansiblelint/rules/run_once.py index 78968b6..d656711 100644 --- a/src/ansiblelint/rules/run_once.py +++ b/src/ansiblelint/rules/run_once.py @@ -1,4 +1,5 @@ """Optional Ansible-lint rule to warn use of run_once with strategy free.""" + from __future__ import annotations import sys @@ -34,7 +35,7 @@ class RunOnce(AnsibleLintRule): if not file or file.kind != "playbook" or not data: return [] - strategy = data.get("strategy", None) + strategy = data.get("strategy") run_once = data.get("run_once", False) if (not strategy and not run_once) or strategy != "free": return [] @@ -43,7 +44,6 @@ class RunOnce(AnsibleLintRule): message="Play uses strategy: free", filename=file, tag=f"{self.id}[play]", - # pylint: disable=protected-access lineno=strategy._line_number, # noqa: SLF001 ), ] @@ -74,8 +74,9 @@ class RunOnce(AnsibleLintRule): if "pytest" in sys.modules: import pytest - from ansiblelint.rules import RulesCollection # pylint: disable=ungrouped-imports - from ansiblelint.runner import Runner # pylint: disable=ungrouped-imports + # pylint: disable=ungrouped-imports + from ansiblelint.rules import RulesCollection + from ansiblelint.runner import Runner @pytest.mark.parametrize( ("test_file", "failure"), diff --git a/src/ansiblelint/rules/sanity.md b/src/ansiblelint/rules/sanity.md index 5b4f3a4..f17cdaf 100644 --- a/src/ansiblelint/rules/sanity.md +++ b/src/ansiblelint/rules/sanity.md @@ -1,10 +1,10 @@ # sanity This rule checks the `tests/sanity/ignore-x.x.txt` file for disallowed ignores. -This rule is extremely opinionated and enforced by Partner Engineering. The +This rule is extremely opinionated and enforced by Partner Engineering as a requirement for Red Hat Certification. The currently allowed ruleset is subject to change, but is starting at a minimal number of allowed ignores for maximum test enforcement. Any commented-out ignore -entries are not evaluated. +entries are not evaluated, and ignore files for unsupported versions of ansible-core are not evaluated. This rule can produce messages like: @@ -29,10 +29,9 @@ Currently allowed ignores for all Ansible versions are: - `compile-2.7!skip` - `compile-3.5` - `compile-3.5!skip` - -Additionally allowed ignores for Ansible 2.9 are: -- `validate-modules:deprecation-mismatch` -- `validate-modules:invalid-documentation` +- `shellcheck` +- `shebang` +- `pylint:used-before-assignment` ## Problematic code diff --git a/src/ansiblelint/rules/sanity.py b/src/ansiblelint/rules/sanity.py index 09fe7cc..921e712 100644 --- a/src/ansiblelint/rules/sanity.py +++ b/src/ansiblelint/rules/sanity.py @@ -1,6 +1,8 @@ """Implementation of sanity rule.""" + from __future__ import annotations +import re import sys from typing import TYPE_CHECKING @@ -27,12 +29,7 @@ class CheckSanityIgnoreFiles(AnsibleLintRule): # Partner Engineering defines this list. Please contact PE for changes. - allowed_ignores_v2_9 = [ - "validate-modules:deprecation-mismatch", # Note: 2.9 expects a deprecated key in the METADATA. It was removed in later versions. - "validate-modules:invalid-documentation", # Note: The removed_at_date key in the deprecated section is invalid for 2.9. - ] - - allowed_ignores_all = [ + allowed_ignores = [ "validate-modules:missing-gplv3-license", "action-plugin-docs", # Added for Networking Collections "import-2.6", @@ -47,7 +44,18 @@ class CheckSanityIgnoreFiles(AnsibleLintRule): "compile-2.7!skip", "compile-3.5", "compile-3.5!skip", + "shebang", # Unreliable test + "shellcheck", # Unreliable test + "pylint:used-before-assignment", # Unreliable test + ] + + no_check_ignore_files = [ + "ignore-2.9", + "ignore-2.10", + "ignore-2.11", + "ignore-2.12", ] + _ids = { "sanity[cannot-ignore]": "Ignore file contains ... at line ..., which is not a permitted ignore.", "sanity[bad-ignore]": "Ignore file entry at ... is formatted incorrectly. Please review.", @@ -62,44 +70,55 @@ class CheckSanityIgnoreFiles(AnsibleLintRule): results: list[MatchError] = [] test = "" + check_dirs = { + "plugins", + "roles", + } + if file.kind != "sanity-ignore-file": return [] with file.path.open(encoding="utf-8") as ignore_file: entries = ignore_file.read().splitlines() - ignores = self.allowed_ignores_all - - # If there is a ignore-2.9.txt file, add the v2_9 list of allowed ignores - if "ignore-2.9.txt" in str(file.abspath): - ignores = self.allowed_ignores_all + self.allowed_ignores_v2_9 + if any(name in str(file.abspath) for name in self.no_check_ignore_files): + return [] for line_num, entry in enumerate(entries, 1): - if entry and entry[0] != "#": - try: - if "#" in entry: - entry, _ = entry.split("#") - (_, test) = entry.split() - if test not in ignores: + base_ignore_dir = "" + + if entry: + # match up to the first "/" + regex = re.match("[^/]*", entry) + + if regex: + base_ignore_dir = regex.group(0) + + if base_ignore_dir in check_dirs: + try: + if "#" in entry: + entry, _ = entry.split("#") + (_, test) = entry.split() + if test not in self.allowed_ignores: + results.append( + self.create_matcherror( + message=f"Ignore file contains {test} at line {line_num}, which is not a permitted ignore.", + tag="sanity[cannot-ignore]", + lineno=line_num, + filename=file, + ), + ) + + except ValueError: results.append( self.create_matcherror( - message=f"Ignore file contains {test} at line {line_num}, which is not a permitted ignore.", - tag="sanity[cannot-ignore]", + message=f"Ignore file entry at {line_num} is formatted incorrectly. Please review.", + tag="sanity[bad-ignore]", lineno=line_num, filename=file, ), ) - except ValueError: - results.append( - self.create_matcherror( - message=f"Ignore file entry at {line_num} is formatted incorrectly. Please review.", - tag="sanity[bad-ignore]", - lineno=line_num, - filename=file, - ), - ) - return results @@ -107,8 +126,9 @@ class CheckSanityIgnoreFiles(AnsibleLintRule): if "pytest" in sys.modules: import pytest - from ansiblelint.rules import RulesCollection # pylint: disable=ungrouped-imports - from ansiblelint.runner import Runner # pylint: disable=ungrouped-imports + # pylint: disable=ungrouped-imports + from ansiblelint.rules import RulesCollection + from ansiblelint.runner import Runner @pytest.mark.parametrize( ("test_file", "failures", "tags"), diff --git a/src/ansiblelint/rules/schema.py b/src/ansiblelint/rules/schema.py index 32ff2eb..6997acd 100644 --- a/src/ansiblelint/rules/schema.py +++ b/src/ansiblelint/rules/schema.py @@ -1,7 +1,9 @@ """Rule definition for JSON Schema Validations.""" + from __future__ import annotations import logging +import re import sys from typing import TYPE_CHECKING, Any @@ -13,6 +15,7 @@ from ansiblelint.schemas.main import validate_file_schema from ansiblelint.text import has_jinja if TYPE_CHECKING: + from ansiblelint.config import Options from ansiblelint.utils import Task @@ -94,7 +97,11 @@ class ValidateSchemaRule(AnsibleLintRule): def matchplay(self, file: Lintable, data: dict[str, Any]) -> list[MatchError]: """Return matches found for a specific playbook.""" results: list[MatchError] = [] - if not data or file.kind not in ("tasks", "handlers", "playbook"): + if ( + not data + or file.kind not in ("tasks", "handlers", "playbook") + or file.failed() + ): return results # check at play level results.extend(self._get_field_matches(file=file, data=data)) @@ -117,7 +124,7 @@ class ValidateSchemaRule(AnsibleLintRule): message=msg, lineno=data.get("__line__", 1), lintable=file, - rule=ValidateSchemaRule(), + rule=self, details=ValidateSchemaRule.description, tag=f"schema[{file.kind}]", ), @@ -129,9 +136,13 @@ class ValidateSchemaRule(AnsibleLintRule): task: Task, file: Lintable | None = None, ) -> bool | str | MatchError | list[MatchError]: - results = [] + results: list[MatchError] = [] if not file: file = Lintable("", kind="tasks") + + if file.failed(): + return results + results.extend(self._get_field_matches(file=file, data=task.raw_task)) for key in pre_checks["task"]: if key in task.raw_task: @@ -141,7 +152,7 @@ class ValidateSchemaRule(AnsibleLintRule): MatchError( message=msg, lintable=file, - rule=ValidateSchemaRule(), + rule=self, details=ValidateSchemaRule.description, tag=f"schema[{tag}]", ), @@ -151,12 +162,15 @@ class ValidateSchemaRule(AnsibleLintRule): def matchyaml(self, file: Lintable) -> list[MatchError]: """Return JSON validation errors found as a list of MatchError(s).""" result: list[MatchError] = [] + + if file.failed(): + return result + if file.kind not in JSON_SCHEMAS: return result - errors = validate_file_schema(file) - if errors: - if errors[0].startswith("Failed to load YAML file"): + for error in validate_file_schema(file): + if error.startswith("Failed to load YAML file"): _logger.debug( "Ignored failure to load %s for schema validation, as !vault may cause it.", file, @@ -165,13 +179,14 @@ class ValidateSchemaRule(AnsibleLintRule): result.append( MatchError( - message=errors[0], + message=error, lintable=file, - rule=ValidateSchemaRule(), + rule=self, details=ValidateSchemaRule.description, tag=f"schema[{file.kind}]", ), ) + break if not result: result = super().matchyaml(file) @@ -183,7 +198,6 @@ if "pytest" in sys.modules: import pytest # pylint: disable=ungrouped-imports - from ansiblelint.config import options from ansiblelint.rules import RulesCollection from ansiblelint.runner import Runner @@ -191,27 +205,30 @@ if "pytest" in sys.modules: ("file", "expected_kind", "expected"), ( pytest.param( - "examples/collection/galaxy.yml", + "examples/.collection/galaxy.yml", "galaxy", - ["'GPL' is not one of"], + [r".*'GPL' is not one of.*https://"], id="galaxy", ), pytest.param( "examples/roles/invalid_requirements_schema/meta/requirements.yml", "requirements", - ["{'foo': 'bar'} is not valid under any of the given schemas"], + [ + # r".*{'foo': 'bar'} is not valid under any of the given schemas.*https://", + r".*{'foo': 'bar'} is not of type 'array'.*https://", + ], id="requirements", ), pytest.param( "examples/roles/invalid_meta_schema/meta/main.yml", "meta", - ["False is not of type 'string'"], + [r".*False is not of type 'string'.*https://"], id="meta", ), pytest.param( "examples/playbooks/vars/invalid_vars_schema.yml", "vars", - ["'123' does not match any of the regexes"], + [r".* '123' does not match any of the regexes.*https://"], id="vars", ), pytest.param( @@ -223,14 +240,23 @@ if "pytest" in sys.modules: pytest.param( "examples/ee_broken/execution-environment.yml", "execution-environment", - ["{'foo': 'bar'} is not valid under any of the given schemas"], + [ + r".*Additional properties are not allowed \('foo' was unexpected\).*https://", + ], id="execution-environment-broken", ), - ("examples/meta/runtime.yml", "meta-runtime", []), + pytest.param( + "examples/meta/runtime.yml", + "meta-runtime", + [], + id="meta-runtime", + ), pytest.param( "examples/broken_collection_meta_runtime/meta/runtime.yml", "meta-runtime", - ["Additional properties are not allowed ('foo' was unexpected)"], + [ + r".*Additional properties are not allowed \('foo' was unexpected\).*https://", + ], id="meta-runtime-broken", ), pytest.param( @@ -242,7 +268,9 @@ if "pytest" in sys.modules: pytest.param( "examples/inventory/broken_dev_inventory.yml", "inventory", - ["Additional properties are not allowed ('foo' was unexpected)"], + [ + r".*Additional properties are not allowed \('foo' was unexpected\).*https://", + ], id="inventory-broken", ), pytest.param( @@ -260,7 +288,17 @@ if "pytest" in sys.modules: pytest.param( "examples/broken/.ansible-lint", "ansible-lint-config", - ["Additional properties are not allowed ('foo' was unexpected)"], + [ + r".*Additional properties are not allowed \('foo' was unexpected\).*https://", + ], + id="ansible-lint-config-broken", + ), + pytest.param( + "examples/broken_supported_ansible_also/.ansible-lint", + "ansible-lint-config", + [ + r".*supported_ansible_also True is not of type 'array'.*https://", + ], id="ansible-lint-config-broken", ), pytest.param( @@ -272,7 +310,9 @@ if "pytest" in sys.modules: pytest.param( "examples/broken/ansible-navigator.yml", "ansible-navigator-config", - ["Additional properties are not allowed ('ansible' was unexpected)"], + [ + r".*Additional properties are not allowed \('ansible' was unexpected\).*https://", + ], id="ansible-navigator-config-broken", ), pytest.param( @@ -284,20 +324,25 @@ if "pytest" in sys.modules: pytest.param( "examples/roles/broken_argument_specs/meta/argument_specs.yml", "role-arg-spec", - ["Additional properties are not allowed ('foo' was unexpected)"], + [ + r".*Additional properties are not allowed \('foo' was unexpected\).*https://", + ], id="role-arg-spec-broken", ), pytest.param( "examples/changelogs/changelog.yaml", "changelog", - ["Additional properties are not allowed ('foo' was unexpected)"], + [ + r".*Additional properties are not allowed \('foo' was unexpected\).*https://", + ], id="changelog", ), pytest.param( "examples/rulebooks/rulebook-fail.yml", "rulebook", [ - "Additional properties are not allowed ('that_should_not_be_here' was unexpected)", + # r".*Additional properties are not allowed \('that_should_not_be_here' was unexpected\).*https://", + r".*'sss' is not of type 'object'.*https://", ], id="rulebook", ), @@ -324,19 +369,24 @@ if "pytest" in sys.modules: ), ), ) - def test_schema(file: str, expected_kind: str, expected: list[str]) -> None: + def test_schema( + file: str, + expected_kind: str, + expected: list[str], + config_options: Options, + ) -> None: """Validate parsing of ansible output.""" lintable = Lintable(file) assert lintable.kind == expected_kind - rules = RulesCollection(options=options) + rules = RulesCollection(options=config_options) rules.register(ValidateSchemaRule()) results = Runner(lintable, rules=rules).run() assert len(results) == len(expected), results for idx, result in enumerate(results): assert result.filename.endswith(file) - assert expected[idx] in result.message + assert re.match(expected[idx], result.message) assert result.tag == f"schema[{expected_kind}]" @pytest.mark.parametrize( @@ -356,12 +406,13 @@ if "pytest" in sys.modules: expected_kind: str, expected_tag: str, count: int, + config_options: Options, ) -> None: """Validate ability to detect schema[moves].""" lintable = Lintable(file) assert lintable.kind == expected_kind - rules = RulesCollection(options=options) + rules = RulesCollection(options=config_options) rules.register(ValidateSchemaRule()) results = Runner(lintable, rules=rules).run() diff --git a/src/ansiblelint/rules/syntax_check.md b/src/ansiblelint/rules/syntax_check.md index e8197a5..566fa33 100644 --- a/src/ansiblelint/rules/syntax_check.md +++ b/src/ansiblelint/rules/syntax_check.md @@ -9,7 +9,7 @@ You can exclude these files from linting, but it is better to make sure they can be loaded by Ansible. This is often achieved by editing the inventory file and/or `ansible.cfg` so ansible can load required variables. -If undefined variables cause the failure, you can use the jinja `default()` +If undefined variables cause the failure, you can use the Jinja `default()` filter to provide fallback values, like in the example below. This rule is among the few `unskippable` rules that cannot be added to @@ -20,9 +20,32 @@ fixtures that are invalid on purpose. One of the most common sources of errors is a failure to assert the presence of various variables at the beginning of the playbook. -This rule can produce messages like below: +This rule can produce messages like: -- `syntax-check[empty-playbook]` is raised when a playbook file has no content. +- `syntax-check[empty-playbook]`: Empty playbook, nothing to do +- `syntax-check[malformed]`: A malformed block was encountered while loading a block +- `syntax-check[missing-file]`: Unable to retrieve file contents ... Could not find or access ... +- `syntax-check[unknown-module]`: couldn't resolve module/action +- `syntax-check[specific]`: for other errors not mentioned above. + +## syntax-check[unknown-module] + +The linter relies on ansible-core code to load the ansible code and it will +produce a syntax error if the code refers to ansible content that is not +installed. You must ensure that all collections and roles used inside your +repository are listed inside a [`requirements.yml`](https://docs.ansible.com/ansible/latest/galaxy/user_guide.html#installing-roles-and-collections-from-the-same-requirements-yml-file) file, so the linter can +install them when they are missing. + +Valid location for `requirements.yml` are: + +- `requirements.yml` +- `roles/requirements.yml` +- `collections/requirements.yml` +- `tests/requirements.yml` +- `tests/integration/requirements.yml` +- `tests/unit/requirements.yml` + +Note: If requirements are test related then they should be inside `tests/`. ## Problematic code diff --git a/src/ansiblelint/rules/syntax_check.py b/src/ansiblelint/rules/syntax_check.py index c6a4c5e..9b072f6 100644 --- a/src/ansiblelint/rules/syntax_check.py +++ b/src/ansiblelint/rules/syntax_check.py @@ -1,4 +1,5 @@ """Rule definition for ansible syntax check.""" + from __future__ import annotations import re @@ -15,6 +16,8 @@ class KnownError: regex: re.Pattern[str] +# Order matters, we only report the first matching pattern, the one at the end +# is used to match generic or less specific patterns. OUTPUT_PATTERNS = ( KnownError( tag="missing-file", @@ -25,9 +28,9 @@ OUTPUT_PATTERNS = ( ), ), KnownError( - tag="specific", + tag="no-file", regex=re.compile( - r"^ERROR! (?P<title>[^\n]*)\n\nThe error appears to be in '(?P<filename>[\w\/\.\-]+)': line (?P<line>\d+), column (?P<column>\d+)", + r"^ERROR! (?P<title>No file specified for [^\n]*)", re.MULTILINE | re.S | re.DOTALL, ), ), @@ -45,6 +48,28 @@ OUTPUT_PATTERNS = ( re.MULTILINE | re.S | re.DOTALL, ), ), + KnownError( + tag="unknown-module", + regex=re.compile( + r"^ERROR! (?P<title>couldn't resolve module/action [^\n]*)\n\nThe error appears to be in '(?P<filename>[\w\/\.\-]+)': line (?P<line>\d+), column (?P<column>\d+)", + re.MULTILINE | re.S | re.DOTALL, + ), + ), + KnownError( + tag="specific", + regex=re.compile( + r"^ERROR! (?P<title>[^\n]*)\n\nThe error appears to be in '(?P<filename>[\w\/\.\-]+)': line (?P<line>\d+), column (?P<column>\d+)", + re.MULTILINE | re.S | re.DOTALL, + ), + ), + # "ERROR! the role 'this_role_is_missing' was not found in ROLE_INCLUDE_PATHS\n\nThe error appears to be in 'FILE_PATH': line 5, column 7, but may\nbe elsewhere in the file depending on the exact syntax problem.\n\nThe offending line appears to be:\n\n roles:\n - this_role_is_missing\n ^ here\n" + KnownError( + tag="specific", + regex=re.compile( + r"^ERROR! (?P<title>the role '.*' was not found in[^\n]*)'(?P<filename>[\w\/\.\-]+)': line (?P<line>\d+), column (?P<column>\d+)", + re.MULTILINE | re.S | re.DOTALL, + ), + ), ) diff --git a/src/ansiblelint/rules/var_naming.md b/src/ansiblelint/rules/var_naming.md index 3386a0c..e4034f0 100644 --- a/src/ansiblelint/rules/var_naming.md +++ b/src/ansiblelint/rules/var_naming.md @@ -22,7 +22,7 @@ Possible errors messages: - `var-naming[no-jinja]`: Variables names must not contain jinja2 templating. - `var-naming[pattern]`: Variables names should match ... regex. - `var-naming[no-role-prefix]`: Variables names from within roles should use - `role_name_` as a prefix. + `role_name_` as a prefix. Underlines are accepted before the prefix. - `var-naming[no-reserved]`: Variables names must not be Ansible reserved names. - `var-naming[read-only]`: This special variable is read-only. diff --git a/src/ansiblelint/rules/var_naming.py b/src/ansiblelint/rules/var_naming.py index 389530d..14a4c40 100644 --- a/src/ansiblelint/rules/var_naming.py +++ b/src/ansiblelint/rules/var_naming.py @@ -1,4 +1,5 @@ """Implementation of var-naming rule.""" + from __future__ import annotations import keyword @@ -9,13 +10,19 @@ from typing import TYPE_CHECKING, Any from ansible.parsing.yaml.objects import AnsibleUnicode from ansible.vars.reserved import get_reserved_names -from ansiblelint.config import options -from ansiblelint.constants import ANNOTATION_KEYS, LINE_NUMBER_KEY, RC +from ansiblelint.config import Options, options +from ansiblelint.constants import ( + ANNOTATION_KEYS, + LINE_NUMBER_KEY, + PLAYBOOK_ROLE_KEYWORDS, + RC, +) from ansiblelint.errors import MatchError from ansiblelint.file_utils import Lintable from ansiblelint.rules import AnsibleLintRule, RulesCollection from ansiblelint.runner import Runner from ansiblelint.skip_utils import get_rule_skips_from_line +from ansiblelint.text import has_jinja, is_fqcn_or_name from ansiblelint.utils import parse_yaml_from_file if TYPE_CHECKING: @@ -160,10 +167,15 @@ class VariableNamingRule(AnsibleLintRule): rule=self, ) - if prefix and not ident.startswith(f"{prefix}_"): + if ( + prefix + and not ident.lstrip("_").startswith(f"{prefix}_") + and not has_jinja(prefix) + and is_fqcn_or_name(prefix) + ): return MatchError( tag="var-naming[no-role-prefix]", - message="Variables names from within roles should use role_name_ as a prefix.", + message=f"Variables names from within roles should use {prefix}_ as a prefix.", rule=self, ) return None @@ -187,6 +199,37 @@ class VariableNamingRule(AnsibleLintRule): else our_vars[LINE_NUMBER_KEY] ) raw_results.append(match_error) + roles = data.get("roles", []) + for role in roles: + if isinstance(role, AnsibleUnicode): + continue + role_fqcn = role.get("role", role.get("name")) + prefix = role_fqcn.split("/" if "/" in role_fqcn else ".")[-1] + for key in list(role.keys()): + if key not in PLAYBOOK_ROLE_KEYWORDS: + match_error = self.get_var_naming_matcherror(key, prefix=prefix) + if match_error: + match_error.filename = str(file.path) + match_error.message += f" (vars: {key})" + match_error.lineno = ( + key.ansible_pos[1] + if isinstance(key, AnsibleUnicode) + else role[LINE_NUMBER_KEY] + ) + raw_results.append(match_error) + + our_vars = role.get("vars", {}) + for key in our_vars: + match_error = self.get_var_naming_matcherror(key, prefix=prefix) + if match_error: + match_error.filename = str(file.path) + match_error.message += f" (vars: {key})" + match_error.lineno = ( + key.ansible_pos[1] + if isinstance(key, AnsibleUnicode) + else our_vars[LINE_NUMBER_KEY] + ) + raw_results.append(match_error) if raw_results: lines = file.content.splitlines() for match in raw_results: @@ -266,7 +309,8 @@ class VariableNamingRule(AnsibleLintRule): if str(file.kind) == "vars" and file.data: meta_data = parse_yaml_from_file(str(file.path)) for key in meta_data: - match_error = self.get_var_naming_matcherror(key) + prefix = file.role if file.role else "" + match_error = self.get_var_naming_matcherror(key, prefix=prefix) if match_error: match_error.filename = filename match_error.lineno = key.ansible_pos[1] @@ -298,13 +342,21 @@ if "pytest" in sys.modules: @pytest.mark.parametrize( ("file", "expected"), ( - pytest.param("examples/playbooks/rule-var-naming-fail.yml", 7, id="0"), + pytest.param( + "examples/playbooks/var-naming/rule-var-naming-fail.yml", + 7, + id="0", + ), pytest.param("examples/Taskfile.yml", 0, id="1"), ), ) - def test_invalid_var_name_playbook(file: str, expected: int) -> None: + def test_invalid_var_name_playbook( + file: str, + expected: int, + config_options: Options, + ) -> None: """Test rule matches.""" - rules = RulesCollection(options=options) + rules = RulesCollection(options=config_options) rules.register(VariableNamingRule()) results = Runner(Lintable(file), rules=rules).run() assert len(results) == expected @@ -337,6 +389,40 @@ if "pytest" in sys.modules: assert result.tag == expected_errors[idx][0] assert result.lineno == expected_errors[idx][1] + def test_var_naming_with_role_prefix( + default_rules_collection: RulesCollection, + ) -> None: + """Test rule matches.""" + results = Runner( + Lintable("examples/roles/role_vars_prefix_detection"), + rules=default_rules_collection, + ).run() + assert len(results) == 2 + for result in results: + assert result.tag == "var-naming[no-role-prefix]" + + def test_var_naming_with_role_prefix_plays( + default_rules_collection: RulesCollection, + ) -> None: + """Test rule matches.""" + results = Runner( + Lintable("examples/playbooks/role_vars_prefix_detection.yml"), + rules=default_rules_collection, + exclude_paths=["examples/roles/role_vars_prefix_detection"], + ).run() + expected_errors = ( + ("var-naming[no-role-prefix]", 9), + ("var-naming[no-role-prefix]", 12), + ("var-naming[no-role-prefix]", 15), + ("var-naming[no-role-prefix]", 25), + ("var-naming[no-role-prefix]", 32), + ("var-naming[no-role-prefix]", 45), + ) + assert len(results) == len(expected_errors) + for idx, result in enumerate(results): + assert result.tag == expected_errors[idx][0] + assert result.lineno == expected_errors[idx][1] + def test_var_naming_with_pattern() -> None: """Test rule matches.""" role_path = "examples/roles/var_naming_pattern/tasks/main.yml" @@ -364,7 +450,7 @@ if "pytest" in sys.modules: def test_var_naming_with_include_role_import_role() -> None: """Test with include role and import role.""" - role_path = "examples/test_collection/roles/my_role/tasks/main.yml" + role_path = "examples/.test_collection/roles/my_role/tasks/main.yml" result = run_ansible_lint(role_path) assert result.returncode == RC.SUCCESS assert "var-naming" not in result.stdout diff --git a/src/ansiblelint/rules/yaml.md b/src/ansiblelint/rules/yaml.md index 8dc56eb..654f80e 100644 --- a/src/ansiblelint/rules/yaml.md +++ b/src/ansiblelint/rules/yaml.md @@ -1,6 +1,8 @@ # yaml -This rule checks YAML syntax and is an implementation of `yamllint`. +This rule checks YAML syntax by using [yamllint] library but with a +[specific default configuration](#yamllint-configuration), one that is +compatible with both, our internal reformatter (`--fix`) and also [prettier]. You can disable YAML syntax violations by adding `yaml` to the `skip_list` in your Ansible-lint configuration as follows: @@ -53,6 +55,7 @@ Some of the detailed error codes that you might see are: - `yaml[empty-lines]` - _too many blank lines (...> ...)_ - `yaml[indentation]` - _Wrong indentation: expected ... but found ..._ - `yaml[key-duplicates]` - _Duplication of key "..." in mapping_ +- `yaml[line-length]` - _Line too long (... > ... characters)_ - `yaml[new-line-at-end-of-file]` - _No new line character at the end of file_ - `yaml[octal-values]`: forbidden implicit or explicit [octal](#octals) value - `yaml[syntax]` - YAML syntax is broken @@ -72,6 +75,13 @@ for it does check these. If for some reason, you do not want to follow our defaults, you can create a `.yamllint` file in your project and this will take precedence over our defaults. +## Additional Information for Multiline Strings + +Adhering to yaml[line-length] rule, for writing multiline strings we recommend +using Block Style Indicator: literal style indicated by a pipe (|) or folded +style indicated by a right angle bracket (>), instead of escaping the newlines +with backslashes. Reference [guide] for writing multiple line strings in yaml. + ## Problematic code ```yaml @@ -91,7 +101,53 @@ foo2: "0o777" # <-- Explicitly quoting octal is less risky. bar: ... # Correct comment indentation. ``` +## Yamllint configuration + +If you decide to add a custom yamllint config to your project, ansible-lint +might refuse to run if it detects that some of your options are incompatible and +ask you to correct them. When this happens, you will see a message like the one +below: + +``` +CRITICAL Found incompatible custom yamllint configuration (.yamllint), please either remove the file or edit it to comply with: + - comments.min-spaces-from-content must be 1 + - braces.min-spaces-inside must be 0 + - braces.max-spaces-inside must be 1 + - octal-values.forbid-implicit-octal must be true + - octal-values.forbid-explicit-octal must be true + +Read https://ansible.readthedocs.io/projects/lint/rules/yaml/ for more details regarding why we have these requirements. +``` + +!!! warning + + [Auto-fix](../autofix.md) functionality will change **inline comment indentation to one + character instead of two**, which is the default of [yamllint]. The reason + for this decision was to keep reformatting compatibility + with [prettier], which is the most popular reformatter. + + ```yaml title=".yamllint" + rules: + comments: + min-spaces-from-content: 1 # prettier compatibility + ``` + + There is no need to create this yamllint config file, but if you also + run yamllint yourself, you might want to create it to make it behave + the same way as ansible-lint. + +Below you can find the default yamllint configuration that our linter will use +when there is no custom file present. + +```yaml +{!../src/ansiblelint/data/.yamllint!} +``` + [1.1]: https://yaml.org/spec/1.1/ [1.2.0]: https://yaml.org/spec/1.2.0/ [1.2.2]: https://yaml.org/spec/1.2.2/ [yaml specification]: https://yaml.org/ +[guide]: + https://docs.ansible.com/ansible/latest/reference_appendices/YAMLSyntax.html#yaml-basics +[prettier]: https://prettier.io/ +[yamllint]: https://yamllint.readthedocs.io/en/stable/ diff --git a/src/ansiblelint/rules/yaml_rule.py b/src/ansiblelint/rules/yaml_rule.py index 4da4d41..3ec5b59 100644 --- a/src/ansiblelint/rules/yaml_rule.py +++ b/src/ansiblelint/rules/yaml_rule.py @@ -1,27 +1,29 @@ """Implementation of yaml linting rule (yamllint integration).""" + from __future__ import annotations import logging import sys -from collections.abc import Iterable +from collections.abc import Iterable, MutableMapping, MutableSequence from typing import TYPE_CHECKING from yamllint.linter import run as run_yamllint from ansiblelint.constants import LINE_NUMBER_KEY, SKIPPED_RULES_KEY from ansiblelint.file_utils import Lintable -from ansiblelint.rules import AnsibleLintRule +from ansiblelint.rules import AnsibleLintRule, TransformMixin from ansiblelint.yaml_utils import load_yamllint_config if TYPE_CHECKING: from typing import Any + from ansiblelint.config import Options from ansiblelint.errors import MatchError _logger = logging.getLogger(__name__) -class YamllintRule(AnsibleLintRule): +class YamllintRule(AnsibleLintRule, TransformMixin): """Violations reported by yamllint.""" id = "yaml" @@ -73,6 +75,12 @@ class YamllintRule(AnsibleLintRule): self.severity = "VERY_LOW" if problem.level == "error": self.severity = "MEDIUM" + # Ignore truthy violation with github workflows ("on:" keys) + if problem.rule == "truthy" and file.path.parent.parts[-2:] == ( + ".github", + "workflows", + ): + continue matches.append( self.create_matcherror( # yamllint does return lower-case sentences @@ -85,6 +93,22 @@ class YamllintRule(AnsibleLintRule): ) return matches + def transform( + self: YamllintRule, + match: MatchError, + lintable: Lintable, + data: MutableMapping[str, Any] | MutableSequence[Any] | str, + ) -> None: + """Transform yaml. + + :param match: MatchError instance + :param lintable: Lintable instance + :param data: data to transform + """ + # This method does nothing because the YAML reformatting is implemented + # in data dumper. Still presence of this method helps us with + # documentation generation. + def _combine_skip_rules(data: Any) -> set[str]: """Return a consolidated list of skipped rules.""" @@ -107,7 +131,7 @@ def _fetch_skips(data: Any, collector: dict[int, set[str]]) -> dict[int, set[str collector[data.get(LINE_NUMBER_KEY)].update(rules) if isinstance(data, Iterable) and not isinstance(data, str): if isinstance(data, dict): - for _entry, value in data.items(): + for value in data.values(): _fetch_skips(value, collector) else: # must be some kind of list for entry in data: @@ -128,7 +152,6 @@ if "pytest" in sys.modules: import pytest # pylint: disable=ungrouped-imports - from ansiblelint.config import options from ansiblelint.rules import RulesCollection from ansiblelint.runner import Runner @@ -180,15 +203,26 @@ if "pytest" in sys.modules: [], id="rule-yaml-pass", ), + pytest.param( + "examples/yamllint/.github/workflows/ci.yml", + "yaml", + [], + id="rule-yaml-github-workflow", + ), ), ) @pytest.mark.filterwarnings("ignore::ansible_compat.runtime.AnsibleWarning") - def test_yamllint(file: str, expected_kind: str, expected: list[str]) -> None: + def test_yamllint( + file: str, + expected_kind: str, + expected: list[str], + config_options: Options, + ) -> None: """Validate parsing of ansible output.""" lintable = Lintable(file) assert lintable.kind == expected_kind - rules = RulesCollection(options=options) + rules = RulesCollection(options=config_options) rules.register(YamllintRule()) results = Runner(lintable, rules=rules).run() diff --git a/src/ansiblelint/runner.py b/src/ansiblelint/runner.py index 9d3500d..f487329 100644 --- a/src/ansiblelint/runner.py +++ b/src/ansiblelint/runner.py @@ -1,8 +1,10 @@ """Runner implementation.""" + from __future__ import annotations import json import logging +import math import multiprocessing import multiprocessing.pool import os @@ -12,7 +14,9 @@ import tempfile import warnings from dataclasses import dataclass from fnmatch import fnmatch +from functools import cache from pathlib import Path +from tempfile import NamedTemporaryFile from typing import TYPE_CHECKING, Any from ansible.errors import AnsibleError @@ -23,31 +27,24 @@ from ansible_compat.runtime import AnsibleWarning import ansiblelint.skip_utils import ansiblelint.utils -from ansiblelint._internal.rules import ( - BaseRule, - LoadingFailureRule, - RuntimeErrorRule, - WarningRule, -) from ansiblelint.app import App, get_app from ansiblelint.constants import States from ansiblelint.errors import LintWarning, MatchError, WarnSource from ansiblelint.file_utils import Lintable, expand_dirs_in_lintables from ansiblelint.logger import timed_info -from ansiblelint.rules.syntax_check import OUTPUT_PATTERNS, AnsibleSyntaxCheckRule +from ansiblelint.rules.syntax_check import OUTPUT_PATTERNS from ansiblelint.text import strip_ansi_escape from ansiblelint.utils import ( PLAYBOOK_DIR, - _include_children, - _roles_children, - _taskshandlers_children, + HandleChildren, + parse_examples_from_plugin, template, ) if TYPE_CHECKING: - from collections.abc import Generator - from typing import Callable + from collections.abc import Callable, Generator + from ansiblelint._internal.rules import BaseRule from ansiblelint.config import Options from ansiblelint.constants import FileType from ansiblelint.rules import RulesCollection @@ -77,11 +74,13 @@ class Runner: verbosity: int = 0, checked_files: set[Lintable] | None = None, project_dir: str | None = None, + _skip_ansible_syntax_check: bool = False, ) -> None: """Initialize a Runner instance.""" self.rules = rules self.lintables: set[Lintable] = set() self.project_dir = os.path.abspath(project_dir) if project_dir else None + self.skip_ansible_syntax_check = _skip_ansible_syntax_check if skip_list is None: skip_list = [] @@ -107,6 +106,8 @@ class Runner: checked_files = set() self.checked_files = checked_files + self.app = get_app(cached=True) + def _update_exclude_paths(self, exclude_paths: list[str]) -> None: if exclude_paths: # These will be (potentially) relative paths @@ -172,19 +173,19 @@ class Runner: if isinstance(warn.source, WarnSource): match = MatchError( message=warn.source.message or warn.category.__name__, - rule=WarningRule(), - filename=warn.source.filename.filename, + rule=self.rules["warning"], + lintable=Lintable(warn.source.filename.filename), tag=warn.source.tag, lineno=warn.source.lineno, ) else: filename = warn.source match = MatchError( - message=warn.message - if isinstance(warn.message, str) - else "?", - rule=WarningRule(), - filename=str(filename), + message=( + warn.message if isinstance(warn.message, str) else "?" + ), + rule=self.rules["warning"], + lintable=Lintable(str(filename)), ) matches.append(match) continue @@ -215,7 +216,7 @@ class Runner: lintable=lintable, message=str(lintable.exc), details=str(lintable.exc.__cause__), - rule=LoadingFailureRule(), + rule=self.rules["load-failure"], tag=f"load-failure[{lintable.exc.__class__.__name__.lower()}]", ), ) @@ -226,60 +227,63 @@ class Runner: MatchError( lintable=lintable, message="File or directory not found.", - rule=LoadingFailureRule(), + rule=self.rules["load-failure"], tag="load-failure[not-found]", ), ) # -- phase 1 : syntax check in parallel -- - app = get_app(offline=True) + if not self.skip_ansible_syntax_check: + # app = get_app(cached=True) - def worker(lintable: Lintable) -> list[MatchError]: - # pylint: disable=protected-access - return self._get_ansible_syntax_check_matches( - lintable=lintable, - app=app, - ) + def worker(lintable: Lintable) -> list[MatchError]: + return self._get_ansible_syntax_check_matches( + lintable=lintable, + app=self.app, + ) - for lintable in self.lintables: - if lintable.kind not in ("playbook", "role") or lintable.stop_processing: - continue - files.append(lintable) + for lintable in self.lintables: + if ( + lintable.kind not in ("playbook", "role") + or lintable.stop_processing + ): + continue + files.append(lintable) - # avoid resource leak warning, https://github.com/python/cpython/issues/90549 - # pylint: disable=unused-variable - global_resource = multiprocessing.Semaphore() # noqa: F841 + # avoid resource leak warning, https://github.com/python/cpython/issues/90549 + # pylint: disable=unused-variable + global_resource = multiprocessing.Semaphore() # noqa: F841 - pool = multiprocessing.pool.ThreadPool(processes=multiprocessing.cpu_count()) - return_list = pool.map(worker, files, chunksize=1) - pool.close() - pool.join() - for data in return_list: - matches.extend(data) + pool = multiprocessing.pool.ThreadPool(processes=threads()) + return_list = pool.map(worker, files, chunksize=1) + pool.close() + pool.join() + for data in return_list: + matches.extend(data) + + matches = self._filter_excluded_matches(matches) - matches = self._filter_excluded_matches(matches) # -- phase 2 --- - if not matches: - # do our processing only when ansible syntax check passed in order - # to avoid causing runtime exceptions. Our processing is not as - # resilient to be able process garbage. - matches.extend(self._emit_matches(files)) + # do our processing only when ansible syntax check passed in order + # to avoid causing runtime exceptions. Our processing is not as + # resilient to be able process garbage. + matches.extend(self._emit_matches(files)) - # remove duplicates from files list - files = [value for n, value in enumerate(files) if value not in files[:n]] + # remove duplicates from files list + files = [value for n, value in enumerate(files) if value not in files[:n]] - for file in self.lintables: - if file in self.checked_files or not file.kind: - continue - _logger.debug( - "Examining %s of type %s", - ansiblelint.file_utils.normpath(file.path), - file.kind, - ) + for file in self.lintables: + if file in self.checked_files or not file.kind or file.failed(): + continue + _logger.debug( + "Examining %s of type %s", + ansiblelint.file_utils.normpath(file.path), + file.kind, + ) - matches.extend( - self.rules.run(file, tags=set(self.tags), skip_list=self.skip_list), - ) + matches.extend( + self.rules.run(file, tags=set(self.tags), skip_list=self.skip_list), + ) # update list of checked files self.checked_files.update(self.lintables) @@ -296,7 +300,12 @@ class Runner: app: App, ) -> list[MatchError]: """Run ansible syntax check and return a list of MatchError(s).""" - default_rule: BaseRule = AnsibleSyntaxCheckRule() + try: + default_rule: BaseRule = self.rules["syntax-check"] + except ValueError: + # if syntax-check is not loaded, we do not perform any syntax check, + # that might happen during testing + return [] fh = None results = [] if lintable.kind not in ("playbook", "role"): @@ -341,6 +350,9 @@ class Runner: # https://github.com/paramiko/paramiko/issues/2038 env = app.runtime.environ.copy() env["PYTHONWARNINGS"] = "ignore" + # Avoid execution failure if user customized any_unparsed_is_failed setting + # https://github.com/ansible/ansible-lint/issues/3650 + env["ANSIBLE_INVENTORY_ANY_UNPARSED_IS_FAILED"] = "False" run = subprocess.run( cmd, @@ -357,6 +369,7 @@ class Runner: filename = lintable lineno = 1 column = None + ignore_rc = False stderr = strip_ansi_escape(run.stderr) stdout = strip_ansi_escape(run.stdout) @@ -376,25 +389,40 @@ class Runner: details = groups.get("details", "") lineno = int(groups.get("line", 1)) - if "filename" in groups: + if ( + "filename" in groups + and str(lintable.path.absolute()) != groups["filename"] + and lintable.filename != groups["filename"] + ): + # avoids creating a new lintable object if the filename + # is matching as this might prevent Lintable.failed() + # feature from working well. filename = Lintable(groups["filename"]) else: filename = lintable column = int(groups.get("column", 1)) - results.append( - MatchError( - message=title, - lintable=filename, - lineno=lineno, - column=column, - rule=rule, - details=details, - tag=f"{rule.id}[{pattern.tag}]", - ), - ) - if not results: - rule = RuntimeErrorRule() + if ( + pattern.tag in ("unknown-module", "specific") + and app.options.nodeps + ): + ignore_rc = True + else: + results.append( + MatchError( + message=title, + lintable=filename, + lineno=lineno, + column=column, + rule=rule, + details=details, + tag=f"{rule.id}[{pattern.tag}]", + ), + ) + break + + if not results and not ignore_rc: + rule = self.rules["internal-error"] message = ( f"Unexpected error code {run.returncode} from " f"execution of: {' '.join(cmd)}" @@ -427,6 +455,9 @@ class Runner: visited: set[Lintable] = set() while visited != self.lintables: for lintable in self.lintables - visited: + visited.add(lintable) + if not lintable.path.exists(): + continue try: children = self.find_children(lintable) for child in children: @@ -437,11 +468,13 @@ class Runner: except MatchError as exc: if not exc.filename: # pragma: no branch exc.filename = str(lintable.path) - exc.rule = LoadingFailureRule() + exc.rule = self.rules["load-failure"] yield exc except AttributeError: - yield MatchError(lintable=lintable, rule=LoadingFailureRule()) - visited.add(lintable) + yield MatchError( + lintable=lintable, + rule=self.rules["load-failure"], + ) def find_children(self, lintable: Lintable) -> list[Lintable]: """Traverse children of a single file or folder.""" @@ -452,21 +485,25 @@ class Runner: add_all_plugin_dirs(playbook_dir or ".") if lintable.kind == "role": playbook_ds = AnsibleMapping({"roles": [{"role": str(lintable.path)}]}) + elif lintable.kind == "plugin": + return self.plugin_children(lintable) elif lintable.kind not in ("playbook", "tasks"): return [] else: try: playbook_ds = ansiblelint.utils.parse_yaml_from_file(str(lintable.path)) except AnsibleError as exc: - raise SystemExit(exc) from exc + msg = f"Loading {lintable.filename} caused an {type(exc).__name__} exception: {exc}, file was ignored." + logging.exception(msg) + return [] results = [] # playbook_ds can be an AnsibleUnicode string, which we consider invalid if isinstance(playbook_ds, str): - raise MatchError(lintable=lintable, rule=LoadingFailureRule()) + raise MatchError(lintable=lintable, rule=self.rules["load-failure"]) for item in ansiblelint.utils.playbook_items(playbook_ds): # if lintable.kind not in ["playbook"]: for child in self.play_children( - lintable.path.parent, + lintable, item, lintable.kind, playbook_dir, @@ -487,35 +524,40 @@ class Runner: if path != path_str: child.path = Path(path) child.name = child.path.name - results.append(child) return results def play_children( self, - basedir: Path, + lintable: Lintable, item: tuple[str, Any], parent_type: FileType, playbook_dir: str, ) -> list[Lintable]: """Flatten the traversed play tasks.""" # pylint: disable=unused-argument - delegate_map: dict[str, Callable[[str, Any, Any, FileType], list[Lintable]]] = { - "tasks": _taskshandlers_children, - "pre_tasks": _taskshandlers_children, - "post_tasks": _taskshandlers_children, - "block": _taskshandlers_children, - "include": _include_children, - "ansible.builtin.include": _include_children, - "import_playbook": _include_children, - "ansible.builtin.import_playbook": _include_children, - "roles": _roles_children, - "dependencies": _roles_children, - "handlers": _taskshandlers_children, - "include_tasks": _include_children, - "ansible.builtin.include_tasks": _include_children, - "import_tasks": _include_children, - "ansible.builtin.import_tasks": _include_children, + basedir = lintable.path.parent + handlers = HandleChildren(self.rules, app=self.app) + + delegate_map: dict[ + str, + Callable[[Lintable, Any, Any, FileType], list[Lintable]], + ] = { + "tasks": handlers.taskshandlers_children, + "pre_tasks": handlers.taskshandlers_children, + "post_tasks": handlers.taskshandlers_children, + "block": handlers.taskshandlers_children, + "include": handlers.include_children, + "ansible.builtin.include": handlers.include_children, + "import_playbook": handlers.include_children, + "ansible.builtin.import_playbook": handlers.include_children, + "roles": handlers.roles_children, + "dependencies": handlers.roles_children, + "handlers": handlers.taskshandlers_children, + "include_tasks": handlers.include_children, + "ansible.builtin.include_tasks": handlers.include_children, + "import_tasks": handlers.include_children, + "ansible.builtin.import_tasks": handlers.include_children, } (k, v) = item add_all_plugin_dirs(str(basedir.resolve())) @@ -527,11 +569,92 @@ class Runner: {"playbook_dir": PLAYBOOK_DIR or str(basedir.resolve())}, fail_on_undefined=False, ) - return delegate_map[k](str(basedir), k, v, parent_type) + return delegate_map[k](lintable, k, v, parent_type) return [] + def plugin_children(self, lintable: Lintable) -> list[Lintable]: + """Collect lintable sections from plugin file.""" + offset, content = parse_examples_from_plugin(lintable) + if not content: + # No examples, nothing to see here + return [] + examples = Lintable( + name=lintable.name, + content=content, + kind="yaml", + base_kind="text/yaml", + parent=lintable, + ) + examples.line_offset = offset -def _get_matches(rules: RulesCollection, options: Options) -> LintResult: + # pylint: disable=consider-using-with + examples.file = NamedTemporaryFile( + mode="w+", + suffix=f"_{lintable.path.name}.yaml", + ) + examples.file.write(content) + examples.file.flush() + examples.filename = examples.file.name + examples.path = Path(examples.file.name) + return [examples] + + +@cache +def threads() -> int: + """Determine how many threads to use. + + Inside containers we want to respect limits imposed. + + When present /sys/fs/cgroup/cpu.max can contain something like: + $ podman/docker run -it --rm --cpus 1.5 ubuntu:latest cat /sys/fs/cgroup/cpu.max + 150000 100000 + # "max 100000" is returned when no limits are set. + + See: https://github.com/python/cpython/issues/80235 + See: https://github.com/python/cpython/issues/70879 + """ + os_cpu_count = multiprocessing.cpu_count() + # Cgroup CPU bandwidth limit available in Linux since 2.6 kernel + + cpu_max_fname = "/sys/fs/cgroup/cpu.max" + cfs_quota_fname = "/sys/fs/cgroup/cpu/cpu.cfs_quota_us" + cfs_period_fname = "/sys/fs/cgroup/cpu/cpu.cfs_period_us" + if os.path.exists(cpu_max_fname): + # cgroup v2 + # https://www.kernel.org/doc/html/latest/admin-guide/cgroup-v2.html + with open(cpu_max_fname, encoding="utf-8") as fh: + cpu_quota_us, cpu_period_us = fh.read().strip().split() + elif os.path.exists(cfs_quota_fname) and os.path.exists(cfs_period_fname): + # cgroup v1 + # https://www.kernel.org/doc/html/latest/scheduler/sched-bwc.html#management + with open(cfs_quota_fname, encoding="utf-8") as fh: + cpu_quota_us = fh.read().strip() + with open(cfs_period_fname, encoding="utf-8") as fh: + cpu_period_us = fh.read().strip() + else: + # No Cgroup CPU bandwidth limit (e.g. non-Linux platform) + cpu_quota_us = "max" + cpu_period_us = "100000" # unused, for consistency with default values + + if cpu_quota_us == "max": + # No active Cgroup quota on a Cgroup-capable platform + return os_cpu_count + cpu_quota_us_int = int(cpu_quota_us) + cpu_period_us_int = int(cpu_period_us) + if cpu_quota_us_int > 0 and cpu_period_us_int > 0: + return math.ceil(cpu_quota_us_int / cpu_period_us_int) + # Setting a negative cpu_quota_us value is a valid way to disable + # cgroup CPU bandwidth limits + return os_cpu_count + + +def get_matches(rules: RulesCollection, options: Options) -> LintResult: + """Get matches for given rules and options. + + :param rules: Rules to use for linting. + :param options: Options to use for linting. + :returns: LintResult containing matches and checked files. + """ lintables = ansiblelint.utils.get_lintables(opts=options, args=options.lintables) for rule in rules: @@ -551,6 +674,7 @@ def _get_matches(rules: RulesCollection, options: Options) -> LintResult: verbosity=options.verbosity, checked_files=checked_files, project_dir=options.project_dir, + _skip_ansible_syntax_check=options._skip_ansible_syntax_check, # noqa: SLF001 ) matches.extend(runner.run()) diff --git a/src/ansiblelint/schemas/__main__.py b/src/ansiblelint/schemas/__main__.py index e3ec8ae..e216c0b 100644 --- a/src/ansiblelint/schemas/__main__.py +++ b/src/ansiblelint/schemas/__main__.py @@ -1,4 +1,5 @@ """Module containing cached JSON schemas.""" + import json import logging import os @@ -68,7 +69,10 @@ def refresh_schemas(min_age_seconds: int = 3600 * 24) -> int: raise RuntimeError(msg) path = Path(__file__).parent.resolve() / f"{kind}.json" _logger.debug("Refreshing %s schema ...", kind) - request = Request(url) + if not url.startswith(("http:", "https:")): + msg = f"Unexpected url schema: {url}" + raise ValueError(msg) + request = Request(url) # noqa: S310 etag = data.get("etag", "") if etag: request.add_header("If-None-Match", f'"{data.get("etag")}"') @@ -108,7 +112,6 @@ def refresh_schemas(min_age_seconds: int = 3600 * 24) -> int: get_schema.cache_clear() else: store_file.touch() - changed = 1 return changed diff --git a/src/ansiblelint/schemas/__store__.json b/src/ansiblelint/schemas/__store__.json index d4bcdca..d66d675 100644 --- a/src/ansiblelint/schemas/__store__.json +++ b/src/ansiblelint/schemas/__store__.json @@ -1,10 +1,10 @@ { "ansible-lint-config": { - "etag": "0ec39ba1ca9c20aea463f7f536c6903c88288f47c1b2b2b3d53b527c293f8cc3", + "etag": "a0bb8004fad70bab34fad94a45b2698125127142ec6b2c8900976aa2bd96a86c", "url": "https://raw.githubusercontent.com/ansible/ansible-lint/main/src/ansiblelint/schemas/ansible-lint-config.json" }, "ansible-navigator-config": { - "etag": "dd0f0dea68266ae61e5a8d6aed0a1279fdee16f2da4911bc27970241df80f798", + "etag": "431f1a81acc74fe1112d5839551105bc2fa4e0314d811699eb525dae4fe3760d", "url": "https://raw.githubusercontent.com/ansible/ansible-navigator/main/src/ansible_navigator/data/ansible-navigator.json" }, "changelog": { @@ -12,19 +12,19 @@ "url": "https://raw.githubusercontent.com/ansible/ansible-lint/main/src/ansiblelint/schemas/changelog.json" }, "execution-environment": { - "etag": "f3abb1716134227ccd667607840dd7bdebfd02a8980603df031282126dc78264", + "etag": "2e1b1d02460fb93892252439e9634d9574dfdd37aea82af32f4622dacd5990b5", "url": "https://raw.githubusercontent.com/ansible/ansible-lint/main/src/ansiblelint/schemas/execution-environment.json" }, "galaxy": { - "etag": "61f38feb51dc7eaff43ab22f3759b3a5202776ee75ee4204f07135282817f724", + "etag": "4224ac235cc5657bf77b5834cea48b4d573cc8b666694f788590e213adfb8113", "url": "https://raw.githubusercontent.com/ansible/ansible-lint/main/src/ansiblelint/schemas/galaxy.json" }, "inventory": { - "etag": "3dcd4890bf31e634a7c4f6138286a42b4985393f210f7ffaa840c2127876aa55", + "etag": "b52c251a121e2e807928db7b4e09338babde9e74a50d0f74e8908f6e230d101d", "url": "https://raw.githubusercontent.com/ansible/ansible-lint/main/src/ansiblelint/schemas/inventory.json" }, "meta": { - "etag": "0f376059285181985711b4271a6ff34a8dde662b9fc221d09bdcd64e4fbf86bf", + "etag": "fdff861b226b13b711dd7f94301ed5becd6dc5d8d4e872f909d4a3d8133d600a", "url": "https://raw.githubusercontent.com/ansible/ansible-lint/main/src/ansiblelint/schemas/meta.json" }, "meta-runtime": { @@ -32,31 +32,31 @@ "url": "https://raw.githubusercontent.com/ansible/ansible-lint/main/src/ansiblelint/schemas/meta-runtime.json" }, "molecule": { - "etag": "3456b2e5aaa02fde359ff147cff81d01a37c07f5e10542b6b8b61aaaf8c756a6", + "etag": "3b625438c28e884ac42a14c09ca542fc3e1b4466abaf47d0c28646e0857d3fb5", "url": "https://raw.githubusercontent.com/ansible/ansible-lint/main/src/ansiblelint/schemas/molecule.json" }, "playbook": { - "etag": "acbd5edfc66279f8c3f6f8a99d0874669a254983ace5e4a2cce6105489ab3e21", + "etag": "4f8cbba62fcf8a1fa6e8ef5e42696aec5b0876487478df83a7ffdf8bdbb4abcf", "url": "https://raw.githubusercontent.com/ansible/ansible-lint/main/src/ansiblelint/schemas/playbook.json" }, "requirements": { - "etag": "93c6ccd1f79f58134795b85f9b1193d6e18417dd01a9d1f37d9f247562a1e6fe", + "etag": "5ae3a6058ac626a341338c760db7cef7f02a8911c7293c7e129dbc6b0f8bb86d", "url": "https://raw.githubusercontent.com/ansible/ansible-lint/main/src/ansiblelint/schemas/requirements.json" }, "role-arg-spec": { - "etag": "498a6f716c7e99bd474ae9e7d34b3f43fbf2aad750f769392fc8e29fa590be6c", + "etag": "e41a42e1ca634a9eb2edbc4a180f404bdc71e17aafa464e6651387c08152bbc5", "url": "https://raw.githubusercontent.com/ansible/ansible-lint/main/src/ansiblelint/schemas/role-arg-spec.json" }, "rulebook": { - "etag": "f0bbd0ecd656b2298febccc6da0ecf4a7bd239cc112b9de8292c1f50bad612e0", + "etag": "baba5774a46fcc2bc8c4a8c2f25b49df64a0856e415dbf601b0559f215e55968", "url": "https://raw.githubusercontent.com/ansible/ansible-rulebook/main/ansible_rulebook/schema/ruleset_schema.json" }, "tasks": { - "etag": "f9fbc0855680d1321fa3902181131d73838d922362d8dfb85a4f59402240cc07", + "etag": "9f3b54cf5cc432d57c9691fb3108a7f37996ab0875e2abb66eda0aa62437dcdc", "url": "https://raw.githubusercontent.com/ansible/ansible-lint/main/src/ansiblelint/schemas/tasks.json" }, "vars": { - "etag": "5d6c2c22a58f2b48c2a8d8d129f2516e4f17ffc78a2c9ba045eb5ede0ff749d7", + "etag": "73feaa77561d1d5b0bebe6cd66d499a28d67037055ac6d746139a38c9d28ca04", "url": "https://raw.githubusercontent.com/ansible/ansible-lint/main/src/ansiblelint/schemas/vars.json" } } diff --git a/src/ansiblelint/schemas/ansible-lint-config.json b/src/ansiblelint/schemas/ansible-lint-config.json index f7d50e4..7f53ffd 100644 --- a/src/ansiblelint/schemas/ansible-lint-config.json +++ b/src/ansiblelint/schemas/ansible-lint-config.json @@ -17,6 +17,7 @@ "$id": "https://raw.githubusercontent.com/ansible/ansible-lint/main/src/ansiblelint/schemas/ansible-lint-config.json", "$schema": "http://json-schema.org/draft-07/schema", "additionalProperties": false, + "description": "https://ansible.readthedocs.io/projects/lint/configuring/", "examples": [ ".ansible-lint", ".config/ansible-lint.yml", @@ -60,6 +61,11 @@ "title": "Loop Var Prefix", "type": "string" }, + "max_block_depth": { + "title": "Maximum Block Depth", + "type": "integer", + "default": 20 + }, "mock_modules": { "items": { "type": "string" @@ -242,6 +248,13 @@ "title": "Strict", "type": "boolean" }, + "supported_ansible_also": { + "items": { + "type": "string" + }, + "title": "Add supported ansible versions", + "type": "array" + }, "tags": { "items": { "type": "string" diff --git a/src/ansiblelint/schemas/ansible-navigator-config.json b/src/ansiblelint/schemas/ansible-navigator-config.json index e81a878..d528267 100644 --- a/src/ansiblelint/schemas/ansible-navigator-config.json +++ b/src/ansiblelint/schemas/ansible-navigator-config.json @@ -1,6 +1,7 @@ { "$schema": "http://json-schema.org/draft-07/schema", "additionalProperties": false, + "description": "See https://ansible.readthedocs.io/projects/navigator/settings/", "properties": { "ansible-navigator": { "additionalProperties": false, @@ -9,7 +10,7 @@ "additionalProperties": false, "properties": { "cmdline": { - "description": "Extra parameters passed to the corresponding command", + "description": "Extra parameters passed to the underlying ansible command (e.g. ansible-playbook, ansible-doc, etc)", "type": "string" }, "config": { @@ -524,7 +525,7 @@ "required": [ "ansible-navigator" ], - "title": "ansible-navigator settings v3", + "title": "ansible-navigator settings v24", "type": "object", - "version": "3" + "version": "24" } diff --git a/src/ansiblelint/schemas/ansible.json b/src/ansiblelint/schemas/ansible.json index 94846d0..9423f7a 100644 --- a/src/ansiblelint/schemas/ansible.json +++ b/src/ansiblelint/schemas/ansible.json @@ -1,5 +1,10 @@ { "$defs": { + "removed-include-module": { + "markdownDescription": "See [include module](https://docs.ansible.com/ansible/latest/collections/ansible/builtin/include_module.html)", + "not": {}, + "title": "Replace 'include' with either 'ansible.builtin.include_tasks' or 'ansible.builtin.import_tasks'" + }, "ansible.builtin.import_playbook": { "additionalProperties": false, "oneOf": [ @@ -163,7 +168,7 @@ }, "ignore_unreachable": { "title": "Ignore Unreachable", - "type": "boolean" + "$ref": "#/$defs/templated-boolean" }, "module_defaults": { "title": "Module Defaults" @@ -348,8 +353,8 @@ "type": "boolean" }, "gather_facts": { - "title": "Gather Facts", - "type": "boolean" + "$ref": "#/$defs/templated-boolean", + "title": "Gather Facts" }, "gather_subset": { "items": { @@ -523,11 +528,11 @@ }, "ignore_unreachable": { "title": "Ignore Unreachable", - "type": "boolean" + "$ref": "#/$defs/templated-boolean" }, "max_fail_percentage": { "title": "Max Fail Percentage", - "type": "number" + "$ref": "#/$defs/templated-integer" }, "module_defaults": { "title": "Module Defaults" @@ -540,15 +545,23 @@ "$ref": "#/$defs/templated-boolean" }, "order": { - "enum": [ - "default", - "sorted", - "reverse_sorted", - "reverse_inventory", - "shuffle" + "oneOf": [ + { + "enum": [ + "inventory", + "reverse_inventory", + "reverse_sorted", + "shuffle", + "sorted" + ], + "type": "string" + }, + { + "$ref": "#/$defs/full-jinja" + } ], "title": "Order", - "type": "string" + "markdownDescription": "See https://docs.ansible.com/ansible/latest/playbook_guide/playbooks_strategies.html#ordering-execution-based-on-inventory" }, "port": { "$ref": "#/$defs/templated-integer", @@ -720,7 +733,7 @@ }, "ignore_unreachable": { "title": "Ignore Unreachable", - "type": "boolean" + "$ref": "#/$defs/templated-boolean" }, "module_defaults": { "title": "Module Defaults" @@ -831,6 +844,15 @@ "title": "Action", "type": "string" }, + "ansible.builtin.include": { + "$ref": "#/$defs/removed-include-module" + }, + "include": { + "$ref": "#/$defs/removed-include-module" + }, + "ansible.legacy.include": { + "$ref": "#/$defs/removed-include-module" + }, "any_errors_fatal": { "$ref": "#/$defs/templated-boolean", "title": "Any Errors Fatal" @@ -914,7 +936,7 @@ }, "ignore_unreachable": { "title": "Ignore Unreachable", - "type": "boolean" + "$ref": "#/$defs/templated-boolean" }, "listen": { "anyOf": [ @@ -1196,6 +1218,7 @@ "$id": "https://raw.githubusercontent.com/ansible/ansible-lint/main/src/ansiblelint/schemas/ansible.json", "$schema": "http://json-schema.org/draft-07/schema", "additionalProperties": false, + "description": "https://docs.ansible.com/ansible/latest/reference_appendices/playbooks_keywords.html", "examples": [], "title": "Ansible Schemas Bundle 22.4", "type": ["array", "object"] diff --git a/src/ansiblelint/schemas/execution-environment.json b/src/ansiblelint/schemas/execution-environment.json index 4720a93..7d44ab3 100644 --- a/src/ansiblelint/schemas/execution-environment.json +++ b/src/ansiblelint/schemas/execution-environment.json @@ -302,7 +302,8 @@ }, "$id": "https://raw.githubusercontent.com/ansible/ansible-lint/main/src/ansiblelint/schemas/execution-environment.json", "$schema": "http://json-schema.org/draft-07/schema", - "description": "See \nV1: https://docs.ansible.com/automation-controller/latest/html/userguide/ee_reference.html\nV3: https://ansible-builder.readthedocs.io/en/latest/definition/", + "description": "See https://ansible-builder.readthedocs.io/en/latest/definition/ for V3 or https://docs.ansible.com/automation-controller/latest/html/userguide/ee_reference.html for older V1 format.\n", + "documentation_url": "https://ansible.readthedocs.io/projects/builder/en/latest/definition/", "examples": ["execution-environment.yml"], "oneOf": [{ "$ref": "#/$defs/v3" }, { "$ref": "#/$defs/v1" }], "title": "Ansible Execution Environment Schema v1/v3" diff --git a/src/ansiblelint/schemas/galaxy.json b/src/ansiblelint/schemas/galaxy.json index 6381f28..ae03445 100644 --- a/src/ansiblelint/schemas/galaxy.json +++ b/src/ansiblelint/schemas/galaxy.json @@ -13,6 +13,7 @@ "description": "An enumeration.", "enum": [ "0BSD", + "389-exception", "AAL", "ADSL", "AFL-1.1", @@ -26,6 +27,7 @@ "AGPL-3.0-or-later", "AMDPLPA", "AML", + "AML-glslang", "AMPAS", "ANTLR-PD", "ANTLR-PD-fallback", @@ -35,10 +37,14 @@ "APSL-1.1", "APSL-1.2", "APSL-2.0", + "ASWF-Digital-Assets-1.0", + "ASWF-Digital-Assets-1.1", "Abstyles", "AdaCore-doc", "Adobe-2006", + "Adobe-Display-PostScript", "Adobe-Glyph", + "Adobe-Utopia", "Afmparse", "Aladdin", "Apache-1.0", @@ -50,13 +56,21 @@ "Artistic-1.0-Perl", "Artistic-1.0-cl8", "Artistic-2.0", + "Asterisk-exception", + "Autoconf-exception-2.0", + "Autoconf-exception-3.0", + "Autoconf-exception-generic", + "Autoconf-exception-generic-3.0", + "Autoconf-exception-macro", "BSD-1-Clause", "BSD-2-Clause", + "BSD-2-Clause-Darwin", "BSD-2-Clause-Patent", "BSD-2-Clause-Views", "BSD-3-Clause", "BSD-3-Clause-Attribution", "BSD-3-Clause-Clear", + "BSD-3-Clause-HP", "BSD-3-Clause-LBNL", "BSD-3-Clause-Modification", "BSD-3-Clause-No-Military-License", @@ -64,6 +78,9 @@ "BSD-3-Clause-No-Nuclear-License-2014", "BSD-3-Clause-No-Nuclear-Warranty", "BSD-3-Clause-Open-MPI", + "BSD-3-Clause-Sun", + "BSD-3-Clause-acpica", + "BSD-3-Clause-flex", "BSD-4-Clause", "BSD-4-Clause-Shortened", "BSD-4-Clause-UC", @@ -71,20 +88,29 @@ "BSD-4.3TAHOE", "BSD-Advertising-Acknowledgement", "BSD-Attribution-HPND-disclaimer", + "BSD-Inferno-Nettverk", "BSD-Protection", "BSD-Source-Code", + "BSD-Source-beginning-file", + "BSD-Systemics", + "BSD-Systemics-W3Works", "BSL-1.0", "BUSL-1.1", "Baekmuk", "Bahyph", "Barr", "Beerware", + "Bison-exception-1.24", + "Bison-exception-2.2", "BitTorrent-1.0", "BitTorrent-1.1", "Bitstream-Charter", "Bitstream-Vera", "BlueOak-1.0.0", + "Boehm-GC", + "Bootloader-exception", "Borceux", + "Brian-Gladman-2-Clause", "Brian-Gladman-3-Clause", "C-UDA-1.0", "CAL-1.0", @@ -96,6 +122,7 @@ "CC-BY-2.5-AU", "CC-BY-3.0", "CC-BY-3.0-AT", + "CC-BY-3.0-AU", "CC-BY-3.0-DE", "CC-BY-3.0-IGO", "CC-BY-3.0-NL", @@ -138,6 +165,7 @@ "CC-BY-SA-3.0", "CC-BY-SA-3.0-AT", "CC-BY-SA-3.0-DE", + "CC-BY-SA-3.0-IGO", "CC-BY-SA-4.0", "CC-PDDC", "CC0-1.0", @@ -159,7 +187,9 @@ "CERN-OHL-S-2.0", "CERN-OHL-W-2.0", "CFITSIO", + "CLISP-exception-2.0", "CMU-Mach", + "CMU-Mach-nodoc", "CNRI-Jython", "CNRI-Python", "CNRI-Python-GPL-Compatible", @@ -169,19 +199,26 @@ "CPOL-1.02", "CUA-OPL-1.0", "Caldera", + "Caldera-no-preamble", "ClArtistic", + "Classpath-exception-2.0", "Clips", "Community-Spec-1.0", "Condor-1.1", "Cornell-Lossless-JPEG", + "Cronyx", "Crossword", "CrystalStacker", "Cube", "D-FSL-1.0", + "DEC-3-Clause", "DL-DE-BY-2.0", + "DL-DE-ZERO-2.0", "DOC", "DRL-1.0", + "DRL-1.1", "DSDP", + "DigiRule-FOSS-exception", "Dotseqn", "ECL-1.0", "ECL-2.0", @@ -198,16 +235,27 @@ "Entessa", "ErlPL-1.1", "Eurosym", + "FBM", "FDK-AAC", + "FLTK-exception", "FSFAP", + "FSFAP-no-warranty-disclaimer", "FSFUL", "FSFULLR", "FSFULLRWD", "FTL", "Fair", + "Fawkes-Runtime-exception", + "Ferguson-Twofish", + "Font-exception-2.0", "Frameworx-1.0", "FreeBSD-DOC", "FreeImage", + "Furuseth", + "GCC-exception-2.0", + "GCC-exception-2.0-note", + "GCC-exception-3.1", + "GCR-docs", "GD", "GFDL-1.1-invariants-only", "GFDL-1.1-invariants-or-later", @@ -229,20 +277,43 @@ "GFDL-1.3-or-later", "GL2PS", "GLWTPL", + "GNAT-exception", + "GNOME-examples-exception", + "GNU-compiler-exception", "GPL-1.0-only", "GPL-1.0-or-later", "GPL-2.0-only", "GPL-2.0-or-later", + "GPL-3.0-interface-exception", + "GPL-3.0-linking-exception", + "GPL-3.0-linking-source-exception", "GPL-3.0-only", "GPL-3.0-or-later", + "GPL-CC-1.0", + "GStreamer-exception-2005", + "GStreamer-exception-2008", "Giftware", "Glide", "Glulxe", + "Gmsh-exception", "Graphics-Gems", "HP-1986", + "HP-1989", "HPND", + "HPND-DEC", + "HPND-Fenneberg-Livingston", + "HPND-INRIA-IMAG", + "HPND-Kevlin-Henney", + "HPND-MIT-disclaimer", "HPND-Markus-Kuhn", + "HPND-Pbmplus", + "HPND-UC", + "HPND-doc", + "HPND-doc-sell", "HPND-export-US", + "HPND-export-US-modify", + "HPND-sell-MIT-disclaimer-xserver", + "HPND-sell-regexpr", "HPND-sell-variant", "HPND-sell-variant-MIT-disclaimer", "HTMLTIDY", @@ -256,9 +327,11 @@ "IPA", "IPL-1.0", "ISC", + "ISC-Veillard", "ImageMagick", "Imlib2", "Info-ZIP", + "Inner-Net-2.0", "Intel", "Intel-ACPI", "Interbase-1.0", @@ -267,7 +340,9 @@ "JSON", "Jam", "JasPer-2.0", + "Kastrup", "Kazlib", + "KiCad-libraries-exception", "Knuth-CTAN", "LAL-1.2", "LAL-1.3", @@ -275,10 +350,14 @@ "LGPL-2.0-or-later", "LGPL-2.1-only", "LGPL-2.1-or-later", + "LGPL-3.0-linking-exception", "LGPL-3.0-only", "LGPL-3.0-or-later", "LGPLLR", + "LLGPL", + "LLVM-exception", "LOOP", + "LPD-document", "LPL-1.0", "LPL-1.02", "LPPL-1.0", @@ -288,24 +367,36 @@ "LPPL-1.3c", "LZMA-SDK-9.11-to-9.20", "LZMA-SDK-9.22", + "LZMA-exception", "Latex2e", + "Latex2e-translated-notice", "Leptonica", "LiLiQ-P-1.1", "LiLiQ-R-1.1", "LiLiQ-Rplus-1.1", "Libpng", + "Libtool-exception", "Linux-OpenIB", + "Linux-man-pages-1-para", "Linux-man-pages-copyleft", + "Linux-man-pages-copyleft-2-para", + "Linux-man-pages-copyleft-var", + "Linux-syscall-note", + "Lucida-Bitmap-Fonts", "MIT", "MIT-0", "MIT-CMU", + "MIT-Festival", "MIT-Modern-Variant", "MIT-Wu", "MIT-advertising", "MIT-enna", "MIT-feh", "MIT-open-group", + "MIT-testregex", "MITNFA", + "MMIXware", + "MPEG-SSG", "MPL-1.0", "MPL-1.1", "MPL-2.0", @@ -314,8 +405,11 @@ "MS-PL", "MS-RL", "MTLL", + "Mackerras-3-Clause", + "Mackerras-3-Clause-acknowledgment", "MakeIndex", "Martin-Birgmeier", + "McPhee-slideshow", "Minpack", "MirOS", "Motosoto", @@ -332,6 +426,7 @@ "NICTA-1.0", "NIST-PD", "NIST-PD-fallback", + "NIST-Software", "NLOD-1.0", "NLOD-2.0", "NLPL", @@ -350,7 +445,9 @@ "Noweb", "O-UDA-1.0", "OCCT-PL", + "OCCT-exception-1.0", "OCLC-2.0", + "OCaml-LGPL-linking-exception", "ODC-By-1.0", "ODbL-1.0", "OFFIS", @@ -383,8 +480,10 @@ "OLDAP-2.6", "OLDAP-2.7", "OLDAP-2.8", + "OLFL-1.3", "OML", "OPL-1.0", + "OPL-UK-3.0", "OPUBL-1.0", "OSET-PL-2.1", "OSL-1.0", @@ -392,14 +491,20 @@ "OSL-2.0", "OSL-2.1", "OSL-3.0", + "OpenJDK-assembly-exception-1.0", "OpenPBS-2.3", "OpenSSL", + "OpenSSL-standalone", + "OpenVision", + "PADL", "PDDL-1.0", "PHP-3.0", "PHP-3.01", + "PS-or-PDF-font-exception-20170817", "PSF-2.0", "Parity-6.0.0", "Parity-7.0.0", + "Pixar", "Plexus", "PolyForm-Noncommercial-1.0.0", "PolyForm-Small-Business-1.0.0", @@ -408,7 +513,11 @@ "Python-2.0.1", "QPL-1.0", "QPL-1.0-INRIA-2004", + "QPL-1.0-INRIA-2004-exception", "Qhull", + "Qt-GPL-exception-1.0", + "Qt-LGPL-exception-1.1", + "Qwt-exception-1.0", "RHeCos-1.1", "RPL-1.1", "RPL-1.5", @@ -417,22 +526,31 @@ "RSCPL", "Rdisc", "Ruby", + "SANE-exception", "SAX-PD", + "SAX-PD-2.0", "SCEA", "SGI-B-1.0", "SGI-B-1.1", "SGI-B-2.0", + "SGI-OpenGL", + "SGP4", "SHL-0.5", "SHL-0.51", + "SHL-2.0", + "SHL-2.1", "SISSL", "SISSL-1.2", + "SL", "SMLNJ", "SMPPL", "SNIA", "SPL-1.0", "SSH-OpenSSH", "SSH-short", + "SSLeay-standalone", "SSPL-1.0", + "SWI-exception", "SWL", "Saxpath", "SchemeReport", @@ -440,29 +558,42 @@ "Sendmail-8.23", "SimPL-2.0", "Sleepycat", + "Soundex", "Spencer-86", "Spencer-94", "Spencer-99", "SugarCRM-1.1.3", + "Sun-PPP", "SunPro", + "Swift-exception", "Symlinks", "TAPR-OHL-1.0", "TCL", "TCP-wrappers", + "TGPPL-1.0", "TMate", "TORQUE-1.1", "TOSL", "TPDL", "TPL-1.0", "TTWL", + "TTYP0", "TU-Berlin-1.0", "TU-Berlin-2.0", + "TermReadKey", + "Texinfo-exception", + "UBDL-exception", "UCAR", "UCL-1.0", + "UMich-Merit", "UPL-1.0", + "URT-RLE", + "Unicode-3.0", "Unicode-DFS-2015", "Unicode-DFS-2016", "Unicode-TOU", + "Universal-FOSS-exception-1.0", + "UnixCrypt", "Unlicense", "VOSTROM", "VSL-1.0", @@ -472,12 +603,16 @@ "W3C-20150513", "WTFPL", "Watcom-1.0", + "Widget-Workshop", "Wsuipa", + "WxWindows-exception-3.1", "X11", "X11-distribute-modifications-variant", "XFree86-1.1", "XSkat", + "Xdebug-1.03", "Xerox", + "Xfig", "Xnet", "YPL-1.0", "YPL-1.1", @@ -485,35 +620,67 @@ "ZPL-2.0", "ZPL-2.1", "Zed", + "Zeeff", "Zend-2.0", "Zimbra-1.3", "Zimbra-1.4", "Zlib", + "bcrypt-Solar-Designer", "blessing", "bzip2-1.0.6", + "check-cvs", "checkmk", "copyleft-next-0.3.0", "copyleft-next-0.3.1", + "cryptsetup-OpenSSL-exception", "curl", "diffmark", + "dtoa", "dvipdfm", + "eCos-exception-2.0", "eGenix", "etalab-2.0", + "fmt-exception", + "freertos-exception-2.0", + "fwlw", "gSOAP-1.3b", + "gnu-javamail-exception", "gnuplot", + "gtkbook", + "hdparm", + "i2p-gpl-java-exception", "iMatix", "libpng-2.0", + "libpri-OpenH323-exception", "libselinux-1.0", "libtiff", "libutil-David-Nugent", + "lsof", + "magaz", + "mailprio", + "metamail", + "mif-exception", "mpi-permissive", "mpich2", "mplus", + "openvpn-openssl-exception", + "pnmstitch", "psfrag", "psutils", + "python-ldap", + "radvd", "snprintf", + "softSurfer", + "ssh-keyscan", + "stunnel-exception", + "swrule", + "u-boot-exception-2.0", + "ulem", + "vsftpd-openssl-exception", "w3m", + "x11vnc-openssl-exception", "xinetd", + "xkeyboard-config-Zinoviev", "xlock", "xpp", "zlib-acknowledgement" @@ -525,6 +692,7 @@ "$schema": "http://json-schema.org/draft-07/schema", "additionalProperties": false, "examples": ["galaxy.yml"], + "markdownDescription": "https://docs.ansible.com/ansible/latest/dev_guide/collections_galaxy_meta.html", "properties": { "authors": { "items": { diff --git a/src/ansiblelint/schemas/inventory.json b/src/ansiblelint/schemas/inventory.json index 80333ce..06cf2ca 100644 --- a/src/ansiblelint/schemas/inventory.json +++ b/src/ansiblelint/schemas/inventory.json @@ -45,7 +45,7 @@ "$id": "https://raw.githubusercontent.com/ansible/ansible-lint/main/src/ansiblelint/schemas/inventory.json", "$schema": "http://json-schema.org/draft-07/schema", "additionalProperties": true, - "description": "Ansible Inventory Schema", + "description": "See https://docs.ansible.com/ansible/latest/inventory_guide/intro_inventory.html", "examples": [ "inventory.yaml", "inventory.yml", diff --git a/src/ansiblelint/schemas/main.py b/src/ansiblelint/schemas/main.py index 590aea3..45b0c48 100644 --- a/src/ansiblelint/schemas/main.py +++ b/src/ansiblelint/schemas/main.py @@ -1,8 +1,11 @@ """Module containing cached JSON schemas.""" + from __future__ import annotations import json import logging +import re +import typing from typing import TYPE_CHECKING import jsonschema @@ -18,20 +21,95 @@ if TYPE_CHECKING: from ansiblelint.file_utils import Lintable +def find_best_deep_match( + errors: jsonschema.ValidationError, +) -> jsonschema.ValidationError: + """Return the deepest schema validation error.""" + + def iter_validation_error( + err: jsonschema.ValidationError, + ) -> typing.Iterator[jsonschema.ValidationError]: + if err.context: + for e in err.context: + yield e + yield from iter_validation_error(e) + + return max(iter_validation_error(errors), key=_deep_match_relevance) + + def validate_file_schema(file: Lintable) -> list[str]: """Return list of JSON validation errors found.""" + schema = {} if file.kind not in JSON_SCHEMAS: return [f"Unable to find JSON Schema '{file.kind}' for '{file.path}' file."] try: # convert yaml to json (keys are converted to strings) yaml_data = yaml_load_safe(file.content) json_data = json.loads(json.dumps(yaml_data)) - jsonschema.validate( - instance=json_data, - schema=_schema_cache[file.kind], - ) + schema = _schema_cache[file.kind] + + validator = jsonschema.validators.validator_for(schema) + v = validator(schema) + try: + error = next(v.iter_errors(json_data)) + except StopIteration: + return [] + if error.context: + error = find_best_deep_match(error) + # determine if we want to use our own messages embedded into schemas inside title/markdownDescription fields + if "not" in error.schema and len(error.schema["not"]) == 0: + message = error.schema["title"] + schema = error.schema + else: + message = f"{error.json_path} {error.message}" + + documentation_url = "" + for json_schema in (error.schema, schema): + for k in ("description", "markdownDescription"): + if k in json_schema: + # Find standalone URLs and also markdown urls. + match = re.search( + r"\[.*?\]\((?P<url>https?://[^\s]+)\)|(?P<url2>https?://[^\s]+)", + json_schema[k], + ) + if match: + documentation_url = next( + x for x in match.groups() if x is not None + ) + break + if documentation_url: + break + if documentation_url: + if not message.endswith("."): + message += "." + message += f" See {documentation_url}" except yaml.constructor.ConstructorError as exc: return [f"Failed to load YAML file '{file.path}': {exc.problem}"] except ValidationError as exc: - return [exc.message] - return [] + message = exc.message + documentation_url = "" + for k in ("description", "markdownDescription"): + if k in schema: + # Find standalone URLs and also markdown urls. + match = re.search( + r"\[.*?\]\((https?://[^\s]+)\)|https?://[^\s]+", + schema[k], + ) + if match: + documentation_url = match.groups()[0] + break + if documentation_url: + if not message.endswith("."): + message += "." + message += f" See {documentation_url}" + return [message] + return [message] + + +def _deep_match_relevance(error: jsonschema.ValidationError) -> tuple[bool | int, ...]: + validator = error.validator + return ( + validator not in ("anyOf", "oneOf"), # type: ignore[comparison-overlap] + len(error.absolute_path), + -len(error.path), + ) diff --git a/src/ansiblelint/schemas/meta.json b/src/ansiblelint/schemas/meta.json index 384d113..8971817 100644 --- a/src/ansiblelint/schemas/meta.json +++ b/src/ansiblelint/schemas/meta.json @@ -110,6 +110,25 @@ "title": "ArchLinuxPlatformModel", "type": "object" }, + "AstraLinuxPlatformModel": { + "properties": { + "name": { + "const": "Astra Linux", + "title": "Name", + "type": "string" + }, + "versions": { + "default": "all", + "items": { + "enum": ["1.8", "1.7", "1.6", "2.12", "all"], + "type": "string" + }, + "type": "array" + } + }, + "title": "AstraLinuxPlatformModel", + "type": "object" + }, "ClearLinuxPlatformModel": { "properties": { "name": { @@ -168,6 +187,7 @@ "sid", "squeeze", "stretch", + "trixie", "wheezy", "all" ], @@ -267,7 +287,14 @@ "versions": { "default": "all", "items": { - "enum": ["ascii", "beowulf", "ceres", "jessie", "all"], + "enum": [ + "ascii", + "beowulf", + "chimaera", + "daedalus", + "jessie", + "all" + ], "type": "string" }, "type": "array" @@ -286,7 +313,7 @@ "versions": { "default": "all", "items": { - "enum": ["5.2", "5.4", "all"], + "enum": ["5.2", "5.4", "5.6", "5.8", "6.0", "6.2", "6.4", "all"], "type": "string" }, "type": "array" @@ -348,6 +375,8 @@ "36", "37", "38", + "39", + "40", "all" ], "type": "string" @@ -503,7 +532,7 @@ "namespace": { "markdownDescription": "Used by molecule and ansible-lint to compute FQRN for roles outside collections", "minLength": 2, - "pattern": "^[a-z][a-z0-9_]+$", + "pattern": "^[a-z][a-z0-9_-]+$", "title": "Namespace Name", "type": "string" }, @@ -838,7 +867,15 @@ "versions": { "default": "all", "items": { - "enum": ["17.01", "18.06", "19.07", "21.02", "22.03", "all"], + "enum": [ + "17.01", + "18.06", + "19.07", + "21.02", + "22.03", + "23.05", + "all" + ], "type": "string" }, "type": "array" @@ -879,6 +916,7 @@ "8.8", "9.0", "9.1", + "9.2", "all" ], "type": "string" @@ -908,6 +946,39 @@ "title": "PAN-OSPlatformModel", "type": "object" }, + "RockyLinuxPlatformModel": { + "properties": { + "name": { + "const": "Rocky", + "title": "Name", + "type": "string" + }, + "versions": { + "default": "all", + "items": { + "enum": [ + "8.0", + "8.1", + "8.2", + "8.3", + "8.4", + "8.5", + "8.6", + "8.7", + "8.8", + "9.0", + "9.1", + "9.2", + "all" + ], + "type": "string" + }, + "type": "array" + } + }, + "title": "RockyLinuxPlatformModel", + "type": "object" + }, "SLESPlatformModel": { "properties": { "name": { @@ -1038,7 +1109,6 @@ "artful", "bionic", "cosmic", - "cuttlefish", "disco", "eoan", "focal", @@ -1046,7 +1116,11 @@ "hirsute", "impish", "jammy", + "kinetic", "lucid", + "lunar", + "mantic", + "noble", "maverick", "natty", "oneiric", @@ -1199,6 +1273,8 @@ "Mojave", "Monterey", "Sierra", + "Sonoma", + "Ventura", "all" ], "type": "string" @@ -1372,6 +1448,9 @@ "$ref": "#/$defs/PAN-OSPlatformModel" }, { + "$ref": "#/$defs/RockyLinuxPlatformModel" + }, + { "$ref": "#/$defs/SLESPlatformModel" }, { @@ -1447,6 +1526,7 @@ }, "$id": "https://raw.githubusercontent.com/ansible/ansible-lint/main/src/ansiblelint/schemas/meta.json", "$schema": "http://json-schema.org/draft-07/schema", + "description": "https://docs.ansible.com/ansible/latest/playbook_guide/playbooks_reuse_roles.html#using-role-dependencies", "examples": ["meta/main.yml"], "properties": { "additionalProperties": false, @@ -1459,7 +1539,14 @@ }, "dependencies": { "items": { - "$ref": "#/$defs/DependencyModel" + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/DependencyModel" + } + ] }, "title": "Dependencies", "type": "array" diff --git a/src/ansiblelint/schemas/molecule.json b/src/ansiblelint/schemas/molecule.json index d957f08..21f1610 100644 --- a/src/ansiblelint/schemas/molecule.json +++ b/src/ansiblelint/schemas/molecule.json @@ -512,6 +512,7 @@ "$id": "https://raw.githubusercontent.com/ansible/ansible-lint/main/src/ansiblelint/schemas/molecule.json", "$schema": "http://json-schema.org/draft-07/schema", "additionalProperties": false, + "description": "https://ansible.readthedocs.io/projects/molecule/configuration/", "examples": ["molecule/*/molecule.yml"], "properties": { "dependency": { diff --git a/src/ansiblelint/schemas/playbook.json b/src/ansiblelint/schemas/playbook.json index 983033f..f4d315b 100644 --- a/src/ansiblelint/schemas/playbook.json +++ b/src/ansiblelint/schemas/playbook.json @@ -171,8 +171,8 @@ "$ref": "#/$defs/ignore_errors" }, "ignore_unreachable": { - "title": "Ignore Unreachable", - "type": "boolean" + "$ref": "#/$defs/templated-boolean", + "title": "Ignore Unreachable" }, "module_defaults": { "title": "Module Defaults" @@ -363,8 +363,8 @@ "type": "boolean" }, "gather_facts": { - "title": "Gather Facts", - "type": "boolean" + "$ref": "#/$defs/templated-boolean", + "title": "Gather Facts" }, "gather_subset": { "items": { @@ -537,12 +537,12 @@ "$ref": "#/$defs/ignore_errors" }, "ignore_unreachable": { - "title": "Ignore Unreachable", - "type": "boolean" + "$ref": "#/$defs/templated-boolean", + "title": "Ignore Unreachable" }, "max_fail_percentage": { - "title": "Max Fail Percentage", - "type": "number" + "$ref": "#/$defs/templated-integer", + "title": "Max Fail Percentage" }, "module_defaults": { "title": "Module Defaults" @@ -555,15 +555,23 @@ "$ref": "#/$defs/templated-boolean" }, "order": { - "enum": [ - "default", - "sorted", - "reverse_sorted", - "reverse_inventory", - "shuffle" + "markdownDescription": "See https://docs.ansible.com/ansible/latest/playbook_guide/playbooks_strategies.html#ordering-execution-based-on-inventory", + "oneOf": [ + { + "enum": [ + "inventory", + "reverse_inventory", + "reverse_sorted", + "shuffle", + "sorted" + ], + "type": "string" + }, + { + "$ref": "#/$defs/full-jinja" + } ], - "title": "Order", - "type": "string" + "title": "Order" }, "port": { "$ref": "#/$defs/templated-integer", @@ -740,8 +748,8 @@ "$ref": "#/$defs/ignore_errors" }, "ignore_unreachable": { - "title": "Ignore Unreachable", - "type": "boolean" + "$ref": "#/$defs/templated-boolean", + "title": "Ignore Unreachable" }, "module_defaults": { "title": "Module Defaults" @@ -796,6 +804,11 @@ "title": "play-role", "type": "object" }, + "removed-include-module": { + "markdownDescription": "See [include module](https://docs.ansible.com/ansible/latest/collections/ansible/builtin/include_module.html)", + "not": {}, + "title": "Replace 'include' with either 'ansible.builtin.include_tasks' or 'ansible.builtin.import_tasks'" + }, "tags": { "anyOf": [ { @@ -847,6 +860,12 @@ "title": "Action", "type": "string" }, + "ansible.builtin.include": { + "$ref": "#/$defs/removed-include-module" + }, + "ansible.legacy.include": { + "$ref": "#/$defs/removed-include-module" + }, "any_errors_fatal": { "$ref": "#/$defs/templated-boolean", "title": "Any Errors Fatal" @@ -929,8 +948,11 @@ "$ref": "#/$defs/ignore_errors" }, "ignore_unreachable": { - "title": "Ignore Unreachable", - "type": "boolean" + "$ref": "#/$defs/templated-boolean", + "title": "Ignore Unreachable" + }, + "include": { + "$ref": "#/$defs/removed-include-module" }, "listen": { "anyOf": [ diff --git a/src/ansiblelint/schemas/requirements.json b/src/ansiblelint/schemas/requirements.json index dc7ded6..ef8d2a4 100644 --- a/src/ansiblelint/schemas/requirements.json +++ b/src/ansiblelint/schemas/requirements.json @@ -130,6 +130,7 @@ "$ref": "#/$defs/RequirementsV2Model" } ], + "description": "https://docs.ansible.com/ansible/latest/galaxy/user_guide.html#installing-roles-and-collections-from-the-same-requirements-yml-file", "examples": ["requirements.yml"], "title": "Ansible Requirements Schema" } diff --git a/src/ansiblelint/schemas/role-arg-spec.json b/src/ansiblelint/schemas/role-arg-spec.json index 433993e..111fbe5 100644 --- a/src/ansiblelint/schemas/role-arg-spec.json +++ b/src/ansiblelint/schemas/role-arg-spec.json @@ -1,5 +1,73 @@ { "$defs": { + "attribute": { + "additionalProperties": false, + "properties": { + "description": { + "description": "Detailed explanation of what this attribute does. It should be written in full sentences.", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ] + }, + "details": { + "description": "Detailed explanation of what this attribute does. It should be written in full sentences.", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ] + }, + "membership": { + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ] + }, + "platform": { + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ] + }, + "support": { + "enum": ["full", "partial", "none", "N/A"], + "type": "string" + }, + "version_added": { + "type": "string" + } + }, + "required": ["description", "support"], + "title": "Attribute" + }, "datatype": { "enum": [ "str", @@ -38,6 +106,12 @@ "entry_point": { "additionalProperties": false, "properties": { + "attributes": { + "additionalProperties": { + "$ref": "#/$defs/attribute" + }, + "type": "object" + }, "author": { "oneOf": [ { @@ -64,6 +138,9 @@ } ] }, + "examples": { + "type": "string" + }, "options": { "additionalProperties": { "$ref": "#/$defs/option" @@ -146,30 +223,27 @@ "title": "Entry Point", "type": "object" }, + "full-jinja": { + "pattern": "^\\{[\\{%](.|[\r\n])*[\\}%]\\}$", + "type": "string" + }, "option": { "additionalProperties": false, - "aliases": { - "items": { + "markdownDescription": "See [argument-spec](https://docs.ansible.com/ansible/latest/dev_guide/developing_program_flow_modules.html#argument-spec)", + "properties": { + "apply_defaults": { "type": "string" }, - "type": "array" - }, - "apply_defaults": { - "type": "string" - }, - "deprecated_aliases": { - "items": { - "$ref": "#/$defs/deprecated_alias" - }, - "type": "array" - }, - "markdownDescription": "xxx", - "options": { - "$ref": "#/$defs/option" - }, - "properties": { "choices": { - "type": "array" + "oneOf": [ + { + "type": "array" + }, + { + "$ref": "#/$defs/full-jinja", + "type": "string" + } + ] }, "default": { "default": "None" @@ -213,6 +287,70 @@ "default": false, "type": "boolean" }, + "mutually_exclusive": { + "type": "array", + "items": { + "items": { + "type": "string" + } + } + }, + "required_together": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required_one_of": { + "type": "array", + "items": { + "items": { + "type": "string" + } + } + }, + "required_if": { + "type": "array", + "items": { + "type": "array", + "prefixItems": [ + { + "type": "string" + }, + {}, + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "boolean" + } + ], + "minItems": 3, + "maxItems": 4 + } + }, + "required_by": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + }, "type": { "$ref": "#/$defs/datatype", "markdownDescription": "See [argument-spec](https://docs.ansible.com/ansible/latest/dev_guide/developing_program_flow_modules.html#argument-spec" @@ -237,7 +375,7 @@ "$schema": "http://json-schema.org/draft-07/schema", "additionalProperties": false, "examples": ["meta/argument_specs.yml"], - "markdownDescription": "Add entry point, usually `main`.\nSee [role-argument-validation](https://docs.ansible.com/ansible/latest/user_guide/playbooks_reuse_roles.html#role-argument-validation)", + "markdownDescription": "See [role-argument-validation](https://docs.ansible.com/ansible/latest/user_guide/playbooks_reuse_roles.html#role-argument-validation)", "properties": { "argument_specs": { "additionalProperties": { diff --git a/src/ansiblelint/schemas/rulebook.json b/src/ansiblelint/schemas/rulebook.json index 6c441cd..6321f08 100644 --- a/src/ansiblelint/schemas/rulebook.json +++ b/src/ansiblelint/schemas/rulebook.json @@ -1,6 +1,8 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "$id": "https://raw.githubusercontent.com/ansible/ansible-rulebook/main/ansible_rulebook/schema/ruleset_schema.json", + "title": "Ansible Rulebook", + "description": "See https://ansible.readthedocs.io/projects/rulebook/en/stable/rulebooks.html", "type": "array", "items": { "$ref": "#/$defs/ruleset" @@ -25,12 +27,19 @@ "type": "boolean", "default": false }, + "match_multiple_rules": { + "type": "boolean", + "default": false + }, "name": { "type": "string" }, "execution_strategy": { "type": "string", - "enum": ["sequential", "parallel"], + "enum": [ + "parallel", + "sequential" + ], "default": "sequential" }, "sources": { @@ -47,6 +56,7 @@ } }, "required": [ + "name", "hosts", "sources", "rules" @@ -147,6 +157,9 @@ "type": "string" }, { + "type": "boolean" + }, + { "$ref": "#/$defs/all-condition" }, { @@ -171,6 +184,9 @@ "$ref": "#/$defs/run-job-template-action" }, { + "$ref": "#/$defs/run-workflow-template-action" + }, + { "$ref": "#/$defs/post-event-action" }, { @@ -190,6 +206,9 @@ }, { "$ref": "#/$defs/shutdown-action" + }, + { + "$ref": "#/$defs/pg-notify-action" } ] } @@ -206,6 +225,9 @@ "$ref": "#/$defs/run-job-template-action" }, { + "$ref": "#/$defs/run-workflow-template-action" + }, + { "$ref": "#/$defs/post-event-action" }, { @@ -225,6 +247,9 @@ }, { "$ref": "#/$defs/shutdown-action" + }, + { + "$ref": "#/$defs/pg-notify-action" } ] } @@ -342,9 +367,6 @@ "run_module": { "type": "object", "properties": { - "copy_files": { - "type": "boolean" - }, "name": { "type": "string" }, @@ -376,7 +398,10 @@ "type": "number" }, "module_args": { - "type": "object" + "type": [ + "object", + "string" + ] }, "extra_vars": { "type": "object" @@ -442,6 +467,91 @@ ], "additionalProperties": false }, + "run-workflow-template-action": { + "type": "object", + "properties": { + "run_workflow_template": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "organization": { + "type": "string" + }, + "job_args": { + "type": "object" + }, + "post_events": { + "type": "boolean" + }, + "set_facts": { + "type": "boolean" + }, + "ruleset": { + "type": "string" + }, + "var_root": { + "type": "string" + }, + "retry": { + "type": "boolean" + }, + "retries": { + "type": "integer" + }, + "delay": { + "type": "integer" + } + }, + "required": [ + "name", + "organization" + ], + "additionalProperties": false + } + }, + "required": [ + "run_workflow_template" + ], + "additionalProperties": false + }, + "pg-notify-action": { + "type": "object", + "properties": { + "pg_notify": { + "type": "object", + "properties": { + "dsn": { + "type": "string" + }, + "channel": { + "type": "string" + }, + "event": { + "type": [ + "string", + "object" + ] + }, + "remove_meta": { + "type": "boolean", + "default": false + } + }, + "required": [ + "dsn", + "channel", + "event" + ], + "additionalProperties": false + } + }, + "required": [ + "pg_notify" + ], + "additionalProperties": false + }, "post-event-action": { "type": "object", "properties": { diff --git a/src/ansiblelint/schemas/tasks.json b/src/ansiblelint/schemas/tasks.json index ec7f85d..d6efec8 100644 --- a/src/ansiblelint/schemas/tasks.json +++ b/src/ansiblelint/schemas/tasks.json @@ -123,8 +123,8 @@ "$ref": "#/$defs/ignore_errors" }, "ignore_unreachable": { - "title": "Ignore Unreachable", - "type": "boolean" + "$ref": "#/$defs/templated-boolean", + "title": "Ignore Unreachable" }, "module_defaults": { "title": "Module Defaults" @@ -238,6 +238,11 @@ "markdownDescription": "Use for protecting sensitive data. See [no_log](https://docs.ansible.com/ansible/latest/reference_appendices/logging.html)", "title": "no_log" }, + "removed-include-module": { + "markdownDescription": "See [include module](https://docs.ansible.com/ansible/latest/collections/ansible/builtin/include_module.html)", + "not": {}, + "title": "Replace 'include' with either 'ansible.builtin.include_tasks' or 'ansible.builtin.import_tasks'" + }, "tags": { "anyOf": [ { @@ -289,6 +294,12 @@ "title": "Action", "type": "string" }, + "ansible.builtin.include": { + "$ref": "#/$defs/removed-include-module" + }, + "ansible.legacy.include": { + "$ref": "#/$defs/removed-include-module" + }, "any_errors_fatal": { "$ref": "#/$defs/templated-boolean", "title": "Any Errors Fatal" @@ -371,8 +382,11 @@ "$ref": "#/$defs/ignore_errors" }, "ignore_unreachable": { - "title": "Ignore Unreachable", - "type": "boolean" + "$ref": "#/$defs/templated-boolean", + "title": "Ignore Unreachable" + }, + "include": { + "$ref": "#/$defs/removed-include-module" }, "listen": { "anyOf": [ diff --git a/src/ansiblelint/schemas/vars.json b/src/ansiblelint/schemas/vars.json index c0b66e8..44acb10 100644 --- a/src/ansiblelint/schemas/vars.json +++ b/src/ansiblelint/schemas/vars.json @@ -17,6 +17,7 @@ "type": "null" } ], + "description": "https://docs.ansible.com/ansible/latest/playbook_guide/playbooks_variables.html", "examples": [ "playbooks/vars/*.yml", "vars/*.yml", diff --git a/src/ansiblelint/skip_utils.py b/src/ansiblelint/skip_utils.py index f2f6177..e1a3a8f 100644 --- a/src/ansiblelint/skip_utils.py +++ b/src/ansiblelint/skip_utils.py @@ -199,7 +199,7 @@ def _append_skipped_rules( ruamel_tasks = _get_tasks_from_blocks(ruamel_task_blocks) # append skipped_rules for each task - for ruamel_task, pyyaml_task in zip(ruamel_tasks, pyyaml_tasks): + for ruamel_task, pyyaml_task in zip(ruamel_tasks, pyyaml_tasks, strict=False): # ignore empty tasks if not pyyaml_task and not ruamel_task: continue @@ -240,7 +240,7 @@ def _get_tasks_from_blocks(task_blocks: Sequence[Any]) -> Generator[Any, None, N if not task or not is_nested_task(task): return for k in NESTED_TASK_KEYS: - if k in task and task[k]: + if task.get(k): if hasattr(task[k], "get"): continue for subtask in task[k]: @@ -279,16 +279,14 @@ def _get_rule_skips_from_yaml( yaml_comment_obj_strings.append(str(obj.ca.items)) if isinstance(obj, dict): for val in obj.values(): - if isinstance(val, (dict, list)): + if isinstance(val, dict | list): traverse_yaml(val) elif isinstance(obj, list): for element in obj: - if isinstance(element, (dict, list)): + if isinstance(element, dict | list): traverse_yaml(element) - else: - return - if isinstance(yaml_input, (dict, list)): + if isinstance(yaml_input, dict | list): traverse_yaml(yaml_input) rule_id_list = [] diff --git a/src/ansiblelint/stats.py b/src/ansiblelint/stats.py index 67320b8..79475d2 100644 --- a/src/ansiblelint/stats.py +++ b/src/ansiblelint/stats.py @@ -1,4 +1,5 @@ """Module hosting functionality about reporting.""" + from __future__ import annotations from dataclasses import dataclass, field diff --git a/src/ansiblelint/testing/__init__.py b/src/ansiblelint/testing/__init__.py index e7f6c1b..9c5463f 100644 --- a/src/ansiblelint/testing/__init__.py +++ b/src/ansiblelint/testing/__init__.py @@ -1,4 +1,5 @@ """Test utils for ansible-lint.""" + from __future__ import annotations import os @@ -13,7 +14,6 @@ from ansiblelint.app import get_app if TYPE_CHECKING: # https://github.com/PyCQA/pylint/issues/3240 - # pylint: disable=unsubscriptable-object CompletedProcess = subprocess.CompletedProcess[Any] from ansiblelint.errors import MatchError from ansiblelint.rules import RulesCollection @@ -156,4 +156,5 @@ def run_ansible_lint( cwd=cwd, env=_env, text=True, + encoding="utf-8", ) diff --git a/src/ansiblelint/testing/fixtures.py b/src/ansiblelint/testing/fixtures.py index 814a076..05e1ad7 100644 --- a/src/ansiblelint/testing/fixtures.py +++ b/src/ansiblelint/testing/fixtures.py @@ -5,21 +5,19 @@ file: pytest_plugins = ['ansiblelint.testing'] """ + from __future__ import annotations -import copy from typing import TYPE_CHECKING import pytest -from ansiblelint.config import Options, options +from ansiblelint.config import Options from ansiblelint.constants import DEFAULT_RULESDIR from ansiblelint.rules import RulesCollection from ansiblelint.testing import RunFromText if TYPE_CHECKING: - from collections.abc import Iterator - from _pytest.fixtures import SubRequest @@ -29,13 +27,12 @@ if TYPE_CHECKING: def fixture_default_rules_collection() -> RulesCollection: """Return default rule collection.""" assert DEFAULT_RULESDIR.is_dir() - # For testing we want to manually enable opt-in rules - test_options = copy.deepcopy(options) - test_options.enable_list = ["no-same-owner"] + config_options = Options() + config_options.enable_list = ["no-same-owner"] # That is instantiated very often and do want to avoid ansible-galaxy # install errors due to concurrency. - test_options.offline = True - return RulesCollection(rulesdirs=[DEFAULT_RULESDIR], options=test_options) + config_options.offline = True + return RulesCollection(rulesdirs=[DEFAULT_RULESDIR], options=config_options) @pytest.fixture() @@ -45,9 +42,10 @@ def default_text_runner(default_rules_collection: RulesCollection) -> RunFromTex @pytest.fixture() -def rule_runner(request: SubRequest, config_options: Options) -> RunFromText: +def rule_runner(request: SubRequest) -> RunFromText: """Return runner for a specific rule class.""" rule_class = request.param + config_options = Options() config_options.enable_list.append(rule_class().id) collection = RulesCollection(options=config_options) collection.register(rule_class()) @@ -55,9 +53,6 @@ def rule_runner(request: SubRequest, config_options: Options) -> RunFromText: @pytest.fixture(name="config_options") -def fixture_config_options() -> Iterator[Options]: +def fixture_config_options() -> Options: """Return configuration options that will be restored after testrun.""" - global options # pylint: disable=global-statement,invalid-name # noqa: PLW0603 - original_options = copy.deepcopy(options) - yield options - options = original_options + return Options() diff --git a/src/ansiblelint/text.py b/src/ansiblelint/text.py index 038fde1..3510f75 100644 --- a/src/ansiblelint/text.py +++ b/src/ansiblelint/text.py @@ -1,4 +1,5 @@ """Text utils.""" + from __future__ import annotations import re @@ -6,6 +7,7 @@ from functools import cache RE_HAS_JINJA = re.compile(r"{[{%#].*[%#}]}", re.DOTALL) RE_HAS_GLOB = re.compile("[][*?]") +RE_IS_FQCN_OR_NAME = re.compile(r"^\w+(\.\w+\.\w+)?$") def strip_ansi_escape(data: str | bytes) -> str: @@ -47,3 +49,9 @@ def has_jinja(value: str) -> bool: def has_glob(value: str) -> bool: """Return true if a string looks like having a glob pattern.""" return bool(isinstance(value, str) and RE_HAS_GLOB.search(value)) + + +@cache +def is_fqcn_or_name(value: str) -> bool: + """Return true if a string seems to be a module/filter old name or a fully qualified one.""" + return bool(isinstance(value, str) and RE_IS_FQCN_OR_NAME.search(value)) diff --git a/src/ansiblelint/transformer.py b/src/ansiblelint/transformer.py index 3716ef9..c610704 100644 --- a/src/ansiblelint/transformer.py +++ b/src/ansiblelint/transformer.py @@ -1,8 +1,10 @@ +# cspell:ignore classinfo """Transformer implementation.""" + from __future__ import annotations import logging -from typing import TYPE_CHECKING, Union, cast +from typing import TYPE_CHECKING, cast from ruamel.yaml.comments import CommentedMap, CommentedSeq @@ -20,7 +22,6 @@ __all__ = ["Transformer"] _logger = logging.getLogger(__name__) -# pylint: disable=too-few-public-methods class Transformer: """Transformer class marshals transformations. @@ -33,6 +34,17 @@ class Transformer: pre-requisite for the planned rule-specific transforms. """ + DUMP_MSG = "Rewriting yaml file:" + FIX_NA_MSG = "Rule specific fix not available for:" + FIX_NE_MSG = "Rule specific fix not enabled for:" + FIX_APPLY_MSG = "Applying rule specific fix for:" + FIX_FAILED_MSG = "Rule specific fix failed for:" + FIX_ISSUE_MSG = ( + "Please file an issue for this with the task or playbook that caused the error." + ) + FIX_APPLIED_MSG = "Rule specific fix applied for:" + FIX_NOT_APPLIED_MSG = "Rule specific fix not applied for:" + def __init__(self, result: LintResult, options: Options): """Initialize a Transformer instance.""" self.write_set = self.effective_write_set(options.write_list) @@ -44,8 +56,8 @@ class Transformer: self.matches_per_file: dict[Lintable, list[MatchError]] = { file: [] for file in result.files } - - for match in self.matches: + not_ignored = [match for match in self.matches if not match.ignored] + for match in not_ignored: try: lintable = lintables[match.filename] except KeyError: @@ -93,10 +105,13 @@ class Transformer: # We need a fresh YAML() instance for each load because ruamel.yaml # stores intermediate state during load which could affect loading # any other files. (Based on suggestion from ruamel.yaml author) - yaml = FormattedYAML() + yaml = FormattedYAML( + # Ansible only uses YAML 1.1, but others files should use newer 1.2 (ruamel.yaml defaults to 1.2) + version=(1, 1) if file.is_owned_by_ansible() else None, + ) - ruamel_data = yaml.loads(data) - if not isinstance(ruamel_data, (CommentedMap, CommentedSeq)): + ruamel_data = yaml.load(data) + if not isinstance(ruamel_data, CommentedMap | CommentedSeq): # This is an empty vars file or similar which loads as None. # It is not safe to write this file or data-loss is likely. # Only maps and sequences can preserve comments. Skip it. @@ -110,6 +125,7 @@ class Transformer: self._do_transforms(file, ruamel_data or data, file_is_yaml, matches) if file_is_yaml: + _logger.debug("%s %s, version=%s", self.DUMP_MSG, file, yaml.version) # noinspection PyUnboundLocalVariable file.content = yaml.dumps(ruamel_data) @@ -125,17 +141,19 @@ class Transformer: ) -> None: """Do Rule-Transforms handling any last-minute MatchError inspections.""" for match in sorted(matches): + match_id = f"{match.tag}/{match.match_type} {match.filename}:{match.lineno}" if not isinstance(match.rule, TransformMixin): + logging.debug("%s %s", self.FIX_NA_MSG, match_id) continue if self.write_set != {"all"}: rule = cast(AnsibleLintRule, match.rule) rule_definition = set(rule.tags) rule_definition.add(rule.id) if rule_definition.isdisjoint(self.write_set): - # rule transform not requested. Skip it. + logging.debug("%s %s", self.FIX_NE_MSG, match_id) continue if file_is_yaml and not match.yaml_path: - data = cast(Union[CommentedMap, CommentedSeq], data) + data = cast(CommentedMap | CommentedSeq, data) if match.match_type == "play": match.yaml_path = get_path_to_play(file, match.lineno, data) elif match.task or file.kind in ( @@ -144,4 +162,16 @@ class Transformer: "playbook", ): match.yaml_path = get_path_to_task(file, match.lineno, data) - match.rule.transform(match, file, data) + + logging.debug("%s %s", self.FIX_APPLY_MSG, match_id) + try: + match.rule.transform(match, file, data) + except Exception as exc: # pylint: disable=broad-except + _logger.error("%s %s", self.FIX_FAILED_MSG, match_id) # noqa: TRY400 + _logger.exception(exc) # noqa: TRY401 + _logger.error(self.FIX_ISSUE_MSG) # noqa: TRY400 + continue + if match.fixed: + _logger.debug("%s %s", self.FIX_APPLIED_MSG, match_id) + else: + _logger.error("%s %s", self.FIX_NOT_APPLIED_MSG, match_id) diff --git a/src/ansiblelint/utils.py b/src/ansiblelint/utils.py index 9cb97aa..3d0e535 100644 --- a/src/ansiblelint/utils.py +++ b/src/ansiblelint/utils.py @@ -22,26 +22,35 @@ """Generic utility helpers.""" from __future__ import annotations +import ast import contextlib import inspect import logging import os import re -from collections.abc import Generator, ItemsView, Iterator, Mapping, Sequence +from collections.abc import ItemsView, Iterable, Iterator, Mapping, Sequence from dataclasses import _MISSING_TYPE, dataclass, field -from functools import cache +from functools import cache, lru_cache from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any +import ruamel.yaml.parser import yaml from ansible.errors import AnsibleError, AnsibleParserError from ansible.module_utils.parsing.convert_bool import boolean from ansible.parsing.dataloader import DataLoader from ansible.parsing.mod_args import ModuleArgsParser +from ansible.parsing.plugin_docs import read_docstring +from ansible.parsing.splitter import split_args from ansible.parsing.yaml.constructor import AnsibleConstructor, AnsibleMapping from ansible.parsing.yaml.loader import AnsibleLoader from ansible.parsing.yaml.objects import AnsibleBaseYAMLObject, AnsibleSequence -from ansible.plugins.loader import add_all_plugin_dirs +from ansible.plugins.loader import ( + PluginLoadContext, + action_loader, + add_all_plugin_dirs, + module_loader, +) from ansible.template import Templar from ansible.utils.collection_loader import AnsibleCollectionConfig from yaml.composer import Composer @@ -51,7 +60,7 @@ from ansiblelint._internal.rules import ( AnsibleParserErrorRule, RuntimeErrorRule, ) -from ansiblelint.app import get_app +from ansiblelint.app import App, get_app from ansiblelint.config import Options, options from ansiblelint.constants import ( ANNOTATION_KEYS, @@ -67,8 +76,10 @@ from ansiblelint.constants import ( from ansiblelint.errors import MatchError from ansiblelint.file_utils import Lintable, discover_lintables from ansiblelint.skip_utils import is_nested_task -from ansiblelint.text import removeprefix +from ansiblelint.text import has_jinja, removeprefix +if TYPE_CHECKING: + from ansiblelint.rules import RulesCollection # ansible-lint doesn't need/want to know about encrypted secrets, so we pass a # string as the password to enable such yaml files to be opened and parsed # successfully. @@ -164,7 +175,6 @@ def ansible_template( for _i in range(10): try: templated = templar.template(varname, **kwargs) - return templated except AnsibleError as exc: if lookup_error in exc.message: return varname @@ -186,16 +196,14 @@ def ansible_template( _logger.warning(err) raise - # pylint: disable=protected-access - templar.environment.filters._delegatee[ # noqa: SLF001 - missing_filter - ] = mock_filter + templar.environment.filters._delegatee[missing_filter] = mock_filter # fmt: skip # noqa: SLF001 # Record the mocked filter so we can warn the user if missing_filter not in options.mock_filters: _logger.debug("Mocking missing filter %s", missing_filter) options.mock_filters.append(missing_filter) continue raise + return templated return None @@ -210,26 +218,23 @@ BLOCK_NAME_TO_ACTION_TYPE_MAP = { } -def tokenize(line: str) -> tuple[str, list[str], dict[str, str]]: +def tokenize(value: str) -> tuple[list[str], dict[str, str]]: """Parse a string task invocation.""" - tokens = line.lstrip().split(" ") - if tokens[0] == "-": - tokens = tokens[1:] - if tokens[0] == "action:" or tokens[0] == "local_action:": - tokens = tokens[1:] - command = tokens[0].replace(":", "") - - args = [] - kwargs = {} - non_kv_found = False - for arg in tokens[1:]: - if "=" in arg and not non_kv_found: - key_value = arg.split("=", 1) - kwargs[key_value[0]] = key_value[1] + # We do not try to tokenize something very simple because it would fail to + # work for a case like: task_include: path with space.yml + if value and "=" not in value: + return ([value], {}) + + parts = split_args(value) + args: list[str] = [] + kwargs: dict[str, str] = {} + for part in parts: + if "=" not in part: + args.append(part) else: - non_kv_found = True - args.append(arg) - return (command, args, kwargs) + k, v = part.split("=", 1) + kwargs[k] = v + return (args, kwargs) def playbook_items(pb_data: AnsibleBaseYAMLObject) -> ItemsView: # type: ignore[type-arg] @@ -278,106 +283,179 @@ def template( return value -def _include_children( - basedir: str, - k: str, - v: Any, - parent_type: FileType, -) -> list[Lintable]: - # handle special case include_tasks: name=filename.yml - if k in INCLUSION_ACTION_NAMES and isinstance(v, dict) and "file" in v: - v = v["file"] - - # we cannot really parse any jinja2 in includes, so we ignore them - if not v or "{{" in v: - return [] - - if "import_playbook" in k and COLLECTION_PLAY_RE.match(v): - # Any import_playbooks from collections should be ignored as ansible - # own syntax check will handle them. - return [] - - # handle include: filename.yml tags=blah - # pylint: disable=unused-variable - (command, args, kwargs) = tokenize(f"{k}: {v}") - - result = path_dwim(basedir, args[0]) - while basedir not in ["", "/"]: - if os.path.exists(result): - break - basedir = os.path.dirname(basedir) - result = path_dwim(basedir, args[0]) - - return [Lintable(result, kind=parent_type)] - - -def _taskshandlers_children( - basedir: str, - k: str, - v: None | Any, - parent_type: FileType, -) -> list[Lintable]: - results: list[Lintable] = [] - if v is None: - raise MatchError( - message="A malformed block was encountered while loading a block.", - rule=RuntimeErrorRule(), - ) - for task_handler in v: - # ignore empty tasks, `-` - if not task_handler: - continue +@dataclass +class HandleChildren: + """Parse task, roles and children.""" + + rules: RulesCollection = field(init=True, repr=False) + app: App + + def include_children( # pylint: disable=too-many-return-statements + self, + lintable: Lintable, + k: str, + v: Any, + parent_type: FileType, + ) -> list[Lintable]: + """Include children.""" + basedir = str(lintable.path.parent) + # import_playbook only accepts a string as argument (no dict syntax) + if k in ( + "import_playbook", + "ansible.builtin.import_playbook", + ) and not isinstance(v, str): + return [] + + # handle special case include_tasks: name=filename.yml + if k in INCLUSION_ACTION_NAMES and isinstance(v, dict) and "file" in v: + v = v["file"] + + # we cannot really parse any jinja2 in includes, so we ignore them + if not v or "{{" in v: + return [] + + if k in ("import_playbook", "ansible.builtin.import_playbook"): + included = Path(basedir) / v + if self.app.runtime.has_playbook(v, basedir=Path(basedir)): + if included.exists(): + return [Lintable(included, kind=parent_type)] + return [] + msg = f"Failed to find {v} playbook." + logging.error(msg) + return [] + + # handle include: filename.yml tags=blah + (args, kwargs) = tokenize(v) + + if args: + file = args[0] + elif "file" in kwargs: + file = kwargs["file"] + else: + return [] - with contextlib.suppress(LookupError): - children = _get_task_handler_children_for_tasks_or_playbooks( - task_handler, - basedir, - k, - parent_type, + result = path_dwim(basedir, file) + while basedir not in ["", "/"]: + if os.path.exists(result): + break + basedir = os.path.dirname(basedir) + result = path_dwim(basedir, file) + + return [Lintable(result, kind=parent_type)] + + def taskshandlers_children( + self, + lintable: Lintable, + k: str, + v: None | Any, + parent_type: FileType, + ) -> list[Lintable]: + """TasksHandlers Children.""" + basedir = str(lintable.path.parent) + results: list[Lintable] = [] + if v is None or isinstance(v, int | str): + raise MatchError( + message="A malformed block was encountered while loading a block.", + rule=RuntimeErrorRule(), ) - results.append(children) - continue + for task_handler in v: + # ignore empty tasks, `-` + if not task_handler: + continue - if any(x in task_handler for x in ROLE_IMPORT_ACTION_NAMES): - task_handler = normalize_task_v2(task_handler) - _validate_task_handler_action_for_role(task_handler["action"]) - results.extend( - _roles_children( + with contextlib.suppress(LookupError): + children = _get_task_handler_children_for_tasks_or_playbooks( + task_handler, basedir, k, - [task_handler["action"].get("name")], parent_type, - main=task_handler["action"].get("tasks_from", "main"), - ), - ) - continue + ) + results.append(children) + continue - if "block" not in task_handler: - continue + if any(x in task_handler for x in ROLE_IMPORT_ACTION_NAMES): + task_handler = normalize_task_v2( + Task(task_handler, filename=str(lintable.path)), + ) + self._validate_task_handler_action_for_role(task_handler["action"]) + name = task_handler["action"].get("name") + if has_jinja(name): + # we cannot deal with dynamic imports + continue + results.extend( + self.roles_children(lintable, k, [name], parent_type), + ) + continue - results.extend( - _taskshandlers_children(basedir, k, task_handler["block"], parent_type), - ) - if "rescue" in task_handler: - results.extend( - _taskshandlers_children( - basedir, - k, - task_handler["rescue"], - parent_type, + if "block" not in task_handler: + continue + + for elem in ("block", "rescue", "always"): + if elem in task_handler: + results.extend( + self.taskshandlers_children( + lintable, + k, + task_handler[elem], + parent_type, + ), + ) + + return results + + def _validate_task_handler_action_for_role(self, th_action: dict[str, Any]) -> None: + """Verify that the task handler action is valid for role include.""" + module = th_action["__ansible_module__"] + + if "name" not in th_action: + raise MatchError( + message=f"Failed to find required 'name' key in {module!s}", + rule=self.rules.rules[0], + lintable=Lintable( + ( + self.rules.options.lintables[0] + if self.rules.options.lintables + else "." + ), ), ) - if "always" in task_handler: - results.extend( - _taskshandlers_children( - basedir, - k, - task_handler["always"], - parent_type, - ), + + if not isinstance(th_action["name"], str): + raise MatchError( + message=f"Value assigned to 'name' key on '{module!s}' is not a string.", + rule=self.rules.rules[1], ) - return results + def roles_children( + self, + lintable: Lintable, + k: str, + v: Sequence[Any], + parent_type: FileType, + ) -> list[Lintable]: + """Roles children.""" + # pylint: disable=unused-argument # parent_type) + basedir = str(lintable.path.parent) + results: list[Lintable] = [] + if not v or not isinstance(v, Iterable): + # typing does not prevent junk from being passed in + return results + for role in v: + if isinstance(role, dict): + if "role" in role or "name" in role: + if "tags" not in role or "skip_ansible_lint" not in role["tags"]: + results.extend( + _look_for_role_files( + basedir, + role.get("role", role.get("name")), + ), + ) + elif k != "dependencies": + msg = f'role dict {role} does not contain a "role" or "name" key' + raise SystemExit(msg) + else: + results.extend(_look_for_role_files(basedir, role)) + return results def _get_task_handler_children_for_tasks_or_playbooks( @@ -393,13 +471,27 @@ def _get_task_handler_children_for_tasks_or_playbooks( for task_handler_key in INCLUSION_ACTION_NAMES: with contextlib.suppress(KeyError): # ignore empty tasks - if not task_handler: # pragma: no branch + if not task_handler or isinstance(task_handler, str): # pragma: no branch continue - file_name = task_handler[task_handler_key] - if isinstance(file_name, Mapping) and file_name.get("file", None): - file_name = file_name["file"] + file_name = "" + action_args = task_handler[task_handler_key] + if isinstance(action_args, str): + (args, kwargs) = tokenize(action_args) + if len(args) == 1: + file_name = args[0] + elif kwargs.get("file", None): + file_name = kwargs["file"] + else: + # ignore invalid data (syntax check will outside the scope) + continue + + if isinstance(action_args, Mapping) and action_args.get("file", None): + file_name = action_args["file"] + if not file_name: + # ignore invalid data (syntax check will outside the scope) + continue f = path_dwim(basedir, file_name) while basedir not in ["", "/"]: if os.path.exists(f): @@ -411,50 +503,6 @@ def _get_task_handler_children_for_tasks_or_playbooks( raise LookupError(msg) -def _validate_task_handler_action_for_role(th_action: dict[str, Any]) -> None: - """Verify that the task handler action is valid for role include.""" - module = th_action["__ansible_module__"] - - if "name" not in th_action: - raise MatchError(message=f"Failed to find required 'name' key in {module!s}") - - if not isinstance(th_action["name"], str): - raise MatchError( - message=f"Value assigned to 'name' key on '{module!s}' is not a string.", - ) - - -def _roles_children( - basedir: str, - k: str, - v: Sequence[Any], - parent_type: FileType, # noqa: ARG001 - main: str = "main", -) -> list[Lintable]: - # pylint: disable=unused-argument # parent_type) - results: list[Lintable] = [] - if not v: - # typing does not prevent junk from being passed in - return results - for role in v: - if isinstance(role, dict): - if "role" in role or "name" in role: - if "tags" not in role or "skip_ansible_lint" not in role["tags"]: - results.extend( - _look_for_role_files( - basedir, - role.get("role", role.get("name")), - main=main, - ), - ) - elif k != "dependencies": - msg = f'role dict {role} does not contain a "role" or "name" key' - raise SystemExit(msg) - else: - results.extend(_look_for_role_files(basedir, role, main=main)) - return results - - def _rolepath(basedir: str, role: str) -> str | None: role_path = None @@ -469,7 +517,7 @@ def _rolepath(basedir: str, role: str) -> str | None: path_dwim(basedir, os.path.join("..", role)), ] - for loc in get_app(offline=True).runtime.config.default_roles_path: + for loc in get_app(cached=True).runtime.config.default_roles_path: loc = os.path.expanduser(loc) possible_paths.append(path_dwim(loc, role)) @@ -486,12 +534,7 @@ def _rolepath(basedir: str, role: str) -> str | None: return role_path -def _look_for_role_files( - basedir: str, - role: str, - main: str | None = "main", # noqa: ARG001 -) -> list[Lintable]: - # pylint: disable=unused-argument # main +def _look_for_role_files(basedir: str, role: str) -> list[Lintable]: role_path = _rolepath(basedir, role) if not role_path: # pragma: no branch return [] @@ -539,13 +582,14 @@ def _extract_ansible_parsed_keys_from_task( return result -def normalize_task_v2(task: dict[str, Any]) -> dict[str, Any]: +def normalize_task_v2(task: Task) -> dict[str, Any]: """Ensure tasks have a normalized action key and strings are converted to python objects.""" + raw_task = task.raw_task result: dict[str, Any] = {} ansible_parsed_keys = ("action", "local_action", "args", "delegate_to") - if is_nested_task(task): - _extract_ansible_parsed_keys_from_task(result, task, ansible_parsed_keys) + if is_nested_task(raw_task): + _extract_ansible_parsed_keys_from_task(result, raw_task, ansible_parsed_keys) # Add dummy action for block/always/rescue statements result["action"] = { "__ansible_module__": "block/always/rescue", @@ -554,7 +598,7 @@ def normalize_task_v2(task: dict[str, Any]) -> dict[str, Any]: return result - sanitized_task = _sanitize_task(task) + sanitized_task = _sanitize_task(raw_task) mod_arg_parser = ModuleArgsParser(sanitized_task) try: @@ -562,12 +606,11 @@ def normalize_task_v2(task: dict[str, Any]) -> dict[str, Any]: skip_action_validation=options.skip_action_validation, ) except AnsibleParserError as exc: - # pylint: disable=raise-missing-from raise MatchError( rule=AnsibleParserErrorRule(), message=exc.message, - filename=task.get(FILENAME_KEY, "Unknown"), - lineno=task.get(LINE_NUMBER_KEY, 0), + lintable=Lintable(task.filename or ""), + lineno=raw_task.get(LINE_NUMBER_KEY, 1), ) from exc # denormalize shell -> command conversion @@ -577,13 +620,13 @@ def normalize_task_v2(task: dict[str, Any]) -> dict[str, Any]: _extract_ansible_parsed_keys_from_task( result, - task, + raw_task, (*ansible_parsed_keys, action), ) if not isinstance(action, str): msg = f"Task actions can only be strings, got {action}" - raise RuntimeError(msg) + raise TypeError(msg) action_unnormalized = action # convert builtin fqn calls to short forms because most rules know only # about short calls but in the future we may switch the normalization to do @@ -599,17 +642,6 @@ def normalize_task_v2(task: dict[str, Any]) -> dict[str, Any]: return result -def normalize_task(task: dict[str, Any], filename: str) -> dict[str, Any]: - """Unify task-like object structures.""" - ansible_action_type = task.get("__ansible_action_type__", "task") - if "__ansible_action_type__" in task: - del task["__ansible_action_type__"] - task = normalize_task_v2(task) - task[FILENAME_KEY] = filename - task["__ansible_action_type__"] = ansible_action_type - return task - - def task_to_str(task: dict[str, Any]) -> str: """Make a string identifier for the given task.""" name = task.get("name") @@ -634,7 +666,7 @@ def task_to_str(task: dict[str, Any]) -> str: _raw_params = action.get("_raw_params", []) if isinstance(_raw_params, list): for item in _raw_params: - args.append(str(item)) + args.extend(str(item)) else: args.append(_raw_params) @@ -698,7 +730,11 @@ class Task(dict[str, Any]): @property def name(self) -> str | None: """Return the name of the task.""" - return self.raw_task.get("name", None) + name = self.raw_task.get("name", None) + if name is not None and not isinstance(name, str): + msg = "Task name can only be a string." + raise RuntimeError(msg) + return name @property def action(self) -> str: @@ -706,7 +742,7 @@ class Task(dict[str, Any]): action_name = self.normalized_task["action"]["__ansible_module_original__"] if not isinstance(action_name, str): msg = "Task actions can only be strings." - raise RuntimeError(msg) + raise TypeError(msg) return action_name @property @@ -729,10 +765,7 @@ class Task(dict[str, Any]): """Return the name of the task.""" if not hasattr(self, "_normalized_task"): try: - self._normalized_task = normalize_task( - self.raw_task, - filename=self.filename, - ) + self._normalized_task = self._normalize_task() except MatchError as err: self.error = err # When we cannot normalize it, we just use the raw task instead @@ -740,15 +773,35 @@ class Task(dict[str, Any]): self._normalized_task = self.raw_task if isinstance(self._normalized_task, _MISSING_TYPE): msg = "Task was not normalized" - raise RuntimeError(msg) + raise TypeError(msg) return self._normalized_task + def _normalize_task(self) -> dict[str, Any]: + """Unify task-like object structures.""" + ansible_action_type = self.raw_task.get("__ansible_action_type__", "task") + if "__ansible_action_type__" in self.raw_task: + del self.raw_task["__ansible_action_type__"] + task = normalize_task_v2(self) + task[FILENAME_KEY] = self.filename + task["__ansible_action_type__"] = ansible_action_type + return task + @property def skip_tags(self) -> list[str]: """Return the list of tags to skip.""" skip_tags: list[str] = self.raw_task.get(SKIPPED_RULES_KEY, []) return skip_tags + def is_handler(self) -> bool: + """Return true for tasks that are handlers.""" + is_handler_file = False + if isinstance(self._normalized_task, dict): + file_name = str(self._normalized_task["action"].get(FILENAME_KEY, None)) + if file_name: + paths = file_name.split("/") + is_handler_file = "handlers" in paths + return is_handler_file if is_handler_file else ".handlers[" in self.position + def __repr__(self) -> str: """Return a string representation of the task.""" return f"Task('{self.name}' [{self.position}])" @@ -761,7 +814,7 @@ class Task(dict[str, Any]): """Allow access as task[...].""" return self.normalized_task[index] - def __iter__(self) -> Generator[str, None, None]: + def __iter__(self) -> Iterator[str]: """Provide support for 'key in task'.""" yield from (f for f in self.normalized_task) @@ -857,7 +910,7 @@ def parse_yaml_linenumbers( node = Composer.compose_node(loader, parent, index) if not isinstance(node, yaml.nodes.Node): msg = "Unexpected yaml data." - raise RuntimeError(msg) + raise TypeError(msg) node.__line__ = line + 1 # type: ignore[attr-defined] return node @@ -870,9 +923,7 @@ def parse_yaml_linenumbers( if hasattr(node, "__line__"): mapping[LINE_NUMBER_KEY] = node.__line__ else: - mapping[ - LINE_NUMBER_KEY - ] = mapping._line_number # pylint: disable=protected-access # noqa: SLF001 + mapping[LINE_NUMBER_KEY] = mapping._line_number # noqa: SLF001 mapping[FILENAME_KEY] = lintable.path return mapping @@ -895,8 +946,9 @@ def parse_yaml_linenumbers( yaml.parser.ParserError, yaml.scanner.ScannerError, yaml.constructor.ConstructorError, + ruamel.yaml.parser.ParserError, ) as exc: - msg = "Failed to load YAML file" + msg = f"Failed to load YAML file: {lintable.path}" raise RuntimeError(msg) from exc if len(result) == 0: @@ -975,7 +1027,6 @@ def is_playbook(filename: str) -> bool: return False -# pylint: disable=too-many-statements def get_lintables( opts: Options = options, args: list[str] | None = None, @@ -1018,3 +1069,49 @@ def _extend_with_roles(lintables: list[Lintable]) -> None: def convert_to_boolean(value: Any) -> bool: """Use Ansible to convert something to a boolean.""" return bool(boolean(value)) + + +def parse_examples_from_plugin(lintable: Lintable) -> tuple[int, str]: + """Parse yaml inside plugin EXAMPLES string. + + Store a line number offset to realign returned line numbers later + """ + offset = 1 + parsed = ast.parse(lintable.content) + for child in parsed.body: + if isinstance(child, ast.Assign): + label = child.targets[0] + if isinstance(label, ast.Name) and label.id == "EXAMPLES": + offset = child.lineno - 1 + break + + docs = read_docstring(str(lintable.path)) + examples = docs["plainexamples"] + + # Ignore the leading newline and lack of document start + # as including those in EXAMPLES would be weird. + return offset, (f"---{examples}" if examples else "") + + +@lru_cache +def load_plugin(name: str) -> PluginLoadContext: + """Return loaded ansible plugin/module.""" + loaded_module = action_loader.find_plugin_with_context( + name, + ignore_deprecated=True, + check_aliases=True, + ) + if not loaded_module.resolved: + loaded_module = module_loader.find_plugin_with_context( + name, + ignore_deprecated=True, + check_aliases=True, + ) + if not loaded_module.resolved and name.startswith("ansible.builtin."): + # fallback to core behavior of using legacy + loaded_module = module_loader.find_plugin_with_context( + name.replace("ansible.builtin.", "ansible.legacy."), + ignore_deprecated=True, + check_aliases=True, + ) + return loaded_module diff --git a/src/ansiblelint/version.py b/src/ansiblelint/version.py index a65c3cf..80a0f7d 100644 --- a/src/ansiblelint/version.py +++ b/src/ansiblelint/version.py @@ -1,4 +1,5 @@ """Ansible-lint version information.""" + try: from ._version import version as __version__ except ImportError: # pragma: no cover diff --git a/src/ansiblelint/yaml_utils.py b/src/ansiblelint/yaml_utils.py index cc7e9ef..a1b963d 100644 --- a/src/ansiblelint/yaml_utils.py +++ b/src/ansiblelint/yaml_utils.py @@ -1,4 +1,5 @@ """Utility helpers to simplify working with yaml-based data.""" + # pylint: disable=too-many-lines from __future__ import annotations @@ -6,21 +7,23 @@ import functools import logging import os import re -from collections.abc import Iterator, Sequence +from collections.abc import Callable, Iterator, Sequence from io import StringIO from pathlib import Path from re import Pattern -from typing import TYPE_CHECKING, Any, Callable, Union, cast +from typing import TYPE_CHECKING, Any, cast import ruamel.yaml.events from ruamel.yaml.comments import CommentedMap, CommentedSeq, Format +from ruamel.yaml.composer import ComposerError from ruamel.yaml.constructor import RoundTripConstructor from ruamel.yaml.emitter import Emitter, ScalarAnalysis # Module 'ruamel.yaml' does not explicitly export attribute 'YAML'; implicit reexport disabled # To make the type checkers happy, we import from ruamel.yaml.main instead. from ruamel.yaml.main import YAML -from ruamel.yaml.scalarint import ScalarInt +from ruamel.yaml.parser import ParserError +from ruamel.yaml.scalarint import HexInt, ScalarInt from yamllint.config import YamlLintConfig from ansiblelint.constants import ( @@ -32,7 +35,8 @@ from ansiblelint.utils import Task if TYPE_CHECKING: # noinspection PyProtectedMember - from ruamel.yaml.comments import LineCol # pylint: disable=ungrouped-imports + from ruamel.yaml.comments import LineCol + from ruamel.yaml.compat import StreamTextType from ruamel.yaml.nodes import ScalarNode from ruamel.yaml.representer import RoundTripRepresenter from ruamel.yaml.tokens import CommentToken @@ -41,28 +45,18 @@ if TYPE_CHECKING: _logger = logging.getLogger(__name__) -YAMLLINT_CONFIG = """ -extends: default -rules: - comments: - # https://github.com/prettier/prettier/issues/6780 - min-spaces-from-content: 1 - # https://github.com/adrienverge/yamllint/issues/384 - comments-indentation: false - document-start: disable - # 160 chars was the default used by old E204 rule, but - # you can easily change it or disable in your .yamllint file. - line-length: - max: 160 - # We are adding an extra space inside braces as that's how prettier does it - # and we are trying not to fight other linters. - braces: - min-spaces-inside: 0 # yamllint defaults to 0 - max-spaces-inside: 1 # yamllint defaults to 0 - octal-values: - forbid-implicit-octal: true # yamllint defaults to false - forbid-explicit-octal: true # yamllint defaults to false -""" + +class CustomYamlLintConfig(YamlLintConfig): # type: ignore[misc] + """Extension of YamlLintConfig.""" + + def __init__( + self, + content: str | None = None, + file: str | Path | None = None, + ) -> None: + """Initialize config.""" + super().__init__(content, file) + self.incompatible = "" def deannotate(data: Any) -> Any: @@ -80,10 +74,10 @@ def deannotate(data: Any) -> Any: return data -@functools.lru_cache(maxsize=1) -def load_yamllint_config() -> YamlLintConfig: +def load_yamllint_config() -> CustomYamlLintConfig: """Load our default yamllint config and any customized override file.""" - config = YamlLintConfig(content=YAMLLINT_CONFIG) + config = CustomYamlLintConfig(file=Path(__file__).parent / "data" / ".yamllint") + config.incompatible = "" # if we detect local yamllint config we use it but raise a warning # as this is likely to get out of sync with our internal config. for path in [ @@ -100,10 +94,65 @@ def load_yamllint_config() -> YamlLintConfig: "internal yamllint config.", file, ) - config_override = YamlLintConfig(file=str(file)) - config_override.extend(config) - config = config_override + custom_config = CustomYamlLintConfig(file=str(file)) + custom_config.extend(config) + config = custom_config break + + # Look for settings incompatible with our reformatting + checks: list[tuple[str, str | int | bool]] = [ + ( + "comments.min-spaces-from-content", + 1, + ), + ( + "comments-indentation", + False, + ), + ( + "braces.min-spaces-inside", + 0, + ), + ( + "braces.max-spaces-inside", + 1, + ), + ( + "octal-values.forbid-implicit-octal", + True, + ), + ( + "octal-values.forbid-explicit-octal", + True, + ), + # ( + # "key-duplicates.forbid-duplicated-merge-keys", # v1.34.0+ + # True, + # ), + # ( + # "quoted-strings.quote-type", "double", + # ), + # ( + # "quoted-strings.required", "only-when-needed", + # ), + ] + errors = [] + for setting, expected_value in checks: + v = config.rules + for key in setting.split("."): + if not isinstance(v, dict): # pragma: no cover + break + if key not in v: # pragma: no cover + break + v = v[key] + if v != expected_value: + msg = f"{setting} must be {str(expected_value).lower()}" + errors.append(msg) + if errors: + nl = "\n" + msg = f"Found incompatible custom yamllint configuration ({file}), please either remove the file or edit it to comply with:{nl} - {(nl + ' - ').join(errors)}.{nl}{nl}Read https://ansible.readthedocs.io/projects/lint/rules/yaml/ for more details regarding why we have these requirements. Fix mode will not be available." + config.incompatible = msg + _logger.debug("Effective yamllint rules used: %s", config.rules) return config @@ -196,7 +245,7 @@ def _nested_items_path( """ # we have to cast each convert_to_tuples assignment or mypy complains # that both assignments (for dict and list) do not have the same type - convert_to_tuples_type = Callable[[], Iterator[tuple[Union[str, int], Any]]] + convert_to_tuples_type = Callable[[], Iterator[tuple[str | int, Any]]] if isinstance(data_collection, dict): convert_data_collection_to_tuples = cast( convert_to_tuples_type, @@ -214,7 +263,7 @@ def _nested_items_path( if key in (*ANNOTATION_KEYS, *ignored_keys): continue yield key, value, parent_path - if isinstance(value, (dict, list)): + if isinstance(value, dict | list): yield from _nested_items_path( data_collection=value, parent_path=[*parent_path, key], @@ -232,7 +281,7 @@ def get_path_to_play( raise ValueError(msg) if lintable.kind != "playbook" or not isinstance(ruamel_data, CommentedSeq): return [] - lc: LineCol # lc uses 0-based counts # pylint: disable=invalid-name + lc: LineCol # lc uses 0-based counts # lineno is 1-based. Convert to 0-based. line_index = lineno - 1 @@ -245,10 +294,10 @@ def get_path_to_play( else: next_play_line_index = None - lc = play.lc # pylint: disable=invalid-name + lc = play.lc if not isinstance(lc.line, int): msg = f"expected lc.line to be an int, got {lc.line!r}" - raise RuntimeError(msg) + raise TypeError(msg) if lc.line == line_index: return [play_index] if play_index > 0 and prev_play_line_index < line_index < lc.line: @@ -300,6 +349,10 @@ def _get_path_to_task_in_playbook( else: next_play_line_index = None + # We clearly haven't found the right spot yet if a following play starts on an earlier line. + if next_play_line_index and lineno > next_play_line_index: + continue + play_keys = list(play.keys()) for tasks_keyword in PLAYBOOK_TASK_KEYWORDS: if not play.get(tasks_keyword): @@ -381,7 +434,7 @@ def _get_path_to_task_in_tasks_block( if not isinstance(task.lc.line, int): msg = f"expected task.lc.line to be an int, got {task.lc.line!r}" - raise RuntimeError(msg) + raise TypeError(msg) if task.lc.line == line_index: return [task_index] if task_index > 0 and prev_task_line_index < line_index < task.lc.line: @@ -418,6 +471,8 @@ def _get_path_to_task_in_nested_tasks_block( continue next_task_key = task_keys_by_index.get(task_index + 1, None) if next_task_key is not None: + if task.lc.data[next_task_key][2] < lineno: + continue next_task_key_line_index = task.lc.data[next_task_key][0] else: next_task_key_line_index = None @@ -461,7 +516,6 @@ class OctalIntYAML11(ScalarInt): v = format(data, "o") anchor = data.yaml_anchor(any=True) # noinspection PyProtectedMember - # pylint: disable=protected-access return representer.insert_underscore( "0", v, @@ -498,7 +552,9 @@ class CustomConstructor(RoundTripConstructor): value_s = value_su.replace("_", "") if value_s[0] in "+-": value_s = value_s[1:] - if value_s[0] == "0": + if value_s[0:2] == "0x": + ret = HexInt(ret, width=len(value_s) - 2) + elif value_s[0] == "0": # got an octal in YAML 1.1 ret = OctalIntYAML11( ret, @@ -582,15 +638,33 @@ class FormattedEmitter(Emitter): """Select how to quote scalars if needed.""" style = super().choose_scalar_style() if ( - style == "" # noqa: PLC1901 + style == "" and self.event.value.startswith("0") and len(self.event.value) > 1 ): - if self.event.tag == "tag:yaml.org,2002:int" and self.event.implicit[0]: - # ensures that "0123" string does not lose its quoting + # We have an as-yet unquoted token that starts with "0" (but is not itself the digit 0). + # It could be: + # - hexadecimal like "0xF1"; comes tagged as int. Should continue unquoted to continue as an int. + # - octal like "0666" or "0o755"; comes tagged as str. **Should** be quoted to be cross-YAML compatible. + # - string like "0.0.0.0" and "00-header". Should not be quoted, unless it has a quote in it. + if ( + self.event.value.startswith("0x") + and self.event.tag == "tag:yaml.org,2002:int" + and self.event.implicit[0] + ): + # hexadecimal + self.event.tag = "tag:yaml.org,2002:str" + return "" + try: + int(self.event.value, 8) + except ValueError: + pass + # fallthrough to string + else: + # octal self.event.tag = "tag:yaml.org,2002:str" self.event.implicit = (True, True, True) - return '"' + return '"' if style != "'": # block scalar, double quoted, etc. return style @@ -598,6 +672,17 @@ class FormattedEmitter(Emitter): return "'" return self.preferred_quote + def increase_indent( + self, + flow: bool = False, # noqa: FBT002 + sequence: bool | None = None, + indentless: bool = False, # noqa: FBT002 + ) -> None: + super().increase_indent(flow, sequence, indentless) + # If our previous node was a sequence and we are still trying to indent, don't + if self.indents.last_seq(): + self.indent = self.column + 1 + def write_indicator( self, indicator: str, # ruamel.yaml typehint is wrong. This is a string. @@ -620,6 +705,9 @@ class FormattedEmitter(Emitter): and not self._in_empty_flow_map ): indicator = (" " * spaces_inside) + "}" + # Indicator sometimes comes with embedded spaces we need to squish + if indicator == " -" and self.indents.last_seq(): + indicator = "-" super().write_indicator(indicator, need_whitespace, whitespace, indention) # if it is the start of a flow mapping, and it's not time # to wrap the lines, insert a space. @@ -691,16 +779,21 @@ class FormattedEmitter(Emitter): and not value.strip() and not isinstance( self.event, - ( - ruamel.yaml.events.CollectionEndEvent, - ruamel.yaml.events.DocumentEndEvent, - ruamel.yaml.events.StreamEndEvent, - ), + ruamel.yaml.events.CollectionEndEvent + | ruamel.yaml.events.DocumentEndEvent + | ruamel.yaml.events.StreamEndEvent + | ruamel.yaml.events.MappingStartEvent, ) ): # drop pure whitespace pre comments # does not apply to End events since they consume one of the newlines. value = "" + elif ( + pre + and not value.strip() + and isinstance(self.event, ruamel.yaml.events.MappingStartEvent) + ): + value = self._re_repeat_blank_lines.sub("", value) elif pre: # preserve content in pre comment with at least one newline, # but no extra blank lines. @@ -727,13 +820,25 @@ class FormattedEmitter(Emitter): class FormattedYAML(YAML): """A YAML loader/dumper that handles ansible content better by default.""" - def __init__( + default_config = { + "explicit_start": True, + "explicit_end": False, + "width": 160, + "indent_sequences": True, + "preferred_quote": '"', + "min_spaces_inside": 0, + "max_spaces_inside": 1, + } + + def __init__( # pylint: disable=too-many-arguments self, *, typ: str | None = None, pure: bool = False, output: Any = None, plug_ins: list[str] | None = None, + version: tuple[int, int] | None = None, + config: dict[str, bool | int | str] | None = None, ): """Return a configured ``ruamel.yaml.YAML`` instance. @@ -793,15 +898,18 @@ class FormattedYAML(YAML): tasks: - name: Task """ - # Default to reading/dumping YAML 1.1 (ruamel.yaml defaults to 1.2) - self._yaml_version_default: tuple[int, int] = (1, 1) - self._yaml_version: str | tuple[int, int] = self._yaml_version_default - + if version: + if isinstance(version, str): + x, y = version.split(".", maxsplit=1) + version = (int(x), int(y)) + self._yaml_version_default: tuple[int, int] = version + self._yaml_version: tuple[int, int] = self._yaml_version_default super().__init__(typ=typ, pure=pure, output=output, plug_ins=plug_ins) # NB: We ignore some mypy issues because ruamel.yaml typehints are not great. - config = self._defaults_from_yamllint_config() + if not config: + config = self._defaults_from_yamllint_config() # these settings are derived from yamllint config self.explicit_start: bool = config["explicit_start"] # type: ignore[assignment] @@ -854,15 +962,8 @@ class FormattedYAML(YAML): @staticmethod def _defaults_from_yamllint_config() -> dict[str, bool | int | str]: """Extract FormattedYAML-relevant settings from yamllint config if possible.""" - config = { - "explicit_start": True, - "explicit_end": False, - "width": 160, - "indent_sequences": True, - "preferred_quote": '"', - "min_spaces_inside": 0, - "max_spaces_inside": 1, - } + config = FormattedYAML.default_config + for rule, rule_config in load_yamllint_config().rules.items(): if not rule_config: # rule disabled @@ -895,10 +996,10 @@ class FormattedYAML(YAML): elif quote_type == "double": config["preferred_quote"] = '"' - return cast(dict[str, Union[bool, int, str]], config) + return cast(dict[str, bool | int | str], config) - @property # type: ignore[override] - def version(self) -> str | tuple[int, int]: + @property + def version(self) -> tuple[int, int] | None: """Return the YAML version used to parse or dump. Ansible uses PyYAML which only supports YAML 1.1. ruamel.yaml defaults to 1.2. @@ -906,19 +1007,25 @@ class FormattedYAML(YAML): We can relax the version requirement once ansible uses a version of PyYAML that includes this PR: https://github.com/yaml/pyyaml/pull/555 """ - return self._yaml_version + if hasattr(self, "_yaml_version"): + return self._yaml_version + return None @version.setter - def version(self, value: str | tuple[int, int] | None) -> None: + def version(self, value: tuple[int, int] | None) -> None: """Ensure that yaml version uses our default value. The yaml Reader updates this value based on the ``%YAML`` directive in files. So, if a file does not include the directive, it sets this to None. But, None effectively resets the parsing version to YAML 1.2 (ruamel's default). """ - self._yaml_version = value if value is not None else self._yaml_version_default + if value is not None: + self._yaml_version = value + elif hasattr(self, "_yaml_version_default"): + self._yaml_version = self._yaml_version_default + # We do nothing if the object did not have a previous default version defined - def loads(self, stream: str) -> Any: + def load(self, stream: Path | StreamTextType) -> Any: """Load YAML content from a string while avoiding known ruamel.yaml issues.""" if not isinstance(stream, str): msg = f"expected a str but got {type(stream)}" @@ -928,10 +1035,18 @@ class FormattedYAML(YAML): # https://sourceforge.net/p/ruamel-yaml/tickets/460/ text, preamble_comment = self._pre_process_yaml(stream) - data = self.load(stream=text) + try: + data = super().load(stream=text) + except ComposerError: + data = self.load_all(stream=text) + except ParserError: + data = None + _logger.error( # noqa: TRY400 + "Invalid yaml, verify the file contents and try again.", + ) if preamble_comment is not None and isinstance( data, - (CommentedMap, CommentedSeq), + CommentedMap | CommentedSeq, ): data.preamble_comment = preamble_comment # type: ignore[union-attr] # Because data can validly also be None for empty documents, we cannot @@ -948,15 +1063,20 @@ class FormattedYAML(YAML): stream.write(preamble_comment) self.dump(data, stream) text = stream.getvalue() - return self._post_process_yaml(text) + strip_version_directive = hasattr(self, "_yaml_version_default") + return self._post_process_yaml( + text, + strip_version_directive=strip_version_directive, + strip_explicit_start=not self.explicit_start, + ) def _prevent_wrapping_flow_style(self, data: Any) -> None: - if not isinstance(data, (CommentedMap, CommentedSeq)): + if not isinstance(data, CommentedMap | CommentedSeq): return for key, value, parent_path in nested_items_path(data): - if not isinstance(value, (CommentedMap, CommentedSeq)): + if not isinstance(value, CommentedMap | CommentedSeq): continue - fa: Format = value.fa # pylint: disable=invalid-name + fa: Format = value.fa if fa.flow_style(): predicted_indent = self._predict_indent_length(parent_path, key) predicted_width = len(str(value)) @@ -1036,7 +1156,12 @@ class FormattedYAML(YAML): return text, "".join(preamble_comments) or None @staticmethod - def _post_process_yaml(text: str) -> str: + def _post_process_yaml( + text: str, + *, + strip_version_directive: bool = False, + strip_explicit_start: bool = False, + ) -> str: """Handle known issues with ruamel.yaml dumping. Make sure there's only one newline at the end of the file. @@ -1048,6 +1173,14 @@ class FormattedYAML(YAML): Make sure null list items don't end in a space. """ + # remove YAML directive + if strip_version_directive and text.startswith("%YAML"): + text = text.split("\n", 1)[1] + + # remove explicit document start + if strip_explicit_start and text.startswith("---"): + text = text.split("\n", 1)[1] + text = text.rstrip("\n") + "\n" lines = text.splitlines(keepends=True) @@ -1092,9 +1225,9 @@ class FormattedYAML(YAML): def clean_json( obj: Any, - func: Callable[[str], Any] = lambda key: key.startswith("__") - if isinstance(key, str) - else False, + func: Callable[[str], Any] = lambda key: ( + key.startswith("__") if isinstance(key, str) else False + ), ) -> Any: """Remove all keys matching the condition from a nested JSON-like object. diff --git a/test/conftest.py b/test/conftest.py index 8ffa3bd..35115b9 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,4 +1,5 @@ """PyTest fixtures for testing the project.""" + from __future__ import annotations import shutil @@ -83,7 +84,7 @@ def regenerate_formatting_fixtures() -> None: # Writing fixtures with ansiblelint.yaml_utils.FormattedYAML() for fixture in fixtures_dir_after.glob("fmt-[0-9].yml"): - data = yaml.loads(fixture.read_text()) + data = yaml.load(fixture.read_text()) output = yaml.dumps(data) fixture.write_text(output) diff --git a/test/fixtures/broken-ansible.cfg/ansible.cfg b/test/fixtures/broken-ansible.cfg/ansible.cfg new file mode 100644 index 0000000..06c1b92 --- /dev/null +++ b/test/fixtures/broken-ansible.cfg/ansible.cfg @@ -0,0 +1,2 @@ +[defaults] +fact_caching_timeout=invalid-value diff --git a/test/fixtures/formatting-after/fmt-2.yml b/test/fixtures/formatting-after/fmt-2.yml index a162721..3601acc 100644 --- a/test/fixtures/formatting-after/fmt-2.yml +++ b/test/fixtures/formatting-after/fmt-2.yml @@ -22,3 +22,10 @@ - 10 - 9999 zero: 0 # Not an octal. See #2071 + +- string: + - 0steps + - 9steps + - 0.0.0.0 + - "0" + - "01234" diff --git a/test/fixtures/formatting-after/fmt-4.yml b/test/fixtures/formatting-after/fmt-4.yml new file mode 100644 index 0000000..5ded596 --- /dev/null +++ b/test/fixtures/formatting-after/fmt-4.yml @@ -0,0 +1,22 @@ +--- +- name: Gather all legacy facts + cisco.ios.ios_facts: + +- name: Update modification and access time of given file + ansible.builtin.file: + path: /etc/some_file + state: file + modification_time: now + access_time: now + +- name: Disable ufw service + ansible.builtin.service: + name: ufw + enabled: false + state: stopped + when: '"ufw" in services' + +- name: Remove file (delete file) + ansible.builtin.file: + path: /etc/foo.txt + state: absent diff --git a/test/fixtures/formatting-after/fmt-5.yml b/test/fixtures/formatting-after/fmt-5.yml new file mode 100644 index 0000000..b259e0e --- /dev/null +++ b/test/fixtures/formatting-after/fmt-5.yml @@ -0,0 +1,25 @@ +--- +- name: Test this playbook + hosts: all + tasks: + - name: Gather all legacy facts + cisco.ios.ios_facts: + + - name: Update modification and access time of given file + ansible.builtin.file: + path: /etc/some_file + state: file + modification_time: now + access_time: now + + - name: Disable ufw service + ansible.builtin.service: + name: ufw + enabled: false + state: stopped + when: '"ufw" in services' + + - name: Remove file (delete file) + ansible.builtin.file: + path: /etc/foo.txt + state: absent diff --git a/test/fixtures/formatting-after/fmt-hex.yml b/test/fixtures/formatting-after/fmt-hex.yml new file mode 100644 index 0000000..7f09cb9 --- /dev/null +++ b/test/fixtures/formatting-after/fmt-hex.yml @@ -0,0 +1,3 @@ +--- +d: 0x123 # <-- hex +e: 0x0123 diff --git a/test/fixtures/formatting-before/fmt-2.yml b/test/fixtures/formatting-before/fmt-2.yml index 2941663..dbfb777 100644 --- a/test/fixtures/formatting-before/fmt-2.yml +++ b/test/fixtures/formatting-before/fmt-2.yml @@ -22,3 +22,10 @@ - 10 - 9999 zero: 0 # Not an octal. See #2071 + + - string: + - 0steps + - 9steps + - 0.0.0.0 + - "0" + - "01234" diff --git a/test/fixtures/formatting-before/fmt-4.yml b/test/fixtures/formatting-before/fmt-4.yml new file mode 100644 index 0000000..579231f --- /dev/null +++ b/test/fixtures/formatting-before/fmt-4.yml @@ -0,0 +1,25 @@ +--- +- name: Gather all legacy facts + cisco.ios.ios_facts: + +- name: Update modification and access time of given file + ansible.builtin.file: + path: /etc/some_file + state: file + modification_time: now + access_time: now + + +- name: Disable ufw service + ansible.builtin.service: + name: ufw + enabled: false + state: stopped + when: '"ufw" in services' + + + +- name: Remove file (delete file) + ansible.builtin.file: + path: /etc/foo.txt + state: absent diff --git a/test/fixtures/formatting-before/fmt-5.yml b/test/fixtures/formatting-before/fmt-5.yml new file mode 100644 index 0000000..a9145e6 --- /dev/null +++ b/test/fixtures/formatting-before/fmt-5.yml @@ -0,0 +1,28 @@ +--- +- name: Test this playbook + hosts: all + tasks: + - name: Gather all legacy facts + cisco.ios.ios_facts: + + - name: Update modification and access time of given file + ansible.builtin.file: + path: /etc/some_file + state: file + modification_time: now + access_time: now + + + - name: Disable ufw service + ansible.builtin.service: + name: ufw + enabled: false + state: stopped + when: '"ufw" in services' + + + + - name: Remove file (delete file) + ansible.builtin.file: + path: /etc/foo.txt + state: absent diff --git a/test/fixtures/formatting-before/fmt-hex.yml b/test/fixtures/formatting-before/fmt-hex.yml new file mode 100644 index 0000000..3bc15a7 --- /dev/null +++ b/test/fixtures/formatting-before/fmt-hex.yml @@ -0,0 +1,3 @@ +--- +d: 0x123 # <-- hex +e: 0x0123 diff --git a/test/fixtures/formatting-prettier/fmt-2.yml b/test/fixtures/formatting-prettier/fmt-2.yml index 90ac484..037c9dd 100644 --- a/test/fixtures/formatting-prettier/fmt-2.yml +++ b/test/fixtures/formatting-prettier/fmt-2.yml @@ -22,3 +22,10 @@ - 10 - 9999 zero: 0 # Not an octal. See #2071 + +- string: + - 0steps + - 9steps + - 0.0.0.0 + - "0" + - "01234" diff --git a/test/fixtures/formatting-prettier/fmt-4.yml b/test/fixtures/formatting-prettier/fmt-4.yml new file mode 100644 index 0000000..5ded596 --- /dev/null +++ b/test/fixtures/formatting-prettier/fmt-4.yml @@ -0,0 +1,22 @@ +--- +- name: Gather all legacy facts + cisco.ios.ios_facts: + +- name: Update modification and access time of given file + ansible.builtin.file: + path: /etc/some_file + state: file + modification_time: now + access_time: now + +- name: Disable ufw service + ansible.builtin.service: + name: ufw + enabled: false + state: stopped + when: '"ufw" in services' + +- name: Remove file (delete file) + ansible.builtin.file: + path: /etc/foo.txt + state: absent diff --git a/test/fixtures/formatting-prettier/fmt-5.yml b/test/fixtures/formatting-prettier/fmt-5.yml new file mode 100644 index 0000000..b259e0e --- /dev/null +++ b/test/fixtures/formatting-prettier/fmt-5.yml @@ -0,0 +1,25 @@ +--- +- name: Test this playbook + hosts: all + tasks: + - name: Gather all legacy facts + cisco.ios.ios_facts: + + - name: Update modification and access time of given file + ansible.builtin.file: + path: /etc/some_file + state: file + modification_time: now + access_time: now + + - name: Disable ufw service + ansible.builtin.service: + name: ufw + enabled: false + state: stopped + when: '"ufw" in services' + + - name: Remove file (delete file) + ansible.builtin.file: + path: /etc/foo.txt + state: absent diff --git a/test/fixtures/formatting-prettier/fmt-hex.yml b/test/fixtures/formatting-prettier/fmt-hex.yml new file mode 100644 index 0000000..7f09cb9 --- /dev/null +++ b/test/fixtures/formatting-prettier/fmt-hex.yml @@ -0,0 +1,3 @@ +--- +d: 0x123 # <-- hex +e: 0x0123 diff --git a/test/local-content/collections/ansible_collections/testns/test_collection/plugins/filter/test_filter.py b/test/local-content/collections/ansible_collections/testns/test_collection/plugins/filter/test_filter.py index 58bc269..6244329 100644 --- a/test/local-content/collections/ansible_collections/testns/test_collection/plugins/filter/test_filter.py +++ b/test/local-content/collections/ansible_collections/testns/test_collection/plugins/filter/test_filter.py @@ -1,5 +1,4 @@ """A filter plugin.""" -# pylint: disable=invalid-name def a_test_filter(a, b): diff --git a/test/local-content/test-roles-failed-complete/roles/role2/test_plugins/b_failed_complete.py b/test/local-content/test-roles-failed-complete/roles/role2/test_plugins/b_failed_complete.py index 92bd6e7..63f4532 100644 --- a/test/local-content/test-roles-failed-complete/roles/role2/test_plugins/b_failed_complete.py +++ b/test/local-content/test-roles-failed-complete/roles/role2/test_plugins/b_failed_complete.py @@ -1,5 +1,4 @@ """A test plugin.""" -# pylint: disable=invalid-name def compatibility_in_test(a, b): diff --git a/test/rules/fixtures/ematcher.py b/test/rules/fixtures/ematcher.py index 1b04b6b..b034064 100644 --- a/test/rules/fixtures/ematcher.py +++ b/test/rules/fixtures/ematcher.py @@ -1,4 +1,5 @@ """Custom rule used as fixture.""" + from ansiblelint.rules import AnsibleLintRule diff --git a/test/rules/fixtures/raw_task.py b/test/rules/fixtures/raw_task.py index 0d5b023..6dfd7d9 100644 --- a/test/rules/fixtures/raw_task.py +++ b/test/rules/fixtures/raw_task.py @@ -1,4 +1,5 @@ """Test Rule that needs_raw_task.""" + from __future__ import annotations from typing import TYPE_CHECKING diff --git a/test/rules/fixtures/unset_variable_matcher.py b/test/rules/fixtures/unset_variable_matcher.py index 8486009..ea8b0c0 100644 --- a/test/rules/fixtures/unset_variable_matcher.py +++ b/test/rules/fixtures/unset_variable_matcher.py @@ -1,4 +1,5 @@ """Custom linting rule used as test fixture.""" + from ansiblelint.rules import AnsibleLintRule diff --git a/test/rules/test_args.py b/test/rules/test_args.py new file mode 100644 index 0000000..30d83f1 --- /dev/null +++ b/test/rules/test_args.py @@ -0,0 +1,19 @@ +"""Tests for args rule.""" + +from ansiblelint.file_utils import Lintable +from ansiblelint.rules import RulesCollection +from ansiblelint.runner import Runner + + +def test_args_module_relative_import(default_rules_collection: RulesCollection) -> None: + """Validate args check of a module with a relative import.""" + lintable = Lintable( + "examples/playbooks/module_relative_import.yml", + kind="playbook", + ) + result = Runner(lintable, rules=default_rules_collection).run() + assert len(result) == 1, result + assert result[0].lineno == 5 + assert result[0].filename == "examples/playbooks/module_relative_import.yml" + assert result[0].tag == "args[module]" + assert result[0].message == "missing required arguments: name" diff --git a/test/rules/test_deprecated_module.py b/test/rules/test_deprecated_module.py index a57d8db..6346b80 100644 --- a/test/rules/test_deprecated_module.py +++ b/test/rules/test_deprecated_module.py @@ -1,4 +1,5 @@ """Tests for deprecated-module rule.""" + from pathlib import Path from ansiblelint.rules import RulesCollection diff --git a/test/rules/test_inline_env_var.py b/test/rules/test_inline_env_var.py index 98f337e..aa833ec 100644 --- a/test/rules/test_inline_env_var.py +++ b/test/rules/test_inline_env_var.py @@ -1,4 +1,5 @@ """Tests for inline-env-var rule.""" + from ansiblelint.rules import RulesCollection from ansiblelint.rules.inline_env_var import EnvVarsInCommandRule from ansiblelint.testing import RunFromText @@ -13,7 +14,7 @@ SUCCESS_PLAY_TASKS = """ HELLO: hello - name: Use some key-value pairs - command: chdir=/tmp creates=/tmp/bobbins warn=no touch bobbins + command: chdir=/tmp creates=/tmp/bobbins touch bobbins - name: Commands can have flags command: abc --xyz=def blah @@ -68,7 +69,7 @@ FAIL_PLAY_TASKS = """ command: HELLO=hello echo $HELLO - name: Typo some stuff - command: cerates=/tmp/blah warn=no touch /tmp/blah + command: crates=/tmp/blah touch /tmp/blah """ diff --git a/test/rules/test_no_changed_when.py b/test/rules/test_no_changed_when.py index c89d8f4..3316e12 100644 --- a/test/rules/test_no_changed_when.py +++ b/test/rules/test_no_changed_when.py @@ -1,4 +1,5 @@ """Tests for no-change-when rule.""" + from ansiblelint.rules import RulesCollection from ansiblelint.rules.no_changed_when import CommandHasChangesCheckRule from ansiblelint.runner import Runner diff --git a/test/rules/test_package_latest.py b/test/rules/test_package_latest.py index 5631f02..972fced 100644 --- a/test/rules/test_package_latest.py +++ b/test/rules/test_package_latest.py @@ -1,4 +1,5 @@ """Tests for package-latest rule.""" + from ansiblelint.rules import RulesCollection from ansiblelint.rules.package_latest import PackageIsNotLatestRule from ansiblelint.runner import Runner @@ -20,4 +21,4 @@ def test_package_not_latest_negative() -> None: failure = "examples/playbooks/package-check-failure.yml" bad_runner = Runner(failure, rules=collection) errs = bad_runner.run() - assert len(errs) == 4 + assert len(errs) == 5 diff --git a/test/rules/test_role_names.py b/test/rules/test_role_names.py index 491cf14..e13e56a 100644 --- a/test/rules/test_role_names.py +++ b/test/rules/test_role_names.py @@ -1,4 +1,5 @@ """Test the RoleNames rule.""" + from __future__ import annotations from typing import TYPE_CHECKING, Any diff --git a/test/rules/test_syntax_check.py b/test/rules/test_syntax_check.py index 2fe36a3..6ec111d 100644 --- a/test/rules/test_syntax_check.py +++ b/test/rules/test_syntax_check.py @@ -1,32 +1,71 @@ """Tests for syntax-check rule.""" + from typing import Any +import pytest + from ansiblelint.file_utils import Lintable from ansiblelint.rules import RulesCollection from ansiblelint.runner import Runner +@pytest.mark.parametrize( + ("filename", "expected_results"), + ( + pytest.param( + "examples/playbooks/conflicting_action.yml", + [ + ( + "syntax-check[specific]", + 4, + 7, + "conflicting action statements: ansible.builtin.debug, ansible.builtin.command", + ), + ], + id="0", + ), + pytest.param( + "examples/playbooks/conflicting_action2.yml", + [ + ( + "parser-error", + 1, + None, + "conflicting action statements: block, include_role", + ), + ( + "syntax-check[specific]", + 5, + 7, + "'include_role' is not a valid attribute for a Block", + ), + ], + id="1", + ), + ), +) def test_get_ansible_syntax_check_matches( default_rules_collection: RulesCollection, + filename: str, + expected_results: list[tuple[str, int, int, str]], ) -> None: """Validate parsing of ansible output.""" lintable = Lintable( - "examples/playbooks/conflicting_action.yml", + filename, kind="playbook", ) - result = Runner(lintable, rules=default_rules_collection).run() + result = sorted(Runner(lintable, rules=default_rules_collection).run()) - assert result[0].lineno == 4 - assert result[0].column == 7 - assert ( - result[0].message - == "conflicting action statements: ansible.builtin.debug, ansible.builtin.command" - ) - # We internally convert absolute paths returned by ansible into paths - # relative to current directory. - assert result[0].filename.endswith("/conflicting_action.yml") - assert len(result) == 1 + assert len(result) == len(expected_results) + for index, expected in enumerate(expected_results): + assert result[index].tag == expected[0] + assert result[index].lineno == expected[1] + assert result[index].column == expected[2] + assert str(expected[3]) in result[index].message + # We internally convert absolute paths returned by ansible into paths + # relative to current directory. + # assert result[index].filename.endswith("/conflicting_action.yml") def test_empty_playbook(default_rules_collection: RulesCollection) -> None: @@ -58,11 +97,10 @@ def test_extra_vars_passed_to_command( assert not result -def test_syntax_check_role() -> None: +def test_syntax_check_role(default_rules_collection: RulesCollection) -> None: """Validate syntax check of a broken role.""" lintable = Lintable("examples/playbooks/roles/invalid_due_syntax", kind="role") - rules = RulesCollection() - result = Runner(lintable, rules=rules).run() + result = Runner(lintable, rules=default_rules_collection).run() assert len(result) == 1, result assert result[0].lineno == 2 assert result[0].filename == "examples/roles/invalid_due_syntax/tasks/main.yml" diff --git a/test/schemas/.mocharc.json b/test/schemas/.mocharc.json index 0148197..c3b1d46 100644 --- a/test/schemas/.mocharc.json +++ b/test/schemas/.mocharc.json @@ -1,7 +1,11 @@ { "colors": true, - "extension": ["ts"], + "extensions": ["ts"], "require": "ts-node/register", + "node-option": [ + "experimental-specifier-resolution=node", + "loader=ts-node/esm" + ], "slow": "500", "spec": "src/**/*.spec.ts" } diff --git a/test/schemas/negative_test/.ansible-lint.md b/test/schemas/negative_test/.ansible-lint.md index f1f2308..7746f3c 100644 --- a/test/schemas/negative_test/.ansible-lint.md +++ b/test/schemas/negative_test/.ansible-lint.md @@ -128,6 +128,7 @@ stdout: ```json { "status": "fail", + "successes": [], "errors": [], "parse_errors": [ { diff --git a/test/schemas/negative_test/.config/ansible-lint.yml.md b/test/schemas/negative_test/.config/ansible-lint.yml.md index 4fe331e..8d055af 100644 --- a/test/schemas/negative_test/.config/ansible-lint.yml.md +++ b/test/schemas/negative_test/.config/ansible-lint.yml.md @@ -29,6 +29,7 @@ stdout: ```json { "status": "fail", + "successes": [], "errors": [ { "filename": "negative_test/.config/ansible-lint.yml", diff --git a/test/schemas/negative_test/changelogs/invalid-date/changelogs/changelog.yaml.md b/test/schemas/negative_test/changelogs/invalid-date/changelogs/changelog.yaml.md index 72b4f96..82b2601 100644 --- a/test/schemas/negative_test/changelogs/invalid-date/changelogs/changelog.yaml.md +++ b/test/schemas/negative_test/changelogs/invalid-date/changelogs/changelog.yaml.md @@ -21,6 +21,7 @@ stdout: ```json { "status": "fail", + "successes": [], "errors": [ { "filename": "negative_test/changelogs/invalid-date/changelogs/changelog.yaml", diff --git a/test/schemas/negative_test/changelogs/invalid-plugin-namespace/changelogs/changelog.yaml.md b/test/schemas/negative_test/changelogs/invalid-plugin-namespace/changelogs/changelog.yaml.md index ef847c3..f9cbc03 100644 --- a/test/schemas/negative_test/changelogs/invalid-plugin-namespace/changelogs/changelog.yaml.md +++ b/test/schemas/negative_test/changelogs/invalid-plugin-namespace/changelogs/changelog.yaml.md @@ -21,6 +21,7 @@ stdout: ```json { "status": "fail", + "successes": [], "errors": [ { "filename": "negative_test/changelogs/invalid-plugin-namespace/changelogs/changelog.yaml", diff --git a/test/schemas/negative_test/changelogs/list/changelogs/changelog.yaml.md b/test/schemas/negative_test/changelogs/list/changelogs/changelog.yaml.md index 5938944..c21eca7 100644 --- a/test/schemas/negative_test/changelogs/list/changelogs/changelog.yaml.md +++ b/test/schemas/negative_test/changelogs/list/changelogs/changelog.yaml.md @@ -21,6 +21,7 @@ stdout: ```json { "status": "fail", + "successes": [], "errors": [ { "filename": "negative_test/changelogs/list/changelogs/changelog.yaml", diff --git a/test/schemas/negative_test/changelogs/no-semver/changelogs/changelog.yaml.md b/test/schemas/negative_test/changelogs/no-semver/changelogs/changelog.yaml.md index 64c4665..9acc793 100644 --- a/test/schemas/negative_test/changelogs/no-semver/changelogs/changelog.yaml.md +++ b/test/schemas/negative_test/changelogs/no-semver/changelogs/changelog.yaml.md @@ -21,6 +21,7 @@ stdout: ```json { "status": "fail", + "successes": [], "errors": [ { "filename": "negative_test/changelogs/no-semver/changelogs/changelog.yaml", diff --git a/test/schemas/negative_test/changelogs/unknown-keys/changelogs/changelog.yaml.md b/test/schemas/negative_test/changelogs/unknown-keys/changelogs/changelog.yaml.md index 490bdbe..35cf572 100644 --- a/test/schemas/negative_test/changelogs/unknown-keys/changelogs/changelog.yaml.md +++ b/test/schemas/negative_test/changelogs/unknown-keys/changelogs/changelog.yaml.md @@ -21,6 +21,7 @@ stdout: ```json { "status": "fail", + "successes": [], "errors": [ { "filename": "negative_test/changelogs/unknown-keys/changelogs/changelog.yaml", diff --git a/test/schemas/negative_test/galaxy_1/galaxy.yml.md b/test/schemas/negative_test/galaxy_1/galaxy.yml.md index bbb79ec..0119fbe 100644 --- a/test/schemas/negative_test/galaxy_1/galaxy.yml.md +++ b/test/schemas/negative_test/galaxy_1/galaxy.yml.md @@ -21,6 +21,7 @@ stdout: ```json { "status": "fail", + "successes": [], "errors": [ { "filename": "negative_test/galaxy_1/galaxy.yml", diff --git a/test/schemas/negative_test/inventory/broken_dev_inventory.yml.md b/test/schemas/negative_test/inventory/broken_dev_inventory.yml.md index d4fefaf..1979297 100644 --- a/test/schemas/negative_test/inventory/broken_dev_inventory.yml.md +++ b/test/schemas/negative_test/inventory/broken_dev_inventory.yml.md @@ -21,6 +21,7 @@ stdout: ```json { "status": "fail", + "successes": [], "errors": [ { "filename": "negative_test/inventory/broken_dev_inventory.yml", diff --git a/test/schemas/negative_test/meta/runtime.yml.md b/test/schemas/negative_test/meta/runtime.yml.md index 761fa6f..45dfc74 100644 --- a/test/schemas/negative_test/meta/runtime.yml.md +++ b/test/schemas/negative_test/meta/runtime.yml.md @@ -21,6 +21,7 @@ stdout: ```json { "status": "fail", + "successes": [], "errors": [ { "filename": "negative_test/meta/runtime.yml", diff --git a/test/schemas/negative_test/molecule/platforms_children/molecule.yml.md b/test/schemas/negative_test/molecule/platforms_children/molecule.yml.md index 68e09eb..5c0320e 100644 --- a/test/schemas/negative_test/molecule/platforms_children/molecule.yml.md +++ b/test/schemas/negative_test/molecule/platforms_children/molecule.yml.md @@ -21,6 +21,7 @@ stdout: ```json { "status": "fail", + "successes": [], "errors": [ { "filename": "negative_test/molecule/platforms_children/molecule.yml", diff --git a/test/schemas/negative_test/molecule/platforms_networks/molecule.yml.md b/test/schemas/negative_test/molecule/platforms_networks/molecule.yml.md index 74b8de7..8ecbddf 100644 --- a/test/schemas/negative_test/molecule/platforms_networks/molecule.yml.md +++ b/test/schemas/negative_test/molecule/platforms_networks/molecule.yml.md @@ -30,6 +30,7 @@ stdout: ```json { "status": "fail", + "successes": [], "errors": [ { "filename": "negative_test/molecule/platforms_networks/molecule.yml", diff --git a/test/schemas/negative_test/playbooks/environment.yml.md b/test/schemas/negative_test/playbooks/environment.yml.md index 8923cb3..b34d039 100644 --- a/test/schemas/negative_test/playbooks/environment.yml.md +++ b/test/schemas/negative_test/playbooks/environment.yml.md @@ -91,6 +91,7 @@ stdout: ```json { "status": "fail", + "successes": [], "errors": [ { "filename": "negative_test/playbooks/environment.yml", @@ -101,6 +102,11 @@ stdout: "path": "$[0]", "message": "'environment', 'hosts' do not match any of the regexes: '^(ansible\\\\.builtin\\\\.)?import_playbook$', 'name', 'tags', 'vars', 'when'" }, + "best_deep_match": { + "path": "$[0].environment", + "message": "'{{ foo }}-123' is not of type 'object'" + }, + "num_sub_errors": 6, "sub_errors": [ { "path": "$[0]", diff --git a/test/schemas/negative_test/playbooks/failed_when.yml.md b/test/schemas/negative_test/playbooks/failed_when.yml.md index e843e1f..c1c6e6c 100644 --- a/test/schemas/negative_test/playbooks/failed_when.yml.md +++ b/test/schemas/negative_test/playbooks/failed_when.yml.md @@ -118,6 +118,7 @@ stdout: ```json { "status": "fail", + "successes": [], "errors": [ { "filename": "negative_test/playbooks/failed_when.yml", @@ -128,6 +129,11 @@ stdout: "path": "$[0]", "message": "'hosts', 'tasks' do not match any of the regexes: '^(ansible\\\\.builtin\\\\.)?import_playbook$', 'name', 'tags', 'vars', 'when'" }, + "best_deep_match": { + "path": "$[0].tasks[0].failed_when", + "message": "123 is not of type 'boolean'" + }, + "num_sub_errors": 9, "sub_errors": [ { "path": "$[0]", diff --git a/test/schemas/negative_test/playbooks/gather_facts.yml.md b/test/schemas/negative_test/playbooks/gather_facts.yml.md index 0eb3a4b..6b8d90a 100644 --- a/test/schemas/negative_test/playbooks/gather_facts.yml.md +++ b/test/schemas/negative_test/playbooks/gather_facts.yml.md @@ -63,7 +63,25 @@ "params": { "type": "boolean" }, - "schemaPath": "#/properties/gather_facts/type" + "schemaPath": "#/oneOf/0/type" + }, + { + "instancePath": "/0/gather_facts", + "keyword": "pattern", + "message": "must match pattern \"^\\{[\\{%](.|[\r\n])*[\\}%]\\}$\"", + "params": { + "pattern": "^\\{[\\{%](.|[\r\n])*[\\}%]\\}$" + }, + "schemaPath": "#/$defs/full-jinja/pattern" + }, + { + "instancePath": "/0/gather_facts", + "keyword": "oneOf", + "message": "must match exactly one schema in oneOf", + "params": { + "passingSchemas": null + }, + "schemaPath": "#/oneOf" }, { "instancePath": "/0", @@ -84,6 +102,7 @@ stdout: ```json { "status": "fail", + "successes": [], "errors": [ { "filename": "negative_test/playbooks/gather_facts.yml", @@ -94,6 +113,11 @@ stdout: "path": "$[0]", "message": "'gather_facts', 'hosts', 'tasks' do not match any of the regexes: '^(ansible\\\\.builtin\\\\.)?import_playbook$', 'name', 'tags', 'vars', 'when'" }, + "best_deep_match": { + "path": "$[0].gather_facts", + "message": "'non' is not of type 'boolean'" + }, + "num_sub_errors": 6, "sub_errors": [ { "path": "$[0]", @@ -113,7 +137,15 @@ stdout: }, { "path": "$[0].gather_facts", + "message": "'non' is not valid under any of the given schemas" + }, + { + "path": "$[0].gather_facts", "message": "'non' is not of type 'boolean'" + }, + { + "path": "$[0].gather_facts", + "message": "'non' does not match '^\\\\{[\\\\{%](.|[\\r\\n])*[\\\\}%]\\\\}$'" } ] } diff --git a/test/schemas/negative_test/playbooks/gather_subset.yml.md b/test/schemas/negative_test/playbooks/gather_subset.yml.md index b426a23..5ee372b 100644 --- a/test/schemas/negative_test/playbooks/gather_subset.yml.md +++ b/test/schemas/negative_test/playbooks/gather_subset.yml.md @@ -84,6 +84,7 @@ stdout: ```json { "status": "fail", + "successes": [], "errors": [ { "filename": "negative_test/playbooks/gather_subset.yml", @@ -94,6 +95,11 @@ stdout: "path": "$[0]", "message": "'gather_subset', 'hosts', 'tasks' do not match any of the regexes: '^(ansible\\\\.builtin\\\\.)?import_playbook$', 'name', 'tags', 'vars', 'when'" }, + "best_deep_match": { + "path": "$[0].gather_subset", + "message": "'all' is not of type 'array'" + }, + "num_sub_errors": 4, "sub_errors": [ { "path": "$[0]", diff --git a/test/schemas/negative_test/playbooks/gather_subset2.yml.md b/test/schemas/negative_test/playbooks/gather_subset2.yml.md index 8d6be68..d5ec667 100644 --- a/test/schemas/negative_test/playbooks/gather_subset2.yml.md +++ b/test/schemas/negative_test/playbooks/gather_subset2.yml.md @@ -230,6 +230,7 @@ stdout: ```json { "status": "fail", + "successes": [], "errors": [ { "filename": "negative_test/playbooks/gather_subset2.yml", @@ -240,6 +241,11 @@ stdout: "path": "$[0]", "message": "'gather_subset', 'hosts', 'tasks' do not match any of the regexes: '^(ansible\\\\.builtin\\\\.)?import_playbook$', 'name', 'tags', 'vars', 'when'" }, + "best_deep_match": { + "path": "$[0].gather_subset[0]", + "message": "'invalid' is not one of ['all', 'min', 'all_ipv4_addresses', 'all_ipv6_addresses', 'apparmor', 'architecture', 'caps', 'chroot,cmdline', 'date_time', 'default_ipv4', 'default_ipv6', 'devices', 'distribution', 'distribution_major_version', 'distribution_release', 'distribution_version', 'dns', 'effective_group_ids', 'effective_user_id', 'env', 'facter', 'fips', 'hardware', 'interfaces', 'is_chroot', 'iscsi', 'kernel', 'local', 'lsb', 'machine', 'machine_id', 'mounts', 'network', 'ohai', 'os_family', 'pkg_mgr', 'platform', 'processor', 'processor_cores', 'processor_count', 'python', 'python_version', 'real_user_id', 'selinux', 'service_mgr', 'ssh_host_key_dsa_public', 'ssh_host_key_ecdsa_public', 'ssh_host_key_ed25519_public', 'ssh_host_key_rsa_public', 'ssh_host_pub_keys', 'ssh_pub_keys', 'system', 'system_capabilities', 'system_capabilities_enforced', 'user', 'user_dir', 'user_gecos', 'user_gid', 'user_id', 'user_shell', 'user_uid', 'virtual', 'virtualization_role', 'virtualization_type']" + }, + "num_sub_errors": 6, "sub_errors": [ { "path": "$[0]", diff --git a/test/schemas/negative_test/playbooks/gather_subset3.yml.md b/test/schemas/negative_test/playbooks/gather_subset3.yml.md index 7dc1b13..c2ed681 100644 --- a/test/schemas/negative_test/playbooks/gather_subset3.yml.md +++ b/test/schemas/negative_test/playbooks/gather_subset3.yml.md @@ -248,6 +248,7 @@ stdout: ```json { "status": "fail", + "successes": [], "errors": [ { "filename": "negative_test/playbooks/gather_subset3.yml", @@ -258,6 +259,11 @@ stdout: "path": "$[0]", "message": "'gather_subset', 'hosts', 'tasks' do not match any of the regexes: '^(ansible\\\\.builtin\\\\.)?import_playbook$', 'name', 'tags', 'vars', 'when'" }, + "best_deep_match": { + "path": "$[0].gather_subset[0]", + "message": "1 is not one of ['all', 'min', 'all_ipv4_addresses', 'all_ipv6_addresses', 'apparmor', 'architecture', 'caps', 'chroot,cmdline', 'date_time', 'default_ipv4', 'default_ipv6', 'devices', 'distribution', 'distribution_major_version', 'distribution_release', 'distribution_version', 'dns', 'effective_group_ids', 'effective_user_id', 'env', 'facter', 'fips', 'hardware', 'interfaces', 'is_chroot', 'iscsi', 'kernel', 'local', 'lsb', 'machine', 'machine_id', 'mounts', 'network', 'ohai', 'os_family', 'pkg_mgr', 'platform', 'processor', 'processor_cores', 'processor_count', 'python', 'python_version', 'real_user_id', 'selinux', 'service_mgr', 'ssh_host_key_dsa_public', 'ssh_host_key_ecdsa_public', 'ssh_host_key_ed25519_public', 'ssh_host_key_rsa_public', 'ssh_host_pub_keys', 'ssh_pub_keys', 'system', 'system_capabilities', 'system_capabilities_enforced', 'user', 'user_dir', 'user_gecos', 'user_gid', 'user_id', 'user_shell', 'user_uid', 'virtual', 'virtualization_role', 'virtualization_type']" + }, + "num_sub_errors": 8, "sub_errors": [ { "path": "$[0]", diff --git a/test/schemas/negative_test/playbooks/gather_subset4.yml.md b/test/schemas/negative_test/playbooks/gather_subset4.yml.md index ada01cb..6372c84 100644 --- a/test/schemas/negative_test/playbooks/gather_subset4.yml.md +++ b/test/schemas/negative_test/playbooks/gather_subset4.yml.md @@ -84,6 +84,7 @@ stdout: ```json { "status": "fail", + "successes": [], "errors": [ { "filename": "negative_test/playbooks/gather_subset4.yml", @@ -94,6 +95,11 @@ stdout: "path": "$[0]", "message": "'gather_subset', 'hosts', 'tasks' do not match any of the regexes: '^(ansible\\\\.builtin\\\\.)?import_playbook$', 'name', 'tags', 'vars', 'when'" }, + "best_deep_match": { + "path": "$[0].gather_subset", + "message": "1 is not of type 'array'" + }, + "num_sub_errors": 4, "sub_errors": [ { "path": "$[0]", diff --git a/test/schemas/negative_test/playbooks/ignore-unreachable.yml b/test/schemas/negative_test/playbooks/ignore-unreachable.yml new file mode 100644 index 0000000..0934936 --- /dev/null +++ b/test/schemas/negative_test/playbooks/ignore-unreachable.yml @@ -0,0 +1,13 @@ +--- +- name: Test + hosts: localhost + tasks: + - name: Debug + ansible.builtin.debug: + msg: ignore_unreachable should not be a string + ignore_unreachable: "yes" + + - name: Debug + ansible.builtin.debug: + msg: jinja evaluation should not be a string + ignore_unreachable: 123 diff --git a/test/schemas/negative_test/playbooks/ignore-unreachable.yml.md b/test/schemas/negative_test/playbooks/ignore-unreachable.yml.md new file mode 100644 index 0000000..b12403d --- /dev/null +++ b/test/schemas/negative_test/playbooks/ignore-unreachable.yml.md @@ -0,0 +1,329 @@ +# ajv errors + +```json +[ + { + "instancePath": "/0", + "keyword": "required", + "message": "must have required property 'ansible.builtin.import_playbook'", + "params": { + "missingProperty": "ansible.builtin.import_playbook" + }, + "schemaPath": "#/oneOf/0/required" + }, + { + "instancePath": "/0", + "keyword": "required", + "message": "must have required property 'import_playbook'", + "params": { + "missingProperty": "import_playbook" + }, + "schemaPath": "#/oneOf/1/required" + }, + { + "instancePath": "/0", + "keyword": "oneOf", + "message": "must match exactly one schema in oneOf", + "params": { + "passingSchemas": null + }, + "schemaPath": "#/oneOf" + }, + { + "instancePath": "/0", + "keyword": "additionalProperties", + "message": "must NOT have additional properties", + "params": { + "additionalProperty": "hosts" + }, + "schemaPath": "#/additionalProperties" + }, + { + "instancePath": "/0", + "keyword": "additionalProperties", + "message": "must NOT have additional properties", + "params": { + "additionalProperty": "tasks" + }, + "schemaPath": "#/additionalProperties" + }, + { + "instancePath": "/0/tasks/0", + "keyword": "required", + "message": "must have required property 'block'", + "params": { + "missingProperty": "block" + }, + "schemaPath": "#/required" + }, + { + "instancePath": "/0/tasks/0/ignore_unreachable", + "keyword": "type", + "message": "must be boolean", + "params": { + "type": "boolean" + }, + "schemaPath": "#/oneOf/0/type" + }, + { + "instancePath": "/0/tasks/0/ignore_unreachable", + "keyword": "pattern", + "message": "must match pattern \"^\\{[\\{%](.|[\r\n])*[\\}%]\\}$\"", + "params": { + "pattern": "^\\{[\\{%](.|[\r\n])*[\\}%]\\}$" + }, + "schemaPath": "#/$defs/full-jinja/pattern" + }, + { + "instancePath": "/0/tasks/0/ignore_unreachable", + "keyword": "oneOf", + "message": "must match exactly one schema in oneOf", + "params": { + "passingSchemas": null + }, + "schemaPath": "#/oneOf" + }, + { + "instancePath": "/0/tasks/0/ignore_unreachable", + "keyword": "type", + "message": "must be boolean", + "params": { + "type": "boolean" + }, + "schemaPath": "#/oneOf/0/type" + }, + { + "instancePath": "/0/tasks/0/ignore_unreachable", + "keyword": "pattern", + "message": "must match pattern \"^\\{[\\{%](.|[\r\n])*[\\}%]\\}$\"", + "params": { + "pattern": "^\\{[\\{%](.|[\r\n])*[\\}%]\\}$" + }, + "schemaPath": "#/$defs/full-jinja/pattern" + }, + { + "instancePath": "/0/tasks/0/ignore_unreachable", + "keyword": "oneOf", + "message": "must match exactly one schema in oneOf", + "params": { + "passingSchemas": null + }, + "schemaPath": "#/oneOf" + }, + { + "instancePath": "/0/tasks/0", + "keyword": "anyOf", + "message": "must match a schema in anyOf", + "params": {}, + "schemaPath": "#/items/anyOf" + }, + { + "instancePath": "/0/tasks/1", + "keyword": "required", + "message": "must have required property 'block'", + "params": { + "missingProperty": "block" + }, + "schemaPath": "#/required" + }, + { + "instancePath": "/0/tasks/1/ignore_unreachable", + "keyword": "type", + "message": "must be boolean", + "params": { + "type": "boolean" + }, + "schemaPath": "#/oneOf/0/type" + }, + { + "instancePath": "/0/tasks/1/ignore_unreachable", + "keyword": "type", + "message": "must be string", + "params": { + "type": "string" + }, + "schemaPath": "#/oneOf/1/type" + }, + { + "instancePath": "/0/tasks/1/ignore_unreachable", + "keyword": "type", + "message": "must be string", + "params": { + "type": "string" + }, + "schemaPath": "#/$defs/full-jinja/type" + }, + { + "instancePath": "/0/tasks/1/ignore_unreachable", + "keyword": "oneOf", + "message": "must match exactly one schema in oneOf", + "params": { + "passingSchemas": null + }, + "schemaPath": "#/oneOf" + }, + { + "instancePath": "/0/tasks/1/ignore_unreachable", + "keyword": "type", + "message": "must be boolean", + "params": { + "type": "boolean" + }, + "schemaPath": "#/oneOf/0/type" + }, + { + "instancePath": "/0/tasks/1/ignore_unreachable", + "keyword": "type", + "message": "must be string", + "params": { + "type": "string" + }, + "schemaPath": "#/oneOf/1/type" + }, + { + "instancePath": "/0/tasks/1/ignore_unreachable", + "keyword": "type", + "message": "must be string", + "params": { + "type": "string" + }, + "schemaPath": "#/$defs/full-jinja/type" + }, + { + "instancePath": "/0/tasks/1/ignore_unreachable", + "keyword": "oneOf", + "message": "must match exactly one schema in oneOf", + "params": { + "passingSchemas": null + }, + "schemaPath": "#/oneOf" + }, + { + "instancePath": "/0/tasks/1", + "keyword": "anyOf", + "message": "must match a schema in anyOf", + "params": {}, + "schemaPath": "#/items/anyOf" + }, + { + "instancePath": "/0", + "keyword": "oneOf", + "message": "must match exactly one schema in oneOf", + "params": { + "passingSchemas": null + }, + "schemaPath": "#/items/oneOf" + } +] +``` + +# check-jsonschema + +stdout: + +```json +{ + "status": "fail", + "successes": [], + "errors": [ + { + "filename": "negative_test/playbooks/ignore-unreachable.yml", + "path": "$[0]", + "message": "{'name': 'Test', 'hosts': 'localhost', 'tasks': [{'name': 'Debug', 'ansible.builtin.debug': {'msg': 'ignore_unreachable should not be a string'}, 'ignore_unreachable': 'yes'}, {'name': 'Debug', 'ansible.builtin.debug': {'msg': 'jinja evaluation should not be a string'}, 'ignore_unreachable': 123}]} is not valid under any of the given schemas", + "has_sub_errors": true, + "best_match": { + "path": "$[0]", + "message": "'hosts', 'tasks' do not match any of the regexes: '^(ansible\\\\.builtin\\\\.)?import_playbook$', 'name', 'tags', 'vars', 'when'" + }, + "best_deep_match": { + "path": "$[0].tasks[0].ignore_unreachable", + "message": "'yes' is not of type 'boolean'" + }, + "num_sub_errors": 19, + "sub_errors": [ + { + "path": "$[0]", + "message": "'hosts', 'tasks' do not match any of the regexes: '^(ansible\\\\.builtin\\\\.)?import_playbook$', 'name', 'tags', 'vars', 'when'" + }, + { + "path": "$[0]", + "message": "{'name': 'Test', 'hosts': 'localhost', 'tasks': [{'name': 'Debug', 'ansible.builtin.debug': {'msg': 'ignore_unreachable should not be a string'}, 'ignore_unreachable': 'yes'}, {'name': 'Debug', 'ansible.builtin.debug': {'msg': 'jinja evaluation should not be a string'}, 'ignore_unreachable': 123}]} is not valid under any of the given schemas" + }, + { + "path": "$[0]", + "message": "'ansible.builtin.import_playbook' is a required property" + }, + { + "path": "$[0]", + "message": "'import_playbook' is a required property" + }, + { + "path": "$[0].tasks[0]", + "message": "{'name': 'Debug', 'ansible.builtin.debug': {'msg': 'ignore_unreachable should not be a string'}, 'ignore_unreachable': 'yes'} is not valid under any of the given schemas" + }, + { + "path": "$[0].tasks[0].ignore_unreachable", + "message": "'yes' is not valid under any of the given schemas" + }, + { + "path": "$[0].tasks[0].ignore_unreachable", + "message": "'yes' is not of type 'boolean'" + }, + { + "path": "$[0].tasks[0].ignore_unreachable", + "message": "'yes' does not match '^\\\\{[\\\\{%](.|[\\r\\n])*[\\\\}%]\\\\}$'" + }, + { + "path": "$[0].tasks[0]", + "message": "'block' is a required property" + }, + { + "path": "$[0].tasks[0].ignore_unreachable", + "message": "'yes' is not valid under any of the given schemas" + }, + { + "path": "$[0].tasks[0].ignore_unreachable", + "message": "'yes' is not of type 'boolean'" + }, + { + "path": "$[0].tasks[0].ignore_unreachable", + "message": "'yes' does not match '^\\\\{[\\\\{%](.|[\\r\\n])*[\\\\}%]\\\\}$'" + }, + { + "path": "$[0].tasks[1]", + "message": "{'name': 'Debug', 'ansible.builtin.debug': {'msg': 'jinja evaluation should not be a string'}, 'ignore_unreachable': 123} is not valid under any of the given schemas" + }, + { + "path": "$[0].tasks[1].ignore_unreachable", + "message": "123 is not valid under any of the given schemas" + }, + { + "path": "$[0].tasks[1].ignore_unreachable", + "message": "123 is not of type 'boolean'" + }, + { + "path": "$[0].tasks[1].ignore_unreachable", + "message": "123 is not of type 'string'" + }, + { + "path": "$[0].tasks[1]", + "message": "'block' is a required property" + }, + { + "path": "$[0].tasks[1].ignore_unreachable", + "message": "123 is not valid under any of the given schemas" + }, + { + "path": "$[0].tasks[1].ignore_unreachable", + "message": "123 is not of type 'boolean'" + }, + { + "path": "$[0].tasks[1].ignore_unreachable", + "message": "123 is not of type 'string'" + } + ] + } + ], + "parse_errors": [] +} +``` diff --git a/test/schemas/negative_test/playbooks/ignore_errors.yml.md b/test/schemas/negative_test/playbooks/ignore_errors.yml.md index 61c3116..c76c098 100644 --- a/test/schemas/negative_test/playbooks/ignore_errors.yml.md +++ b/test/schemas/negative_test/playbooks/ignore_errors.yml.md @@ -136,6 +136,7 @@ stdout: ```json { "status": "fail", + "successes": [], "errors": [ { "filename": "negative_test/playbooks/ignore_errors.yml", @@ -146,6 +147,11 @@ stdout: "path": "$[0]", "message": "'hosts', 'tasks' do not match any of the regexes: '^(ansible\\\\.builtin\\\\.)?import_playbook$', 'name', 'tags', 'vars', 'when'" }, + "best_deep_match": { + "path": "$[0].tasks[0].ignore_errors", + "message": "'should_ignore_errors' is not of type 'boolean'" + }, + "num_sub_errors": 11, "sub_errors": [ { "path": "$[0]", diff --git a/test/schemas/negative_test/playbooks/import_playbook.yml.md b/test/schemas/negative_test/playbooks/import_playbook.yml.md index def3dce..a04a1b8 100644 --- a/test/schemas/negative_test/playbooks/import_playbook.yml.md +++ b/test/schemas/negative_test/playbooks/import_playbook.yml.md @@ -55,6 +55,7 @@ stdout: ```json { "status": "fail", + "successes": [], "errors": [ { "filename": "negative_test/playbooks/import_playbook.yml", @@ -65,6 +66,11 @@ stdout: "path": "$[0]", "message": "{'ansible.builtin.import_playbook': {}} should not be valid under {'required': ['ansible.builtin.import_playbook']}" }, + "best_deep_match": { + "path": "$[0].ansible.builtin.import_playbook", + "message": "{} is not of type 'string'" + }, + "num_sub_errors": 3, "sub_errors": [ { "path": "$[0].ansible.builtin.import_playbook", diff --git a/test/schemas/negative_test/playbooks/import_playbook_exclusive.yml.md b/test/schemas/negative_test/playbooks/import_playbook_exclusive.yml.md index 184a434..143165f 100644 --- a/test/schemas/negative_test/playbooks/import_playbook_exclusive.yml.md +++ b/test/schemas/negative_test/playbooks/import_playbook_exclusive.yml.md @@ -85,6 +85,7 @@ stdout: ```json { "status": "fail", + "successes": [], "errors": [ { "filename": "negative_test/playbooks/import_playbook_exclusive.yml", @@ -95,6 +96,11 @@ stdout: "path": "$[0]", "message": "{'ansible.builtin.import_playbook': 'foo.yml', 'import_playbook': 'other.yml'} should not be valid under {'required': ['ansible.builtin.import_playbook']}" }, + "best_deep_match": { + "path": "$[0]", + "message": "{'ansible.builtin.import_playbook': 'foo.yml', 'import_playbook': 'other.yml'} should not be valid under {'required': ['import_playbook']}" + }, + "num_sub_errors": 6, "sub_errors": [ { "path": "$[0]", diff --git a/test/schemas/negative_test/playbooks/include.yml b/test/schemas/negative_test/playbooks/include.yml new file mode 100644 index 0000000..5504e13 --- /dev/null +++ b/test/schemas/negative_test/playbooks/include.yml @@ -0,0 +1,3 @@ +- hosts: localhost + tasks: + - include: foo.yml # <-- removed in Ansible 2.16 diff --git a/test/schemas/negative_test/playbooks/include.yml.md b/test/schemas/negative_test/playbooks/include.yml.md new file mode 100644 index 0000000..c577d84 --- /dev/null +++ b/test/schemas/negative_test/playbooks/include.yml.md @@ -0,0 +1,142 @@ +# ajv errors + +```json +[ + { + "instancePath": "/0", + "keyword": "required", + "message": "must have required property 'ansible.builtin.import_playbook'", + "params": { + "missingProperty": "ansible.builtin.import_playbook" + }, + "schemaPath": "#/oneOf/0/required" + }, + { + "instancePath": "/0", + "keyword": "required", + "message": "must have required property 'import_playbook'", + "params": { + "missingProperty": "import_playbook" + }, + "schemaPath": "#/oneOf/1/required" + }, + { + "instancePath": "/0", + "keyword": "oneOf", + "message": "must match exactly one schema in oneOf", + "params": { + "passingSchemas": null + }, + "schemaPath": "#/oneOf" + }, + { + "instancePath": "/0", + "keyword": "additionalProperties", + "message": "must NOT have additional properties", + "params": { + "additionalProperty": "hosts" + }, + "schemaPath": "#/additionalProperties" + }, + { + "instancePath": "/0", + "keyword": "additionalProperties", + "message": "must NOT have additional properties", + "params": { + "additionalProperty": "tasks" + }, + "schemaPath": "#/additionalProperties" + }, + { + "instancePath": "/0/tasks/0", + "keyword": "required", + "message": "must have required property 'block'", + "params": { + "missingProperty": "block" + }, + "schemaPath": "#/required" + }, + { + "instancePath": "/0/tasks/0/include", + "keyword": "not", + "message": "must NOT be valid", + "params": {}, + "schemaPath": "#/$defs/removed-include-module/not" + }, + { + "instancePath": "/0/tasks/0", + "keyword": "anyOf", + "message": "must match a schema in anyOf", + "params": {}, + "schemaPath": "#/items/anyOf" + }, + { + "instancePath": "/0", + "keyword": "oneOf", + "message": "must match exactly one schema in oneOf", + "params": { + "passingSchemas": null + }, + "schemaPath": "#/items/oneOf" + } +] +``` + +# check-jsonschema + +stdout: + +```json +{ + "status": "fail", + "successes": [], + "errors": [ + { + "filename": "negative_test/playbooks/include.yml", + "path": "$[0]", + "message": "{'hosts': 'localhost', 'tasks': [{'include': 'foo.yml'}]} is not valid under any of the given schemas", + "has_sub_errors": true, + "best_match": { + "path": "$[0]", + "message": "'hosts', 'tasks' do not match any of the regexes: '^(ansible\\\\.builtin\\\\.)?import_playbook$', 'name', 'tags', 'vars', 'when'" + }, + "best_deep_match": { + "path": "$[0].tasks[0].include", + "message": "'foo.yml' should not be valid under {}" + }, + "num_sub_errors": 6, + "sub_errors": [ + { + "path": "$[0]", + "message": "'hosts', 'tasks' do not match any of the regexes: '^(ansible\\\\.builtin\\\\.)?import_playbook$', 'name', 'tags', 'vars', 'when'" + }, + { + "path": "$[0]", + "message": "{'hosts': 'localhost', 'tasks': [{'include': 'foo.yml'}]} is not valid under any of the given schemas" + }, + { + "path": "$[0]", + "message": "'ansible.builtin.import_playbook' is a required property" + }, + { + "path": "$[0]", + "message": "'import_playbook' is a required property" + }, + { + "path": "$[0].tasks[0]", + "message": "{'include': 'foo.yml'} is not valid under any of the given schemas" + }, + { + "path": "$[0].tasks[0]", + "message": "'block' is a required property" + }, + { + "path": "$[0].tasks[0].include", + "message": "'foo.yml' should not be valid under {}" + } + ] + } + ], + "parse_errors": [] +} +``` diff --git a/test/schemas/negative_test/playbooks/invalid-failed-when.yml.md b/test/schemas/negative_test/playbooks/invalid-failed-when.yml.md index 3a41059..e2d1a0d 100644 --- a/test/schemas/negative_test/playbooks/invalid-failed-when.yml.md +++ b/test/schemas/negative_test/playbooks/invalid-failed-when.yml.md @@ -170,6 +170,7 @@ stdout: ```json { "status": "fail", + "successes": [], "errors": [ { "filename": "negative_test/playbooks/invalid-failed-when.yml", @@ -180,6 +181,11 @@ stdout: "path": "$[0]", "message": "'hosts', 'tasks' do not match any of the regexes: '^(ansible\\\\.builtin\\\\.)?import_playbook$', 'name', 'tags', 'vars', 'when'" }, + "best_deep_match": { + "path": "$[0].tasks[0].failed_when", + "message": "123 is not of type 'boolean'" + }, + "num_sub_errors": 15, "sub_errors": [ { "path": "$[0]", diff --git a/test/schemas/negative_test/playbooks/invalid-serial.yml.md b/test/schemas/negative_test/playbooks/invalid-serial.yml.md index 5c48b21..80785f0 100644 --- a/test/schemas/negative_test/playbooks/invalid-serial.yml.md +++ b/test/schemas/negative_test/playbooks/invalid-serial.yml.md @@ -118,6 +118,7 @@ stdout: ```json { "status": "fail", + "successes": [], "errors": [ { "filename": "negative_test/playbooks/invalid-serial.yml", @@ -128,6 +129,11 @@ stdout: "path": "$[0]", "message": "'hosts', 'serial' do not match any of the regexes: '^(ansible\\\\.builtin\\\\.)?import_playbook$', 'name', 'tags', 'vars', 'when'" }, + "best_deep_match": { + "path": "$[0].serial", + "message": "'10%BAD' is not of type 'integer'" + }, + "num_sub_errors": 9, "sub_errors": [ { "path": "$[0]", diff --git a/test/schemas/negative_test/playbooks/invalid.yml.md b/test/schemas/negative_test/playbooks/invalid.yml.md index c3435dd..6a48a92 100644 --- a/test/schemas/negative_test/playbooks/invalid.yml.md +++ b/test/schemas/negative_test/playbooks/invalid.yml.md @@ -46,6 +46,7 @@ stdout: ```json { "status": "fail", + "successes": [], "errors": [ { "filename": "negative_test/playbooks/invalid.yml", @@ -56,6 +57,11 @@ stdout: "path": "$[0]", "message": "{'name': 'foo', 'hosts': 'localhost', 'import_playbook': 'included.yml'} should not be valid under {'required': ['import_playbook']}" }, + "best_deep_match": { + "path": "$[0]", + "message": "'hosts' does not match any of the regexes: '^(ansible\\\\.builtin\\\\.)?import_playbook$', 'name', 'tags', 'vars', 'when'" + }, + "num_sub_errors": 2, "sub_errors": [ { "path": "$[0]", diff --git a/test/schemas/negative_test/playbooks/invalid_become.yml.md b/test/schemas/negative_test/playbooks/invalid_become.yml.md index 37d730d..e4fd6d5 100644 --- a/test/schemas/negative_test/playbooks/invalid_become.yml.md +++ b/test/schemas/negative_test/playbooks/invalid_become.yml.md @@ -93,6 +93,7 @@ stdout: ```json { "status": "fail", + "successes": [], "errors": [ { "filename": "negative_test/playbooks/invalid_become.yml", @@ -103,6 +104,11 @@ stdout: "path": "$[0]", "message": "'become', 'hosts' do not match any of the regexes: '^(ansible\\\\.builtin\\\\.)?import_playbook$', 'name', 'tags', 'vars', 'when'" }, + "best_deep_match": { + "path": "$[0].become", + "message": "'yes' is not of type 'boolean'" + }, + "num_sub_errors": 6, "sub_errors": [ { "path": "$[0]", diff --git a/test/schemas/negative_test/playbooks/local_action.yml.md b/test/schemas/negative_test/playbooks/local_action.yml.md index 17f6244..d41de95 100644 --- a/test/schemas/negative_test/playbooks/local_action.yml.md +++ b/test/schemas/negative_test/playbooks/local_action.yml.md @@ -94,6 +94,7 @@ stdout: ```json { "status": "fail", + "successes": [], "errors": [ { "filename": "negative_test/playbooks/local_action.yml", @@ -104,6 +105,11 @@ stdout: "path": "$[0]", "message": "'hosts', 'tasks' do not match any of the regexes: '^(ansible\\\\.builtin\\\\.)?import_playbook$', 'name', 'tags', 'vars', 'when'" }, + "best_deep_match": { + "path": "$[0].tasks[0].local_action", + "message": "[] is not of type 'string', 'object'" + }, + "num_sub_errors": 6, "sub_errors": [ { "path": "$[0]", diff --git a/test/schemas/negative_test/playbooks/loop.yml.md b/test/schemas/negative_test/playbooks/loop.yml.md index 88df838..c7b3e45 100644 --- a/test/schemas/negative_test/playbooks/loop.yml.md +++ b/test/schemas/negative_test/playbooks/loop.yml.md @@ -94,6 +94,7 @@ stdout: ```json { "status": "fail", + "successes": [], "errors": [ { "filename": "negative_test/playbooks/loop.yml", @@ -104,6 +105,11 @@ stdout: "path": "$[0]", "message": "'hosts', 'tasks' do not match any of the regexes: '^(ansible\\\\.builtin\\\\.)?import_playbook$', 'name', 'tags', 'vars', 'when'" }, + "best_deep_match": { + "path": "$[0].tasks[0].loop", + "message": "123 is not of type 'string', 'array'" + }, + "num_sub_errors": 6, "sub_errors": [ { "path": "$[0]", diff --git a/test/schemas/negative_test/playbooks/loop2.yml.md b/test/schemas/negative_test/playbooks/loop2.yml.md index df60a41..cf77f85 100644 --- a/test/schemas/negative_test/playbooks/loop2.yml.md +++ b/test/schemas/negative_test/playbooks/loop2.yml.md @@ -94,6 +94,7 @@ stdout: ```json { "status": "fail", + "successes": [], "errors": [ { "filename": "negative_test/playbooks/loop2.yml", @@ -104,6 +105,11 @@ stdout: "path": "$[0]", "message": "'hosts', 'tasks' do not match any of the regexes: '^(ansible\\\\.builtin\\\\.)?import_playbook$', 'name', 'tags', 'vars', 'when'" }, + "best_deep_match": { + "path": "$[0].tasks[0].loop", + "message": "{} is not of type 'string', 'array'" + }, + "num_sub_errors": 6, "sub_errors": [ { "path": "$[0]", diff --git a/test/schemas/negative_test/playbooks/no_log_partial_template.yml.md b/test/schemas/negative_test/playbooks/no_log_partial_template.yml.md index ee73686..c7fd4fd 100644 --- a/test/schemas/negative_test/playbooks/no_log_partial_template.yml.md +++ b/test/schemas/negative_test/playbooks/no_log_partial_template.yml.md @@ -136,6 +136,7 @@ stdout: ```json { "status": "fail", + "successes": [], "errors": [ { "filename": "negative_test/playbooks/no_log_partial_template.yml", @@ -146,6 +147,11 @@ stdout: "path": "$[0]", "message": "'hosts', 'tasks' do not match any of the regexes: '^(ansible\\\\.builtin\\\\.)?import_playbook$', 'name', 'tags', 'vars', 'when'" }, + "best_deep_match": { + "path": "$[0].tasks[0].no_log", + "message": "'foo-{{ some_var }}' is not of type 'boolean'" + }, + "num_sub_errors": 11, "sub_errors": [ { "path": "$[0]", diff --git a/test/schemas/negative_test/playbooks/no_log_string.yml.md b/test/schemas/negative_test/playbooks/no_log_string.yml.md index c8213c0..98b4bc2 100644 --- a/test/schemas/negative_test/playbooks/no_log_string.yml.md +++ b/test/schemas/negative_test/playbooks/no_log_string.yml.md @@ -136,6 +136,7 @@ stdout: ```json { "status": "fail", + "successes": [], "errors": [ { "filename": "negative_test/playbooks/no_log_string.yml", @@ -146,6 +147,11 @@ stdout: "path": "$[0]", "message": "'hosts', 'tasks' do not match any of the regexes: '^(ansible\\\\.builtin\\\\.)?import_playbook$', 'name', 'tags', 'vars', 'when'" }, + "best_deep_match": { + "path": "$[0].tasks[0].no_log", + "message": "'some_var' is not of type 'boolean'" + }, + "num_sub_errors": 11, "sub_errors": [ { "path": "$[0]", diff --git a/test/schemas/negative_test/playbooks/roles.yml.md b/test/schemas/negative_test/playbooks/roles.yml.md index 9b4e25a..72d7b85 100644 --- a/test/schemas/negative_test/playbooks/roles.yml.md +++ b/test/schemas/negative_test/playbooks/roles.yml.md @@ -75,6 +75,7 @@ stdout: ```json { "status": "fail", + "successes": [], "errors": [ { "filename": "negative_test/playbooks/roles.yml", @@ -85,6 +86,11 @@ stdout: "path": "$[0]", "message": "'hosts', 'roles' do not match any of the regexes: '^(ansible\\\\.builtin\\\\.)?import_playbook$', 'name', 'tags', 'vars', 'when'" }, + "best_deep_match": { + "path": "$[0].roles", + "message": "'xxx' is not of type 'array'" + }, + "num_sub_errors": 4, "sub_errors": [ { "path": "$[0]", diff --git a/test/schemas/negative_test/playbooks/run_once_list.yml.md b/test/schemas/negative_test/playbooks/run_once_list.yml.md index 84b7dc1..63424ff 100644 --- a/test/schemas/negative_test/playbooks/run_once_list.yml.md +++ b/test/schemas/negative_test/playbooks/run_once_list.yml.md @@ -154,6 +154,7 @@ stdout: ```json { "status": "fail", + "successes": [], "errors": [ { "filename": "negative_test/playbooks/run_once_list.yml", @@ -164,6 +165,11 @@ stdout: "path": "$[0]", "message": "'hosts', 'tasks' do not match any of the regexes: '^(ansible\\\\.builtin\\\\.)?import_playbook$', 'name', 'tags', 'vars', 'when'" }, + "best_deep_match": { + "path": "$[0].tasks[0].run_once", + "message": "['{{ true }}', 'xxx'] is not of type 'boolean'" + }, + "num_sub_errors": 11, "sub_errors": [ { "path": "$[0]", diff --git a/test/schemas/negative_test/playbooks/tags-mapping.yml.md b/test/schemas/negative_test/playbooks/tags-mapping.yml.md index aada0c6..10cdb9b 100644 --- a/test/schemas/negative_test/playbooks/tags-mapping.yml.md +++ b/test/schemas/negative_test/playbooks/tags-mapping.yml.md @@ -107,6 +107,7 @@ stdout: ```json { "status": "fail", + "successes": [], "errors": [ { "filename": "negative_test/playbooks/tags-mapping.yml", @@ -117,6 +118,11 @@ stdout: "path": "$[0]", "message": "'hosts' does not match any of the regexes: '^(ansible\\\\.builtin\\\\.)?import_playbook$', 'name', 'tags', 'vars', 'when'" }, + "best_deep_match": { + "path": "$[0].tags", + "message": "{} is not of type 'string'" + }, + "num_sub_errors": 9, "sub_errors": [ { "path": "$[0]", diff --git a/test/schemas/negative_test/playbooks/tags-number.yml.md b/test/schemas/negative_test/playbooks/tags-number.yml.md index 3d32737..48a264a 100644 --- a/test/schemas/negative_test/playbooks/tags-number.yml.md +++ b/test/schemas/negative_test/playbooks/tags-number.yml.md @@ -107,6 +107,7 @@ stdout: ```json { "status": "fail", + "successes": [], "errors": [ { "filename": "negative_test/playbooks/tags-number.yml", @@ -117,6 +118,11 @@ stdout: "path": "$[0]", "message": "'hosts' does not match any of the regexes: '^(ansible\\\\.builtin\\\\.)?import_playbook$', 'name', 'tags', 'vars', 'when'" }, + "best_deep_match": { + "path": "$[0].tags", + "message": "123 is not of type 'string'" + }, + "num_sub_errors": 9, "sub_errors": [ { "path": "$[0]", diff --git a/test/schemas/negative_test/playbooks/tasks.yml.md b/test/schemas/negative_test/playbooks/tasks.yml.md index 309912b..08d7fe4 100644 --- a/test/schemas/negative_test/playbooks/tasks.yml.md +++ b/test/schemas/negative_test/playbooks/tasks.yml.md @@ -141,6 +141,7 @@ stdout: ```json { "status": "fail", + "successes": [], "errors": [ { "filename": "negative_test/playbooks/tasks.yml", @@ -151,6 +152,11 @@ stdout: "path": "$[0]", "message": "'handlers', 'hosts', 'post_tasks', 'pre_tasks', 'tasks' do not match any of the regexes: '^(ansible\\\\.builtin\\\\.)?import_playbook$', 'name', 'tags', 'vars', 'when'" }, + "best_deep_match": { + "path": "$[0].handlers", + "message": "1.0 is not of type 'array', 'null'" + }, + "num_sub_errors": 7, "sub_errors": [ { "path": "$[0]", diff --git a/test/schemas/negative_test/playbooks/tasks/args_integer.yml.md b/test/schemas/negative_test/playbooks/tasks/args_integer.yml.md index 8820251..25000f8 100644 --- a/test/schemas/negative_test/playbooks/tasks/args_integer.yml.md +++ b/test/schemas/negative_test/playbooks/tasks/args_integer.yml.md @@ -64,6 +64,7 @@ stdout: ```json { "status": "fail", + "successes": [], "errors": [ { "filename": "negative_test/playbooks/tasks/args_integer.yml", @@ -74,6 +75,11 @@ stdout: "path": "$[0]", "message": "'block' is a required property" }, + "best_deep_match": { + "path": "$[0].args", + "message": "123 is not of type 'object'" + }, + "num_sub_errors": 3, "sub_errors": [ { "path": "$[0]", diff --git a/test/schemas/negative_test/playbooks/tasks/args_string.yml.md b/test/schemas/negative_test/playbooks/tasks/args_string.yml.md index 6359a14..b1bf502 100644 --- a/test/schemas/negative_test/playbooks/tasks/args_string.yml.md +++ b/test/schemas/negative_test/playbooks/tasks/args_string.yml.md @@ -55,6 +55,7 @@ stdout: ```json { "status": "fail", + "successes": [], "errors": [ { "filename": "negative_test/playbooks/tasks/args_string.yml", @@ -65,6 +66,11 @@ stdout: "path": "$[0]", "message": "'block' is a required property" }, + "best_deep_match": { + "path": "$[0].args", + "message": "'{{ }}123' is not of type 'object'" + }, + "num_sub_errors": 3, "sub_errors": [ { "path": "$[0]", diff --git a/test/schemas/negative_test/playbooks/tasks/become_method_invalid.yml.md b/test/schemas/negative_test/playbooks/tasks/become_method_invalid.yml.md index fc1e692..b94527a 100644 --- a/test/schemas/negative_test/playbooks/tasks/become_method_invalid.yml.md +++ b/test/schemas/negative_test/playbooks/tasks/become_method_invalid.yml.md @@ -140,6 +140,7 @@ stdout: ```json { "status": "fail", + "successes": [], "errors": [ { "filename": "negative_test/playbooks/tasks/become_method_invalid.yml", @@ -150,6 +151,11 @@ stdout: "path": "$[0]", "message": "'block' is a required property" }, + "best_deep_match": { + "path": "$[0].become_method", + "message": "True is not one of ['ansible.builtin.sudo', 'ansible.builtin.su', 'community.general.pbrun', 'community.general.pfexec', 'ansible.builtin.runas', 'community.general.dzdo', 'community.general.ksu', 'community.general.doas', 'community.general.machinectl', 'community.general.pmrun', 'community.general.sesu', 'community.general.sudosu']" + }, + "num_sub_errors": 10, "sub_errors": [ { "path": "$[0].become_method", diff --git a/test/schemas/negative_test/playbooks/tasks/ignore_errors.yml.md b/test/schemas/negative_test/playbooks/tasks/ignore_errors.yml.md index 559a200..abd8968 100644 --- a/test/schemas/negative_test/playbooks/tasks/ignore_errors.yml.md +++ b/test/schemas/negative_test/playbooks/tasks/ignore_errors.yml.md @@ -82,6 +82,7 @@ stdout: ```json { "status": "fail", + "successes": [], "errors": [ { "filename": "negative_test/playbooks/tasks/ignore_errors.yml", @@ -92,6 +93,11 @@ stdout: "path": "$[0]", "message": "'block' is a required property" }, + "best_deep_match": { + "path": "$[0].ignore_errors", + "message": "'should_ignore_errors' is not of type 'boolean'" + }, + "num_sub_errors": 6, "sub_errors": [ { "path": "$[0].ignore_errors", diff --git a/test/schemas/negative_test/playbooks/tasks/invalid_block.yml.md b/test/schemas/negative_test/playbooks/tasks/invalid_block.yml.md index bf4b30e..f952161 100644 --- a/test/schemas/negative_test/playbooks/tasks/invalid_block.yml.md +++ b/test/schemas/negative_test/playbooks/tasks/invalid_block.yml.md @@ -35,6 +35,7 @@ stdout: ```json { "status": "fail", + "successes": [], "errors": [ { "filename": "negative_test/playbooks/tasks/invalid_block.yml", @@ -45,6 +46,11 @@ stdout: "path": "$[0]", "message": "{'block': {}} should not be valid under {'required': ['block']}" }, + "best_deep_match": { + "path": "$[0].block", + "message": "{} is not of type 'array'" + }, + "num_sub_errors": 1, "sub_errors": [ { "path": "$[0].block", diff --git a/test/schemas/negative_test/playbooks/tasks/local_action.yml.md b/test/schemas/negative_test/playbooks/tasks/local_action.yml.md index cf67e7b..0b1151b 100644 --- a/test/schemas/negative_test/playbooks/tasks/local_action.yml.md +++ b/test/schemas/negative_test/playbooks/tasks/local_action.yml.md @@ -40,6 +40,7 @@ stdout: ```json { "status": "fail", + "successes": [], "errors": [ { "filename": "negative_test/playbooks/tasks/local_action.yml", @@ -50,6 +51,11 @@ stdout: "path": "$[0]", "message": "'block' is a required property" }, + "best_deep_match": { + "path": "$[0].local_action", + "message": "[] is not of type 'string', 'object'" + }, + "num_sub_errors": 1, "sub_errors": [ { "path": "$[0]", diff --git a/test/schemas/negative_test/playbooks/tasks/loop.yml.md b/test/schemas/negative_test/playbooks/tasks/loop.yml.md index de8277f..45a1908 100644 --- a/test/schemas/negative_test/playbooks/tasks/loop.yml.md +++ b/test/schemas/negative_test/playbooks/tasks/loop.yml.md @@ -40,6 +40,7 @@ stdout: ```json { "status": "fail", + "successes": [], "errors": [ { "filename": "negative_test/playbooks/tasks/loop.yml", @@ -50,6 +51,11 @@ stdout: "path": "$[0]", "message": "'block' is a required property" }, + "best_deep_match": { + "path": "$[0].loop", + "message": "{} is not of type 'string', 'array'" + }, + "num_sub_errors": 1, "sub_errors": [ { "path": "$[0]", diff --git a/test/schemas/negative_test/playbooks/tasks/loop2.yml.md b/test/schemas/negative_test/playbooks/tasks/loop2.yml.md index c36d7c9..e29af19 100644 --- a/test/schemas/negative_test/playbooks/tasks/loop2.yml.md +++ b/test/schemas/negative_test/playbooks/tasks/loop2.yml.md @@ -40,6 +40,7 @@ stdout: ```json { "status": "fail", + "successes": [], "errors": [ { "filename": "negative_test/playbooks/tasks/loop2.yml", @@ -50,6 +51,11 @@ stdout: "path": "$[0]", "message": "'block' is a required property" }, + "best_deep_match": { + "path": "$[0].loop", + "message": "123 is not of type 'string', 'array'" + }, + "num_sub_errors": 1, "sub_errors": [ { "path": "$[0]", diff --git a/test/schemas/negative_test/playbooks/tasks/no_log_number.yml.md b/test/schemas/negative_test/playbooks/tasks/no_log_number.yml.md index 4b9516c..bc85fd7 100644 --- a/test/schemas/negative_test/playbooks/tasks/no_log_number.yml.md +++ b/test/schemas/negative_test/playbooks/tasks/no_log_number.yml.md @@ -100,6 +100,7 @@ stdout: ```json { "status": "fail", + "successes": [], "errors": [ { "filename": "negative_test/playbooks/tasks/no_log_number.yml", @@ -110,6 +111,11 @@ stdout: "path": "$[0]", "message": "'block' is a required property" }, + "best_deep_match": { + "path": "$[0].no_log", + "message": "123 is not of type 'boolean'" + }, + "num_sub_errors": 6, "sub_errors": [ { "path": "$[0].no_log", diff --git a/test/schemas/negative_test/playbooks/tasks/no_log_string.yml.md b/test/schemas/negative_test/playbooks/tasks/no_log_string.yml.md index 6742175..ec88c66 100644 --- a/test/schemas/negative_test/playbooks/tasks/no_log_string.yml.md +++ b/test/schemas/negative_test/playbooks/tasks/no_log_string.yml.md @@ -82,6 +82,7 @@ stdout: ```json { "status": "fail", + "successes": [], "errors": [ { "filename": "negative_test/playbooks/tasks/no_log_string.yml", @@ -92,6 +93,11 @@ stdout: "path": "$[0]", "message": "'block' is a required property" }, + "best_deep_match": { + "path": "$[0].no_log", + "message": "'some_var' is not of type 'boolean'" + }, + "num_sub_errors": 6, "sub_errors": [ { "path": "$[0].no_log", diff --git a/test/schemas/negative_test/playbooks/tasks/tags-mapping.yml.md b/test/schemas/negative_test/playbooks/tasks/tags-mapping.yml.md index d860605..998d783 100644 --- a/test/schemas/negative_test/playbooks/tasks/tags-mapping.yml.md +++ b/test/schemas/negative_test/playbooks/tasks/tags-mapping.yml.md @@ -78,6 +78,7 @@ stdout: ```json { "status": "fail", + "successes": [], "errors": [ { "filename": "negative_test/playbooks/tasks/tags-mapping.yml", @@ -88,6 +89,11 @@ stdout: "path": "$[0]", "message": "'block' is a required property" }, + "best_deep_match": { + "path": "$[0].tags", + "message": "{} is not of type 'string'" + }, + "num_sub_errors": 6, "sub_errors": [ { "path": "$[0].tags", diff --git a/test/schemas/negative_test/playbooks/tasks/tags-string.yml.md b/test/schemas/negative_test/playbooks/tasks/tags-string.yml.md index 0bb7ed0..cdf421d 100644 --- a/test/schemas/negative_test/playbooks/tasks/tags-string.yml.md +++ b/test/schemas/negative_test/playbooks/tasks/tags-string.yml.md @@ -78,6 +78,7 @@ stdout: ```json { "status": "fail", + "successes": [], "errors": [ { "filename": "negative_test/playbooks/tasks/tags-string.yml", @@ -88,6 +89,11 @@ stdout: "path": "$[0]", "message": "'block' is a required property" }, + "best_deep_match": { + "path": "$[0].tags", + "message": "123 is not of type 'string'" + }, + "num_sub_errors": 6, "sub_errors": [ { "path": "$[0].tags", diff --git a/test/schemas/negative_test/playbooks/tasks/when_integer.yml.md b/test/schemas/negative_test/playbooks/tasks/when_integer.yml.md index bc59cc4..8acb890 100644 --- a/test/schemas/negative_test/playbooks/tasks/when_integer.yml.md +++ b/test/schemas/negative_test/playbooks/tasks/when_integer.yml.md @@ -100,6 +100,7 @@ stdout: ```json { "status": "fail", + "successes": [], "errors": [ { "filename": "negative_test/playbooks/tasks/when_integer.yml", @@ -110,6 +111,11 @@ stdout: "path": "$[0]", "message": "'block' is a required property" }, + "best_deep_match": { + "path": "$[0].when", + "message": "123 is not of type 'boolean'" + }, + "num_sub_errors": 8, "sub_errors": [ { "path": "$[0].when", diff --git a/test/schemas/negative_test/playbooks/tasks/when_object.yml.md b/test/schemas/negative_test/playbooks/tasks/when_object.yml.md index 6c28d0c..4ea653b 100644 --- a/test/schemas/negative_test/playbooks/tasks/when_object.yml.md +++ b/test/schemas/negative_test/playbooks/tasks/when_object.yml.md @@ -100,6 +100,7 @@ stdout: ```json { "status": "fail", + "successes": [], "errors": [ { "filename": "negative_test/playbooks/tasks/when_object.yml", @@ -110,6 +111,11 @@ stdout: "path": "$[0]", "message": "'block' is a required property" }, + "best_deep_match": { + "path": "$[0].when", + "message": "{} is not of type 'boolean'" + }, + "num_sub_errors": 8, "sub_errors": [ { "path": "$[0].when", diff --git a/test/schemas/negative_test/playbooks/tasks/with_items_boolean.yml.md b/test/schemas/negative_test/playbooks/tasks/with_items_boolean.yml.md index ffc8ef8..92340d2 100644 --- a/test/schemas/negative_test/playbooks/tasks/with_items_boolean.yml.md +++ b/test/schemas/negative_test/playbooks/tasks/with_items_boolean.yml.md @@ -53,6 +53,7 @@ stdout: ```json { "status": "fail", + "successes": [], "errors": [ { "filename": "negative_test/playbooks/tasks/with_items_boolean.yml", @@ -63,6 +64,11 @@ stdout: "path": "$[0]", "message": "'block' is a required property" }, + "best_deep_match": { + "path": "$[0].with_items", + "message": "True is not of type 'string'" + }, + "num_sub_errors": 3, "sub_errors": [ { "path": "$[0]", diff --git a/test/schemas/negative_test/playbooks/tasks/with_items_untemplated_string.yml.md b/test/schemas/negative_test/playbooks/tasks/with_items_untemplated_string.yml.md index 158b0ee..8ecd7bf 100644 --- a/test/schemas/negative_test/playbooks/tasks/with_items_untemplated_string.yml.md +++ b/test/schemas/negative_test/playbooks/tasks/with_items_untemplated_string.yml.md @@ -53,6 +53,7 @@ stdout: ```json { "status": "fail", + "successes": [], "errors": [ { "filename": "negative_test/playbooks/tasks/with_items_untemplated_string.yml", @@ -63,6 +64,11 @@ stdout: "path": "$[0]", "message": "'block' is a required property" }, + "best_deep_match": { + "path": "$[0].with_items", + "message": "'foobar' does not match '^\\\\{[\\\\{%](.|[\\r\\n])*[\\\\}%]\\\\}$'" + }, + "num_sub_errors": 3, "sub_errors": [ { "path": "$[0]", diff --git a/test/schemas/negative_test/playbooks/var_files_list_number.yml.md b/test/schemas/negative_test/playbooks/var_files_list_number.yml.md index e915593..c47cc1b 100644 --- a/test/schemas/negative_test/playbooks/var_files_list_number.yml.md +++ b/test/schemas/negative_test/playbooks/var_files_list_number.yml.md @@ -93,6 +93,7 @@ stdout: ```json { "status": "fail", + "successes": [], "errors": [ { "filename": "negative_test/playbooks/var_files_list_number.yml", @@ -103,6 +104,11 @@ stdout: "path": "$[0]", "message": "'hosts' does not match any of the regexes: '^(ansible\\\\.builtin\\\\.)?import_playbook$', 'name', 'tags', 'vars', 'when'" }, + "best_deep_match": { + "path": "$[0].vars_files[0]", + "message": "0 is not of type 'string'" + }, + "num_sub_errors": 7, "sub_errors": [ { "path": "$[0]", diff --git a/test/schemas/negative_test/playbooks/var_files_list_of_list_number.yml.md b/test/schemas/negative_test/playbooks/var_files_list_of_list_number.yml.md index 3494498..2f9b9cb 100644 --- a/test/schemas/negative_test/playbooks/var_files_list_of_list_number.yml.md +++ b/test/schemas/negative_test/playbooks/var_files_list_of_list_number.yml.md @@ -102,6 +102,7 @@ stdout: ```json { "status": "fail", + "successes": [], "errors": [ { "filename": "negative_test/playbooks/var_files_list_of_list_number.yml", @@ -112,6 +113,11 @@ stdout: "path": "$[0]", "message": "'hosts' does not match any of the regexes: '^(ansible\\\\.builtin\\\\.)?import_playbook$', 'name', 'tags', 'vars', 'when'" }, + "best_deep_match": { + "path": "$[0].vars_files[0][0]", + "message": "0 is not of type 'string'" + }, + "num_sub_errors": 8, "sub_errors": [ { "path": "$[0]", diff --git a/test/schemas/negative_test/playbooks/var_files_number.yml.md b/test/schemas/negative_test/playbooks/var_files_number.yml.md index fa97e7e..f121b09 100644 --- a/test/schemas/negative_test/playbooks/var_files_number.yml.md +++ b/test/schemas/negative_test/playbooks/var_files_number.yml.md @@ -79,6 +79,7 @@ stdout: ```json { "status": "fail", + "successes": [], "errors": [ { "filename": "negative_test/playbooks/var_files_number.yml", @@ -89,6 +90,11 @@ stdout: "path": "$[0]", "message": "'hosts' does not match any of the regexes: '^(ansible\\\\.builtin\\\\.)?import_playbook$', 'name', 'tags', 'vars', 'when'" }, + "best_deep_match": { + "path": "$[0].vars_files", + "message": "0 is not of type 'object'" + }, + "num_sub_errors": 5, "sub_errors": [ { "path": "$[0]", diff --git a/test/schemas/negative_test/playbooks/vars/asterisk.yml.md b/test/schemas/negative_test/playbooks/vars/asterisk.yml.md index 1ea9a98..9204de1 100644 --- a/test/schemas/negative_test/playbooks/vars/asterisk.yml.md +++ b/test/schemas/negative_test/playbooks/vars/asterisk.yml.md @@ -46,6 +46,7 @@ stdout: ```json { "status": "fail", + "successes": [], "errors": [ { "filename": "negative_test/playbooks/vars/asterisk.yml", @@ -56,6 +57,11 @@ stdout: "path": "$", "message": "{'*foo': '...'} is not of type 'string'" }, + "best_deep_match": { + "path": "$", + "message": "'*foo' does not match any of the regexes: '^(?!(False|None|True|and|any_errors_fatal|as|assert|async|await|become|become_exe|become_flags|become_method|become_user|break|check_mode|class|collections|connection|continue|debugger|def|del|diff|elif|else|environment|except|fact_path|finally|for|force_handlers|from|gather_facts|gather_subset|gather_timeout|global|handlers|hosts|if|ignore_errors|ignore_unreachable|import|in|is|lambda|max_fail_percentage|module_defaults|name|no_log|nonlocal|not|or|order|pass|port|post_tasks|pre_tasks|raise|remote_user|return|roles|run_once|serial|strategy|tags|tasks|throttle|timeout|try|vars|vars_files|vars_prompt|while|with|yield)$)[a-zA-Z_][\\\\w]*$'" + }, + "num_sub_errors": 2, "sub_errors": [ { "path": "$", diff --git a/test/schemas/negative_test/playbooks/vars/dash-in-var-name.yml.md b/test/schemas/negative_test/playbooks/vars/dash-in-var-name.yml.md index b862e69..6e0a83b 100644 --- a/test/schemas/negative_test/playbooks/vars/dash-in-var-name.yml.md +++ b/test/schemas/negative_test/playbooks/vars/dash-in-var-name.yml.md @@ -46,6 +46,7 @@ stdout: ```json { "status": "fail", + "successes": [], "errors": [ { "filename": "negative_test/playbooks/vars/dash-in-var-name.yml", @@ -56,6 +57,11 @@ stdout: "path": "$", "message": "{'foo-bar': '...'} is not of type 'string'" }, + "best_deep_match": { + "path": "$", + "message": "'foo-bar' does not match any of the regexes: '^(?!(False|None|True|and|any_errors_fatal|as|assert|async|await|become|become_exe|become_flags|become_method|become_user|break|check_mode|class|collections|connection|continue|debugger|def|del|diff|elif|else|environment|except|fact_path|finally|for|force_handlers|from|gather_facts|gather_subset|gather_timeout|global|handlers|hosts|if|ignore_errors|ignore_unreachable|import|in|is|lambda|max_fail_percentage|module_defaults|name|no_log|nonlocal|not|or|order|pass|port|post_tasks|pre_tasks|raise|remote_user|return|roles|run_once|serial|strategy|tags|tasks|throttle|timeout|try|vars|vars_files|vars_prompt|while|with|yield)$)[a-zA-Z_][\\\\w]*$'" + }, + "num_sub_errors": 2, "sub_errors": [ { "path": "$", diff --git a/test/schemas/negative_test/playbooks/vars/list.yml.md b/test/schemas/negative_test/playbooks/vars/list.yml.md index e2c9bf5..82f599a 100644 --- a/test/schemas/negative_test/playbooks/vars/list.yml.md +++ b/test/schemas/negative_test/playbooks/vars/list.yml.md @@ -46,6 +46,7 @@ stdout: ```json { "status": "fail", + "successes": [], "errors": [ { "filename": "negative_test/playbooks/vars/list.yml", @@ -56,6 +57,11 @@ stdout: "path": "$", "message": "['foo', 'bar'] is not of type 'object'" }, + "best_deep_match": { + "path": "$", + "message": "['foo', 'bar'] is not of type 'object'" + }, + "num_sub_errors": 2, "sub_errors": [ { "path": "$", diff --git a/test/schemas/negative_test/playbooks/vars/numeric-var-name.yml.md b/test/schemas/negative_test/playbooks/vars/numeric-var-name.yml.md index 7ddcff6..9f15015 100644 --- a/test/schemas/negative_test/playbooks/vars/numeric-var-name.yml.md +++ b/test/schemas/negative_test/playbooks/vars/numeric-var-name.yml.md @@ -46,6 +46,7 @@ stdout: ```json { "status": "fail", + "successes": [], "errors": [ { "filename": "negative_test/playbooks/vars/numeric-var-name.yml", @@ -56,6 +57,11 @@ stdout: "path": "$", "message": "{'12': '...'} is not of type 'string'" }, + "best_deep_match": { + "path": "$", + "message": "'12' does not match any of the regexes: '^(?!(False|None|True|and|any_errors_fatal|as|assert|async|await|become|become_exe|become_flags|become_method|become_user|break|check_mode|class|collections|connection|continue|debugger|def|del|diff|elif|else|environment|except|fact_path|finally|for|force_handlers|from|gather_facts|gather_subset|gather_timeout|global|handlers|hosts|if|ignore_errors|ignore_unreachable|import|in|is|lambda|max_fail_percentage|module_defaults|name|no_log|nonlocal|not|or|order|pass|port|post_tasks|pre_tasks|raise|remote_user|return|roles|run_once|serial|strategy|tags|tasks|throttle|timeout|try|vars|vars_files|vars_prompt|while|with|yield)$)[a-zA-Z_][\\\\w]*$'" + }, + "num_sub_errors": 2, "sub_errors": [ { "path": "$", diff --git a/test/schemas/negative_test/playbooks/vars/play-keyword.yml.md b/test/schemas/negative_test/playbooks/vars/play-keyword.yml.md index 6b88b2a..d463c1c 100644 --- a/test/schemas/negative_test/playbooks/vars/play-keyword.yml.md +++ b/test/schemas/negative_test/playbooks/vars/play-keyword.yml.md @@ -46,6 +46,7 @@ stdout: ```json { "status": "fail", + "successes": [], "errors": [ { "filename": "negative_test/playbooks/vars/play-keyword.yml", @@ -56,6 +57,11 @@ stdout: "path": "$", "message": "{'environment': '...'} is not of type 'string'" }, + "best_deep_match": { + "path": "$", + "message": "'environment' does not match any of the regexes: '^(?!(False|None|True|and|any_errors_fatal|as|assert|async|await|become|become_exe|become_flags|become_method|become_user|break|check_mode|class|collections|connection|continue|debugger|def|del|diff|elif|else|environment|except|fact_path|finally|for|force_handlers|from|gather_facts|gather_subset|gather_timeout|global|handlers|hosts|if|ignore_errors|ignore_unreachable|import|in|is|lambda|max_fail_percentage|module_defaults|name|no_log|nonlocal|not|or|order|pass|port|post_tasks|pre_tasks|raise|remote_user|return|roles|run_once|serial|strategy|tags|tasks|throttle|timeout|try|vars|vars_files|vars_prompt|while|with|yield)$)[a-zA-Z_][\\\\w]*$'" + }, + "num_sub_errors": 2, "sub_errors": [ { "path": "$", diff --git a/test/schemas/negative_test/playbooks/vars/python-keyword.yml.md b/test/schemas/negative_test/playbooks/vars/python-keyword.yml.md index ca42f74..667364c 100644 --- a/test/schemas/negative_test/playbooks/vars/python-keyword.yml.md +++ b/test/schemas/negative_test/playbooks/vars/python-keyword.yml.md @@ -55,6 +55,7 @@ stdout: ```json { "status": "fail", + "successes": [], "errors": [ { "filename": "negative_test/playbooks/vars/python-keyword.yml", @@ -65,6 +66,11 @@ stdout: "path": "$", "message": "{'async': '...', 'lambda': '...'} is not of type 'string'" }, + "best_deep_match": { + "path": "$", + "message": "'async', 'lambda' do not match any of the regexes: '^(?!(False|None|True|and|any_errors_fatal|as|assert|async|await|become|become_exe|become_flags|become_method|become_user|break|check_mode|class|collections|connection|continue|debugger|def|del|diff|elif|else|environment|except|fact_path|finally|for|force_handlers|from|gather_facts|gather_subset|gather_timeout|global|handlers|hosts|if|ignore_errors|ignore_unreachable|import|in|is|lambda|max_fail_percentage|module_defaults|name|no_log|nonlocal|not|or|order|pass|port|post_tasks|pre_tasks|raise|remote_user|return|roles|run_once|serial|strategy|tags|tasks|throttle|timeout|try|vars|vars_files|vars_prompt|while|with|yield)$)[a-zA-Z_][\\\\w]*$'" + }, + "num_sub_errors": 2, "sub_errors": [ { "path": "$", diff --git a/test/schemas/negative_test/playbooks/vars/varname-numeric-prefix.yml.md b/test/schemas/negative_test/playbooks/vars/varname-numeric-prefix.yml.md index 8b73b0a..620a03c 100644 --- a/test/schemas/negative_test/playbooks/vars/varname-numeric-prefix.yml.md +++ b/test/schemas/negative_test/playbooks/vars/varname-numeric-prefix.yml.md @@ -46,6 +46,7 @@ stdout: ```json { "status": "fail", + "successes": [], "errors": [ { "filename": "negative_test/playbooks/vars/varname-numeric-prefix.yml", @@ -56,6 +57,11 @@ stdout: "path": "$", "message": "{'5foo': '...'} is not of type 'string'" }, + "best_deep_match": { + "path": "$", + "message": "'5foo' does not match any of the regexes: '^(?!(False|None|True|and|any_errors_fatal|as|assert|async|await|become|become_exe|become_flags|become_method|become_user|break|check_mode|class|collections|connection|continue|debugger|def|del|diff|elif|else|environment|except|fact_path|finally|for|force_handlers|from|gather_facts|gather_subset|gather_timeout|global|handlers|hosts|if|ignore_errors|ignore_unreachable|import|in|is|lambda|max_fail_percentage|module_defaults|name|no_log|nonlocal|not|or|order|pass|port|post_tasks|pre_tasks|raise|remote_user|return|roles|run_once|serial|strategy|tags|tasks|throttle|timeout|try|vars|vars_files|vars_prompt|while|with|yield)$)[a-zA-Z_][\\\\w]*$'" + }, + "num_sub_errors": 2, "sub_errors": [ { "path": "$", diff --git a/test/schemas/negative_test/playbooks/vas_prompt.yml.md b/test/schemas/negative_test/playbooks/vas_prompt.yml.md index d2d809d..ca1863f 100644 --- a/test/schemas/negative_test/playbooks/vas_prompt.yml.md +++ b/test/schemas/negative_test/playbooks/vas_prompt.yml.md @@ -75,6 +75,7 @@ stdout: ```json { "status": "fail", + "successes": [], "errors": [ { "filename": "negative_test/playbooks/vas_prompt.yml", @@ -85,6 +86,11 @@ stdout: "path": "$[0]", "message": "'hosts' does not match any of the regexes: '^(ansible\\\\.builtin\\\\.)?import_playbook$', 'name', 'tags', 'vars', 'when'" }, + "best_deep_match": { + "path": "$[0].vars_prompt[0]", + "message": "Additional properties are not allowed ('tags' was unexpected)" + }, + "num_sub_errors": 5, "sub_errors": [ { "path": "$[0]", diff --git a/test/schemas/negative_test/playbooks/when.yml.md b/test/schemas/negative_test/playbooks/when.yml.md index 4c23dcb..125e9d6 100644 --- a/test/schemas/negative_test/playbooks/when.yml.md +++ b/test/schemas/negative_test/playbooks/when.yml.md @@ -195,6 +195,7 @@ stdout: ```json { "status": "fail", + "successes": [], "errors": [ { "filename": "negative_test/playbooks/when.yml", @@ -205,6 +206,11 @@ stdout: "path": "$[0]", "message": "'gather_facts', 'hosts', 'tasks' do not match any of the regexes: '^(ansible\\\\.builtin\\\\.)?import_playbook$', 'name', 'tags', 'vars', 'when'" }, + "best_deep_match": { + "path": "$[0].tasks[0].when[1]", + "message": "123 is not of type 'boolean'" + }, + "num_sub_errors": 17, "sub_errors": [ { "path": "$[0]", diff --git a/test/schemas/negative_test/reqs3/meta/requirements.yml.md b/test/schemas/negative_test/reqs3/meta/requirements.yml.md index 5de6643..d0d7623 100644 --- a/test/schemas/negative_test/reqs3/meta/requirements.yml.md +++ b/test/schemas/negative_test/reqs3/meta/requirements.yml.md @@ -62,6 +62,7 @@ stdout: ```json { "status": "fail", + "successes": [], "errors": [ { "filename": "negative_test/reqs3/meta/requirements.yml", @@ -72,6 +73,11 @@ stdout: "path": "$", "message": "{'foo': 'bar'} is not of type 'array'" }, + "best_deep_match": { + "path": "$", + "message": "{'foo': 'bar'} is not of type 'array'" + }, + "num_sub_errors": 4, "sub_errors": [ { "path": "$", diff --git a/test/schemas/negative_test/roles/meta/argument_specs.yml.md b/test/schemas/negative_test/roles/meta/argument_specs.yml.md index 34da932..e06b00d 100644 --- a/test/schemas/negative_test/roles/meta/argument_specs.yml.md +++ b/test/schemas/negative_test/roles/meta/argument_specs.yml.md @@ -21,6 +21,7 @@ stdout: ```json { "status": "fail", + "successes": [], "errors": [ { "filename": "negative_test/roles/meta/argument_specs.yml", diff --git a/test/schemas/negative_test/roles/meta/main.yml.md b/test/schemas/negative_test/roles/meta/main.yml.md index 2c9e99b..5ed52bf 100644 --- a/test/schemas/negative_test/roles/meta/main.yml.md +++ b/test/schemas/negative_test/roles/meta/main.yml.md @@ -39,6 +39,7 @@ stdout: ```json { "status": "fail", + "successes": [], "errors": [ { "filename": "negative_test/roles/meta/main.yml", diff --git a/test/schemas/negative_test/roles/meta_invalid_collection/meta/main.yml.md b/test/schemas/negative_test/roles/meta_invalid_collection/meta/main.yml.md index 1b8dcd0..bea80be 100644 --- a/test/schemas/negative_test/roles/meta_invalid_collection/meta/main.yml.md +++ b/test/schemas/negative_test/roles/meta_invalid_collection/meta/main.yml.md @@ -21,6 +21,7 @@ stdout: ```json { "status": "fail", + "successes": [], "errors": [ { "filename": "negative_test/roles/meta_invalid_collection/meta/main.yml", diff --git a/test/schemas/negative_test/roles/meta_invalid_collections/meta/main.yml.md b/test/schemas/negative_test/roles/meta_invalid_collections/meta/main.yml.md index 5d775f0..722b549 100644 --- a/test/schemas/negative_test/roles/meta_invalid_collections/meta/main.yml.md +++ b/test/schemas/negative_test/roles/meta_invalid_collections/meta/main.yml.md @@ -21,6 +21,7 @@ stdout: ```json { "status": "fail", + "successes": [], "errors": [ { "filename": "negative_test/roles/meta_invalid_collections/meta/main.yml", diff --git a/test/schemas/negative_test/roles/meta_invalid_role_namespace/meta/main.yml.md b/test/schemas/negative_test/roles/meta_invalid_role_namespace/meta/main.yml.md index ad7e9d3..73369a2 100644 --- a/test/schemas/negative_test/roles/meta_invalid_role_namespace/meta/main.yml.md +++ b/test/schemas/negative_test/roles/meta_invalid_role_namespace/meta/main.yml.md @@ -19,15 +19,6 @@ "failingKeyword": "then" }, "schemaPath": "#/allOf/0/if" - }, - { - "instancePath": "/galaxy_info/namespace", - "keyword": "pattern", - "message": "must match pattern \"^[a-z][a-z0-9_]+$\"", - "params": { - "pattern": "^[a-z][a-z0-9_]+$" - }, - "schemaPath": "#/properties/namespace/pattern" } ] ``` @@ -39,18 +30,13 @@ stdout: ```json { "status": "fail", + "successes": [], "errors": [ { "filename": "negative_test/roles/meta_invalid_role_namespace/meta/main.yml", "path": "$.galaxy_info", "message": "'author' is a required property", "has_sub_errors": false - }, - { - "filename": "negative_test/roles/meta_invalid_role_namespace/meta/main.yml", - "path": "$.galaxy_info.namespace", - "message": "'foo-bar' does not match '^[a-z][a-z0-9_]+$'", - "has_sub_errors": false } ], "parse_errors": [] diff --git a/test/schemas/negative_test/roles/role_with_bad_deps_in_meta/meta/main.yml b/test/schemas/negative_test/roles/role_with_bad_deps_in_meta/meta/main.yml index 81d4d3d..0e94325 100644 --- a/test/schemas/negative_test/roles/role_with_bad_deps_in_meta/meta/main.yml +++ b/test/schemas/negative_test/roles/role_with_bad_deps_in_meta/meta/main.yml @@ -11,3 +11,4 @@ galaxy_info: dependencies: - version: foo # invalid, should have at least name, role or src properties + - 1234 # invalid, needs to be a string diff --git a/test/schemas/negative_test/roles/role_with_bad_deps_in_meta/meta/main.yml.md b/test/schemas/negative_test/roles/role_with_bad_deps_in_meta/meta/main.yml.md index f09b1ac..a518b18 100644 --- a/test/schemas/negative_test/roles/role_with_bad_deps_in_meta/meta/main.yml.md +++ b/test/schemas/negative_test/roles/role_with_bad_deps_in_meta/meta/main.yml.md @@ -4,6 +4,15 @@ [ { "instancePath": "/dependencies/0", + "keyword": "type", + "message": "must be string", + "params": { + "type": "string" + }, + "schemaPath": "#/properties/dependencies/items/anyOf/0/type" + }, + { + "instancePath": "/dependencies/0", "keyword": "required", "message": "must have required property 'role'", "params": { @@ -37,6 +46,38 @@ "schemaPath": "#/anyOf" }, { + "instancePath": "/dependencies/0", + "keyword": "anyOf", + "message": "must match a schema in anyOf", + "params": {}, + "schemaPath": "#/properties/dependencies/items/anyOf" + }, + { + "instancePath": "/dependencies/1", + "keyword": "type", + "message": "must be string", + "params": { + "type": "string" + }, + "schemaPath": "#/properties/dependencies/items/anyOf/0/type" + }, + { + "instancePath": "/dependencies/1", + "keyword": "type", + "message": "must be object", + "params": { + "type": "object" + }, + "schemaPath": "#/type" + }, + { + "instancePath": "/dependencies/1", + "keyword": "anyOf", + "message": "must match a schema in anyOf", + "params": {}, + "schemaPath": "#/properties/dependencies/items/anyOf" + }, + { "instancePath": "/galaxy_info", "keyword": "required", "message": "must have required property 'author'", @@ -64,6 +105,7 @@ stdout: ```json { "status": "fail", + "successes": [], "errors": [ { "filename": "negative_test/roles/role_with_bad_deps_in_meta/meta/main.yml", @@ -72,11 +114,24 @@ stdout: "has_sub_errors": true, "best_match": { "path": "$.dependencies[0]", - "message": "'role' is a required property" + "message": "{'version': 'foo'} is not of type 'string'" + }, + "best_deep_match": { + "path": "$.dependencies[0]", + "message": "{'version': 'foo'} is not of type 'string'" }, + "num_sub_errors": 4, "sub_errors": [ { "path": "$.dependencies[0]", + "message": "{'version': 'foo'} is not of type 'string'" + }, + { + "path": "$.dependencies[0]", + "message": "{'version': 'foo'} is not valid under any of the given schemas" + }, + { + "path": "$.dependencies[0]", "message": "'role' is a required property" }, { @@ -91,6 +146,31 @@ stdout: }, { "filename": "negative_test/roles/role_with_bad_deps_in_meta/meta/main.yml", + "path": "$.dependencies[1]", + "message": "1234 is not valid under any of the given schemas", + "has_sub_errors": true, + "best_match": { + "path": "$.dependencies[1]", + "message": "1234 is not of type 'string'" + }, + "best_deep_match": { + "path": "$.dependencies[1]", + "message": "1234 is not of type 'string'" + }, + "num_sub_errors": 1, + "sub_errors": [ + { + "path": "$.dependencies[1]", + "message": "1234 is not of type 'string'" + }, + { + "path": "$.dependencies[1]", + "message": "1234 is not of type 'object'" + } + ] + }, + { + "filename": "negative_test/roles/role_with_bad_deps_in_meta/meta/main.yml", "path": "$.galaxy_info", "message": "'author' is a required property", "has_sub_errors": false diff --git a/test/schemas/package-lock.json b/test/schemas/package-lock.json index 3745a97..52bee86 100644 --- a/test/schemas/package-lock.json +++ b/test/schemas/package-lock.json @@ -5,22 +5,22 @@ "packages": { "": { "dependencies": { - "ajv-formats": "^2.1.1", + "ajv-formats": "^3.0.1", "js-yaml": "^4.1.0", "safe-stable-stringify": "^2.4.3", - "ts-node": "^10.9.1", - "vscode-json-languageservice": "^5.3.5" + "ts-node": "^10.9.2", + "vscode-json-languageservice": "^5.3.11" }, "devDependencies": { - "@types/chai": "^4.3.5", - "@types/js-yaml": "^4.0.5", + "@types/chai": "^4.3.16", + "@types/js-yaml": "^4.0.9", "@types/minimatch": "^5.1.2", - "@types/mocha": "^10.0.1", - "@types/node": "^20.3.1", - "chai": "^4.3.7", - "minimatch": "^9.0.1", - "mocha": "^10.2.0", - "typescript": "^5.1.3" + "@types/mocha": "^10.0.6", + "@types/node": "^20.12.13", + "chai": "^5.1.1", + "minimatch": "^9.0.4", + "mocha": "^10.4.0", + "typescript": "^5.4.5" } }, "node_modules/@cspotcode/source-map-support": { @@ -77,15 +77,15 @@ "integrity": "sha512-eZxlbI8GZscaGS7kkc/trHTT5xgrjH3/1n2JDwusC9iahPKWMRvRjJSAN5mCXviuTGQ/lHnhvv8Q1YTpnfz9gA==" }, "node_modules/@types/chai": { - "version": "4.3.5", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.5.tgz", - "integrity": "sha512-mEo1sAde+UCE6b2hxn332f1g1E8WfYRu6p5SvTKr2ZKC1f7gFJXk4h5PyGP9Dt6gCaG8y8XhwnXWC6Iy2cmBng==", + "version": "4.3.16", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.16.tgz", + "integrity": "sha512-PatH4iOdyh3MyWtmHVFXLWCCIhUbopaltqddG9BzB+gMIzee2MJrvd+jouii9Z3wzQJruGWAm7WOMjgfG8hQlQ==", "dev": true }, "node_modules/@types/js-yaml": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.5.tgz", - "integrity": "sha512-FhpRzf927MNQdRZP0J5DLIdTXhjLYzeUTmLAu69mnVksLH9CJY3IuSeEgbKUki7GQZm0WqDkGzyxju2EZGD2wA==", + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", "dev": true }, "node_modules/@types/minimatch": { @@ -95,20 +95,23 @@ "dev": true }, "node_modules/@types/mocha": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.1.tgz", - "integrity": "sha512-/fvYntiO1GeICvqbQ3doGDIP97vWmvFt83GKguJ6prmQM2iXZfFcq6YE8KteFyRtX2/h5Hf91BYvPodJKFYv5Q==", + "version": "10.0.6", + "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.6.tgz", + "integrity": "sha512-dJvrYWxP/UcXm36Qn36fxhUKu8A/xMRXVT2cliFF1Z7UA9liG5Psj3ezNSZw+5puH2czDXRLcXQxf8JbJt0ejg==", "dev": true }, "node_modules/@types/node": { - "version": "20.3.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.3.1.tgz", - "integrity": "sha512-EhcH/wvidPy1WeML3TtYFGR83UzjxeWRen9V402T8aUGYsCHOmfoisV3ZSg03gAFIbLq8TnWOJ0f4cALtnSEUg==" + "version": "20.12.13", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.13.tgz", + "integrity": "sha512-gBGeanV41c1L171rR7wjbMiEpEI/l5XFQdLLfhr/REwpgDy/4U8y89+i8kRiLzDyZdOkXh+cRaTetUnCYutoXA==", + "dependencies": { + "undici-types": "~5.26.4" + } }, "node_modules/@vscode/l10n": { - "version": "0.0.13", - "resolved": "https://registry.npmjs.org/@vscode/l10n/-/l10n-0.0.13.tgz", - "integrity": "sha512-A3uY356uOU9nGa+TQIT/i3ziWUgJjVMUrGGXSrtRiTwklyCFjGVWIOHoEIHbJpiyhDkJd9kvIWUOfXK1IkK8XQ==" + "version": "0.0.18", + "resolved": "https://registry.npmjs.org/@vscode/l10n/-/l10n-0.0.18.tgz", + "integrity": "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==" }, "node_modules/acorn": { "version": "8.6.0", @@ -145,9 +148,9 @@ } }, "node_modules/ajv-formats": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", - "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", "dependencies": { "ajv": "^8.0.0" }, @@ -217,12 +220,12 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, "node_modules/assertion-error": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", - "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "dev": true, "engines": { - "node": "*" + "node": ">=12" } }, "node_modules/balanced-match": { @@ -241,22 +244,21 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", "dev": true, "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "balanced-match": "^1.0.0" } }, "node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, "dependencies": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" }, "engines": { "node": ">=8" @@ -281,21 +283,19 @@ } }, "node_modules/chai": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.7.tgz", - "integrity": "sha512-HLnAzZ2iupm25PlN0xFreAlBA5zaBSv3og0DdeGA4Ar6h6rJ3A0rolRUKJhSF2V10GZKDgWF/VmAEsNWjCRB+A==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.1.1.tgz", + "integrity": "sha512-pT1ZgP8rPNqUgieVaEY+ryQr6Q4HXNg8Ei9UnLUrjN4IA7dvQC5JB+/kxVcPNDHyBcc/26CXPkbNzq3qwrOEKA==", "dev": true, "dependencies": { - "assertion-error": "^1.1.0", - "check-error": "^1.0.2", - "deep-eql": "^4.1.2", - "get-func-name": "^2.0.0", - "loupe": "^2.3.1", - "pathval": "^1.1.1", - "type-detect": "^4.0.5" + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" }, "engines": { - "node": ">=4" + "node": ">=12" } }, "node_modules/chalk": { @@ -327,12 +327,12 @@ } }, "node_modules/check-error": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.2.tgz", - "integrity": "sha1-V00xLt2Iu13YkS6Sht1sCu1KrII=", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", + "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", "dev": true, "engines": { - "node": "*" + "node": ">= 16" } }, "node_modules/chokidar": { @@ -391,12 +391,6 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", - "dev": true - }, "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", @@ -438,13 +432,10 @@ } }, "node_modules/deep-eql": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.3.tgz", - "integrity": "sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.1.tgz", + "integrity": "sha512-nwQCf6ne2gez3o1MxWifqkciwt0zhl0LO1/UwVu4uMBuPmflWM4oQ70XMqHqnBJA+nhzncaqL9HVL6KkHJ28lw==", "dev": true, - "dependencies": { - "type-detect": "^4.0.0" - }, "engines": { "node": ">=6" } @@ -491,9 +482,9 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, "dependencies": { "to-regex-range": "^5.0.1" @@ -530,7 +521,7 @@ "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", "dev": true }, "node_modules/fsevents": { @@ -557,29 +548,28 @@ } }, "node_modules/get-func-name": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.0.tgz", - "integrity": "sha1-6td0q+5y4gQJQzoGY2YCPdaIekE=", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", "dev": true, "engines": { "node": "*" } }, "node_modules/glob": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", - "integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", "dev": true, "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" + "minimatch": "^5.0.1", + "once": "^1.3.0" }, "engines": { - "node": "*" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -598,15 +588,15 @@ } }, "node_modules/glob/node_modules/minimatch": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.1.tgz", - "integrity": "sha512-reLxBcKUPNBnc/sVtAbxgRVFSegoGeLaSjmphNhcwcolhYLRgtJscn5mRl6YRZNQv40Y7P6JM2YhSIsbL9OB5A==", + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", "dev": true, "dependencies": { - "brace-expansion": "^1.1.7" + "brace-expansion": "^2.0.1" }, "engines": { - "node": "*" + "node": ">=10" } }, "node_modules/has-flag": { @@ -630,7 +620,7 @@ "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "dev": true, "dependencies": { "once": "^1.3.0", @@ -732,9 +722,9 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" }, "node_modules/jsonc-parser": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz", - "integrity": "sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==" + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.1.tgz", + "integrity": "sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA==" }, "node_modules/locate-path": { "version": "6.0.0", @@ -768,12 +758,12 @@ } }, "node_modules/loupe": { - "version": "2.3.4", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.4.tgz", - "integrity": "sha512-OvKfgCC2Ndby6aSTREl5aCCPTNIzlDfQZvZxNUrBrihDhL3xcrYegTblhmEiCrg2kKQz4XsFIaemE5BF4ybSaQ==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.0.tgz", + "integrity": "sha512-qKl+FrLXUhFuHUoDJG7f8P8gEMHq9NFS0c6ghXG1J0rldmZFQZoNVv/vyirE9qwCIhWZDsvEFd1sbFu3GvRQFg==", "dev": true, "dependencies": { - "get-func-name": "^2.0.0" + "get-func-name": "^2.0.1" } }, "node_modules/make-error": { @@ -782,9 +772,9 @@ "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==" }, "node_modules/minimatch": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.1.tgz", - "integrity": "sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==", + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz", + "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==", "dev": true, "dependencies": { "brace-expansion": "^2.0.1" @@ -796,19 +786,10 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/minimatch/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0" - } - }, "node_modules/mocha": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-10.2.0.tgz", - "integrity": "sha512-IDY7fl/BecMwFHzoqF2sg/SHHANeBoMMXFlS9r0OXKDssYE1M5O43wUY/9BVPeIvfH2zmEbBfseqN9gBQZzXkg==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-10.4.0.tgz", + "integrity": "sha512-eqhGB8JKapEYcC4ytX/xrzKforgEc3j1pGlAXVy3eRwrtAy5/nIfT1SvgGzfN0XZZxeLq0aQWkOUAmqIJiv+bA==", "dev": true, "dependencies": { "ansi-colors": "4.1.1", @@ -818,13 +799,12 @@ "diff": "5.0.0", "escape-string-regexp": "4.0.0", "find-up": "5.0.0", - "glob": "7.2.0", + "glob": "8.1.0", "he": "1.2.0", "js-yaml": "4.1.0", "log-symbols": "4.1.0", "minimatch": "5.0.1", "ms": "2.1.3", - "nanoid": "3.3.3", "serialize-javascript": "6.0.0", "strip-json-comments": "3.1.1", "supports-color": "8.1.1", @@ -839,19 +819,6 @@ }, "engines": { "node": ">= 14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mochajs" - } - }, - "node_modules/mocha/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0" } }, "node_modules/mocha/node_modules/minimatch": { @@ -872,18 +839,6 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "dev": true }, - "node_modules/nanoid": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.3.tgz", - "integrity": "sha512-p1sjXuopFs0xg+fPASzQ28agW1oHD7xDsd9Xkf3T15H3c/cifrFHVwrh74PdoklAPi+i7MdRsE47vm2r6JoB+w==", - "dev": true, - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -896,7 +851,7 @@ "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", "dev": true, "dependencies": { "wrappy": "1" @@ -941,22 +896,13 @@ "node": ">=8" } }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/pathval": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", - "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz", + "integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==", "dev": true, "engines": { - "node": "*" + "node": ">= 14.16" } }, "node_modules/picomatch": { @@ -1120,9 +1066,9 @@ } }, "node_modules/ts-node": { - "version": "10.9.1", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.1.tgz", - "integrity": "sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==", + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -1169,19 +1115,10 @@ "node": ">=0.3.1" } }, - "node_modules/type-detect": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", - "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", - "dev": true, - "engines": { - "node": ">=4" - } - }, "node_modules/typescript": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.3.tgz", - "integrity": "sha512-XH627E9vkeqhlZFQuL+UsyAXEnibT0kWR2FWONlr4sTjvxyJYnyefgrkyECLzM5NenmKzRAy2rR/OlYLA1HkZw==", + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", + "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -1190,6 +1127,11 @@ "node": ">=14.17" } }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -1204,31 +1146,31 @@ "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==" }, "node_modules/vscode-json-languageservice": { - "version": "5.3.5", - "resolved": "https://registry.npmjs.org/vscode-json-languageservice/-/vscode-json-languageservice-5.3.5.tgz", - "integrity": "sha512-DasT+bKtpaS2rTPEB4VMROnvO1WES2KD8RZZxXbumnk9sk5wco10VdB6sJgTlsKQN14tHQLZDXuHnSoSAlE8LQ==", + "version": "5.3.11", + "resolved": "https://registry.npmjs.org/vscode-json-languageservice/-/vscode-json-languageservice-5.3.11.tgz", + "integrity": "sha512-WYS72Ymria3dn8ZbjtBbt5K71m05wY1Q6hpXV5JxUT0q75Ts0ljLmnZJAVpx8DjPgYbFD+Z8KHpWh2laKLUCtQ==", "dependencies": { - "@vscode/l10n": "^0.0.13", - "jsonc-parser": "^3.2.0", - "vscode-languageserver-textdocument": "^1.0.8", - "vscode-languageserver-types": "^3.17.3", - "vscode-uri": "^3.0.7" + "@vscode/l10n": "^0.0.18", + "jsonc-parser": "^3.2.1", + "vscode-languageserver-textdocument": "^1.0.11", + "vscode-languageserver-types": "^3.17.5", + "vscode-uri": "^3.0.8" } }, "node_modules/vscode-languageserver-textdocument": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.8.tgz", - "integrity": "sha512-1bonkGqQs5/fxGT5UchTgjGVnfysL0O8v1AYMBjqTbWQTFn721zaPGDYFkOKtfDgFiSgXM3KwaG3FMGfW4Ed9Q==" + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.11.tgz", + "integrity": "sha512-X+8T3GoiwTVlJbicx/sIAF+yuJAqz8VvwJyoMVhwEMoEKE/fkDmrqUgDMyBECcM2A2frVZIUj5HI/ErRXCfOeA==" }, "node_modules/vscode-languageserver-types": { - "version": "3.17.3", - "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.3.tgz", - "integrity": "sha512-SYU4z1dL0PyIMd4Vj8YOqFvHu7Hz/enbWtpfnVbJHU4Nd1YNYx8u0ennumc6h48GQNeOLxmwySmnADouT/AuZA==" + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==" }, "node_modules/vscode-uri": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.0.7.tgz", - "integrity": "sha512-eOpPHogvorZRobNqJGhapa0JdwaxpjVvyBp0QIUMRMSf8ZAlqOdEquKuRmw9Qwu0qXtJIWqFtMkmvJjUZmMjVA==" + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.0.8.tgz", + "integrity": "sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==" }, "node_modules/workerpool": { "version": "6.2.1", @@ -1256,7 +1198,7 @@ "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "dev": true }, "node_modules/y18n": { @@ -1380,15 +1322,15 @@ "integrity": "sha512-eZxlbI8GZscaGS7kkc/trHTT5xgrjH3/1n2JDwusC9iahPKWMRvRjJSAN5mCXviuTGQ/lHnhvv8Q1YTpnfz9gA==" }, "@types/chai": { - "version": "4.3.5", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.5.tgz", - "integrity": "sha512-mEo1sAde+UCE6b2hxn332f1g1E8WfYRu6p5SvTKr2ZKC1f7gFJXk4h5PyGP9Dt6gCaG8y8XhwnXWC6Iy2cmBng==", + "version": "4.3.16", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.16.tgz", + "integrity": "sha512-PatH4iOdyh3MyWtmHVFXLWCCIhUbopaltqddG9BzB+gMIzee2MJrvd+jouii9Z3wzQJruGWAm7WOMjgfG8hQlQ==", "dev": true }, "@types/js-yaml": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.5.tgz", - "integrity": "sha512-FhpRzf927MNQdRZP0J5DLIdTXhjLYzeUTmLAu69mnVksLH9CJY3IuSeEgbKUki7GQZm0WqDkGzyxju2EZGD2wA==", + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", "dev": true }, "@types/minimatch": { @@ -1398,20 +1340,23 @@ "dev": true }, "@types/mocha": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.1.tgz", - "integrity": "sha512-/fvYntiO1GeICvqbQ3doGDIP97vWmvFt83GKguJ6prmQM2iXZfFcq6YE8KteFyRtX2/h5Hf91BYvPodJKFYv5Q==", + "version": "10.0.6", + "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.6.tgz", + "integrity": "sha512-dJvrYWxP/UcXm36Qn36fxhUKu8A/xMRXVT2cliFF1Z7UA9liG5Psj3ezNSZw+5puH2czDXRLcXQxf8JbJt0ejg==", "dev": true }, "@types/node": { - "version": "20.3.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.3.1.tgz", - "integrity": "sha512-EhcH/wvidPy1WeML3TtYFGR83UzjxeWRen9V402T8aUGYsCHOmfoisV3ZSg03gAFIbLq8TnWOJ0f4cALtnSEUg==" + "version": "20.12.13", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.13.tgz", + "integrity": "sha512-gBGeanV41c1L171rR7wjbMiEpEI/l5XFQdLLfhr/REwpgDy/4U8y89+i8kRiLzDyZdOkXh+cRaTetUnCYutoXA==", + "requires": { + "undici-types": "~5.26.4" + } }, "@vscode/l10n": { - "version": "0.0.13", - "resolved": "https://registry.npmjs.org/@vscode/l10n/-/l10n-0.0.13.tgz", - "integrity": "sha512-A3uY356uOU9nGa+TQIT/i3ziWUgJjVMUrGGXSrtRiTwklyCFjGVWIOHoEIHbJpiyhDkJd9kvIWUOfXK1IkK8XQ==" + "version": "0.0.18", + "resolved": "https://registry.npmjs.org/@vscode/l10n/-/l10n-0.0.18.tgz", + "integrity": "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==" }, "acorn": { "version": "8.6.0", @@ -1435,9 +1380,9 @@ } }, "ajv-formats": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", - "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", "requires": { "ajv": "^8.0.0" } @@ -1484,9 +1429,9 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, "assertion-error": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", - "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "dev": true }, "balanced-match": { @@ -1502,22 +1447,21 @@ "dev": true }, "brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", "dev": true, "requires": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "balanced-match": "^1.0.0" } }, "braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, "requires": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" } }, "browser-stdout": { @@ -1533,18 +1477,16 @@ "dev": true }, "chai": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.7.tgz", - "integrity": "sha512-HLnAzZ2iupm25PlN0xFreAlBA5zaBSv3og0DdeGA4Ar6h6rJ3A0rolRUKJhSF2V10GZKDgWF/VmAEsNWjCRB+A==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.1.1.tgz", + "integrity": "sha512-pT1ZgP8rPNqUgieVaEY+ryQr6Q4HXNg8Ei9UnLUrjN4IA7dvQC5JB+/kxVcPNDHyBcc/26CXPkbNzq3qwrOEKA==", "dev": true, "requires": { - "assertion-error": "^1.1.0", - "check-error": "^1.0.2", - "deep-eql": "^4.1.2", - "get-func-name": "^2.0.0", - "loupe": "^2.3.1", - "pathval": "^1.1.1", - "type-detect": "^4.0.5" + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" } }, "chalk": { @@ -1569,9 +1511,9 @@ } }, "check-error": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.2.tgz", - "integrity": "sha1-V00xLt2Iu13YkS6Sht1sCu1KrII=", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", + "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", "dev": true }, "chokidar": { @@ -1616,12 +1558,6 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, - "concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", - "dev": true - }, "create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", @@ -1651,13 +1587,10 @@ "dev": true }, "deep-eql": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.3.tgz", - "integrity": "sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw==", - "dev": true, - "requires": { - "type-detect": "^4.0.0" - } + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.1.tgz", + "integrity": "sha512-nwQCf6ne2gez3o1MxWifqkciwt0zhl0LO1/UwVu4uMBuPmflWM4oQ70XMqHqnBJA+nhzncaqL9HVL6KkHJ28lw==", + "dev": true }, "diff": { "version": "5.0.0", @@ -1689,9 +1622,9 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, "requires": { "to-regex-range": "^5.0.1" @@ -1716,7 +1649,7 @@ "fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", "dev": true }, "fsevents": { @@ -1733,32 +1666,31 @@ "dev": true }, "get-func-name": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.0.tgz", - "integrity": "sha1-6td0q+5y4gQJQzoGY2YCPdaIekE=", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", "dev": true }, "glob": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", - "integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", "dev": true, "requires": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" + "minimatch": "^5.0.1", + "once": "^1.3.0" }, "dependencies": { "minimatch": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.1.tgz", - "integrity": "sha512-reLxBcKUPNBnc/sVtAbxgRVFSegoGeLaSjmphNhcwcolhYLRgtJscn5mRl6YRZNQv40Y7P6JM2YhSIsbL9OB5A==", + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", "dev": true, "requires": { - "brace-expansion": "^1.1.7" + "brace-expansion": "^2.0.1" } } } @@ -1787,7 +1719,7 @@ "inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "dev": true, "requires": { "once": "^1.3.0", @@ -1862,9 +1794,9 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" }, "jsonc-parser": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz", - "integrity": "sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==" + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.1.tgz", + "integrity": "sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA==" }, "locate-path": { "version": "6.0.0", @@ -1886,12 +1818,12 @@ } }, "loupe": { - "version": "2.3.4", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.4.tgz", - "integrity": "sha512-OvKfgCC2Ndby6aSTREl5aCCPTNIzlDfQZvZxNUrBrihDhL3xcrYegTblhmEiCrg2kKQz4XsFIaemE5BF4ybSaQ==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.0.tgz", + "integrity": "sha512-qKl+FrLXUhFuHUoDJG7f8P8gEMHq9NFS0c6ghXG1J0rldmZFQZoNVv/vyirE9qwCIhWZDsvEFd1sbFu3GvRQFg==", "dev": true, "requires": { - "get-func-name": "^2.0.0" + "get-func-name": "^2.0.1" } }, "make-error": { @@ -1900,29 +1832,18 @@ "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==" }, "minimatch": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.1.tgz", - "integrity": "sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==", + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz", + "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==", "dev": true, "requires": { "brace-expansion": "^2.0.1" - }, - "dependencies": { - "brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "requires": { - "balanced-match": "^1.0.0" - } - } } }, "mocha": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-10.2.0.tgz", - "integrity": "sha512-IDY7fl/BecMwFHzoqF2sg/SHHANeBoMMXFlS9r0OXKDssYE1M5O43wUY/9BVPeIvfH2zmEbBfseqN9gBQZzXkg==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-10.4.0.tgz", + "integrity": "sha512-eqhGB8JKapEYcC4ytX/xrzKforgEc3j1pGlAXVy3eRwrtAy5/nIfT1SvgGzfN0XZZxeLq0aQWkOUAmqIJiv+bA==", "dev": true, "requires": { "ansi-colors": "4.1.1", @@ -1932,13 +1853,12 @@ "diff": "5.0.0", "escape-string-regexp": "4.0.0", "find-up": "5.0.0", - "glob": "7.2.0", + "glob": "8.1.0", "he": "1.2.0", "js-yaml": "4.1.0", "log-symbols": "4.1.0", "minimatch": "5.0.1", "ms": "2.1.3", - "nanoid": "3.3.3", "serialize-javascript": "6.0.0", "strip-json-comments": "3.1.1", "supports-color": "8.1.1", @@ -1948,15 +1868,6 @@ "yargs-unparser": "2.0.0" }, "dependencies": { - "brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "requires": { - "balanced-match": "^1.0.0" - } - }, "minimatch": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.0.1.tgz", @@ -1974,12 +1885,6 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "dev": true }, - "nanoid": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.3.tgz", - "integrity": "sha512-p1sjXuopFs0xg+fPASzQ28agW1oHD7xDsd9Xkf3T15H3c/cifrFHVwrh74PdoklAPi+i7MdRsE47vm2r6JoB+w==", - "dev": true - }, "normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -1989,7 +1894,7 @@ "once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", "dev": true, "requires": { "wrappy": "1" @@ -2019,16 +1924,10 @@ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "dev": true }, - "path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", - "dev": true - }, "pathval": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", - "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz", + "integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==", "dev": true }, "picomatch": { @@ -2136,9 +2035,9 @@ } }, "ts-node": { - "version": "10.9.1", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.1.tgz", - "integrity": "sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==", + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "requires": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -2162,16 +2061,15 @@ } } }, - "type-detect": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", - "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", - "dev": true - }, "typescript": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.3.tgz", - "integrity": "sha512-XH627E9vkeqhlZFQuL+UsyAXEnibT0kWR2FWONlr4sTjvxyJYnyefgrkyECLzM5NenmKzRAy2rR/OlYLA1HkZw==" + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", + "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==" + }, + "undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" }, "uri-js": { "version": "4.4.1", @@ -2187,31 +2085,31 @@ "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==" }, "vscode-json-languageservice": { - "version": "5.3.5", - "resolved": "https://registry.npmjs.org/vscode-json-languageservice/-/vscode-json-languageservice-5.3.5.tgz", - "integrity": "sha512-DasT+bKtpaS2rTPEB4VMROnvO1WES2KD8RZZxXbumnk9sk5wco10VdB6sJgTlsKQN14tHQLZDXuHnSoSAlE8LQ==", + "version": "5.3.11", + "resolved": "https://registry.npmjs.org/vscode-json-languageservice/-/vscode-json-languageservice-5.3.11.tgz", + "integrity": "sha512-WYS72Ymria3dn8ZbjtBbt5K71m05wY1Q6hpXV5JxUT0q75Ts0ljLmnZJAVpx8DjPgYbFD+Z8KHpWh2laKLUCtQ==", "requires": { - "@vscode/l10n": "^0.0.13", - "jsonc-parser": "^3.2.0", - "vscode-languageserver-textdocument": "^1.0.8", - "vscode-languageserver-types": "^3.17.3", - "vscode-uri": "^3.0.7" + "@vscode/l10n": "^0.0.18", + "jsonc-parser": "^3.2.1", + "vscode-languageserver-textdocument": "^1.0.11", + "vscode-languageserver-types": "^3.17.5", + "vscode-uri": "^3.0.8" } }, "vscode-languageserver-textdocument": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.8.tgz", - "integrity": "sha512-1bonkGqQs5/fxGT5UchTgjGVnfysL0O8v1AYMBjqTbWQTFn721zaPGDYFkOKtfDgFiSgXM3KwaG3FMGfW4Ed9Q==" + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.11.tgz", + "integrity": "sha512-X+8T3GoiwTVlJbicx/sIAF+yuJAqz8VvwJyoMVhwEMoEKE/fkDmrqUgDMyBECcM2A2frVZIUj5HI/ErRXCfOeA==" }, "vscode-languageserver-types": { - "version": "3.17.3", - "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.3.tgz", - "integrity": "sha512-SYU4z1dL0PyIMd4Vj8YOqFvHu7Hz/enbWtpfnVbJHU4Nd1YNYx8u0ennumc6h48GQNeOLxmwySmnADouT/AuZA==" + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==" }, "vscode-uri": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.0.7.tgz", - "integrity": "sha512-eOpPHogvorZRobNqJGhapa0JdwaxpjVvyBp0QIUMRMSf8ZAlqOdEquKuRmw9Qwu0qXtJIWqFtMkmvJjUZmMjVA==" + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.0.8.tgz", + "integrity": "sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==" }, "workerpool": { "version": "6.2.1", @@ -2233,7 +2131,7 @@ "wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "dev": true }, "y18n": { diff --git a/test/schemas/package.json b/test/schemas/package.json index c318ca0..bc7d264 100644 --- a/test/schemas/package.json +++ b/test/schemas/package.json @@ -1,28 +1,29 @@ { "dependencies": { - "ajv-formats": "^2.1.1", + "ajv-formats": "^3.0.1", "js-yaml": "^4.1.0", "safe-stable-stringify": "^2.4.3", - "ts-node": "^10.9.1", - "vscode-json-languageservice": "^5.3.5" + "ts-node": "^10.9.2", + "vscode-json-languageservice": "^5.3.11" }, "scripts": { - "compile": "tsc -p ./src", + "compile": "tsc", "deps": "npx --yes npm-check-updates -u && npm install --ignore-scripts", "test": "python3 src/rebuild.py && mocha" }, "devDependencies": { - "@types/chai": "^4.3.5", - "@types/js-yaml": "^4.0.5", + "@types/chai": "^4.3.16", + "@types/js-yaml": "^4.0.9", "@types/minimatch": "^5.1.2", - "@types/mocha": "^10.0.1", - "@types/node": "^20.3.1", - "chai": "^4.3.7", - "minimatch": "^9.0.1", - "mocha": "^10.2.0", - "typescript": "^5.1.3" + "@types/mocha": "^10.0.6", + "@types/node": "^20.12.13", + "chai": "^5.1.1", + "minimatch": "^9.0.4", + "mocha": "^10.4.0", + "typescript": "^5.4.5" }, "directories": { "test": "./src" - } + }, + "type": "module" } diff --git a/test/schemas/src/rebuild.py b/test/schemas/src/rebuild.py index 2fab8c0..5eb4807 100644 --- a/test/schemas/src/rebuild.py +++ b/test/schemas/src/rebuild.py @@ -1,4 +1,5 @@ """Utility to generate some complex patterns.""" + import copy import json import keyword @@ -63,11 +64,11 @@ def is_ref_used(obj: Any, ref: str) -> bool: if obj.get("$ref", None) == ref_use: return True for _ in obj.values(): - if isinstance(_, (dict, list)) and is_ref_used(_, ref): + if isinstance(_, dict | list) and is_ref_used(_, ref): return True elif isinstance(obj, list): for _ in obj: - if isinstance(_, (dict, list)) and is_ref_used(_, ref): + if isinstance(_, dict | list) and is_ref_used(_, ref): return True return False @@ -119,16 +120,13 @@ if __name__ == "__main__": for key, value in combined_json["$defs"][subschema].items(): sub_json[key] = value sub_json["$comment"] = "Generated from ansible.json, do not edit." - sub_json[ - "$id" - ] = f"https://raw.githubusercontent.com/ansible/ansible-lint/main/src/ansiblelint/schemas/{subschema}.json" + sub_json["$id"] = ( + f"https://raw.githubusercontent.com/ansible/ansible-lint/main/src/ansiblelint/schemas/{subschema}.json" + ) # Remove all unreferenced ($ref) definitions ($defs) recursively while True: - spare = [] - for k in sub_json["$defs"]: - if not is_ref_used(sub_json, k): - spare.append(k) + spare = [k for k in sub_json["$defs"] if not is_ref_used(sub_json, k)] for k in spare: print(f"{subschema}: deleting unused '{k}' definition") # noqa: T201 del sub_json["$defs"][k] diff --git a/test/schemas/src/schema.spec.ts b/test/schemas/src/schema.spec.ts index b826461..beb6ee2 100644 --- a/test/schemas/src/schema.spec.ts +++ b/test/schemas/src/schema.spec.ts @@ -5,9 +5,7 @@ import { minimatch } from "minimatch"; import yaml from "js-yaml"; import { assert } from "chai"; import stringify from "safe-stable-stringify"; -import { integer } from "vscode-languageserver-types"; -import { exec } from "child_process"; -const spawnSync = require("child_process").spawnSync; +import { spawnSync } from "child_process"; function ansiRegex({ onlyFirst = false } = {}) { const pattern = [ @@ -21,7 +19,7 @@ function ansiRegex({ onlyFirst = false } = {}) { function stripAnsi(data: string) { if (typeof data !== "string") { throw new TypeError( - `Expected a \`string\`, got \`${typeof data}\ = ${data}` + `Expected a \`string\`, got \`${typeof data}\ = ${data}`, ); } return data.replace(ansiRegex(), ""); @@ -57,7 +55,7 @@ describe("schemas under f/", function () { const validator = ajv.compile(schema_json); if (schema_json.examples == undefined) { console.error( - `Schema file ${schema_file} is missing an examples key that we need for documenting file matching patterns.` + `Schema file ${schema_file} is missing an examples key that we need for documenting file matching patterns.`, ); return process.exit(1); } @@ -67,7 +65,7 @@ describe("schemas under f/", function () { it(`linting ${test_file} using ${schema_file}`, function () { var errors_md = ""; const result = validator( - yaml.load(fs.readFileSync(test_file, "utf8")) + yaml.load(fs.readFileSync(test_file, "utf8")), ); if (validator.errors) { errors_md += "# ajv errors\n\n```json\n"; @@ -76,17 +74,17 @@ describe("schemas under f/", function () { } // validate using check-jsonschema (python-jsonschema): // const py = exec(); - // Do not use python -m ... calling notation because for some + // Do not use python3 -m ... calling notation because for some // reason, nodejs environment lacks some env variables needed // and breaks usage from inside virtualenvs. const proc = spawnSync( `${process.env.VIRTUAL_ENV}/bin/check-jsonschema -v -o json --schemafile f/${schema_file} ${test_file}`, - { shell: true, encoding: "utf-8", stdio: "pipe" } + { shell: true, encoding: "utf-8", stdio: "pipe" }, ); if (proc.status != 0) { // real errors are sent to stderr due to https://github.com/python-jsonschema/check-jsonschema/issues/88 errors_md += "# check-jsonschema\n\nstdout:\n\n```json\n"; - errors_md += stripAnsi(proc.output[1]); + errors_md += stripAnsi(proc.output[1] || ""); errors_md += "```\n"; if (proc.output[2]) { errors_md += "\nstderr:\n\n```\n"; @@ -110,10 +108,10 @@ describe("schemas under f/", function () { assert.equal( result, !expect_fail, - `${JSON.stringify(validator.errors)}` + `${JSON.stringify(validator.errors)}`, ); }); - } + }, ); // All /$defs/ that have examples property are assumed to be // subschemas, "tasks" being the primary such case, which is also used @@ -130,15 +128,15 @@ describe("schemas under f/", function () { ({ file: test_file, expect_fail }) => { it(`linting ${test_file} using ${subschema_uri}`, function () { const result = subschema_validator( - yaml.load(fs.readFileSync(test_file, "utf8")) + yaml.load(fs.readFileSync(test_file, "utf8")), ); assert.equal( result, !expect_fail, - `${JSON.stringify(validator.errors)}` + `${JSON.stringify(validator.errors)}`, ); }); - } + }, ); } } @@ -148,29 +146,29 @@ describe("schemas under f/", function () { // find all tests for each schema file function getTestFiles( - globs: string[] + globs: string[], ): { file: string; expect_fail: boolean }[] { const files = Array.from( new Set( globs .map((glob: any) => minimatch.match(test_files, path.join("**", glob))) - .flat() - ) + .flat(), + ), ); const negative_files = Array.from( new Set( globs .map((glob: any) => - minimatch.match(negative_test_files, path.join("**", glob)) + minimatch.match(negative_test_files, path.join("**", glob)), ) - .flat() - ) + .flat(), + ), ); // All fails ending with fail, like `foo.fail.yml` are expected to fail validation let result = files.map((f) => ({ file: f, expect_fail: false })); result = result.concat( - negative_files.map((f) => ({ file: f, expect_fail: true })) + negative_files.map((f) => ({ file: f, expect_fail: true })), ); return result; } diff --git a/test/schemas/test/playbooks/gather_facts.yml b/test/schemas/test/playbooks/gather_facts.yml index 598188d..bdba790 100644 --- a/test/schemas/test/playbooks/gather_facts.yml +++ b/test/schemas/test/playbooks/gather_facts.yml @@ -4,3 +4,9 @@ tasks: - ansible.builtin.debug: msg: foo + +- hosts: localhost + gather_facts: "{{ facts_var_bool | default(false) }}" + tasks: + - ansible.builtin.debug: + msg: bar diff --git a/test/schemas/test/playbooks/ignore-unreachable.yml b/test/schemas/test/playbooks/ignore-unreachable.yml new file mode 100644 index 0000000..8dfdc21 --- /dev/null +++ b/test/schemas/test/playbooks/ignore-unreachable.yml @@ -0,0 +1,13 @@ +--- +- name: Test + hosts: localhost + tasks: + - name: Debug + ansible.builtin.debug: + msg: ignore_unreachable should be a boolean + ignore_unreachable: true + + - name: Debug + ansible.builtin.debug: + msg: "foo" + ignore_unreachable: '{{ "yes" | bool }}' diff --git a/test/schemas/test/playbooks/order.yml b/test/schemas/test/playbooks/order.yml new file mode 100644 index 0000000..08534de --- /dev/null +++ b/test/schemas/test/playbooks/order.yml @@ -0,0 +1,15 @@ +--- +- name: Test + hosts: localhost + order: "{{ host_order | default('shuffle') }}" + gather_facts: false + serial: 1 + tasks: + - name: ABC + ansible.builtin.debug: + msg: "hello" +- name: Test 2 + hosts: localhost + order: inventory + gather_facts: false + tasks: [] diff --git a/test/schemas/test/roles/foo/meta/argument_specs.yml b/test/schemas/test/roles/foo/meta/argument_specs.yml index c8d8c68..a83b82c 100644 --- a/test/schemas/test/roles/foo/meta/argument_specs.yml +++ b/test/schemas/test/roles/foo/meta/argument_specs.yml @@ -40,6 +40,35 @@ argument_specs: - 3 - 123 + complex_required_options: + type: dict + description: Contains sub-options with interacting requirements + options: + foo: + type: str + bar: + type: str + baz: + type: str + + mutually_exclusive: + - ["foo", "bar"] + + required_together: + - ["bar", "baz"] + + required_one_of: + - ["foo", "bar", "baz"] + + required_if: + - ["foo", "must_have_bar_and_baz_default", ["bar", "baz"]] + - ["foo", "must_have_bar_and_baz_explicit", ["bar", "baz"], false] + - ["foo", "must_have_one_of_bar_or_baz", ["bar", "baz"], true] + + required_by: + foo: "bar" + bar: ["foo", "baz"] + seealso: - module: community.foo.bar - module: community.foo.baz @@ -55,11 +84,31 @@ argument_specs: name: The Ansible documentation. description: A link to the Ansible documentation. + examples: |- + - name: Use role + include_role: foo.bar.baz + alternate: short_description: The alternate entry point for the my_app role. author: - Foobar Baz - Bert Foo + attributes: + idempotent: + description: Whether the role is idempotent. + support: full + check_mode: + description: + - Whether the role supports check mode. + support: partial + details: + - Does not work if O(my_app_int=5). + version_added: 1.2.0 + action_group: + description: + - Use C(group/foo.bar.baz) in C(module_defaults) to set authentication options for the C(foo.bar) modules used by this role. + support: full + membership: foo.bar.baz options: my_app_int: type: "int" diff --git a/test/schemas/test/roles/foo/meta/main.yml b/test/schemas/test/roles/foo/meta/main.yml index b84b10c..2536c22 100644 --- a/test/schemas/test/roles/foo/meta/main.yml +++ b/test/schemas/test/roles/foo/meta/main.yml @@ -5,6 +5,7 @@ dependencies: version: "1.0" - name: ansible-role-bar version: "1.0" + - ansible-role-baz # from Bitbucket - src: git+http://bitbucket.org/willthames/git-ansible-galaxy version: v1.4 diff --git a/test/schemas/tsconfig.json b/test/schemas/tsconfig.json index fe51c68..b2291af 100644 --- a/test/schemas/tsconfig.json +++ b/test/schemas/tsconfig.json @@ -2,16 +2,16 @@ "compilerOptions": { "declaration": true, "esModuleInterop": true, - "lib": ["es5", "es2015.promise"], - "module": "commonjs", + "lib": ["ESNext"], + "module": "esnext", "moduleResolution": "node", - "outDir": "../lib/umd", + "outDir": "../../.tox/out", "resolveJsonModule": true, "sourceMap": true, "strict": true, "stripInternal": true, - "target": "es5" + "target": "ESNext", }, "exclude": ["node_modules"], - "include": ["src/**/*"] + "include": ["src/**/*"], } diff --git a/test/test_adjacent_plugins.py b/test/test_adjacent_plugins.py new file mode 100644 index 0000000..3e642ce --- /dev/null +++ b/test/test_adjacent_plugins.py @@ -0,0 +1,25 @@ +"""Test ability to recognize adjacent modules/plugins.""" + +import logging + +import pytest + +from ansiblelint.rules import RulesCollection +from ansiblelint.runner import Runner + + +def test_adj_action( + default_rules_collection: RulesCollection, + caplog: pytest.LogCaptureFixture, +) -> None: + """Assures local collections are found.""" + playbook_path = "examples/playbooks/adj_action.yml" + + with caplog.at_level(logging.DEBUG): + runner = Runner(playbook_path, rules=default_rules_collection, verbosity=1) + results = runner.run() + assert "Unable to load module" not in caplog.text + assert "Unable to resolve FQCN" not in caplog.text + + assert len(runner.lintables) == 1 + assert len(results) == 0 diff --git a/test/test_ansiblelintrule.py b/test/test_ansiblelintrule.py index c576e0f..dcce2ea 100644 --- a/test/test_ansiblelintrule.py +++ b/test/test_ansiblelintrule.py @@ -1,15 +1,15 @@ """Generic tests for AnsibleLintRule class.""" + from __future__ import annotations from typing import TYPE_CHECKING, Any import pytest -from ansiblelint.config import options -from ansiblelint.rules import AnsibleLintRule +from ansiblelint.rules import AnsibleLintRule, RulesCollection if TYPE_CHECKING: - from _pytest.monkeypatch import MonkeyPatch + from ansiblelint.config import Options def test_unjinja() -> None: @@ -20,12 +20,14 @@ def test_unjinja() -> None: @pytest.mark.parametrize("rule_config", ({}, {"foo": True, "bar": 1})) -def test_rule_config(rule_config: dict[str, Any], monkeypatch: MonkeyPatch) -> None: - """Check that a rule config is inherited from options.""" - rule_id = "rule-0" - monkeypatch.setattr(AnsibleLintRule, "id", rule_id) - monkeypatch.setitem(options.rules, rule_id, rule_config) - - rule = AnsibleLintRule() - assert set(rule.rule_config.items()) == set(rule_config.items()) - assert all(rule.get_config(k) == v for k, v in rule_config.items()) +def test_rule_config( + rule_config: dict[str, Any], + config_options: Options, +) -> None: + """Check that a rule config can be accessed.""" + config_options.rules["load-failure"] = rule_config + rules = RulesCollection(options=config_options) + for rule in rules: + if rule.id == "load-failure": + assert rule._collection # noqa: SLF001 + assert rule.rule_config == rule_config diff --git a/test/test_ansiblesyntax.py b/test/test_ansiblesyntax.py index f71a525..649833e 100644 --- a/test/test_ansiblesyntax.py +++ b/test/test_ansiblesyntax.py @@ -3,6 +3,7 @@ This module contains tests that validate that linter does not produce errors when encountering what counts as valid Ansible syntax. """ + from ansiblelint.testing import RunFromText PB_WITH_NULL_TASKS = """\ diff --git a/test/test_app.py b/test/test_app.py index 140f5f6..cbeae3d 100644 --- a/test/test_app.py +++ b/test/test_app.py @@ -1,4 +1,5 @@ """Test for app module.""" + from pathlib import Path from ansiblelint.constants import RC diff --git a/test/test_cli.py b/test/test_cli.py index a37a43d..b2c3320 100644 --- a/test/test_cli.py +++ b/test/test_cli.py @@ -1,4 +1,5 @@ """Test cli arguments and config.""" + from __future__ import annotations import os @@ -66,37 +67,77 @@ def test_ensure_config_are_equal( @pytest.mark.parametrize( - ("with_base", "args", "config"), + ("with_base", "args", "config", "expected"), ( - (True, ["--write"], "test/fixtures/config-with-write-all.yml"), - (True, ["--write=all"], "test/fixtures/config-with-write-all.yml"), - (True, ["--write", "all"], "test/fixtures/config-with-write-all.yml"), - (True, ["--write=none"], "test/fixtures/config-with-write-none.yml"), - (True, ["--write", "none"], "test/fixtures/config-with-write-none.yml"), - ( + pytest.param( + True, + ["--fix"], + "test/fixtures/config-with-write-all.yml", + ["all"], + id="1", + ), + pytest.param( + True, + ["--fix=all"], + "test/fixtures/config-with-write-all.yml", + ["all"], + id="2", + ), + pytest.param( + True, + ["--fix", "all"], + "test/fixtures/config-with-write-all.yml", + ["all"], + id="3", + ), + pytest.param( True, - ["--write=rule-tag,rule-id"], + ["--fix=none"], + "test/fixtures/config-with-write-none.yml", + [], + id="4", + ), + pytest.param( + True, + ["--fix", "none"], + "test/fixtures/config-with-write-none.yml", + [], + id="5", + ), + pytest.param( + True, + ["--fix=rule-tag,rule-id"], "test/fixtures/config-with-write-subset.yml", + ["rule-tag", "rule-id"], + id="6", ), - ( + pytest.param( True, - ["--write", "rule-tag,rule-id"], + ["--fix", "rule-tag,rule-id"], "test/fixtures/config-with-write-subset.yml", + ["rule-tag", "rule-id"], + id="7", ), - ( + pytest.param( True, - ["--write", "rule-tag", "--write", "rule-id"], + ["--fix", "rule-tag", "--fix", "rule-id"], "test/fixtures/config-with-write-subset.yml", + ["rule-tag", "rule-id"], + id="8", ), - ( + pytest.param( False, - ["--write", "examples/playbooks/example.yml"], + ["--fix", "examples/playbooks/example.yml"], "test/fixtures/config-with-write-all.yml", + ["all"], + id="9", ), - ( + pytest.param( False, - ["--write", "examples/playbooks/example.yml", "non-existent.yml"], + ["--fix", "examples/playbooks/example.yml", "non-existent.yml"], "test/fixtures/config-with-write-all.yml", + ["all"], + id="10", ), ), ) @@ -105,21 +146,22 @@ def test_ensure_write_cli_does_not_consume_lintables( with_base: bool, args: list[str], config: str, + expected: list[str], ) -> None: - """Check equality of the CLI --write options to config files.""" + """Check equality of the CLI --fix options to config files.""" cli_parser = cli.get_cli_parser() command = base_arguments + args if with_base else args options = cli_parser.parse_args(command) file_config = cli.load_config(config)[0] - file_value = file_config.get("write_list") + file_config.get("write_list") orig_cli_value = options.write_list - cli_value = cli.WriteArgAction.merge_write_list_config( + cli_value = cli.WriteArgAction.merge_fix_list_config( from_file=[], from_cli=orig_cli_value, ) - assert file_value == cli_value + assert cli_value == expected def test_config_can_be_overridden(base_arguments: list[str]) -> None: diff --git a/test/test_cli_role_paths.py b/test/test_cli_role_paths.py index 148e1ed..131c3b5 100644 --- a/test/test_cli_role_paths.py +++ b/test/test_cli_role_paths.py @@ -1,4 +1,5 @@ """Tests related to role paths.""" + from __future__ import annotations import os @@ -174,7 +175,10 @@ def test_run_single_role_path_with_roles_path_env(local_test_dir: Path) -> None: @pytest.mark.parametrize( ("result", "env"), - ((True, {"GITHUB_ACTIONS": "true", "GITHUB_WORKFLOW": "foo"}), (False, None)), + ( + (True, {"GITHUB_ACTIONS": "true", "GITHUB_WORKFLOW": "foo", "NO_COLOR": "1"}), + (False, None), + ), ids=("on", "off"), ) def test_run_playbook_github(result: bool, env: dict[str, str]) -> None: @@ -192,3 +196,41 @@ def test_run_playbook_github(result: bool, env: dict[str, str]) -> None: "Package installs should not use latest" ) assert (expected in result_gh.stderr) is result + + +def test_run_role_identified(local_test_dir: Path) -> None: + """Test that role name is identified correctly.""" + cwd = local_test_dir + + env = os.environ.copy() + env["ANSIBLE_ROLES_PATH"] = os.path.realpath( + (cwd / "../examples/roles/role_detection").resolve(), + ) + result = run_ansible_lint( + Path("roles/role_detection/foo/defaults/main.yml"), + cwd=cwd, + env=env, + ) + assert result.returncode == RC.SUCCESS + + +def test_run_role_identified_prefix_missing(local_test_dir: Path) -> None: + """Test that role name is identified correctly, with prefix violations.""" + cwd = local_test_dir + + env = os.environ.copy() + env["ANSIBLE_ROLES_PATH"] = os.path.realpath( + (cwd / "../examples/roles/role_detection/base").resolve(), + ) + result = run_ansible_lint( + Path("roles/role_detection/base/bar/defaults/main.yml"), + cwd=cwd, + env=env, + ) + assert result.returncode == RC.VIOLATIONS_FOUND + assert ( + "Variables names from within roles should use bar_ as a prefix" in result.stdout + ) + assert ( + "Variables names from within roles should use bar_ as a prefix" in result.stdout + ) diff --git a/test/test_config.py b/test/test_config.py index 51a09b0..4c4ff5a 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -1,4 +1,5 @@ """Tests for config module.""" + from ansiblelint.config import PROFILES from ansiblelint.rules import RulesCollection diff --git a/test/test_constants.py b/test/test_constants.py index 52b297a..ad957e2 100644 --- a/test/test_constants.py +++ b/test/test_constants.py @@ -1,4 +1,5 @@ """Tests for constants module.""" + from ansiblelint.constants import States diff --git a/test/test_dependencies_in_meta.py b/test/test_dependencies_in_meta.py index 44007b7..0206164 100644 --- a/test/test_dependencies_in_meta.py +++ b/test/test_dependencies_in_meta.py @@ -1,4 +1,5 @@ """Tests about dependencies in meta.""" + from ansiblelint.rules import RulesCollection from ansiblelint.runner import Runner diff --git a/test/test_errors.py b/test/test_errors.py new file mode 100644 index 0000000..69b7fe8 --- /dev/null +++ b/test/test_errors.py @@ -0,0 +1,25 @@ +"""Test ansiblelint.errors.""" + +import pytest + +from ansiblelint.errors import MatchError + + +def test_matcherror() -> None: + """.""" + match = MatchError("foo", lineno=1, column=2) + with pytest.raises(TypeError): + assert match <= 0 + + assert match != 0 + + assert match.position == "1:2" + + match2 = MatchError("foo", lineno=1) + assert match2.position == "1" + + # str and repr are for the moment the same + assert str(match) == repr(match) + + # tests implicit level + assert match.level == "warning" diff --git a/test/test_examples.py b/test/test_examples.py index 2842930..7840360 100644 --- a/test/test_examples.py +++ b/test/test_examples.py @@ -1,4 +1,5 @@ """Assure samples produced desire outcomes.""" + import pytest from ansiblelint.app import get_app @@ -17,31 +18,37 @@ def test_example(default_rules_collection: RulesCollection) -> None: @pytest.mark.parametrize( - ("filename", "line", "column"), + ("filename", "expected_results"), ( pytest.param( "examples/playbooks/syntax-error-string.yml", - 6, - 7, - id="syntax-error", + [("syntax-check[unknown-module]", 6, 7)], + id="0", + ), + pytest.param( + "examples/playbooks/syntax-error.yml", + [("syntax-check[specific]", 2, 3)], + id="1", ), - pytest.param("examples/playbooks/syntax-error.yml", 2, 3, id="syntax-error"), ), ) def test_example_syntax_error( default_rules_collection: RulesCollection, filename: str, - line: int, - column: int, + expected_results: list[tuple[str, int | None, int | None]], ) -> None: """Validates that loading valid YAML string produce error.""" result = Runner(filename, rules=default_rules_collection).run() - assert len(result) == 1 - assert result[0].rule.id == "syntax-check" - # This also ensures that line and column numbers start at 1, so they - # match what editors will show (or output from other linters) - assert result[0].lineno == line - assert result[0].column == column + assert len(result) == len(expected_results) + for i, expected in enumerate(expected_results): + if expected[0] is not None: + assert result[i].tag == expected[0] + # This also ensures that line and column numbers start at 1, so they + # match what editors will show (or output from other linters) + if expected[1] is not None: + assert result[i].lineno == expected[1] + if expected[2] is not None: + assert result[i].column == expected[2] def test_example_custom_module(default_rules_collection: RulesCollection) -> None: @@ -67,7 +74,7 @@ def test_vault_partial( default_rules_collection: RulesCollection, caplog: pytest.LogCaptureFixture, ) -> None: - """Check ability to precess files that container !vault inside.""" + """Check ability to process files that container !vault inside.""" result = Runner( "examples/playbooks/vars/vault_partial.yml", rules=default_rules_collection, diff --git a/test/test_file_path_evaluation.py b/test/test_file_path_evaluation.py index b31f923..69f02bb 100644 --- a/test/test_file_path_evaluation.py +++ b/test/test_file_path_evaluation.py @@ -1,4 +1,5 @@ """Testing file path evaluation when using import_tasks / include_tasks.""" + from __future__ import annotations import textwrap @@ -42,14 +43,14 @@ LAYOUT_IMPORTS: dict[str, str] = { "tasks/subtasks/subtask_1.yml": textwrap.dedent( """\ --- - - name: subtask_1 | From subtask 1 import subtask 2 + - name: subtasks | subtask_1 | From subtask 1 import subtask 2 ansible.builtin.import_tasks: tasks/subtasks/subtask_2.yml """, ), "tasks/subtasks/subtask_2.yml": textwrap.dedent( """\ --- - - name: subtask_2 | From subtask 2 do something + - name: subtasks | subtask_2 | From subtask 2 do something debug: # <-- expected to raise fqcn[action-core] msg: | Something... @@ -86,14 +87,14 @@ LAYOUT_INCLUDES: dict[str, str] = { "tasks/subtasks/subtask_1.yml": textwrap.dedent( """\ --- - - name: subtask_1 | From subtask 1 import subtask 2 + - name: subtasks | subtask_1 | From subtask 1 import subtask 2 ansible.builtin.include_tasks: tasks/subtasks/subtask_2.yml """, ), "tasks/subtasks/subtask_2.yml": textwrap.dedent( """\ --- - - name: subtask_2 | From subtask 2 do something + - name: subtasks | subtask_2 | From subtask 2 do something debug: # <-- expected to raise fqcn[action-core] msg: | Something... @@ -105,8 +106,8 @@ LAYOUT_INCLUDES: dict[str, str] = { @pytest.mark.parametrize( "ansible_project_layout", ( - pytest.param(LAYOUT_IMPORTS, id="using only import_tasks"), - pytest.param(LAYOUT_INCLUDES, id="using only include_tasks"), + pytest.param(LAYOUT_IMPORTS, id="using-only-import_tasks"), + pytest.param(LAYOUT_INCLUDES, id="using-only-include_tasks"), ), ) def test_file_path_evaluation( diff --git a/test/test_file_utils.py b/test/test_file_utils.py index b7b9115..74f1934 100644 --- a/test/test_file_utils.py +++ b/test/test_file_utils.py @@ -1,4 +1,5 @@ """Tests for file utility functions.""" + from __future__ import annotations import copy @@ -59,7 +60,7 @@ def test_expand_path_vars(monkeypatch: MonkeyPatch) -> None: pytest.param(Path("$TEST_PATH"), "/test/path", id="pathlib.Path"), pytest.param("$TEST_PATH", "/test/path", id="str"), pytest.param(" $TEST_PATH ", "/test/path", id="stripped-str"), - pytest.param("~", os.path.expanduser("~"), id="home"), # noqa: PTH:111 + pytest.param("~", os.path.expanduser("~"), id="home"), # noqa: PTH111 ), ) def test_expand_paths_vars( @@ -236,7 +237,7 @@ def test_discover_lintables_umlaut(monkeypatch: MonkeyPatch) -> None: "tasks", id="33", ), # content should determine is tasks - pytest.param("examples/collection/galaxy.yml", "galaxy", id="34"), + pytest.param("examples/.collection/galaxy.yml", "galaxy", id="34"), pytest.param("examples/meta/runtime.yml", "meta-runtime", id="35"), pytest.param("examples/meta/changelogs/changelog.yaml", "changelog", id="36"), pytest.param("examples/inventory/inventory.yml", "inventory", id="37"), @@ -258,6 +259,12 @@ def test_discover_lintables_umlaut(monkeypatch: MonkeyPatch) -> None: "playbook", id="43", ), # content should determine it as a play + pytest.param( + "plugins/modules/fake_module.py", + "plugin", + id="44", + ), + pytest.param("examples/meta/changelogs/changelog.yml", "changelog", id="45"), ), ) def test_kinds(path: str, kind: FileType) -> None: diff --git a/test/test_formatter.py b/test/test_formatter.py index 68f0508..c41f673 100644 --- a/test/test_formatter.py +++ b/test/test_formatter.py @@ -1,4 +1,5 @@ """Test for output formatter.""" + # Copyright (c) 2016 Will Thames <will@thames.id.au> # # Permission is hereby granted, free of charge, to any person obtaining a copy @@ -23,10 +24,12 @@ import pathlib from ansiblelint.errors import MatchError from ansiblelint.file_utils import Lintable from ansiblelint.formatters import Formatter -from ansiblelint.rules import AnsibleLintRule +from ansiblelint.rules import AnsibleLintRule, RulesCollection +collection = RulesCollection() rule = AnsibleLintRule() rule.id = "TCF0001" +collection.register(rule) formatter = Formatter(pathlib.Path.cwd(), display_relative_path=True) # These details would generate a rich rendering error if not escaped: DETAILS = "Some [/tmp/foo] details." diff --git a/test/test_formatter_base.py b/test/test_formatter_base.py index 5cc86b8..462fc62 100644 --- a/test/test_formatter_base.py +++ b/test/test_formatter_base.py @@ -1,4 +1,5 @@ """Tests related to base formatter.""" + from __future__ import annotations from pathlib import Path @@ -12,12 +13,18 @@ from ansiblelint.formatters import BaseFormatter @pytest.mark.parametrize( ("base_dir", "relative_path"), ( - (None, True), - ("/whatever", False), - (Path("/whatever"), False), + pytest.param(None, True, id="0"), + pytest.param("/whatever", False, id="1"), + pytest.param(Path("/whatever"), False, id="2"), + ), +) +@pytest.mark.parametrize( + "path", + ( + pytest.param("/whatever/string", id="a"), + pytest.param(Path("/whatever/string"), id="b"), ), ) -@pytest.mark.parametrize("path", ("/whatever/string", Path("/whatever/string"))) def test_base_formatter_when_base_dir( base_dir: Any, relative_path: bool, @@ -28,18 +35,15 @@ def test_base_formatter_when_base_dir( base_formatter = BaseFormatter(base_dir, relative_path) # type: ignore[var-annotated] # When - output_path = ( - base_formatter._format_path( # pylint: disable=protected-access # noqa: SLF001 - path, - ) + output_path = base_formatter._format_path( # noqa: SLF001 + path, ) # Then - assert isinstance(output_path, (str, Path)) - # pylint: disable=protected-access + assert isinstance(output_path, str | Path) assert base_formatter.base_dir is None or isinstance( base_formatter.base_dir, - (str, Path), + str | Path, ) assert output_path == path @@ -47,11 +51,17 @@ def test_base_formatter_when_base_dir( @pytest.mark.parametrize( "base_dir", ( - Path("/whatever"), - "/whatever", + pytest.param(Path("/whatever"), id="0"), + pytest.param("/whatever", id="1"), + ), +) +@pytest.mark.parametrize( + "path", + ( + pytest.param("/whatever/string", id="a"), + pytest.param(Path("/whatever/string"), id="b"), ), ) -@pytest.mark.parametrize("path", ("/whatever/string", Path("/whatever/string"))) def test_base_formatter_when_base_dir_is_given_and_relative_is_true( path: str | Path, base_dir: str | Path, @@ -61,13 +71,11 @@ def test_base_formatter_when_base_dir_is_given_and_relative_is_true( base_formatter = BaseFormatter(base_dir, True) # type: ignore[var-annotated] # When - # pylint: disable=protected-access output_path = base_formatter._format_path(path) # noqa: SLF001 # Then - assert isinstance(output_path, (str, Path)) - # pylint: disable=protected-access - assert isinstance(base_formatter.base_dir, (str, Path)) + assert isinstance(output_path, str | Path) + assert isinstance(base_formatter.base_dir, str | Path) assert output_path == Path(path).name diff --git a/test/test_formatter_json.py b/test/test_formatter_json.py index 25aa5f5..763c843 100644 --- a/test/test_formatter_json.py +++ b/test/test_formatter_json.py @@ -1,4 +1,5 @@ """Test the codeclimate JSON formatter.""" + from __future__ import annotations import json @@ -11,7 +12,7 @@ import pytest from ansiblelint.errors import MatchError from ansiblelint.file_utils import Lintable from ansiblelint.formatters import CodeclimateJSONFormatter -from ansiblelint.rules import AnsibleLintRule +from ansiblelint.rules import AnsibleLintRule, RulesCollection class TestCodeclimateJSONFormatter: @@ -20,12 +21,14 @@ class TestCodeclimateJSONFormatter: rule = AnsibleLintRule() matches: list[MatchError] = [] formatter: CodeclimateJSONFormatter | None = None + collection = RulesCollection() def setup_class(self) -> None: """Set up few MatchError objects.""" self.rule = AnsibleLintRule() self.rule.id = "TCF0001" self.rule.severity = "VERY_HIGH" + self.collection.register(self.rule) self.matches = [] self.matches.append( MatchError( @@ -51,7 +54,7 @@ class TestCodeclimateJSONFormatter: display_relative_path=True, ) - def test_format_list(self) -> None: + def test_json_format_list(self) -> None: """Test if the return value is a string.""" assert isinstance(self.formatter, CodeclimateJSONFormatter) assert isinstance(self.formatter.format_result(self.matches), str) @@ -64,10 +67,10 @@ class TestCodeclimateJSONFormatter: # https://github.com/ansible/ansible-navigator/issues/1490 assert "\n" not in output - def test_single_match(self) -> None: + def test_json_single_match(self) -> None: """Test negative case. Only lists are allowed. Otherwise a RuntimeError will be raised.""" assert isinstance(self.formatter, CodeclimateJSONFormatter) - with pytest.raises(RuntimeError): + with pytest.raises(TypeError): self.formatter.format_result(self.matches[0]) # type: ignore[arg-type] def test_result_is_list(self) -> None: diff --git a/test/test_formatter_sarif.py b/test/test_formatter_sarif.py index 026d336..982bb6e 100644 --- a/test/test_formatter_sarif.py +++ b/test/test_formatter_sarif.py @@ -1,4 +1,5 @@ """Test the codeclimate JSON formatter.""" + from __future__ import annotations import json @@ -13,54 +14,75 @@ import pytest from ansiblelint.errors import MatchError from ansiblelint.file_utils import Lintable from ansiblelint.formatters import SarifFormatter -from ansiblelint.rules import AnsibleLintRule +from ansiblelint.rules import AnsibleLintRule, RulesCollection class TestSarifFormatter: """Unit test for SarifFormatter.""" - rule = AnsibleLintRule() + rule1 = AnsibleLintRule() + rule2 = AnsibleLintRule() matches: list[MatchError] = [] formatter: SarifFormatter | None = None + collection = RulesCollection() + collection.register(rule1) + collection.register(rule2) def setup_class(self) -> None: """Set up few MatchError objects.""" - self.rule = AnsibleLintRule() - self.rule.id = "TCF0001" - self.rule.severity = "VERY_HIGH" - self.rule.description = "This is the rule description." - self.rule.link = "https://rules/help#TCF0001" - self.rule.tags = ["tag1", "tag2"] - self.matches = [] - self.matches.append( - MatchError( - message="message", - lineno=1, - column=10, - details="details", - lintable=Lintable("filename.yml", content=""), - rule=self.rule, - tag="yaml[test]", - ), - ) - self.matches.append( - MatchError( - message="message", - lineno=2, - details="", - lintable=Lintable("filename.yml", content=""), - rule=self.rule, - tag="yaml[test]", - ), + self.rule1.id = "TCF0001" + self.rule1.severity = "VERY_HIGH" + self.rule1.description = "This is the rule description." + self.rule1.link = "https://rules/help#TCF0001" + self.rule1.tags = ["tag1", "tag2"] + + self.rule2.id = "TCF0002" + self.rule2.severity = "MEDIUM" + self.rule2.link = "https://rules/help#TCF0002" + self.rule2.tags = ["tag3", "tag4"] + + self.matches.extend( + [ + MatchError( + message="message1", + lineno=1, + column=10, + details="details1", + lintable=Lintable("filename1.yml", content=""), + rule=self.rule1, + tag="yaml[test1]", + ignored=False, + ), + MatchError( + message="message2", + lineno=2, + details="", + lintable=Lintable("filename2.yml", content=""), + rule=self.rule1, + tag="yaml[test2]", + ignored=True, + ), + MatchError( + message="message3", + lineno=666, + column=667, + details="details3", + lintable=Lintable("filename3.yml", content=""), + rule=self.rule2, + tag="yaml[test3]", + ignored=False, + ), + ], ) + self.formatter = SarifFormatter(pathlib.Path.cwd(), display_relative_path=True) - def test_format_list(self) -> None: + def test_sarif_format_list(self) -> None: """Test if the return value is a string.""" assert isinstance(self.formatter, SarifFormatter) assert isinstance(self.formatter.format_result(self.matches), str) - def test_result_is_json(self) -> None: + def test_sarif_result_is_json(self) -> None: """Test if returned string value is a JSON.""" assert isinstance(self.formatter, SarifFormatter) output = self.formatter.format_result(self.matches) @@ -68,17 +90,22 @@ class TestSarifFormatter: # https://github.com/ansible/ansible-navigator/issues/1490 assert "\n" not in output - def test_single_match(self) -> None: + def test_sarif_single_match(self) -> None: """Test negative case. Only lists are allowed. Otherwise, a RuntimeError will be raised.""" assert isinstance(self.formatter, SarifFormatter) - with pytest.raises(RuntimeError): + with pytest.raises(TypeError): self.formatter.format_result(self.matches[0]) # type: ignore[arg-type] - def test_result_is_list(self) -> None: - """Test if the return SARIF object contains the results with length of 2.""" + def test_sarif_format(self) -> None: + """Test if the return SARIF object contains the expected results.""" assert isinstance(self.formatter, SarifFormatter) sarif = json.loads(self.formatter.format_result(self.matches)) - assert len(sarif["runs"][0]["results"]) == 2 + assert len(sarif["runs"][0]["results"]) == 3 + for result in sarif["runs"][0]["results"]: + # Ensure all reported entries have a level + assert "level" in result + # Ensure reported levels are either error or warning + assert result["level"] in ("error", "warning") def test_validate_sarif_schema(self) -> None: """Test if the returned JSON is a valid SARIF report.""" @@ -90,16 +117,18 @@ class TestSarifFormatter: assert driver["name"] == SarifFormatter.TOOL_NAME assert driver["informationUri"] == SarifFormatter.TOOL_URL rules = driver["rules"] - assert len(rules) == 1 + assert len(rules) == 3 assert rules[0]["id"] == self.matches[0].tag assert rules[0]["name"] == self.matches[0].tag assert rules[0]["shortDescription"]["text"] == self.matches[0].message - assert rules[0]["defaultConfiguration"]["level"] == "error" + assert rules[0]["defaultConfiguration"][ + "level" + ] == SarifFormatter.get_sarif_rule_severity_level(self.matches[0].rule) assert rules[0]["help"]["text"] == self.matches[0].rule.description assert rules[0]["properties"]["tags"] == self.matches[0].rule.tags assert rules[0]["helpUri"] == self.matches[0].rule.url results = sarif["runs"][0]["results"] - assert len(results) == 2 + assert len(results) == 3 for i, result in enumerate(results): assert result["ruleId"] == self.matches[i].tag assert ( @@ -126,6 +155,9 @@ class TestSarifFormatter: "startColumn" not in result["locations"][0]["physicalLocation"]["region"] ) + assert result["level"] == SarifFormatter.get_sarif_result_severity_level( + self.matches[i], + ) assert sarif["runs"][0]["originalUriBaseIds"][SarifFormatter.BASE_URI_ID]["uri"] assert results[0]["message"]["text"] == self.matches[0].details assert results[1]["message"]["text"] == self.matches[1].message @@ -151,8 +183,8 @@ def test_sarif_parsable_ignored() -> None: @pytest.mark.parametrize( ("file", "return_code"), ( - pytest.param("examples/playbooks/valid.yml", 0), - pytest.param("playbook.yml", 2), + pytest.param("examples/playbooks/valid.yml", 0, id="0"), + pytest.param("playbook.yml", 2, id="1"), ), ) def test_sarif_file(file: str, return_code: int) -> None: @@ -168,12 +200,12 @@ def test_sarif_file(file: str, return_code: int) -> None: result = subprocess.run([*cmd, file], check=False, capture_output=True) assert result.returncode == return_code assert os.path.exists(output_file.name) # noqa: PTH110 - assert os.path.getsize(output_file.name) > 0 + assert pathlib.Path(output_file.name).stat().st_size > 0 @pytest.mark.parametrize( ("file", "return_code"), - (pytest.param("examples/playbooks/valid.yml", 0),), + (pytest.param("examples/playbooks/valid.yml", 0, id="0"),), ) def test_sarif_file_creates_it_if_none_exists(file: str, return_code: int) -> None: """Test ability to create sarif file if none exists and dump output to it (--sarif-file).""" @@ -188,5 +220,5 @@ def test_sarif_file_creates_it_if_none_exists(file: str, return_code: int) -> No result = subprocess.run([*cmd, file], check=False, capture_output=True) assert result.returncode == return_code assert os.path.exists(sarif_file_name) # noqa: PTH110 - assert os.path.getsize(sarif_file_name) > 0 + assert pathlib.Path(sarif_file_name).stat().st_size > 0 pathlib.Path.unlink(pathlib.Path(sarif_file_name)) diff --git a/test/test_import_include_role.py b/test/test_import_include_role.py index bc3fdbe..b221646 100644 --- a/test/test_import_include_role.py +++ b/test/test_import_include_role.py @@ -1,4 +1,5 @@ """Tests related to role imports.""" + from __future__ import annotations from typing import TYPE_CHECKING diff --git a/test/test_import_playbook.py b/test/test_import_playbook.py index 66d8763..63c91d2 100644 --- a/test/test_import_playbook.py +++ b/test/test_import_playbook.py @@ -1,4 +1,5 @@ """Test ability to import playbooks.""" + from ansiblelint.rules import RulesCollection from ansiblelint.runner import Runner @@ -16,3 +17,29 @@ def test_task_hook_import_playbook(default_rules_collection: RulesCollection) -> assert "Commands should not change things" in results_text assert "[name]" in results_text assert "All tasks should be named" in results_text + + +def test_import_playbook_from_collection( + default_rules_collection: RulesCollection, +) -> None: + """Assures import_playbook from collection.""" + playbook_path = "examples/playbooks/test_import_playbook.yml" + runner = Runner(playbook_path, rules=default_rules_collection) + results = runner.run() + + assert len(runner.lintables) == 1 + assert len(results) == 0 + + +def test_import_playbook_invalid( + default_rules_collection: RulesCollection, +) -> None: + """Assures import_playbook from collection.""" + playbook_path = "examples/playbooks/test_import_playbook_invalid.yml" + runner = Runner(playbook_path, rules=default_rules_collection) + results = runner.run() + + assert len(runner.lintables) == 1 + assert len(results) == 1 + assert results[0].tag == "syntax-check[specific]" + assert results[0].lineno == 2 diff --git a/test/test_import_tasks.py b/test/test_import_tasks.py index aec1c25..ceb5c28 100644 --- a/test/test_import_tasks.py +++ b/test/test_import_tasks.py @@ -1,4 +1,5 @@ """Test related to import of invalid files.""" + import pytest from ansiblelint.rules import RulesCollection @@ -6,24 +7,28 @@ from ansiblelint.runner import Runner @pytest.mark.parametrize( - "playbook_path", + ("playbook_path", "lintable_count", "match_count"), ( pytest.param( "examples/playbooks/test_import_with_conflicting_action_statements.yml", + 2, + 4, id="0", ), - pytest.param("examples/playbooks/test_import_with_malformed.yml", id="1"), + pytest.param("examples/playbooks/test_import_with_malformed.yml", 2, 2, id="1"), ), ) def test_import_tasks( default_rules_collection: RulesCollection, playbook_path: str, + lintable_count: int, + match_count: int, ) -> None: """Assures import_playbook includes are recognized.""" runner = Runner(playbook_path, rules=default_rules_collection) results = runner.run() - assert len(runner.lintables) == 1 - assert len(results) == 1 + assert len(runner.lintables) == lintable_count + assert len(results) == match_count # Assures we detected the issues from imported file - assert results[0].rule.id == "syntax-check" + assert results[0].rule.id in ("syntax-check", "load-failure") diff --git a/test/test_include_miss_file_with_role.py b/test/test_include_miss_file_with_role.py index 6834758..599928e 100644 --- a/test/test_include_miss_file_with_role.py +++ b/test/test_include_miss_file_with_role.py @@ -1,4 +1,5 @@ """Tests related to inclusions.""" + import pytest from _pytest.logging import LogCaptureFixture diff --git a/test/test_internal_rules.py b/test/test_internal_rules.py index b949238..e1cc69e 100644 --- a/test/test_internal_rules.py +++ b/test/test_internal_rules.py @@ -1,8 +1,35 @@ """Tests for internal rules.""" + +import pytest + from ansiblelint._internal.rules import BaseRule +from ansiblelint.rules import RulesCollection +from ansiblelint.runner import Runner def test_base_rule_url() -> None: """Test that rule URL is set to expected value.""" rule = BaseRule() - assert rule.url == "https://ansible-lint.readthedocs.io/rules/" + assert rule.url == "https://ansible.readthedocs.io/projects/lint/rules/" + + +@pytest.mark.parametrize( + ("path"), + ( + pytest.param( + "examples/playbooks/incorrect_module_args.yml", + id="playbook", + ), + ), +) +def test_incorrect_module_args( + path: str, + default_rules_collection: RulesCollection, +) -> None: + """Check that we fail when file encoding is wrong.""" + runner = Runner(path, rules=default_rules_collection) + matches = runner.run() + assert len(matches) == 1, matches + assert matches[0].rule.id == "load-failure" + assert "Failed to find required 'name' key in include_role" in matches[0].message + assert matches[0].tag == "internal-error" diff --git a/test/test_lint_rule.py b/test/test_lint_rule.py index 2e13aa2..d0edbe9 100644 --- a/test/test_lint_rule.py +++ b/test/test_lint_rule.py @@ -1,4 +1,5 @@ """Tests for lintable.""" + # Copyright (c) 2013-2014 Will Thames <will@thames.id.au> # # Permission is hereby granted, free of charge, to any person obtaining a copy @@ -18,13 +19,12 @@ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. - -from test.rules.fixtures import ematcher, raw_task - import pytest from ansiblelint.file_utils import Lintable +from .rules.fixtures import ematcher, raw_task + @pytest.fixture(name="lintable") def fixture_lintable() -> Lintable: diff --git a/test/test_list_rules.py b/test/test_list_rules.py index dab16e3..85ef53f 100644 --- a/test/test_list_rules.py +++ b/test/test_list_rules.py @@ -18,6 +18,13 @@ def test_list_rules_includes_opt_in_rules(project_path: Path) -> None: assert ("opt-in" in result_list_rules.stdout) is True +def test_list_rules_includes_autofix() -> None: + """Checks that listing rules also includes the autofix label for applicable rules.""" + result_list_rules = run_ansible_lint("--list-rules") + + assert ("autofix" in result_list_rules.stdout) is True + + @pytest.mark.parametrize( ("result", "returncode", "format_string"), ( diff --git a/test/test_load_failure.py b/test/test_load_failure.py index 98d178f..72112d6 100644 --- a/test/test_load_failure.py +++ b/test/test_load_failure.py @@ -1,4 +1,5 @@ """Tests for LoadFailureRule.""" + import pytest from ansiblelint.rules import RulesCollection diff --git a/test/test_loaders.py b/test/test_loaders.py index be12cfd..6e8d66b 100644 --- a/test/test_loaders.py +++ b/test/test_loaders.py @@ -1,4 +1,5 @@ """Tests for loaders submodule.""" + import os import tempfile import uuid @@ -31,7 +32,7 @@ def test_load_ignore_txt_default_success() -> None: _ignore_file.write( dedent( """ - # See https://ansible-lint.readthedocs.io/configuring/#ignoring-rules-for-entire-files + # See https://ansible.readthedocs.io/projects/lint/configuring/#ignoring-rules-for-entire-files playbook2.yml package-latest # comment playbook2.yml foo-bar """, diff --git a/test/test_local_content.py b/test/test_local_content.py index 8455aaf..63472c2 100644 --- a/test/test_local_content.py +++ b/test/test_local_content.py @@ -1,4 +1,5 @@ """Test playbooks with local content.""" + from ansiblelint.rules import RulesCollection from ansiblelint.runner import Runner diff --git a/test/test_main.py b/test/test_main.py index 870926f..e7258ee 100644 --- a/test/test_main.py +++ b/test/test_main.py @@ -1,4 +1,5 @@ """Tests related to ansiblelint.__main__ module.""" + import os import shutil import subprocess @@ -10,6 +11,7 @@ import pytest from pytest_mock import MockerFixture from ansiblelint.config import get_version_warning +from ansiblelint.constants import RC @pytest.mark.parametrize( @@ -52,9 +54,9 @@ def test_call_from_outside_venv(expected_warning: bool) -> None: @pytest.mark.parametrize( ("ver_diff", "found", "check", "outlen"), ( - ("v1.2.2", True, "pre-release", 1), - ("v1.2.3", False, "", 1), - ("v1.2.4", True, "new release", 2), + pytest.param("v1.2.2", True, "pre-release", 1, id="0"), + pytest.param("v1.2.3", False, "", 1, id="1"), + pytest.param("v1.2.4", True, "new release", 2, id="2"), ), ) def test_get_version_warning( @@ -82,3 +84,48 @@ def test_get_version_warning( else: assert check in msg assert len(msg.split("\n")) == outlen + + +def test_get_version_warning_no_pip(mocker: MockerFixture) -> None: + """Test that we do not display any message if install method is not pip.""" + mocker.patch("ansiblelint.config.guess_install_method", return_value="") + assert get_version_warning() == "" + + +@pytest.mark.parametrize( + ("lintable"), + ( + pytest.param("examples/playbooks/nodeps.yml", id="1"), + pytest.param("examples/playbooks/nodeps2.yml", id="2"), + ), +) +def test_nodeps(lintable: str) -> None: + """Asserts ability to be called w/ or w/o venv activation.""" + env = os.environ.copy() + env["ANSIBLE_LINT_NODEPS"] = "1" + py_path = Path(sys.executable).parent + proc = subprocess.run( + [str(py_path / "ansible-lint"), lintable], + check=False, + capture_output=True, + text=True, + env=env, + ) + assert proc.returncode == 0, proc + + +def test_broken_ansible_cfg() -> None: + """Asserts behavior when encountering broken ansible.cfg files.""" + py_path = Path(sys.executable).parent + proc = subprocess.run( + [str(py_path / "ansible-lint"), "--version"], + check=False, + capture_output=True, + text=True, + cwd="test/fixtures/broken-ansible.cfg", + ) + assert proc.returncode == RC.INVALID_CONFIG, proc + assert ( + "Invalid type for configuration option setting: CACHE_PLUGIN_TIMEOUT" + in proc.stderr + ) diff --git a/test/test_matcherrror.py b/test/test_matcherrror.py index 03d9cbd..5b67e23 100644 --- a/test/test_matcherrror.py +++ b/test/test_matcherrror.py @@ -1,7 +1,8 @@ """Tests for MatchError.""" import operator -from typing import Any, Callable +from collections.abc import Callable +from typing import Any import pytest @@ -121,18 +122,18 @@ class TestMatchErrorCompare: @pytest.mark.parametrize( "other", ( - None, - "foo", - 42, - Exception("foo"), + pytest.param(None, id="none"), + pytest.param("foo", id="str"), + pytest.param(42, id="int"), + pytest.param(Exception("foo"), id="exc"), ), ids=repr, ) @pytest.mark.parametrize( ("operation", "operator_char"), ( - pytest.param(operator.le, "<=", id="<="), - pytest.param(operator.gt, ">", id=">"), + pytest.param(operator.le, "<=", id="le"), + pytest.param(operator.gt, ">", id="gt"), ), ) def test_matcherror_compare_no_other_fallback( @@ -143,12 +144,9 @@ def test_matcherror_compare_no_other_fallback( """Check that MatchError comparison with other types causes TypeError.""" expected_error = ( r"^(" - r"unsupported operand type\(s\) for {operator!s}:|" - r"'{operator!s}' not supported between instances of" - r") 'MatchError' and '{other_type!s}'$".format( - other_type=type(other).__name__, - operator=operator_char, - ) + rf"unsupported operand type\(s\) for {operator_char!s}:|" + rf"'{operator_char!s}' not supported between instances of" + rf") 'MatchError' and '{type(other).__name__!s}'$" ) with pytest.raises(TypeError, match=expected_error): operation(MatchError("foo"), other) @@ -157,21 +155,20 @@ def test_matcherror_compare_no_other_fallback( @pytest.mark.parametrize( "other", ( - None, - "foo", - 42, - Exception("foo"), - DummyTestObject(), + pytest.param(None, id="none"), + pytest.param("foo", id="str"), + pytest.param(42, id="int"), + pytest.param(Exception("foo"), id="exception"), + pytest.param(DummyTestObject(), id="obj"), ), ids=repr, ) @pytest.mark.parametrize( ("operation", "expected_value"), ( - (operator.eq, False), - (operator.ne, True), + pytest.param(operator.eq, False, id="eq"), + pytest.param(operator.ne, True, id="ne"), ), - ids=("==", "!="), ) def test_matcherror_compare_with_other_fallback( other: object, @@ -185,16 +182,15 @@ def test_matcherror_compare_with_other_fallback( @pytest.mark.parametrize( ("operation", "expected_value"), ( - (operator.eq, "EQ_SENTINEL"), - (operator.ne, "NE_SENTINEL"), + pytest.param(operator.eq, "EQ_SENTINEL", id="eq"), + pytest.param(operator.ne, "NE_SENTINEL", id="ne"), # NOTE: these are swapped because when we do `x < y`, and `x.__lt__(y)` # NOTE: returns `NotImplemented`, Python will reverse the check into # NOTE: `y > x`, and so `y.__gt__(x) is called. # Ref: https://docs.python.org/3/reference/datamodel.html#object.__lt__ - (operator.lt, "GT_SENTINEL"), - (operator.gt, "LT_SENTINEL"), + pytest.param(operator.lt, "GT_SENTINEL", id="gt"), + pytest.param(operator.gt, "LT_SENTINEL", id="lt"), ), - ids=("==", "!=", "<", ">"), ) def test_matcherror_compare_with_dummy_sentinel( operation: Callable[..., bool], diff --git a/test/test_mockings.py b/test/test_mockings.py index 0e8d77a..417d5d5 100644 --- a/test/test_mockings.py +++ b/test/test_mockings.py @@ -1,18 +1,18 @@ """Test mockings module.""" -from typing import Any + +from pathlib import Path import pytest from ansiblelint._mockings import _make_module_stub -from ansiblelint.config import options +from ansiblelint.config import Options from ansiblelint.constants import RC -def test_make_module_stub(mocker: Any) -> None: +def test_make_module_stub(config_options: Options) -> None: """Test make module stub.""" - mocker.patch("ansiblelint.config.options.cache_dir", return_value=".") - assert options.cache_dir is not None + config_options.cache_dir = Path() # current directory with pytest.raises(SystemExit) as exc: - _make_module_stub(module_name="", options=options) + _make_module_stub(module_name="", options=config_options) assert exc.type == SystemExit assert exc.value.code == RC.INVALID_CONFIG diff --git a/test/test_profiles.py b/test/test_profiles.py index a40382c..a1d9865 100644 --- a/test/test_profiles.py +++ b/test/test_profiles.py @@ -1,4 +1,5 @@ """Tests for the --profile feature.""" + import platform import subprocess import sys @@ -21,7 +22,7 @@ def test_profile_min() -> None: filter_rules_with_profile(collection.rules, "min") assert ( - len(collection.rules) == 3 + len(collection.rules) == 4 ), "Failed to unload rule that is not part of 'min' profile." diff --git a/test/test_requirements.py b/test/test_requirements.py new file mode 100644 index 0000000..0703d34 --- /dev/null +++ b/test/test_requirements.py @@ -0,0 +1,18 @@ +"""Tests requirements module.""" + +from ansible_compat.runtime import Runtime + +from ansiblelint.requirements import Reqs + + +def test_reqs() -> None: + """Performs basic testing of Reqs class.""" + reqs = Reqs() + runtime = Runtime() + assert "ansible-core" in reqs + # checks that this ansible core version is not supported: + assert reqs.matches("ansible-core", "0.0") is False + # assert that invalid package name + assert reqs.matches("this-package-does-not-exist", "0.0") is False + # check the current ansible core version is supported: + assert reqs.matches("ansible-core", runtime.version) diff --git a/test/test_rule_properties.py b/test/test_rule_properties.py index 7db3afd..3e5eb3e 100644 --- a/test/test_rule_properties.py +++ b/test/test_rule_properties.py @@ -1,4 +1,5 @@ """Tests related to rule properties.""" + from ansiblelint.rules import RulesCollection diff --git a/test/test_rules_collection.py b/test/test_rules_collection.py index 66c69ec..44317fe 100644 --- a/test/test_rules_collection.py +++ b/test/test_rules_collection.py @@ -1,4 +1,5 @@ """Tests for rule collection class.""" + # Copyright (c) 2013-2014 Will Thames <will@thames.id.au> # # Permission is hereby granted, free of charge, to any person obtaining a copy @@ -23,14 +24,17 @@ from __future__ import annotations import collections import re from pathlib import Path +from typing import TYPE_CHECKING import pytest -from ansiblelint.config import options from ansiblelint.file_utils import Lintable from ansiblelint.rules import RulesCollection from ansiblelint.testing import run_ansible_lint +if TYPE_CHECKING: + from ansiblelint.config import Options + @pytest.fixture(name="test_rules_collection") def fixture_test_rules_collection() -> RulesCollection: @@ -153,12 +157,12 @@ def test_rich_rule_listing() -> None: assert rule.description[:30] in result.stdout -def test_rules_id_format() -> None: +def test_rules_id_format(config_options: Options) -> None: """Assure all our rules have consistent format.""" rule_id_re = re.compile("^[a-z-]{4,30}$") rules = RulesCollection( [Path("./src/ansiblelint/rules").resolve()], - options=options, + options=config_options, conditional=False, ) keys: set[str] = set() @@ -171,5 +175,5 @@ def test_rules_id_format() -> None: rule.help or rule.description or rule.__doc__ ), f"Rule {rule.id} must have at least one of: .help, .description, .__doc__" assert "yaml" in keys, "yaml rule is missing" - assert len(rules) == 49 # update this number when adding new rules! + assert len(rules) == 50 # update this number when adding new rules! assert len(keys) == len(rules), "Duplicate rule ids?" diff --git a/test/test_runner.py b/test/test_runner.py index e89cee1..aa76b65 100644 --- a/test/test_runner.py +++ b/test/test_runner.py @@ -1,4 +1,5 @@ """Tests for runner submodule.""" + # Copyright (c) 2013-2014 Will Thames <will@thames.id.au> # # Permission is hereby granted, free of charge, to any person obtaining a copy @@ -48,7 +49,7 @@ LOTS_OF_WARNINGS_PLAYBOOK = Path("examples/playbooks/lots_of_warnings.yml").reso pytest.param( LOTS_OF_WARNINGS_PLAYBOOK, [LOTS_OF_WARNINGS_PLAYBOOK], - 992, + 993, id="lots_of_warnings", ), pytest.param(Path("examples/playbooks/become.yml"), [], 0, id="become"), @@ -86,21 +87,23 @@ def test_runner_exclude_paths(default_rules_collection: RulesCollection) -> None assert len(matches) == 0 -@pytest.mark.parametrize(("exclude_path"), ("**/playbooks/*.yml",)) +@pytest.mark.parametrize( + ("exclude_path"), + (pytest.param("**/playbooks_globs/*b.yml", id="1"),), +) def test_runner_exclude_globs( default_rules_collection: RulesCollection, exclude_path: str, ) -> None: """Test that globs work.""" runner = Runner( - "examples/playbooks", + "examples/playbooks_globs", rules=default_rules_collection, exclude_paths=[exclude_path], ) matches = runner.run() - # we expect to find one match from the very few .yaml file we have there (most of them have .yml extension) - assert len(matches) == 1 + assert len(matches) == 0 @pytest.mark.parametrize( @@ -175,6 +178,52 @@ def test_files_not_scanned_twice(default_rules_collection: RulesCollection) -> N assert len(run2) == 0 +@pytest.mark.parametrize( + ("filename", "failures", "checked_files_no"), + ( + pytest.param( + "examples/playbooks/common-include-wrong-syntax.yml", + 1, + 1, + id="1", + ), + pytest.param( + "examples/playbooks/common-include-wrong-syntax2.yml", + 1, + 1, + id="2", + ), + pytest.param( + "examples/playbooks/common-include-wrong-syntax3.yml", + 0, + 2, + id="3", + ), + ), +) +def test_include_wrong_syntax( + filename: str, + failures: int, + checked_files_no: int, + default_rules_collection: RulesCollection, +) -> None: + """Ensure that lintables aren't double-checked.""" + checked_files: set[Lintable] = set() + + path = Path(filename).resolve() + runner = Runner( + path, + rules=default_rules_collection, + verbosity=0, + checked_files=checked_files, + ) + result = runner.run() + assert len(runner.checked_files) == checked_files_no + assert len(result) == failures, result + for item in result: + assert item.tag == "syntax-check[no-file]" + + def test_runner_not_found(default_rules_collection: RulesCollection) -> None: """Ensure that lintables aren't double-checked.""" checked_files: set[Lintable] = set() @@ -208,3 +257,16 @@ def test_runner_tmp_file( result = runner.run() assert len(result) == 1 assert result[0].tag == "syntax-check[empty-playbook]" + + +def test_with_full_path(default_rules_collection: RulesCollection) -> None: + """Ensure that lintables include file path starting from home directory.""" + filename = Path("examples/playbooks/deep").absolute() + runner = Runner( + filename, + rules=default_rules_collection, + verbosity=0, + ) + result = runner.run() + assert len(result) == 1 + assert result[0].tag == "name[casing]" diff --git a/test/test_schemas.py b/test/test_schemas.py index 6392241..646a283 100644 --- a/test/test_schemas.py +++ b/test/test_schemas.py @@ -1,16 +1,18 @@ """Test schemas modules.""" + import json import logging +import os import subprocess import sys import urllib +import warnings from pathlib import Path -from time import sleep from typing import Any from unittest.mock import DEFAULT, MagicMock, patch +import license_expression import pytest -import spdx.config from ansiblelint.file_utils import Lintable from ansiblelint.schemas import __file__ as schema_module @@ -18,21 +20,9 @@ from ansiblelint.schemas.__main__ import refresh_schemas from ansiblelint.schemas.main import validate_file_schema schema_path = Path(schema_module).parent -spdx_config_path = Path(spdx.config.__file__).parent - - -def test_refresh_schemas() -> None: - """Test for schema update skip.""" - # This is written as a single test in order to avoid concurrency issues, - # which caused random issues on CI when the two tests run in parallel - # and or in different order. - assert refresh_schemas(min_age_seconds=3600 * 24 * 365 * 10) == 0 - sleep(1) - # this should disable the cache and force an update - assert refresh_schemas(min_age_seconds=0) == 1 - sleep(1) - # should be cached now - assert refresh_schemas(min_age_seconds=10) == 0 +spdx_config_path = ( + Path(license_expression.__file__).parent / "data" / "scancode-licensedb-index.json" +) def urlopen_side_effect(*_args: Any, **kwargs: Any) -> DEFAULT: @@ -59,7 +49,7 @@ def test_request_timeouterror_handling( error_msg = "Simulating handshake operation time out." mock_request.urlopen.side_effect = urllib.error.URLError(TimeoutError(error_msg)) with caplog.at_level(logging.DEBUG): - assert refresh_schemas(min_age_seconds=0) == 1 + assert refresh_schemas(min_age_seconds=0) == 0 mock_request.urlopen.assert_called() assert "Skipped schema refresh due to unexpected exception: " in caplog.text assert error_msg in caplog.text @@ -73,7 +63,7 @@ def test_schema_refresh_cli() -> None: capture_output=True, text=True, ) - assert proc.returncode == 0 + assert proc.returncode == 0, proc def test_validate_file_schema() -> None: @@ -86,24 +76,33 @@ def test_validate_file_schema() -> None: def test_spdx() -> None: """Test that SPDX license identifiers are in sync.""" - _licenses = spdx_config_path / "licenses.json" - license_ids = set() - with _licenses.open(encoding="utf-8") as license_fh: + with spdx_config_path.open(encoding="utf-8") as license_fh: licenses = json.load(license_fh) - for lic in licenses["licenses"]: - if lic.get("isDeprecatedLicenseId"): + for lic in licenses: + if lic.get("is_deprecated"): + continue + lic_id = lic["spdx_license_key"] + if lic_id.startswith("LicenseRef"): continue - license_ids.add(lic["licenseId"]) + license_ids.add(lic_id) galaxy_json = schema_path / "galaxy.json" with galaxy_json.open(encoding="utf-8") as f: schema = json.load(f) spx_enum = schema["$defs"]["SPDXLicenseEnum"]["enum"] if set(spx_enum) != license_ids: - with galaxy_json.open("w", encoding="utf-8") as f: - schema["$defs"]["SPDXLicenseEnum"]["enum"] = sorted(license_ids) - json.dump(schema, f, indent=2) - pytest.fail( - "SPDX license list inside galaxy.json JSON Schema file was updated.", - ) + # In absence of a + if os.environ.get("PIP_CONSTRAINT", "/dev/null") == "/dev/null": + with galaxy_json.open("w", encoding="utf-8") as f: + schema["$defs"]["SPDXLicenseEnum"]["enum"] = sorted(license_ids) + json.dump(schema, f, indent=2) + pytest.fail( + "SPDX license list inside galaxy.json JSON Schema file was updated.", + ) + else: + warnings.warn( + "test_spdx failure was ignored because constraints were not pinned (PIP_CONSTRAINTS). This is expected for py310 and py-devel jobs.", + category=pytest.PytestWarning, + stacklevel=1, + ) diff --git a/test/test_skip_import_playbook.py b/test/test_skip_import_playbook.py index 777fec6..8674c16 100644 --- a/test/test_skip_import_playbook.py +++ b/test/test_skip_import_playbook.py @@ -1,4 +1,5 @@ """Test related to skipping import_playbook.""" + from pathlib import Path import pytest diff --git a/test/test_skip_inside_yaml.py b/test/test_skip_inside_yaml.py index 363734e..8050f13 100644 --- a/test/test_skip_inside_yaml.py +++ b/test/test_skip_inside_yaml.py @@ -1,4 +1,5 @@ """Tests related to use of inline noqa.""" + import pytest from ansiblelint.rules import RulesCollection diff --git a/test/test_skip_playbook_items.py b/test/test_skip_playbook_items.py index 2861c6a..2fc05ea 100644 --- a/test/test_skip_playbook_items.py +++ b/test/test_skip_playbook_items.py @@ -1,4 +1,5 @@ """Tests related to use of noqa inside playbooks.""" + import pytest from ansiblelint.testing import RunFromText diff --git a/test/test_skiputils.py b/test/test_skiputils.py index 7e736e7..2975945 100644 --- a/test/test_skiputils.py +++ b/test/test_skiputils.py @@ -1,4 +1,5 @@ """Validate ansiblelint.skip_utils.""" + from __future__ import annotations from pathlib import Path @@ -39,8 +40,8 @@ PLAYBOOK_WITH_NOQA = """\ @pytest.mark.parametrize( ("line", "expected"), ( - ("foo # noqa: bar", "bar"), - ("foo # noqa bar", "bar"), + pytest.param("foo # noqa: bar", "bar", id="0"), + pytest.param("foo # noqa bar", "bar", id="1"), ), ) def test_get_rule_skips_from_line(line: str, expected: str) -> None: diff --git a/test/test_strict.py b/test/test_strict.py index ba93d7c..5994ffd 100644 --- a/test/test_strict.py +++ b/test/test_strict.py @@ -1,4 +1,5 @@ """Test strict mode.""" + import os import pytest diff --git a/test/test_task_includes.py b/test/test_task_includes.py index 3b02d00..80b5856 100644 --- a/test/test_task_includes.py +++ b/test/test_task_includes.py @@ -1,4 +1,5 @@ """Tests related to task inclusions.""" + import pytest from ansiblelint.file_utils import Lintable @@ -9,7 +10,12 @@ from ansiblelint.runner import Runner @pytest.mark.parametrize( ("filename", "file_count", "match_count"), ( - pytest.param("examples/playbooks/blockincludes.yml", 4, 3, id="blockincludes"), + pytest.param( + "examples/playbooks/blockincludes.yml", + 4, + 3, + id="blockincludes", + ), pytest.param( "examples/playbooks/blockincludes2.yml", 4, diff --git a/test/test_text.py b/test/test_text.py index fa91fee..22214c7 100644 --- a/test/test_text.py +++ b/test/test_text.py @@ -1,4 +1,5 @@ """Tests for text module.""" + from typing import Any import pytest diff --git a/test/test_transform_mixin.py b/test/test_transform_mixin.py index d639bff..44b851b 100644 --- a/test/test_transform_mixin.py +++ b/test/test_transform_mixin.py @@ -1,4 +1,5 @@ """Tests for TransformMixin.""" + from __future__ import annotations from typing import TYPE_CHECKING @@ -55,72 +56,91 @@ def test_seek_with_bad_path( @pytest.mark.parametrize( ("yaml_path", "data", "expected"), ( - ([], DUMMY_MAP, DUMMY_MAP), - (["foo"], DUMMY_MAP, DUMMY_MAP["foo"]), - (["bar"], DUMMY_MAP, DUMMY_MAP["bar"]), - (["bar", "some"], DUMMY_MAP, DUMMY_MAP["bar"]["some"]), - (["fruits"], DUMMY_MAP, DUMMY_MAP["fruits"]), - (["fruits", 0], DUMMY_MAP, DUMMY_MAP["fruits"][0]), - (["fruits", 1], DUMMY_MAP, DUMMY_MAP["fruits"][1]), - (["answer"], DUMMY_MAP, DUMMY_MAP["answer"]), - (["answer", 0], DUMMY_MAP, DUMMY_MAP["answer"][0]), - (["answer", 0, "forty-two"], DUMMY_MAP, DUMMY_MAP["answer"][0]["forty-two"]), - ( + pytest.param([], DUMMY_MAP, DUMMY_MAP, id="0"), + pytest.param(["foo"], DUMMY_MAP, DUMMY_MAP["foo"], id="1"), + pytest.param(["bar"], DUMMY_MAP, DUMMY_MAP["bar"], id="2"), + pytest.param(["bar", "some"], DUMMY_MAP, DUMMY_MAP["bar"]["some"], id="3"), + pytest.param(["fruits"], DUMMY_MAP, DUMMY_MAP["fruits"], id="4"), + pytest.param(["fruits", 0], DUMMY_MAP, DUMMY_MAP["fruits"][0], id="5"), + pytest.param(["fruits", 1], DUMMY_MAP, DUMMY_MAP["fruits"][1], id="6"), + pytest.param(["answer"], DUMMY_MAP, DUMMY_MAP["answer"], id="7"), + pytest.param(["answer", 0], DUMMY_MAP, DUMMY_MAP["answer"][0], id="8"), + pytest.param( + ["answer", 0, "forty-two"], + DUMMY_MAP, + DUMMY_MAP["answer"][0]["forty-two"], + id="9", + ), + pytest.param( ["answer", 0, "forty-two", 0], DUMMY_MAP, DUMMY_MAP["answer"][0]["forty-two"][0], + id="10", ), - ( + pytest.param( ["answer", 0, "forty-two", 1], DUMMY_MAP, DUMMY_MAP["answer"][0]["forty-two"][1], + id="11", ), - ( + pytest.param( ["answer", 0, "forty-two", 2], DUMMY_MAP, DUMMY_MAP["answer"][0]["forty-two"][2], + id="12", + ), + pytest.param([], DUMMY_LIST, DUMMY_LIST, id="13"), + pytest.param([0], DUMMY_LIST, DUMMY_LIST[0], id="14"), + pytest.param([0, "foo"], DUMMY_LIST, DUMMY_LIST[0]["foo"], id="15"), + pytest.param([1], DUMMY_LIST, DUMMY_LIST[1], id="16"), + pytest.param([1, "bar"], DUMMY_LIST, DUMMY_LIST[1]["bar"], id="17"), + pytest.param( + [1, "bar", "some"], + DUMMY_LIST, + DUMMY_LIST[1]["bar"]["some"], + id="18", ), - ([], DUMMY_LIST, DUMMY_LIST), - ([0], DUMMY_LIST, DUMMY_LIST[0]), - ([0, "foo"], DUMMY_LIST, DUMMY_LIST[0]["foo"]), - ([1], DUMMY_LIST, DUMMY_LIST[1]), - ([1, "bar"], DUMMY_LIST, DUMMY_LIST[1]["bar"]), - ([1, "bar", "some"], DUMMY_LIST, DUMMY_LIST[1]["bar"]["some"]), - ([1, "fruits"], DUMMY_LIST, DUMMY_LIST[1]["fruits"]), - ([1, "fruits", 0], DUMMY_LIST, DUMMY_LIST[1]["fruits"][0]), - ([1, "fruits", 1], DUMMY_LIST, DUMMY_LIST[1]["fruits"][1]), - ([2], DUMMY_LIST, DUMMY_LIST[2]), - ([2, "answer"], DUMMY_LIST, DUMMY_LIST[2]["answer"]), - ([2, "answer", 0], DUMMY_LIST, DUMMY_LIST[2]["answer"][0]), - ( + pytest.param([1, "fruits"], DUMMY_LIST, DUMMY_LIST[1]["fruits"], id="19"), + pytest.param([1, "fruits", 0], DUMMY_LIST, DUMMY_LIST[1]["fruits"][0], id="20"), + pytest.param([1, "fruits", 1], DUMMY_LIST, DUMMY_LIST[1]["fruits"][1], id="21"), + pytest.param([2], DUMMY_LIST, DUMMY_LIST[2], id="22"), + pytest.param([2, "answer"], DUMMY_LIST, DUMMY_LIST[2]["answer"], id="23"), + pytest.param([2, "answer", 0], DUMMY_LIST, DUMMY_LIST[2]["answer"][0], id="24"), + pytest.param( [2, "answer", 0, "forty-two"], DUMMY_LIST, DUMMY_LIST[2]["answer"][0]["forty-two"], + id="25", ), - ( + pytest.param( [2, "answer", 0, "forty-two", 0], DUMMY_LIST, DUMMY_LIST[2]["answer"][0]["forty-two"][0], + id="26", ), - ( + pytest.param( [2, "answer", 0, "forty-two", 1], DUMMY_LIST, DUMMY_LIST[2]["answer"][0]["forty-two"][1], + id="27", ), - ( + pytest.param( [2, "answer", 0, "forty-two", 2], DUMMY_LIST, DUMMY_LIST[2]["answer"][0]["forty-two"][2], + id="28", ), - ( + pytest.param( [], "this is a string that should be returned as is, ignoring path.", "this is a string that should be returned as is, ignoring path.", + id="29", ), - ( + pytest.param( [2, "answer", 0, "forty-two", 2], "this is a string that should be returned as is, ignoring path.", "this is a string that should be returned as is, ignoring path.", + id="30", ), ), ) diff --git a/test/test_transformer.py b/test/test_transformer.py index 78dd121..51e97d5 100644 --- a/test/test_transformer.py +++ b/test/test_transformer.py @@ -1,125 +1,247 @@ +# cspell:ignore classinfo """Tests for Transformer.""" + from __future__ import annotations +import builtins import os import shutil from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any +from unittest import mock import pytest +import ansiblelint.__main__ as main +from ansiblelint.app import App +from ansiblelint.file_utils import Lintable +from ansiblelint.rules import TransformMixin + # noinspection PyProtectedMember -from ansiblelint.runner import LintResult, _get_matches +from ansiblelint.runner import LintResult, get_matches from ansiblelint.transformer import Transformer if TYPE_CHECKING: - from argparse import Namespace - from collections.abc import Iterator - from ansiblelint.config import Options + from ansiblelint.errors import MatchError from ansiblelint.rules import RulesCollection -@pytest.fixture(name="copy_examples_dir") -def fixture_copy_examples_dir( - tmp_path: Path, - config_options: Namespace, -) -> Iterator[tuple[Path, Path]]: - """Fixture that copies the examples/ dir into a tmpdir.""" - examples_dir = Path("examples") - - shutil.copytree(examples_dir, tmp_path / "examples") - old_cwd = Path.cwd() - try: - os.chdir(tmp_path) - config_options.cwd = tmp_path - yield old_cwd, tmp_path - finally: - os.chdir(old_cwd) - - @pytest.fixture(name="runner_result") def fixture_runner_result( config_options: Options, default_rules_collection: RulesCollection, - playbook: str, + playbook_str: str, + monkeypatch: pytest.MonkeyPatch, ) -> LintResult: """Fixture that runs the Runner to populate a LintResult for a given file.""" - config_options.lintables = [playbook] - result = _get_matches(rules=default_rules_collection, options=config_options) + # needed for testing transformer when roles/modules are missing: + monkeypatch.setenv("ANSIBLE_LINT_NODEPS", "1") + config_options.lintables = [playbook_str] + result = get_matches(rules=default_rules_collection, options=config_options) return result @pytest.mark.parametrize( - ("playbook", "matches_count", "transformed"), + ("playbook_str", "matches_count", "transformed", "is_owned_by_ansible"), ( # reuse TestRunner::test_runner test cases to ensure transformer does not mangle matches pytest.param( "examples/playbooks/nomatchestest.yml", 0, False, + True, id="nomatchestest", ), - pytest.param("examples/playbooks/unicode.yml", 1, False, id="unicode"), + pytest.param("examples/playbooks/unicode.yml", 1, False, True, id="unicode"), pytest.param( "examples/playbooks/lots_of_warnings.yml", - 992, + 993, False, + True, id="lots_of_warnings", ), - pytest.param("examples/playbooks/become.yml", 0, False, id="become"), + pytest.param("examples/playbooks/become.yml", 0, False, True, id="become"), pytest.param( "examples/playbooks/contains_secrets.yml", 0, False, + True, id="contains_secrets", ), pytest.param( "examples/playbooks/vars/empty_vars.yml", 0, False, + True, id="empty_vars", ), - pytest.param("examples/playbooks/vars/strings.yml", 0, True, id="strings"), - pytest.param("examples/playbooks/vars/empty.yml", 1, False, id="empty"), - pytest.param("examples/playbooks/name-case.yml", 1, True, id="name_case"), - pytest.param("examples/playbooks/fqcn.yml", 3, True, id="fqcn"), + pytest.param( + "examples/playbooks/vars/strings.yml", + 0, + True, + True, + id="strings", + ), + pytest.param("examples/playbooks/vars/empty.yml", 1, False, True, id="empty"), + pytest.param("examples/playbooks/fqcn.yml", 3, True, True, id="fqcn"), + pytest.param( + "examples/playbooks/multi_yaml_doc.yml", + 1, + False, + True, + id="multi_yaml_doc", + ), + pytest.param( + "examples/playbooks/transform_command_instead_of_shell.yml", + 3, + True, + True, + id="cmd_instead_of_shell", + ), + pytest.param( + "examples/playbooks/transform-deprecated-local-action.yml", + 1, + True, + True, + id="dep_local_action", + ), + pytest.param( + "examples/playbooks/transform-block-indentation-indicator.yml", + 0, + True, + True, + id="multiline_msg_with_indent_indicator", + ), + pytest.param( + "examples/playbooks/transform-jinja.yml", + 7, + True, + True, + id="jinja_spacing", + ), + pytest.param( + "examples/playbooks/transform-no-jinja-when.yml", + 3, + True, + True, + id="no_jinja_when", + ), + pytest.param( + "examples/playbooks/vars/transform_nested_data.yml", + 3, + True, + True, + id="nested", + ), + pytest.param( + "examples/playbooks/transform-key-order.yml", + 6, + True, + True, + id="key_order_transform", + ), + pytest.param( + "examples/playbooks/transform-no-free-form.yml", + 5, + True, + True, + id="no_free_form_transform", + ), + pytest.param( + "examples/playbooks/transform-partial-become.yml", + 4, + True, + True, + id="partial_become", + ), + pytest.param( + "examples/playbooks/transform-key-order-play.yml", + 1, + True, + True, + id="key_order_play_transform", + ), + pytest.param( + "examples/playbooks/transform-key-order-block.yml", + 1, + True, + True, + id="key_order_block_transform", + ), + pytest.param( + "examples/.github/workflows/sample.yml", + 0, + False, + False, + id="github-workflow", + ), + pytest.param( + "examples/playbooks/invalid-transform.yml", + 1, + False, + True, + id="invalid_transform", + ), + pytest.param( + "examples/roles/name_prefix/tasks/test.yml", + 1, + True, + True, + id="name_casing_prefix", + ), + pytest.param( + "examples/roles/name_casing/tasks/main.yml", + 2, + True, + True, + id="name_case_roles", + ), + pytest.param( + "examples/playbooks/4114/transform-with-missing-role-and-modules.yml", + 1, + True, + True, + id="4114", + ), ), ) -def test_transformer( # pylint: disable=too-many-arguments, too-many-locals +@mock.patch.dict(os.environ, {"ANSIBLE_LINT_WRITE_TMP": "1"}, clear=True) +def test_transformer( # pylint: disable=too-many-arguments config_options: Options, - copy_examples_dir: tuple[Path, Path], - playbook: str, + playbook_str: str, runner_result: LintResult, transformed: bool, + is_owned_by_ansible: bool, matches_count: int, ) -> None: """Test that transformer can go through any corner cases. Based on TestRunner::test_runner """ + # test ability to detect is_owned_by_ansible + assert Lintable(playbook_str).is_owned_by_ansible() == is_owned_by_ansible + playbook = Path(playbook_str) config_options.write_list = ["all"] - transformer = Transformer(result=runner_result, options=config_options) - transformer.run() matches = runner_result.matches assert len(matches) == matches_count - orig_dir, tmp_dir = copy_examples_dir - orig_playbook = orig_dir / playbook - expected_playbook = orig_dir / playbook.replace(".yml", ".transformed.yml") - transformed_playbook = tmp_dir / playbook - - orig_playbook_content = orig_playbook.read_text() - expected_playbook_content = expected_playbook.read_text() - transformed_playbook_content = transformed_playbook.read_text() + transformer = Transformer(result=runner_result, options=config_options) + transformer.run() + orig_content = playbook.read_text(encoding="utf-8") if transformed: - assert orig_playbook_content != transformed_playbook_content - else: - assert orig_playbook_content == transformed_playbook_content + expected_content = playbook.with_suffix( + f".transformed{playbook.suffix}", + ).read_text(encoding="utf-8") + transformed_content = playbook.with_suffix(f".tmp{playbook.suffix}").read_text( + encoding="utf-8", + ) - assert transformed_playbook_content == expected_playbook_content + assert orig_content != transformed_content + assert expected_content == transformed_content + playbook.with_suffix(f".tmp{playbook.suffix}").unlink() @pytest.mark.parametrize( @@ -173,3 +295,341 @@ def test_effective_write_set(write_list: list[str], expected: set[str]) -> None: """Make sure effective_write_set handles all/none keywords correctly.""" actual = Transformer.effective_write_set(write_list) assert actual == expected + + +def test_pruned_err_after_fix(monkeypatch: pytest.MonkeyPatch, tmpdir: Path) -> None: + """Test that pruned errors are not reported after fixing. + + :param monkeypatch: Monkeypatch + :param tmpdir: Temporary directory + """ + file = Path("examples/playbooks/transform-jinja.yml") + source = Path.cwd() / file + dest = tmpdir / source.name + shutil.copyfile(source, dest) + + monkeypatch.setattr("sys.argv", ["ansible-lint", str(dest), "--fix=all"]) + + fix_called = False + orig_fix = main.fix + + def test_fix( + runtime_options: Options, + result: LintResult, + rules: RulesCollection, + ) -> None: + """Wrap main.fix to check if it was called and match count is correct. + + :param runtime_options: Runtime options + :param result: Lint result + :param rules: Rules collection + """ + nonlocal fix_called + fix_called = True + assert len(result.matches) == 7 + orig_fix(runtime_options, result, rules) + + report_called = False + + class TestApp(App): + """Wrap App to check if it was called and match count is correct.""" + + def report_outcome( + self: TestApp, + result: LintResult, + *, + mark_as_success: bool = False, + ) -> int: + """Wrap App.report_outcome to check if it was called and match count is correct. + + :param result: Lint result + :param mark_as_success: Mark as success + :returns: Exit code + """ + nonlocal report_called + report_called = True + assert len(result.matches) == 1 + return super().report_outcome(result, mark_as_success=mark_as_success) + + monkeypatch.setattr("ansiblelint.__main__.fix", test_fix) + monkeypatch.setattr("ansiblelint.app.App", TestApp) + + main.main() + assert fix_called + assert report_called + + +class TransformTests: + """A carrier for some common test constants.""" + + FILE_NAME = "examples/playbooks/transform-no-free-form.yml" + FILE_TYPE = "playbook" + LINENO = 5 + ID = "no-free-form" + MATCH_TYPE = "task" + VERSION_PART = "version=(1, 1)" + + @classmethod + def match_id(cls) -> str: + """Generate a match id. + + :returns: Match id string + """ + return f"{cls.ID}/{cls.MATCH_TYPE} {cls.FILE_NAME}:{cls.LINENO}" + + @classmethod + def rewrite_part(cls) -> str: + """Generate a rewrite part. + + :returns: Rewrite part string + """ + return f"{cls.FILE_NAME} ({cls.FILE_TYPE}), {cls.VERSION_PART}" + + +@pytest.fixture(name="test_result") +def fixture_test_result( + config_options: Options, + default_rules_collection: RulesCollection, +) -> tuple[LintResult, Options]: + """Fixture that runs the Runner to populate a LintResult for a given file. + + The results are confirmed and a limited to a single match. + + :param config_options: Configuration options + :param default_rules_collection: Default rules collection + :returns: Tuple of LintResult and Options + """ + config_options.write_list = [TransformTests.ID] + config_options.lintables = [TransformTests.FILE_NAME] + + result = get_matches(rules=default_rules_collection, options=config_options) + match = result.matches[0] + + def write(*_args: Any, **_kwargs: Any) -> None: + """Don't rewrite the test fixture. + + :param _args: Arguments + :param _kwargs: Keyword arguments + """ + + setattr(match.lintable, "write", write) # noqa: B010 + + assert match.rule.id == TransformTests.ID + assert match.filename == TransformTests.FILE_NAME + assert match.lineno == TransformTests.LINENO + assert match.match_type == TransformTests.MATCH_TYPE + result.matches = [match] + + return result, config_options + + +def test_transform_na( + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, + test_result: tuple[LintResult, Options], +) -> None: + """Test the transformer is not available. + + :param caplog: Log capture fixture + :param monkeypatch: Monkeypatch + :param test_result: Test result fixture + """ + result = test_result[0] + options = test_result[1] + + _isinstance = builtins.isinstance + called = False + + def mp_isinstance(t_object: Any, classinfo: type) -> bool: + if classinfo is TransformMixin: + nonlocal called + called = True + return False + return _isinstance(t_object, classinfo) + + monkeypatch.setattr(builtins, "isinstance", mp_isinstance) + + transformer = Transformer(result=result, options=options) + with caplog.at_level(10): + transformer.run() + + assert called + logs = [record for record in caplog.records if record.module == "transformer"] + assert len(logs) == 2 + + log_0 = f"{transformer.FIX_NA_MSG} {TransformTests.match_id()}" + assert logs[0].message == log_0 + assert logs[0].levelname == "DEBUG" + + log_1 = f"{transformer.DUMP_MSG} {TransformTests.rewrite_part()}" + assert logs[1].message == log_1 + assert logs[1].levelname == "DEBUG" + + +def test_transform_no_tb( + caplog: pytest.LogCaptureFixture, + test_result: tuple[LintResult, Options], +) -> None: + """Test the transformer does not traceback. + + :param caplog: Log capture fixture + :param test_result: Test result fixture + :raises RuntimeError: If the rule is not a TransformMixin + """ + result = test_result[0] + options = test_result[1] + exception_msg = "FixFailure" + + def transform(*_args: Any, **_kwargs: Any) -> None: + """Raise an exception for the transform call. + + :raises RuntimeError: Always + """ + raise RuntimeError(exception_msg) + + if isinstance(result.matches[0].rule, TransformMixin): + setattr(result.matches[0].rule, "transform", transform) # noqa: B010 + else: + err = "Rule is not a TransformMixin" + raise TypeError(err) + + transformer = Transformer(result=result, options=options) + with caplog.at_level(10): + transformer.run() + + logs = [record for record in caplog.records if record.module == "transformer"] + assert len(logs) == 5 + + log_0 = f"{transformer.FIX_APPLY_MSG} {TransformTests.match_id()}" + assert logs[0].message == log_0 + assert logs[0].levelname == "DEBUG" + + log_1 = f"{transformer.FIX_FAILED_MSG} {TransformTests.match_id()}" + assert logs[1].message == log_1 + assert logs[1].levelname == "ERROR" + + log_2 = exception_msg + assert logs[2].message == log_2 + assert logs[2].levelname == "ERROR" + + log_3 = f"{transformer.FIX_ISSUE_MSG}" + assert logs[3].message == log_3 + assert logs[3].levelname == "ERROR" + + log_4 = f"{transformer.DUMP_MSG} {TransformTests.rewrite_part()}" + assert logs[4].message == log_4 + assert logs[4].levelname == "DEBUG" + + +def test_transform_applied( + caplog: pytest.LogCaptureFixture, + test_result: tuple[LintResult, Options], +) -> None: + """Test the transformer is applied. + + :param caplog: Log capture fixture + :param test_result: Test result fixture + """ + result = test_result[0] + options = test_result[1] + + transformer = Transformer(result=result, options=options) + with caplog.at_level(10): + transformer.run() + + logs = [record for record in caplog.records if record.module == "transformer"] + assert len(logs) == 3 + + log_0 = f"{transformer.FIX_APPLY_MSG} {TransformTests.match_id()}" + assert logs[0].message == log_0 + assert logs[0].levelname == "DEBUG" + + log_1 = f"{transformer.FIX_APPLIED_MSG} {TransformTests.match_id()}" + assert logs[1].message == log_1 + assert logs[1].levelname == "DEBUG" + + log_2 = f"{transformer.DUMP_MSG} {TransformTests.rewrite_part()}" + assert logs[2].message == log_2 + assert logs[2].levelname == "DEBUG" + + +def test_transform_not_enabled( + caplog: pytest.LogCaptureFixture, + test_result: tuple[LintResult, Options], +) -> None: + """Test the transformer is not enabled. + + :param caplog: Log capture fixture + :param test_result: Test result fixture + """ + result = test_result[0] + options = test_result[1] + options.write_list = [] + + transformer = Transformer(result=result, options=options) + with caplog.at_level(10): + transformer.run() + + logs = [record for record in caplog.records if record.module == "transformer"] + assert len(logs) == 2 + + log_0 = f"{transformer.FIX_NE_MSG} {TransformTests.match_id()}" + assert logs[0].message == log_0 + assert logs[0].levelname == "DEBUG" + + log_1 = f"{transformer.DUMP_MSG} {TransformTests.rewrite_part()}" + assert logs[1].message == log_1 + assert logs[1].levelname == "DEBUG" + + +def test_transform_not_applied( + caplog: pytest.LogCaptureFixture, + test_result: tuple[LintResult, Options], +) -> None: + """Test the transformer is not applied. + + :param caplog: Log capture fixture + :param test_result: Test result fixture + :raises RuntimeError: If the rule is not a TransformMixin + """ + result = test_result[0] + options = test_result[1] + + called = False + + def transform(match: MatchError, *_args: Any, **_kwargs: Any) -> None: + """Do not apply the transform. + + :param match: Match object + :param _args: Arguments + :param _kwargs: Keyword arguments + """ + nonlocal called + called = True + match.fixed = False + + if isinstance(result.matches[0].rule, TransformMixin): + setattr(result.matches[0].rule, "transform", transform) # noqa: B010 + else: + err = "Rule is not a TransformMixin" + raise TypeError(err) + + transformer = Transformer(result=result, options=options) + with caplog.at_level(10): + transformer.run() + + assert called + logs = [record for record in caplog.records if record.module == "transformer"] + assert len(logs) == 3 + + log_0 = f"{transformer.FIX_APPLY_MSG} {TransformTests.match_id()}" + assert logs[0].message == log_0 + assert logs[0].levelname == "DEBUG" + + log_1 = f"{transformer.FIX_NOT_APPLIED_MSG} {TransformTests.match_id()}" + assert logs[1].message == log_1 + assert logs[1].levelname == "ERROR" + + log_2 = f"{transformer.DUMP_MSG} {TransformTests.rewrite_part()}" + assert logs[2].message == log_2 + assert logs[2].levelname == "DEBUG" diff --git a/test/test_utils.py b/test/test_utils.py index 1b9a2dc..6f728dc 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -51,37 +51,44 @@ runtime = Runtime(require_module=True) @pytest.mark.parametrize( - ("string", "expected_cmd", "expected_args", "expected_kwargs"), + ("string", "expected_args", "expected_kwargs"), ( - pytest.param("", "", [], {}, id="blank"), - pytest.param("vars:", "vars", [], {}, id="single_word"), - pytest.param("hello: a=1", "hello", [], {"a": "1"}, id="string_module_and_arg"), - pytest.param("action: hello a=1", "hello", [], {"a": "1"}, id="strips_action"), + pytest.param("", [], {}, id="a"), + pytest.param("a=1", [], {"a": "1"}, id="b"), + pytest.param("hello a=1", ["hello"], {"a": "1"}, id="c"), pytest.param( - "action: whatever bobbins x=y z=x c=3", - "whatever", - ["bobbins", "x=y", "z=x", "c=3"], - {}, + "whatever bobbins x=y z=x c=3", + ["whatever", "bobbins"], + {"x": "y", "z": "x", "c": "3"}, id="more_than_one_arg", ), pytest.param( - "action: command chdir=wxy creates=zyx tar xzf zyx.tgz", - "command", - ["tar", "xzf", "zyx.tgz"], + "command chdir=wxy creates=zyx tar xzf zyx.tgz", + ["command", "tar", "xzf", "zyx.tgz"], {"chdir": "wxy", "creates": "zyx"}, id="command_with_args", ), + pytest.param( + "{{ varset }}.yml", + ["{{ varset }}.yml"], + {}, + id="x", + ), + pytest.param( + "foo bar.yml", + ["foo bar.yml"], + {}, + id="path-with-spaces", + ), ), ) def test_tokenize( string: str, - expected_cmd: str, expected_args: Sequence[str], expected_kwargs: dict[str, Any], ) -> None: """Test that tokenize works for different input types.""" - (cmd, args, kwargs) = utils.tokenize(string) - assert cmd == expected_cmd + (args, kwargs) = utils.tokenize(string) assert args == expected_args assert kwargs == expected_kwargs @@ -113,37 +120,42 @@ def test_normalize( alternate_forms: tuple[dict[str, Any]], ) -> None: """Test that tasks specified differently are normalized same way.""" - normal_form = utils.normalize_task(reference_form, "tasks.yml") + task = utils.Task(reference_form, filename="tasks.yml") + normal_form = task._normalize_task() # noqa: SLF001 for form in alternate_forms: - assert normal_form == utils.normalize_task(form, "tasks.yml") + task2 = utils.Task(form, filename="tasks.yml") + assert normal_form == task2._normalize_task() # noqa: SLF001 def test_normalize_complex_command() -> None: """Test that tasks specified differently are normalized same way.""" - task1 = { - "name": "hello", - "action": {"module": "pip", "name": "df", "editable": "false"}, - } - task2 = {"name": "hello", "pip": {"name": "df", "editable": "false"}} - task3 = {"name": "hello", "pip": "name=df editable=false"} - task4 = {"name": "hello", "action": "pip name=df editable=false"} - assert utils.normalize_task(task1, "tasks.yml") == utils.normalize_task( - task2, - "tasks.yml", + task1 = utils.Task( + { + "name": "hello", + "action": {"module": "pip", "name": "df", "editable": "false"}, + }, + filename="tasks.yml", ) - assert utils.normalize_task(task2, "tasks.yml") == utils.normalize_task( - task3, - "tasks.yml", + task2 = utils.Task( + {"name": "hello", "pip": {"name": "df", "editable": "false"}}, + filename="tasks.yml", ) - assert utils.normalize_task(task3, "tasks.yml") == utils.normalize_task( - task4, - "tasks.yml", + task3 = utils.Task( + {"name": "hello", "pip": "name=df editable=false"}, + filename="tasks.yml", ) + task4 = utils.Task( + {"name": "hello", "action": "pip name=df editable=false"}, + filename="tasks.yml", + ) + assert task1._normalize_task() == task2._normalize_task() # noqa: SLF001 + assert task2._normalize_task() == task3._normalize_task() # noqa: SLF001 + assert task3._normalize_task() == task4._normalize_task() # noqa: SLF001 @pytest.mark.parametrize( - ("task", "expected_form"), + ("task_raw", "expected_form"), ( pytest.param( { @@ -191,8 +203,12 @@ def test_normalize_complex_command() -> None: ), ), ) -def test_normalize_task_v2(task: dict[str, Any], expected_form: dict[str, Any]) -> None: +def test_normalize_task_v2( + task_raw: dict[str, Any], + expected_form: dict[str, Any], +) -> None: """Check that it normalizes task and returns the expected form.""" + task = utils.Task(task_raw) assert utils.normalize_task_v2(task) == expected_form @@ -262,8 +278,8 @@ def test_template(template: str, output: str) -> None: def test_task_to_str_unicode() -> None: """Ensure that extracting messages from tasks preserves Unicode.""" - task = {"fail": {"msg": "unicode é ô à"}} - result = utils.task_to_str(utils.normalize_task(task, "filename.yml")) + task = utils.Task({"fail": {"msg": "unicode é ô à"}}, filename="filename.yml") + result = utils.task_to_str(task._normalize_task()) # noqa: SLF001 assert result == "fail msg=unicode é ô à" @@ -447,3 +463,20 @@ def test_task_in_list(file: str, names: list[str], positions: list[str]) -> None for index, task in enumerate(tasks): assert task.name == names[index] assert task.position == positions[index] + + +def test_find_children_in_module(default_rules_collection: RulesCollection) -> None: + """Verify correct function of find_children() in tasks.""" + lintable = Lintable("plugins/modules/fake_module.py") + children = Runner( + rules=default_rules_collection, + ).find_children(lintable) + assert len(children) == 1 + child = children[0] + + # Parent is a python file + assert lintable.base_kind == "text/python" + + # Child correctly looks like a YAML file + assert child.base_kind == "text/yaml" + assert child.content.startswith("---") diff --git a/test/test_verbosity.py b/test/test_verbosity.py index d3ddb3c..38df170 100644 --- a/test/test_verbosity.py +++ b/test/test_verbosity.py @@ -1,4 +1,5 @@ """Tests related to our logging/verbosity setup.""" + from __future__ import annotations from pathlib import Path @@ -6,6 +7,7 @@ from pathlib import Path import pytest from ansiblelint.testing import run_ansible_lint +from ansiblelint.text import strip_ansi_escape # substrs is a list of tuples, where: @@ -83,6 +85,9 @@ def test_verbosity( else: result = run_ansible_lint(str(fakerole), cwd=project_path) + result.stderr = strip_ansi_escape(result.stderr) + result.stdout = strip_ansi_escape(result.stdout) + assert result.returncode == 2, result for substr, invert in substrs: if invert: assert substr not in result.stderr, result.stderr diff --git a/test/test_with_skip_tagid.py b/test/test_with_skip_tagid.py index 5fbea8f..a2a46c3 100644 --- a/test/test_with_skip_tagid.py +++ b/test/test_with_skip_tagid.py @@ -1,4 +1,5 @@ """Tests related to skip tag id.""" + from ansiblelint.rules import RulesCollection from ansiblelint.rules.yaml_rule import YamllintRule from ansiblelint.runner import Runner @@ -26,7 +27,7 @@ def test_negative_with_id() -> None: def test_negative_with_tag() -> None: """Negative test with_tag.""" - with_tag = "trailing-spaces" + with_tag = "yaml[trailing-spaces]" bad_runner = Runner(FILE, rules=collection, tags=frozenset([with_tag])) errs = bad_runner.run() assert len(errs) == 1 @@ -39,6 +40,13 @@ def test_positive_skip_id() -> None: assert [] == good_runner.run() +def test_positive_skip_id_2() -> None: + """Positive test skip_id.""" + skip_id = "key-order" + good_runner = Runner(FILE, rules=collection, tags=frozenset([skip_id])) + assert [] == good_runner.run() + + def test_positive_skip_tag() -> None: """Positive test skip_tag.""" skip_tag = "yaml[trailing-spaces]" diff --git a/test/test_yaml_utils.py b/test/test_yaml_utils.py index 5546e58..f4d9b46 100644 --- a/test/test_yaml_utils.py +++ b/test/test_yaml_utils.py @@ -1,16 +1,18 @@ """Tests for yaml-related utility functions.""" + +# pylint: disable=too-many-lines from __future__ import annotations from io import StringIO from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast import pytest from ruamel.yaml.main import YAML from yamllint.linter import run as run_yamllint import ansiblelint.yaml_utils -from ansiblelint.file_utils import Lintable +from ansiblelint.file_utils import Lintable, cwd from ansiblelint.utils import task_in_list if TYPE_CHECKING: @@ -202,8 +204,7 @@ def test_custom_ruamel_yaml_emitter( assert output == expected_output -@pytest.fixture(name="yaml_formatting_fixtures") -def fixture_yaml_formatting_fixtures(fixture_filename: str) -> tuple[str, str, str]: +def load_yaml_formatting_fixtures(fixture_filename: str) -> tuple[str, str, str]: """Get the contents for the formatting fixture files. To regenerate these fixtures, please run ``pytest --regenerate-formatting-fixtures``. @@ -220,30 +221,67 @@ def fixture_yaml_formatting_fixtures(fixture_filename: str) -> tuple[str, str, s @pytest.mark.parametrize( - "fixture_filename", + ("before", "after", "version"), + ( + pytest.param("---\nfoo: bar\n", "---\nfoo: bar\n", None, id="1"), + # verify that 'on' is not translated to bool (1.2 behavior) + pytest.param("---\nfoo: on\n", "---\nfoo: on\n", None, id="2"), + # When version is manually mentioned by us, we expect to output without version directive + pytest.param("---\nfoo: on\n", "---\nfoo: on\n", (1, 2), id="3"), + pytest.param("---\nfoo: on\n", "---\nfoo: true\n", (1, 1), id="4"), + pytest.param("%YAML 1.1\n---\nfoo: on\n", "---\nfoo: true\n", (1, 1), id="5"), + # verify that in-line directive takes precedence but dumping strips if we mention a specific version + pytest.param("%YAML 1.1\n---\nfoo: on\n", "---\nfoo: true\n", (1, 2), id="6"), + # verify that version directive are kept if present + pytest.param("%YAML 1.1\n---\nfoo: on\n", "---\nfoo: true\n", None, id="7"), + pytest.param( + "%YAML 1.2\n---\nfoo: on\n", + "%YAML 1.2\n---\nfoo: on\n", + None, + id="8", + ), + pytest.param("---\nfoo: YES\n", "---\nfoo: true\n", (1, 1), id="9"), + pytest.param("---\nfoo: YES\n", "---\nfoo: YES\n", (1, 2), id="10"), + pytest.param("---\nfoo: YES\n", "---\nfoo: YES\n", None, id="11"), + ), +) +def test_fmt(before: str, after: str, version: tuple[int, int] | None) -> None: + """Tests behavior of formatter in regards to different YAML versions, specified or not.""" + yaml = ansiblelint.yaml_utils.FormattedYAML(version=version) + data = yaml.load(before) + result = yaml.dumps(data) + assert result == after + + +@pytest.mark.parametrize( + ("fixture_filename", "version"), ( - "fmt-1.yml", - "fmt-2.yml", - "fmt-3.yml", + pytest.param("fmt-1.yml", (1, 1), id="1"), + pytest.param("fmt-2.yml", (1, 1), id="2"), + pytest.param("fmt-3.yml", (1, 1), id="3"), + pytest.param("fmt-4.yml", (1, 1), id="4"), + pytest.param("fmt-5.yml", (1, 1), id="5"), + pytest.param("fmt-hex.yml", (1, 1), id="hex"), ), ) def test_formatted_yaml_loader_dumper( - yaml_formatting_fixtures: tuple[str, str, str], - fixture_filename: str, # noqa: ARG001 + fixture_filename: str, + version: tuple[int, int], ) -> None: """Ensure that FormattedYAML loads/dumps formatting fixtures consistently.""" - # pylint: disable=unused-argument - before_content, prettier_content, after_content = yaml_formatting_fixtures + before_content, prettier_content, after_content = load_yaml_formatting_fixtures( + fixture_filename, + ) assert before_content != prettier_content assert before_content != after_content - yaml = ansiblelint.yaml_utils.FormattedYAML() + yaml = ansiblelint.yaml_utils.FormattedYAML(version=version) - data_before = yaml.loads(before_content) + data_before = yaml.load(before_content) dump_from_before = yaml.dumps(data_before) - data_prettier = yaml.loads(prettier_content) + data_prettier = yaml.load(prettier_content) dump_from_prettier = yaml.dumps(data_prettier) - data_after = yaml.loads(after_content) + data_after = yaml.load(after_content) dump_from_after = yaml.dumps(data_after) # comparing data does not work because the Comment objects @@ -274,7 +312,7 @@ def fixture_lintable(file_path: str) -> Lintable: def fixture_ruamel_data(lintable: Lintable) -> CommentedMap | CommentedSeq: """Return the loaded YAML data for the Lintable.""" yaml = ansiblelint.yaml_utils.FormattedYAML() - data: CommentedMap | CommentedSeq = yaml.loads(lintable.content) + data: CommentedMap | CommentedSeq = yaml.load(lintable.content) return data @@ -384,7 +422,7 @@ def fixture_ruamel_data(lintable: Lintable) -> CommentedMap | CommentedSeq: ), pytest.param( "examples/playbooks/rule-partial-become-without-become-pass.yml", - 10, + 12, [1], id="4_play_playbook-first_line_in_play_2", ), @@ -402,7 +440,7 @@ def fixture_ruamel_data(lintable: Lintable) -> CommentedMap | CommentedSeq: ), pytest.param( "examples/playbooks/rule-partial-become-without-become-pass.yml", - 19, + 21, [2], id="4_play_playbook-first_line_in_play_3", ), @@ -420,7 +458,7 @@ def fixture_ruamel_data(lintable: Lintable) -> CommentedMap | CommentedSeq: ), pytest.param( "examples/playbooks/rule-partial-become-without-become-pass.yml", - 28, + 31, [3], id="4_play_playbook-first_line_in_play_4", ), @@ -601,7 +639,7 @@ def test_get_path_to_play( ), pytest.param( "examples/playbooks/rule-partial-become-without-become-pass.yml", - 7, + 8, [0, "tasks", 0], id="4_play_playbook-play_1_first_line_task_1", ), @@ -613,7 +651,7 @@ def test_get_path_to_play( ), pytest.param( "examples/playbooks/rule-partial-become-without-become-pass.yml", - 10, + 13, [], id="4_play_playbook-play_2_line_before_tasks", ), @@ -625,7 +663,7 @@ def test_get_path_to_play( ), pytest.param( "examples/playbooks/rule-partial-become-without-become-pass.yml", - 13, + 15, [1, "tasks", 0], id="4_play_playbook-play_2_first_line_task_1", ), @@ -643,7 +681,7 @@ def test_get_path_to_play( ), pytest.param( "examples/playbooks/rule-partial-become-without-become-pass.yml", - 19, + 23, [], id="4_play_playbook-play_3_line_before_tasks", ), @@ -655,7 +693,7 @@ def test_get_path_to_play( ), pytest.param( "examples/playbooks/rule-partial-become-without-become-pass.yml", - 23, + 25, [2, "tasks", 0], id="4_play_playbook-play_3_first_line_task_1", ), @@ -673,7 +711,7 @@ def test_get_path_to_play( ), pytest.param( "examples/playbooks/rule-partial-become-without-become-pass.yml", - 28, + 33, [], id="4_play_playbook-play_4_line_before_tasks", ), @@ -685,13 +723,13 @@ def test_get_path_to_play( ), pytest.param( "examples/playbooks/rule-partial-become-without-become-pass.yml", - 32, + 35, [3, "tasks", 0], id="4_play_playbook-play_4_first_line_task_1", ), pytest.param( "examples/playbooks/rule-partial-become-without-become-pass.yml", - 33, + 39, [3, "tasks", 0], id="4_play_playbook-play_4_middle_line_task_1", ), @@ -730,12 +768,12 @@ def test_get_path_to_play( pytest.param( "examples/playbooks/include.yml", 14, - [0, "tasks", 1], + [0, "tasks", 2], id="playbook-multi_tasks_blocks-tasks_last_task_before_handlers", ), pytest.param( "examples/playbooks/include.yml", - 16, + 17, [0, "handlers", 0], id="playbook-multi_tasks_blocks-handlers_task", ), @@ -953,3 +991,35 @@ def test_deannotate( ) -> None: """Ensure deannotate works as intended.""" assert ansiblelint.yaml_utils.deannotate(before) == after + + +def test_yamllint_incompatible_config() -> None: + """Ensure we can detect incompatible yamllint settings.""" + with (cwd(Path("examples/yamllint/incompatible-config")),): + config = ansiblelint.yaml_utils.load_yamllint_config() + assert config.incompatible + + +@pytest.mark.parametrize( + ("yaml_version", "explicit_start"), + ( + pytest.param((1, 1), True), + pytest.param((1, 1), False), + ), +) +def test_document_start( + yaml_version: tuple[int, int] | None, + explicit_start: bool, +) -> None: + """Ensure the explicit_start config option from .yamllint is applied correctly.""" + config = ansiblelint.yaml_utils.FormattedYAML.default_config + config["explicit_start"] = explicit_start + + yaml = ansiblelint.yaml_utils.FormattedYAML( + version=yaml_version, + config=cast(dict[str, bool | int | str], config), + ) + assert ( + yaml.dumps(yaml.load(_SINGLE_QUOTE_WITHOUT_INDENTS)).startswith("---") + == explicit_start + ) diff --git a/tools/generate_docs.py b/tools/generate_docs.py new file mode 100755 index 0000000..9b2b5dc --- /dev/null +++ b/tools/generate_docs.py @@ -0,0 +1,36 @@ +#!python3 +"""Script that tests rule markdown documentation.""" +from __future__ import annotations + +import subprocess +from pathlib import Path + +from ansiblelint.cli import get_rules_dirs +from ansiblelint.config import Options +from ansiblelint.rules import RulesCollection, TransformMixin + +if __name__ == "__main__": + subprocess.run( + "ansible-lint -L --format=md", # noqa: S607 + shell=True, # noqa: S602 + check=True, + stdout=subprocess.DEVNULL, + ) + + file = Path("docs/_autofix_rules.md") + options = Options() + options.rulesdirs = get_rules_dirs([]) + options.list_rules = True + rules = RulesCollection( + options.rulesdirs, + options=options, + ) + contents: list[str] = [] + for rule in rules.alphabetical(): + if issubclass(rule.__class__, TransformMixin): + url = f"rules/{rule.id}.md" + contents.append(f"- [{rule.id}]({url})\n") + + # Write the injected contents to the file. + with file.open(encoding="utf-8", mode="w") as fh: + fh.writelines(contents) diff --git a/tools/test-hook.sh b/tools/test-hook.sh index 85d2d27..d83918e 100755 --- a/tools/test-hook.sh +++ b/tools/test-hook.sh @@ -12,7 +12,7 @@ set -euo pipefail rm -rf .tox/x mkdir -p .tox/x cd .tox/x -git init +git init --initial-branch=main # we add a file to the repo to avoid error due to no file to to lint touch foo.yml git add foo.yml @@ -9,6 +9,8 @@ envlist = schemas py py-devel + lower + pre eco isolated_build = true skip_missing_interpreters = True @@ -18,19 +20,23 @@ requires = [testenv] description = - Run the tests under {basepython} and - devel: ansible devel branch + Run the tests under {basepython} + devel: and ansible devel branch + pre: and enable --pre when installing dependencies, testing prereleases deps = devel: ansible-core @ git+https://github.com/ansible/ansible.git # GPLv3+ devel: ansible-compat @ git+https://github.com/ansible/ansible-compat.git # GPLv3+ extras = test commands_pre = - sh -c "rm -f .tox/.coverage.* 2>/dev/null || true" - bash ./tools/install-reqs.sh -commands = + sh -c "rm -f {envdir}/.coverage.* 2>/dev/null || true" # safety measure to assure we do not accidentally run tests with broken dependencies {envpython} -m pip check + {envpython} -m pip freeze + bash ./tools/install-reqs.sh + ansible --version +commands = + sh -c "{envpython} -m pip freeze > {envdir}/log/requirements.txt" coverage run -m pytest {posargs:\ -n auto \ -ra \ @@ -38,7 +44,7 @@ commands = --doctest-modules \ --durations=10 \ } - sh -c "coverage combine -a -q --data-file=.coverage .tox/.coverage.*" + {py,py310,py311,py312,py313}: sh -c "coverage combine -a -q --data-file={envdir}/.coverage {toxworkdir}/*/.coverage.* && coverage xml --data-file={envdir}/.coverage -o {envdir}/coverage.xml --fail-under=0" passenv = CURL_CA_BUNDLE # https proxies, https://github.com/tox-dev/tox/issues/1437 @@ -60,13 +66,14 @@ passenv = setenv = # Avoid runtime warning that might affect our devel testing devel: ANSIBLE_DEVEL_WARNING = false - COVERAGE_FILE = {env:COVERAGE_FILE:{toxworkdir}/.coverage.{envname}} + COVERAGE_FILE = {env:COVERAGE_FILE:{envdir}/.coverage.{envname}} COVERAGE_PROCESS_START={toxinidir}/pyproject.toml - PIP_CONSTRAINT = {toxinidir}/.config/requirements.txt - devel,pkg: PIP_CONSTRAINT = /dev/null + PIP_CONSTRAINT = {toxinidir}/.config/constraints.txt + devel,pkg,pre,py310: PIP_CONSTRAINT = /dev/null PIP_DISABLE_PIP_VERSION_CHECK = 1 PRE_COMMIT_COLOR = always FORCE_COLOR = 1 + pre: PIP_PRE = 1 allowlist_externals = bash find @@ -76,6 +83,7 @@ allowlist_externals = sh tox ./tools/test-hook.sh + {toxworkdir}/.pipx/bin/ansible-lint # https://tox.wiki/en/latest/upgrading.html#editable-mode package = editable @@ -83,14 +91,13 @@ package = editable description = Run all linters # pip compile includes python version in output constraints, so we want to # be sure that version does not change randomly. -basepython = python3.9 +basepython = python3.10 deps = pre-commit>=2.6.0 setuptools>=51.1.1 pytest>=7.2.2 # to updated schemas skip_install = true commands_pre = - {[testenv]commands_pre} commands = {envpython} -m pre_commit run --all-files --show-diff-on-failure {posargs:} passenv = @@ -101,6 +108,11 @@ setenv = # avoid messing pre-commit with out own constraints PIP_CONSTRAINT= +[testenv:lower] +description = Install using lower-constraints.txt file for testing oldest versions. +setenv = + PIP_CONSTRAINT = {toxinidir}/.github/lower-constraints.txt + [testenv:hook] description = Validate pre-commit hook definition deps = pre-commit @@ -114,12 +126,13 @@ description = Bump all test dependencies # we reuse the lint environment envdir = {toxworkdir}/lint skip_install = true -basepython = python3.9 +basepython = python3.10 deps = {[testenv:lint]deps} setenv = # without his upgrade would likely not do anything PIP_CONSTRAINT = /dev/null +commands_pre = commands = -pre-commit run --all-files --show-diff-on-failure --hook-stage manual lock -pre-commit run --all-files --show-diff-on-failure --hook-stage manual up @@ -141,8 +154,10 @@ setenv = TERM = dump skip_install = false usedevelop = true +commands_pre = + ansible-lint --version commands = - mkdocs build {posargs:} + mkdocs {posargs:build --strict --site-dir=_readthedocs/html/} [testenv:redirects] description = Update documentation redirections for readthedocs @@ -155,9 +170,9 @@ commands = [testenv:schemas] description = Rebuild and test JSON Schemas deps = - check-jsonschema + check-jsonschema>=0.26.3 setenv = - # without his upgrade would likely not do anything + # without this upgrade would likely not do anything PIP_CONSTRAINT = /dev/null skip_install = true changedir = test/schemas @@ -187,11 +202,15 @@ description = deps = build >= 0.9.0 twine >= 4.0.1 + pipx skip_install = true # Ref: https://twitter.com/di_codes/status/1044358639081975813 commands_pre = - {[testenv]commands_pre} commands = + # Testing pipx usage + bash -c "PIPX_BIN_DIR={toxworkdir}/.pipx/bin PIPX_HOME={toxworkdir}/.pipx pipx install --force -e ." + # Testing that calling the pipx installation does return 0 return code and no output in stderr + bash -c "if stderr=$({toxworkdir}/.pipx/bin/ansible-lint --version >/dev/null) && test -z \"$stderr\"; then echo "ok"; fi" # build wheel and sdist using PEP-517 {envpython} -c 'import os.path, shutil, sys; \ dist_dir = os.path.join("{toxinidir}", "dist"); \ @@ -200,9 +219,9 @@ commands = shutil.rmtree(dist_dir)' {envpython} -m build --outdir {toxinidir}/dist/ {toxinidir} # Validate metadata using twine - twine check --strict {toxinidir}/dist/* + python3 -m twine check --strict {toxinidir}/dist/* # Install the wheel - sh -c 'python3 -m pip install "ansible-lint[lock] @ file://$(echo {toxinidir}/dist/*.whl)"' + sh -c 'python3 -m pip install "ansible-lint @ file://$(echo {toxinidir}/dist/*.whl)"' # Uninstall it python3 -m pip uninstall -y ansible-lint @@ -210,9 +229,11 @@ commands = description = Remove temporary files skip_install = true deps = +commands_pre = +commands_post = commands = - find . -type f -name '*.py[co]' -delete -o -type d -name __pycache__ -name coverage.xml -name .coverage - rm -rf .mypy_cache + find . -type d \( -name __pycache__ -o -name .mypy_cache \) -delete + find . -type f \( -name '*.py[co]' -o -name ".coverage*" -o -name coverage.xml \) -delete [testenv:coverage] description = Combines and displays coverage results |