diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-05-14 20:04:50 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-05-14 20:04:50 +0000 |
commit | 782f8df6e41f29dce2db4970a3ad84aaeb7d8c5f (patch) | |
tree | 3a88a542cd8074743d251881131510157cfc510b | |
parent | Initial commit. (diff) | |
download | ansible-lint-upstream.tar.xz ansible-lint-upstream.zip |
Adding upstream version 4.3.7.upstream/4.3.7upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
305 files changed, 13182 insertions, 0 deletions
diff --git a/.ansible-lint b/.ansible-lint new file mode 100644 index 0000000..8e7ec00 --- /dev/null +++ b/.ansible-lint @@ -0,0 +1,2 @@ +exclude_paths: +- .github/ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..8af2326 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,20 @@ +[run] +branch = true +parallel = true + +[report] +skip_covered = True +show_missing = True +exclude_lines = + \#\s*pragma: no cover + ^\s*raise AssertionError\b + ^\s*raise NotImplementedError\b + ^\s*return NotImplemented\b + ^\s*raise$ + ^if __name__ == ['"]__main__['"]:$ + +[paths] +source = lib/ansiblelint + */.tox/*/lib/python*/site-packages/ansiblelint + */.tox/pypy*/site-packages/ansiblelint + */lib/ansiblelint @@ -0,0 +1,123 @@ +[flake8] + +# Don't even try to analyze these: +exclude = + # No need to traverse egg files + *.egg, + # No need to traverse egg info dir + *.egg-info, + # No need to traverse eggs directory + .eggs, + # No need to traverse our git directory + .git, + # GitHub configs + .github, + # Cache files of MyPy + .mypy_cache, + # Cache files of pytest + .pytest_cache, + # Temp dir of pytest-testmon + .tmontmp, + # Countless third-party libs in venvs + .tox, + # Occasional virtualenv dir + .venv + # VS Code + .vscode, + # There's no value in checking cache directories + __pycache__, + # Temporary build dir + build, + # This contains sdists and wheels of ansible-lint that we don't want to check + dist, + # Occasional virtualenv dir + env, + # Metadata of `pip wheel` cmd is autogenerated + pip-wheel-metadata, + +# Let's not overcomplicate the code: +max-complexity = 10 + +# Accessibility/large fonts and PEP8 friendly: +#max-line-length = 79 +# Accessibility/large fonts and PEP8 unfriendly: +max-line-length = 100 + +# Allow certain violations in certain files: +per-file-ignores = + # FIXME: D100 Missing docstring in public module + # FIXME: D101 Missing docstring in public class + # FIXME: D102 Missing docstring in public method + # FIXME: D103 Missing docstring in public function + # FIXME: drop these once they're made simpler + # Ref: https://github.com/ansible/ansible-lint/issues/744 + # lib/ansiblelint/__main__.py:32:1: C901 'main' is too complex (12) + lib/ansiblelint/__main__.py: C901 + lib/ansiblelint/cli.py: D101 D102 D103 + lib/ansiblelint/formatters/__init__.py: D101 D102 + lib/ansiblelint/utils.py: D103 + lib/ansiblelint/rules/*.py: D100 D101 D102 + + # FIXME: drop these once they're fixed + # Ref: https://github.com/ansible/ansible-lint/issues/725 + test/__init__.py: D102 + test/conftest.py: D100 D103 + test/rules/EMatcherRule.py: D100 D101 D102 + test/rules/UnsetVariableMatcherRule.py: D100 D101 D102 + test/TestAlwaysRunRule.py: PT009 D100 D101 D102 + test/TestAnsibleLintRule.py: D100 D103 + test/TestBaseFormatter.py: D100 D103 + test/TestBecomeUserWithoutBecome.py: PT009 D100 D101 D102 + test/TestCliRolePaths.py: PT009 D100 D101 D102 + test/TestCommandLineInvocationSameAsConfig.py: D100 D103 + test/TestCommandHasChangesCheck.py: PT009 D100 D101 D102 + test/TestComparisonToEmptyString.py: PT009 D100 D101 D102 + test/TestComparisonToLiteralBool.py: PT009 D100 D101 D102 + test/TestDependenciesInMeta.py: D100 D103 + test/TestDeprecatedModule.py: PT009 D100 D101 D102 + test/TestEnvVarsInCommand.py: PT009 D100 D101 D102 + test/TestFormatter.py: D100 D101 D102 + test/TestImportIncludeRole.py: D100 D103 + test/TestImportWithMalformed.py: D100 D103 + test/TestIncludeMissingFileRule.py: D100 D103 + test/TestIncludeMissFileWithRole.py: D100 D103 + test/TestLineNumber.py: D100 + test/TestLineTooLong.py: PT009 D100 D101 D102 + test/TestLintRule.py: PT009 D100 D101 D102 + test/TestNestedJinjaRule.py: D100 D103 + test/TestMatchError.py: D101 + test/TestMetaChangeFromDefault.py: PT009 D100 D101 D102 + test/TestMetaMainHasInfo.py: PT009 D100 D101 D102 + test/TestMetaTagValid.py: PT009 D100 D101 D102 + test/TestMetaVideoLinks.py: PT009 D100 D101 D102 + test/TestNoFormattingInWhenRule.py: PT009 D100 D101 D102 + test/TestOctalPermissions.py: PT009 D100 D101 D102 + test/TestPackageIsNotLatest.py: PT009 D100 D101 D102 + test/TestPretaskIncludePlaybook.py: D100 D103 + test/TestRoleHandlers.py: PT009 D100 D101 D102 + test/TestRoleRelativePath.py: PT009 D100 D101 D102 + test/TestRuleProperties.py: D100 D103 + test/TestRulesCollection.py: D100 D103 + test/TestRunner.py: D100 D103 + test/TestShellWithoutPipefail.py: PT009 D100 D101 D102 + test/TestSkipImportPlaybook.py: D100 D103 + test/TestSkipInsideYaml.py: D100 D103 + test/TestSkipPlaybookItems.py: D100 D103 + test/TestSudoRule.py: PT009 D100 D101 D102 + test/TestTaskHasName.py: PT009 D100 D101 D102 + test/TestTaskIncludes.py: D100 D103 + test/TestTaskNoLocalAction.py: PT009 D100 D101 D102 + test/TestUseCommandInsteadOfShell.py: PT009 D100 D101 D102 + test/TestUseHandlerRatherThanWhenChanged.py: PT009 D100 D101 D102 + test/TestUsingBareVariablesIsDeprecated.py: PT009 D100 D101 D102 + test/TestVariableHasSpaces.py: PT009 D100 D101 D102 + test/TestWithSkipTagId.py: PT009 D100 D101 D102 + +# flake8-pytest-style +# PT001: +pytest-fixture-no-parentheses = true +# PT006: +pytest-parametrize-names-type = tuple +# PT007: +pytest-parametrize-values-type = tuple +pytest-parametrize-values-row-type = tuple diff --git a/.git_archival.txt b/.git_archival.txt new file mode 100644 index 0000000..5a4bffc --- /dev/null +++ b/.git_archival.txt @@ -0,0 +1 @@ +ref-names: HEAD -> master, tag: v4.3.7 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..2e46433 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +# Force LF line endings for text files +* text=auto eol=lf + +*.png binary + +# Needed for setuptools-scm-git-archive +.git_archival.txt export-subst diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..0164155 --- /dev/null +++ b/.github/CODE_OF_CONDUCT.md @@ -0,0 +1,3 @@ +# Community Code of Conduct + +Please see the official [Ansible Community Code of Conduct](https://docs.ansible.com/ansible/latest/community/code_of_conduct.html). diff --git a/.github/CONTRIBUTING.rst b/.github/CONTRIBUTING.rst new file mode 100644 index 0000000..1319470 --- /dev/null +++ b/.github/CONTRIBUTING.rst @@ -0,0 +1,79 @@ +Contributing to Ansible-lint +============================ + +To contribute to ansible-lint, please use pull requests on a branch +of your own fork. + +After `creating your fork on GitHub`_, you can do: + +.. code-block:: shell-session + + $ git clone git@github.com:yourname/ansible-lint + $ cd ansible-lint + $ git checkout -b your-branch-name + # DO SOME CODING HERE + $ git add your new files + $ git commit -v + $ git push origin your-branch-name + +You will then be able to create a pull request from your commit. + +All fixes to core functionality (i.e. anything except docs or examples) +should be accompanied by tests that fail prior to your change and +succeed afterwards. + +Feel free to raise issues in the repo if you feel unable to +contribute a code fix. + +.. _creating your fork on GitHub: + https://guides.github.com/activities/forking/ + +Standards +--------- + +ansible-lint is flake8 compliant with ``max-line-length`` set to 100 +(see `.flake8`_). + +ansible-lint works only with `supported Ansible versions`_ at the +time it was released. + +Automated tests will be run against all PRs for flake8 compliance +and Ansible compatibility — to check before pushing commits, just +use `tox`_. + +.. _.flake8: https://github.com/ansible/ansible-lint/blob/master/.flake8 +.. _supported Ansible versions: + https://docs.ansible.com/ansible/devel/reference_appendices + /release_and_maintenance.html#release-status +.. _tox: https://tox.readthedocs.io + +.. DO-NOT-REMOVE-deps-snippet-PLACEHOLDER + +Talk to us +---------- + +Discussion around ansible-lint happens in ``#ansible-galaxy`` IRC +channel on Freenode and the `Ansible Development List`_. + +For the full list of Ansible IRC and Mailing list, please see the +`Ansible Communication`_ page. +Release announcements will be made to the `Ansible Announce`_ list. + +Possible security bugs should be reported via email +to security@ansible.com. + +.. _Ansible Announce: + https://groups.google.com/forum/#!forum/ansible-announce +.. _Ansible Development List: + https://groups.google.com/forum/#!forum/ansible-devel +.. _Ansible Communication: + https://docs.ansible.com/ansible/latest/community/communication.html + +Code of Conduct +--------------- + +As with all Ansible projects, we have a `Code of Conduct`_. + +.. _Code of Conduct: + https://docs.ansible.com/ansible/latest/community + /code_of_conduct.html diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 0000000..e07e744 --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,32 @@ +# Issue Type +- Bug report +- Feature request + +# Ansible and Ansible Lint details + +``` +ansible --version +ansible-lint --version +``` + +- ansible installation method: one of source, pip, OS package +- ansible-lint installation method: one of source, pip, OS package + +# Desired Behaviour + +Please give some details of the feature being requested +or what should happen if providing a bug report + +Possible security bugs should be reported via email to `security@ansible.com` + +# Actual Behaviour (Bug report only) + +Please give some details of what is actually happening. +Include a [minimum complete verifiable example] with: +- playbook +- output of running ansible-lint +- if you're getting a stack trace, output of + `ansible-playbook --syntax-check playbook` + + +[minimum complete verifiable example]: http://stackoverflow.com/help/mcve diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..3a96005 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,65 @@ +--- +name: 🐛 Bug report +about: Create a bug report. Please test against the master branch before submitting it. +labels: priority/medium, status/new, type/bug +--- +<!--- Verify first that your issue is not already reported on GitHub --> +<!--- Also test if the latest release and master branch are affected too --> + +##### Summary +<!--- Explain the problem briefly below --> + + +##### Issue Type + +- Bug Report + +##### Ansible and Ansible Lint details +<!--- Paste verbatim output between tripple backticks --> +```console (paste below) +ansible --version + +ansible-lint --version + +``` + +- ansible installation method: one of source, pip, OS package +- ansible-lint installation method: one of source, pip, OS package + +##### OS / ENVIRONMENT +<!--- Provide all relevant information below, e.g. target OS versions, network device firmware, etc. --> + + +##### STEPS TO REPRODUCE +<!--- Describe exactly how to reproduce the problem, using a minimal test-case --> + +<!--- Paste example playbooks or commands between tripple backticks below --> +```console (paste below) + +``` + +<!--- HINT: You can paste gist.github.com links for larger files --> + +##### Desired Behaviour +<!--- Describe what you expected to happen when running the steps above --> + +Possible security bugs should be reported via email to `security@ansible.com` + +##### Actual Behaviour +<!--- Describe what actually happened. If possible run with extra verbosity (-vvvv) --> + +Please give some details of what is actually happening. +Include a [minimum complete verifiable example] with: +- playbook +- output of running ansible-lint +- if you're getting a stack trace, output of + `ansible-playbook --syntax-check playbook` + + +<!--- Paste verbatim command output between tripple backticks --> +```paste below + +``` + + +[minimum complete verifiable example]: http://stackoverflow.com/help/mcve diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..3f5190f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,16 @@ +# Ref: https://help.github.com/en/github/building-a-strong-community/configuring-issue-templates-for-your-repository#configuring-the-template-chooser +blank_issues_enabled: false # default is true +contact_links: +- name: 🔐 Security bug report 🔥 + url: https://docs.ansible.com/ansible/latest/community/reporting_bugs_and_features.html + about: | + Please learn how to report security vulnerabilities here. + + For all security related bugs, email security@ansible.com + instead of using this issue tracker and you will receive + a prompt response. + + For more information, see https://docs.ansible.com/ansible/latest/community/reporting_bugs_and_features.html +- name: 📝 Ansible Code of Conduct + url: https://docs.ansible.com/ansible/latest/community/code_of_conduct.html + about: ❤ Be nice to other members of the community. ☮ Behave. diff --git a/.github/ISSUE_TEMPLATE/documentation_report.md b/.github/ISSUE_TEMPLATE/documentation_report.md new file mode 100644 index 0000000..1d1cfc1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/documentation_report.md @@ -0,0 +1,24 @@ +--- +name: 📝 Documentation Report +about: Ask us about docs +labels: priority/medium, status/new, documentation +--- +<!--- Verify first that your improvement is not already reported on GitHub --> +<!--- Also test if the latest release and master branch are affected too --> + +##### SUMMARY +<!--- Explain the problem briefly below, add suggestions to wording or structure --> + +<!--- HINT: Did you know the documentation has an "Edit on GitHub" link on every page ? --> + +##### ISSUE TYPE + +- Documentation Report + +##### OS / ENVIRONMENT +<!--- Provide all relevant information below, e.g. OS version, browser, etc. --> + +##### ADDITIONAL INFORMATION +<!--- Describe how this improves the documentation, e.g. before/after situation or screenshots --> + +<!--- HINT: You can paste gist.github.com links for larger files --> diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..b02df01 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,24 @@ +--- +name: ✨ Feature request +about: Suggest an idea for this project +labels: priority/medium, status/new, type/enchancement +--- +<!--- Verify first that your feature was not already discussed on GitHub --> + +##### Summary +<!--- Describe the new feature/improvement briefly below --> + + +##### Issue Type + +- Feature Idea + +##### Additional Information +<!--- Describe how the feature would be used, why it is needed and what it would solve --> + +<!--- Paste example playbooks or commands between quotes below --> +```console + +``` + +<!--- HINT: You can also paste gist.github.com links for larger files --> diff --git a/.github/SECURITY.rst b/.github/SECURITY.rst new file mode 100644 index 0000000..b9190d8 --- /dev/null +++ b/.github/SECURITY.rst @@ -0,0 +1,17 @@ +Security Policy +--------------- + +Supported Versions +================== + +Ansible applies security fixes according to the 3-versions-back support +policy. Please find more information in `our docs +<https://docs.ansible.com/ansible/devel/reference_appendices/release_and_maintenance.html#release-status>`_. + +Reporting a Vulnerability +========================= + +We encourage responsible disclosure practices for security +vulnerabilities. Please read our `policies for reporting bugs +<https://docs.ansible.com/ansible/devel/community/reporting_bugs_and_features.html#reporting-a-bug>`_ +if you want to report a security issue that might affect Ansible. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..892b0dd --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,22 @@ +version: 2 +updates: +- package-ecosystem: pip + directory: /docs + schedule: + day: sunday + interval: weekly + labels: + - dependabot-deps-updates + - skip-changelog + versioning-strategy: lockfile-only + open-pull-requests-limit: 3 +- package-ecosystem: pip + directory: / + schedule: + day: sunday + interval: weekly + labels: + - dependabot-deps-updates + - skip-changelog + versioning-strategy: lockfile-only + open-pull-requests-limit: 3 diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml new file mode 100644 index 0000000..f457c5d --- /dev/null +++ b/.github/release-drafter.yml @@ -0,0 +1,21 @@ +# Format and labels used aim to match those used by Ansible project +categories: + - title: 'Major Changes' + labels: + - 'major' # c6476b + - title: 'Minor Changes' + labels: + - 'feature' # 006b75 + - 'enhancement' # ededed + - title: 'Bugfixes' + labels: + - 'bug' # fbca04 + - title: 'Deprecations' + labels: + - 'deprecated' # fef2c0 +exclude-labels: + - 'skip-changelog' +template: | + ## Changes + + $CHANGES diff --git a/.github/workflows/tox.yml b/.github/workflows/tox.yml new file mode 100644 index 0000000..3966ef1 --- /dev/null +++ b/.github/workflows/tox.yml @@ -0,0 +1,296 @@ +name: gh + +on: + create: # is used for publishing to PyPI and TestPyPI + tags: # any tag regardless of its name, no branches + push: # only publishes pushes to the main branch to TestPyPI + branches: # any branch but not tag + - >- + ** + tags-ignore: + - >- + ** + pull_request: + schedule: + - cron: 1 0 * * * # Run daily at 0:01 UTC + # Run every Friday at 18:02 UTC + # https://crontab.guru/#2_18_*_*_5 + # - cron: 2 18 * * 5 + +jobs: + linters: + name: >- + ${{ matrix.env.TOXENV }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + python-version: + - 3.8 + os: + - ubuntu-latest + env: + - TOXENV: lint + - TOXENV: docs + - TOXENV: build-dists,metadata-validation + env: + TOX_PARALLEL_NO_SPINNER: 1 + + steps: + - uses: actions/checkout@master + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v1 + with: + python-version: ${{ matrix.python-version }} + - name: set PY_SHA256 + run: echo "::set-env name=PY_SHA256::$(python -VV | sha256sum | cut -d' ' -f1)" + - name: Pre-commit cache + uses: actions/cache@v1 + with: + path: ~/.cache/pre-commit + key: ${{ runner.os }}-pre-commit-${{ env.PY_SHA256 }}-${{ hashFiles('setup.cfg') }}-${{ hashFiles('tox.ini') }}-${{ hashFiles('pyproject.toml') }}-${{ hashFiles('.pre-commit-config.yaml') }}-${{ hashFiles('pytest.ini') }} + - name: Pip cache + uses: actions/cache@v1 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ env.PY_SHA256 }}-${{ hashFiles('setup.cfg') }}-${{ hashFiles('tox.ini') }}-${{ hashFiles('pyproject.toml') }}-${{ hashFiles('.pre-commit-config.yaml') }}-${{ hashFiles('pytest.ini') }} + restore-keys: | + ${{ runner.os }}-pip- + ${{ runner.os }}- + - name: Install tox + run: | + python -m pip install --upgrade tox + - name: Log installed dists + run: >- + python -m pip freeze --all + - name: >- + Initialize tox envs + run: >- + python -m + tox + --parallel auto + --parallel-live + --notest + --skip-missing-interpreters false + -vv + env: ${{ matrix.env }} + - name: Test with tox + run: | + python -m tox --parallel auto --parallel-live + env: ${{ matrix.env }} + - name: Archive logs + uses: actions/upload-artifact@v2 + with: + name: logs.zip + path: .tox/**/log/ + + unit: + name: >- + py${{ matrix.python-version }}@${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + # fail-fast: false + # 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 + # combination unless there are good reasons to test it, like having + # proof that we failed to catch a bug by not running it. Using + # distribution should be prefferred instead of custom builds. + matrix: + python-version: + # keep list sorted as it determines UI order too + - 3.6 + - 3.7 + - 3.8 + # NOTE: Installing ansible under 3.10-dev is currently not + # NOTE: possible because compiling cffi explodes. + os: + # https://help.github.com/en/actions/reference/virtual-environments-for-github-hosted-runners + - ubuntu-latest # 18.04 + # - windows-latest + # - windows-2016 + include: + - os: ubuntu-20.04 + python-version: 3.9-dev + - os: macOS-latest + python-version: 3.6 + - os: macOS-latest + python-version: 3.8 + + env: + TOX_PARALLEL_NO_SPINNER: 1 + + steps: + - uses: actions/checkout@master + - name: Get history and tags for SCM versioning to work + run: | + git fetch --prune --unshallow + git fetch --depth=1 origin +refs/tags/*:refs/tags/* + - name: Set up stock Python ${{ matrix.python-version }} from GitHub + if: >- + !endsWith(matrix.python-version, '-dev') + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Set up Python ${{ matrix.python-version }} from deadsnakes + if: >- + endsWith(matrix.python-version, '-dev') + uses: deadsnakes/action@v1.0.0 + with: + python-version: ${{ matrix.python-version }} + - name: >- + Log the currently selected Python + version info (${{ matrix.python-version }}) + run: | + python --version --version + which python + - name: Pip cache + uses: actions/cache@v1 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ env.PY_SHA256 }}-${{ hashFiles('setup.cfg') }}-${{ hashFiles('tox.ini') }}-${{ hashFiles('pyproject.toml') }}-${{ hashFiles('.pre-commit-config.yaml') }}-${{ hashFiles('pytest.ini') }} + restore-keys: | + ${{ runner.os }}-pip- + ${{ runner.os }}- + - name: Install tox + run: | + python -m pip install --upgrade tox + - name: Log installed dists + run: >- + python -m pip freeze --all + - name: >- + Initialize tox envs + run: >- + python -m + tox + --parallel auto + --parallel-live + --notest + --skip-missing-interpreters false + -vv + env: + TOXENV: ansible28,ansible29,ansible210,ansibledevel + - name: "Test with tox: ansible28" + run: | + python -m tox + env: + TOXENV: ansible28 + # sequential run improves browsing experience (almost no speed impact) + - name: "Test with tox: ansible29" + run: | + python -m tox + env: + TOXENV: ansible29 + - name: "Test with tox: ansible210" + run: | + python -m tox + env: + TOXENV: ansible210 + - name: "Test with tox: ansibledevel" + run: | + python -m tox + env: + TOXENV: ansibledevel + - name: Archive logs + uses: actions/upload-artifact@v2 + with: + name: logs.zip + path: .tox/**/log/ + # https://github.com/actions/upload-artifact/issues/123 + continue-on-error: true + - name: Report junit failures + uses: shyim/junit-report-annotations-action@3d2e5374f2b13e70f6f3209a21adfdbc42c466ae + with: + path: .tox/junit.*.xml + if: always() + + publish: + name: Publish to PyPI registry + needs: + - linters + - unit + runs-on: ubuntu-latest + + env: + PY_COLORS: 1 + TOXENV: build-dists,metadata-validation + TOX_PARALLEL_NO_SPINNER: 1 + + steps: + - name: Switch to using Python 3.8 by default + uses: actions/setup-python@v2 + with: + python-version: 3.8 + - name: Install tox + run: >- + python -m + pip install + --user + tox + - name: Check out src from Git + uses: actions/checkout@v2 + with: + # Get shallow Git history (default) for tag creation events + # but have a complete clone for any other workflows. + # Both options fetch tags but since we're going to remove + # one from HEAD in non-create-tag workflows, we need full + # history for them. + fetch-depth: >- + ${{ + ( + github.event_name == 'create' && + github.event.ref_type == 'tag' + ) && + 1 || 0 + }} + - name: Drop Git tags from HEAD for non-tag-create events + if: >- + github.event_name != 'create' || + github.event.ref_type != 'tag' + run: >- + git tag --points-at HEAD + | + xargs git tag --delete + - name: Instruct setuptools-scm not to add a local version part + if: >- + github.event_name == 'push' && + github.ref == format( + 'refs/heads/{0}', github.event.repository.default_branch + ) + run: | + echo 'local_scheme = "no-local-version"' >> pyproject.toml + git update-index --assume-unchanged pyproject.toml + - name: Pre-populate tox env + run: >- + python -m + tox + --parallel auto + --parallel-live + --notest + --skip-missing-interpreters false + -vvvv + - name: Build dists + run: python -m tox -p auto --parallel-live -vvvv + - name: Publish to test.pypi.org + if: >- + ( + github.event_name == 'push' && + github.ref == format( + 'refs/heads/{0}', github.event.repository.default_branch + ) + ) || + ( + github.event_name == 'create' && + github.event.ref_type == 'tag' + ) + uses: pypa/gh-action-pypi-publish@master + with: + password: ${{ secrets.testpypi_password }} + repository_url: https://test.pypi.org/legacy/ + - name: Publish to pypi.org + if: >- # "create" workflows run separately from "push" & "pull_request" + github.event_name == 'create' && + github.event.ref_type == 'tag' + uses: pypa/gh-action-pypi-publish@master + with: + password: ${{ secrets.pypi_password }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c542d2d --- /dev/null +++ b/.gitignore @@ -0,0 +1,46 @@ +# Byte-compiled / optimized / DLL files +__pycache__ +*.py[co] +*$py.class + +# Packages +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib64/ +parts/ +pip-wheel-metadata +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# Installer logs +pip-log.txt + +# Unit test / coverage reports +.tox + +# Needed for CLI tests +.sandbox + +# pyenv +.python-version + +# Coverage artifacts +.coverage +coverage.xml +pip-wheel-metadata +.test-results/ + +# mypy +.mypy_cache + +# .cache is used by progressive mode +.cache diff --git a/.isort.cfg b/.isort.cfg new file mode 100644 index 0000000..1ad6bcc --- /dev/null +++ b/.isort.cfg @@ -0,0 +1,9 @@ +[settings] +include_trailing_comma = true +known_first_party = ansiblelint +known_third_party = ansible,pytest,ruamel,setuptools,sphinx,yaml +# picked to match the current flake8 value: +line_length = 100 +# https://github.com/timothycrosley/isort#multi-line-output-modes +multi_line_output = 5 +use_parentheses = true diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..2cac471 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,71 @@ +--- +repos: +- repo: local + hooks: + - id: immutable-setup-py + name: Verify that setup.py stays immutable + description: >- + This is a sanity check that makes sure that + the `setup.py` file isn't changed. + # Using Python here because using + # shell test does not seem to work in CIs: + entry: >- + sh -c 'git hash-object setup.py + | + python -c raise\ SystemExit\(input\(\)\ !=\ \"b72e95ce049c4c67c6487a2171fec8d1b0b958b1\"\) + ' + pass_filenames: false + language: system + files: >- + ^setup\.py$ +- repo: https://github.com/pre-commit/pre-commit-hooks.git + rev: v3.1.0 + hooks: + - id: end-of-file-fixer + - id: trailing-whitespace + exclude: > + (?x)^( + test/(with-skip-tag-id|unicode).yml| + examples/example.yml + )$ + - id: mixed-line-ending + - id: check-byte-order-marker + - id: check-executables-have-shebangs + - id: check-merge-conflict + - id: debug-statements + language_version: python3 +- repo: https://github.com/adrienverge/yamllint.git + rev: v1.24.2 + hooks: + - id: yamllint + files: \.(yaml|yml)$ + types: [file, yaml] + entry: yamllint --strict +- repo: https://github.com/pre-commit/mirrors-isort + rev: v5.1.4 + hooks: + - id: isort + args: + # https://github.com/pre-commit/mirrors-isort/issues/9#issuecomment-624404082 + - --filter-files +- repo: https://gitlab.com/pycqa/flake8.git + rev: 3.8.3 + hooks: + - id: flake8 + language_version: python3 + additional_dependencies: + - flake8-2020>=1.6.0 + - flake8-docstrings>=1.5.0 + - flake8-pytest-style>=1.2.2 +- repo: https://github.com/pre-commit/mirrors-mypy + rev: v0.782 + hooks: + - id: mypy + # empty args needed in order to match mypy cli behavior + args: [] + additional_dependencies: + - Sphinx>=3.1.2 +- repo: https://github.com/pre-commit/mirrors-pylint + rev: v2.5.3 + hooks: + - id: pylint diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml new file mode 100644 index 0000000..d917473 --- /dev/null +++ b/.pre-commit-hooks.yaml @@ -0,0 +1,14 @@ +--- + +# For use with pre-commit. +# See usage instructions at http://pre-commit.com + +- id: ansible-lint + name: Ansible-lint + description: This hook runs ansible-lint. + entry: ansible-lint --force-color + language: python + # do not pass files to ansible-lint, see: + # https://github.com/ansible/ansible-lint/issues/611 + pass_filenames: false + always_run: true diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..a7881e0 --- /dev/null +++ b/.pylintrc @@ -0,0 +1,37 @@ +[IMPORTS] +preferred-modules = + unittest:pytest, + +[MESSAGES CONTROL] + +disable = + # TODO(ssbarnea): remove temporary skips adding during initial adoption: + bad-continuation, + broad-except, + consider-using-in, + dangerous-default-value, + duplicate-code, + fixme, + import-error, + inconsistent-return-statements, + invalid-name, + missing-class-docstring, + missing-function-docstring, + missing-module-docstring, + no-else-continue, + no-else-return, + no-member, + no-self-use, + not-callable, + protected-access, + redefined-builtin, + redefined-outer-name, + too-few-public-methods, + too-many-arguments, + too-many-branches, + too-many-instance-attributes, + too-many-return-statements, + unused-argument, + unused-variable, + useless-object-inheritance, + wrong-import-order, diff --git a/.readthedocs.yml b/.readthedocs.yml new file mode 100644 index 0000000..bb33e2e --- /dev/null +++ b/.readthedocs.yml @@ -0,0 +1,41 @@ +# Read the Docs configuration file +# See https://docs.readthedocs.io/en/stable/config-file/v2.html +# for details + +--- + +# Required +version: 2 + +# Build documentation in the docs/ directory with Sphinx +sphinx: + builder: html + configuration: docs/conf.py + fail_on_warning: true + +# Build documentation with MkDocs +#mkdocs: +# configuration: mkdocs.yml +# fail_on_warning: true + +# Optionally build your docs in additional formats +# such as PDF and ePub +formats: [] + +submodules: + include: all # [] + exclude: [] + recursive: true + +build: + image: latest + +# Optionally set the version of Python and requirements required +# to build docs +python: + version: 3.8 + install: + - method: pip + path: . + - requirements: docs/requirements.txt + system_packages: false diff --git a/.yamllint b/.yamllint new file mode 100644 index 0000000..c7a1f61 --- /dev/null +++ b/.yamllint @@ -0,0 +1,4 @@ +rules: + indentation: + level: error + indent-sequences: consistent diff --git a/CHANGELOG.rst b/CHANGELOG.rst new file mode 100644 index 0000000..661e007 --- /dev/null +++ b/CHANGELOG.rst @@ -0,0 +1,188 @@ +Current changes can now be accessed from `github releases <https://github.com/ansible/ansible-lint/releases/>`_. + +4.3.0 - Released 2020-08-17 +=========================== + +Major Changes: + +* Require Python 3.6 or newer (#775) @ssbarnea +* Require Ansible 2.8 or newer (#721) @ssbarnea +* LRU Cache for frequently called functions (#891) @ragne +* Change documentation website to RTD (#875) @ssbarnea +* Add rules for verifying the existence of imported and included files (#691) @jlusiardi +* Add a new rule for detecting nested jinja mustache syntax (#686) @europ + +Minor Changes: + +* Refactored import_playbook tests (#951) @ssbarnea +* Added MissingFilePermissionsRule (#943) @ssbarnea +* Enable github actions parsable format (#926) @ssbarnea +* Add linter branding for docs (#914) @ssbarnea +* Assure we do not produce duplicated matches (#912) @ssbarnea +* Enable annotations on failed tests (#910) @ssbarnea +* Refactor `_taskshandlers_children` complexity (#903) @webknjaz +* Make import sections consistent (#897) @ssbarnea +* Allow backticks in shell commands (#894) @turettn +* Add ansible210 testing (#888) @ssbarnea +* Enable isort (#887) @ssbarnea +* Combine MatchError into Match (#884) @ssbarnea +* Improve MatchError class (#881) @ssbarnea +* Expose package version (#867) @ssbarnea +* Replace custom theme with sphinx-ansible-theme (#856) @ssbarnea +* Improve unjinja function (#853) @ssbarnea +* Refactor MetaMainHasInfoRule (#846) @ssato +* Remove dependency on ansible.utils.color (#833) @ssbarnea +* Moved exit codes to constants (#821) @ssbarnea +* Document module dependencies (#817) @ssbarnea +* Refactor Runner out of __init__ (#816) @ssbarnea +* Added reproducer for become in blocks (#793) @ssbarnea +* Convert failed to find required 'name' key in include_role into a match (#781) @ssbarnea +* Fix exclude_paths from get_playbooks_and_roles (#774) @ssbarnea +* Update ComparisonToEmptyStringRule.py (#770) @vbotka +* Remove bin/ansible-lint script (#762) @ssbarnea +* Fix logging configuration (#757) @ssbarnea +* Allow returning line number in matchplay (#756) @albinvass +* Update cli output on README (#754) @ssbarnea +* Migrate some test to pytest (#740) @cans +* Use python logging (#732) @ssbarnea +* Make config loading failures visible (#726) @ssbarnea +* Add a test that fails with `AttributeError` on malformed `import_tasks` file content (#720) @mdaniel +* Consistent relative path display (#692) @cans + +Bugfixes: + +* E501: Add become_user and become inheritance (#964) @Tompage1994 +* Add missing hosts to test files (#952) @ssbarnea +* E208: Improve MissingFilePermissionsRule detection (#949) @ssbarnea +* Make pre-commit hook use auto-detect mode (#932) @ssbarnea +* Fix severity formatter wrong use of color (#919) @ssbarnea +* Avoid displaying Null with missing filenames (#918) @ssbarnea +* Include contributing inside docs (#905) @ssbarnea +* Fix spelling mistakes in documentation (#901) @MorganGeek +* Avoid failure with playbooks having tasks key a null value (#899) @ssbarnea +* Fix `MatchError` comparison fallback implementation (#896) @webknjaz +* Avoid sorting failure with matches without an id (#879) @ssbarnea +* Fix broken always_run rule on Ansible 2.10 (#878) @ssbarnea +* Allow null config file (#814) @ssbarnea +* Fixed the search method when the file path not exists (#807) @cahlchang +* Restore playbook auto-detection (#767) @ssbarnea +* Gracefully process a missing git binary when falling-back to pure-python discovery (#738) @anryko +* Resurrect support for editable mode installs (#722) @webknjaz +* Avoid exception from 505 rule (#709) @ssbarnea + +4.2.0 - Released 2019-12-04 +=========================== + +Features: + +- Enable ansible-lint to auto-detect roles/playbooks `#615 <https://github.com/ansible/ansible-lint/pull/615>`_ +- Normalize displayed file paths `#620 <https://github.com/ansible/ansible-lint/pull/620>`_ + +Bugfixes: + +- Fix role detection to include tasks/main.yml `#631 <https://github.com/ansible/ansible-lint/pull/631>`_ +- Fix pre-commit hooks `#612 <https://github.com/ansible/ansible-lint/pull/612>`_ +- Ensure variable syntax before matching in VariableHasSpacesRule `#535 <https://github.com/ansible/ansible-lint/pull/535>`_ +- Fix false positive with multiline template in UsingBareVariablesIsDeprecatedRule `#251 <https://github.com/ansible/ansible-lint/pull/251>`_ +- Fix role metadata checks when they include unexpected types `#533 <https://github.com/ansible/ansible-lint/pull/533>`_ `#513 <https://github.com/ansible/ansible-lint/pull/513>`_ +- Support inline rule skipping inside a block `#528 <https://github.com/ansible/ansible-lint/pull/528>`_ +- Look for noqa skips in handlers, pre_tasks, and post_tasks `#520 <https://github.com/ansible/ansible-lint/pull/520>`_ +- Fix skipping when using import_playbook `#517 <https://github.com/ansible/ansible-lint/pull/517>`_ +- Fix parsing inline args for import_role and include_role `#511 <https://github.com/ansible/ansible-lint/pull/511>`_ +- Fix syntax proposed by 104 to not fail 206 `#501 <https://github.com/ansible/ansible-lint/pull/501>`_ +- Fix VariableHasSpacesRule false positive for whitespace control chars in vars `#500 <https://github.com/ansible/ansible-lint/pull/500>`_ + +Docs/Misc: + +- Disable docs build on macos with py38 `#630 <https://github.com/ansible/ansible-lint/pull/630>`_ +- Update dependencies and CI to supported versions of ansible `#530 <https://github.com/ansible/ansible-lint/pull/530>`_ +- Declare support for Python 3.8 `#601 <https://github.com/ansible/ansible-lint/pull/601>`_ + +Dev/Contributor: + +- Enable flake-docstrings to check for pep257 `#621 <https://github.com/ansible/ansible-lint/pull/621>`_ +- Remove code related to unsupported ansible versions before 2.4 `#622 <https://github.com/ansible/ansible-lint/pull/622>`_ +- Replace nosetests with pytest `#604 <https://github.com/ansible/ansible-lint/pull/604>`_ +- Support newer setuptools and require 34.0.0 or later `#591 <https://github.com/ansible/ansible-lint/pull/591>`_ `#600 <https://github.com/ansible/ansible-lint/pull/600>`_ +- Added SSL proxy variables to tox passenv `#593 <https://github.com/ansible/ansible-lint/pull/593>`_ +- Have RunFromText test helper use named files for playbooks `#519 <https://github.com/ansible/ansible-lint/pull/519>`_ +- Fully depend on Pip having PEP 517 implementation `#607 <https://github.com/ansible/ansible-lint/pull/607>`_ +- Fixed metadata and travis deployment `#598 <https://github.com/ansible/ansible-lint/pull/598>`_ + +4.1.0 - Released 11-Feb-2019 +============================ + +- Support skipping specific rule(s) for a specific task `#460 <https://github.com/ansible/ansible-lint/pull/460>`_ +- Lint all yaml in tasks/ and handlers/ regardless of import or include `#462 <https://github.com/ansible/ansible-lint/pull/462>`_ +- New rule: shell task uses pipeline without pipefail `#199 <https://github.com/ansible/ansible-lint/pull/199>`_ +- Remove rule 405 checking for retry on package modules `#465 <https://github.com/ansible/ansible-lint/pull/465>`_ +- Limit env var check to command, not shell `#477 <https://github.com/ansible/ansible-lint/pull/477>`_ +- Extend max line length rule from 120 to 160 `#474 <https://github.com/ansible/ansible-lint/pull/474>`_ +- Do not flag octal file mode permission when it is a string `#480 <https://github.com/ansible/ansible-lint/pull/480>`_ +- Check ANSIBLE_ROLES_PATH before basedir `#478 <https://github.com/ansible/ansible-lint/pull/478>`_ +- Fix crash on indexing empty cmd arguments `#473 <https://github.com/ansible/ansible-lint/pull/473>`_ +- Handle argv syntax for the command module `#424 <https://github.com/ansible/ansible-lint/pull/424>`_ +- Add another possible license default with SPDX `#472 <https://github.com/ansible/ansible-lint/pull/472>`_ +- Ignore comments for line-based rules `#453 <https://github.com/ansible/ansible-lint/pull/453>`_ +- Allow config skip_list to have rule number id not in quotes `#463 <https://github.com/ansible/ansible-lint/pull/463>`_ + +4.0.1 - Released 04-Jan-2019 +============================ + +Bugfix release + +- Allow install with python35 and add to tox testing `#452 <https://github.com/ansible/ansible-lint/pull/452>`_ +- Fix 503 UseHandlerRatherThanWhenChangedRule attempt to iterate on bool `#455 <https://github.com/ansible/ansible-lint/pull/455>`_ +- Improve regex on rule 602 `#454 <https://github.com/ansible/ansible-lint/pull/454>`_ +- Refactor RoleRelativePathRule, fix keyerror `#446 <https://github.com/ansible/ansible-lint/pull/446>`_ +- Rule 405 now ignore case of 'yum: list=package' `#444 <https://github.com/ansible/ansible-lint/pull/444>`_ +- Allow jinja escaping in variables `#440 <https://github.com/ansible/ansible-lint/pull/440>`_ + +4.0.0 - Released 18-Dec-2018 +============================ + +* New documentation site `docs.ansible.com/ansible-lint <https://docs.ansible.com/ansible-lint/>`_ +* Additional default rules for ansible-lint, listed in `docsite default rules <https://docs.ansible.com/ansible-lint/rules/default_rules.html>`_ +* Fixed running with role path containing single or multiple dirs #390 +* Fixed double sudo rule output #393 +* Severity property added to rules to be used by Galaxy #379 +* Packaging: consistency and automation #389 +* Updated rule TrailingWhitespaceRule.py to remove carriage return char #323 +* Allow snake_case module names for rules #82 +* Suggest tempfile module instead of mktemp command #422 +* Update tox to run with only supported ansible versions #406 +* GitHub repository edits: move to ansible org, add CODE_OF_CONDUCT, add ROADMAP, label edits + +3.5.1 +===== + +Use ``yaml.safe_load`` for loading the configuration file + +3.5.0 +===== + +* New ids and tags, add doc generator. Old tag names remain backwardly compatible (awcrosby) +* Add more package formats to PackageIsNotLatestRule (simon04) +* Improve handling of meta/main.yml dependencies (MatrixCrawler) +* Correctly handle role argument trailing slash (zoredache) +* Handle ``include_task`` and ``import_task`` (zeot) +* Add a new rule to detect jinja in when clauses (greg-hellings) +* Suggest ``replace`` as another alternative to ``sed`` (inponomarev) +* YAML syntax highlighting for false positives (gundalow) + +3.4.23 +====== + +Fix bug with using comma-separated ``skip_list`` arguments + +3.4.22 +====== + +* Allow ``include_role`` and ``import_role`` (willthames) +* Support arbitrary number of exclude flags (KellerFuchs) +* Fix task has name check for empty name fields (ekeih) +* Allow vault encrypted variables in YAML files (mozz) +* Octal permission check improvements - readability, test + coverage and bug fixes (willthames) +* Fix very weird bug with line numbers in some test environments (kouk) +* Python 3 fixes for octal literals in tests (willthames) diff --git a/DCO_1_1.md b/DCO_1_1.md new file mode 100644 index 0000000..1c497a0 --- /dev/null +++ b/DCO_1_1.md @@ -0,0 +1,45 @@ +DCO +=== + +All contributors must use `git commit --signoff` for any +commit to be merged, and agree that usage of --signoff constitutes +agreement with the terms of DCO 1.1, which appears below: + +``` +Developer Certificate of Origin +Version 1.1 + +Copyright (C) 2004, 2006 The Linux Foundation and its contributors. +1 Letterman Drive +Suite D4700 +San Francisco, CA, 94129 + +Everyone is permitted to copy and distribute verbatim copies of this +license document, but changing it is not allowed. + +Developer's Certificate of Origin 1.1 + +By making a contribution to this project, I certify that: + +(a) The contribution was created in whole or in part by me and I + have the right to submit it under the open source license + indicated in the file; or + +(b) The contribution is based upon previous work that, to the best + of my knowledge, is covered under an appropriate open source + license and I have the right under that license to submit that + work with modifications, whether created in whole or in part + by me, under the same open source license (unless I am + permitted to submit under a different license), as indicated + in the file; or + +(c) The contribution was provided directly to me by some other + person who certified (a), (b) or (c) and I have not modified + it. + +(d) I understand and agree that this project and the contribution + are public and that a record of the contribution (including all + personal information I submit with it, including my sign-off) is + maintained indefinitely and may be redistributed consistent with + this project or the open source license(s) involved. +``` @@ -0,0 +1,21 @@ +Copyright (c) 2013-2018 Will Thames <will@thames.id.au> +Copyright (c) 2018 Ansible by Red Hat + + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +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. diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..0e061fa --- /dev/null +++ b/README.rst @@ -0,0 +1,640 @@ +.. image:: https://img.shields.io/pypi/v/ansible-lint.svg + :target: https://pypi.org/project/ansible-lint + :alt: PyPI version + +.. image:: https://img.shields.io/badge/Ansible--lint-rules%20table-blue.svg + :target: https://ansible-lint.readthedocs.io/en/latest/default_rules.html + :alt: Ansible-lint rules explanation + +.. image:: https://img.shields.io/badge/Code%20of%20Conduct-black.svg + :target: https://docs.ansible.com/ansible/latest/community/code_of_conduct.html + :alt: Ansible Code of Conduct + +.. image:: https://img.shields.io/badge/Mailing%20lists-Ansible-orange.svg + :target: https://docs.ansible.com/ansible/latest/community/communication.html#mailing-list-information + :alt: Ansible mailing lists + +.. image:: https://github.com/ansible/ansible-lint/workflows/gh/badge.svg + :target: https://github.com/ansible/ansible-lint/actions?query=workflow%3Agh+branch%3Amaster+event%3Apush + :alt: GitHub Actions CI/CD + +.. image:: https://img.shields.io/lgtm/grade/python/g/ansible/ansible-lint.svg?logo=lgtm&logoWidth=18 + :target: https://lgtm.com/projects/g/ansible/ansible-lint/context:python + :alt: Language grade: Python + +.. image:: https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white + :target: https://github.com/pre-commit/pre-commit + :alt: pre-commit + + +Ansible-lint +============ + +``ansible-lint`` checks playbooks for practices and behaviour that could +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/en/latest/>`_ + +Installing +========== + +.. installing-docs-inclusion-marker-do-not-remove + +Installing on Windows is not supported because we use symlinks inside Python packages. + +Using Pip +--------- + +.. code-block:: bash + + pip install ansible-lint + +.. _installing_from_source: + +From Source +----------- + +**Note**: pip 19.0+ is required for installation. Please consult with the `PyPA User Guide`_ +to learn more about managing Pip versions. + +.. code-block:: bash + + pip install git+https://github.com/ansible/ansible-lint.git + +.. _PyPA User Guide: https://packaging.python.org/tutorials/installing-packages/#ensure-pip-setuptools-and-wheel-are-up-to-date + +.. installing-docs-inclusion-marker-end-do-not-remove + +Usage +===== + +.. usage-docs-inclusion-marker-do-not-remove + +Command Line Options +-------------------- + +The following is the output from ``ansible-lint --help``, providing an overview of the basic command line options: + +.. code-block:: + + usage: ansible-lint [-h] [-L] [-f {rich,plain,rst}] [-q] [-p] [--parseable-severity] [-r RULESDIR] + [-R] [--show-relpath] [-t TAGS] [-T] [-v] [-x SKIP_LIST] + [-w WARN_LIST [WARN_LIST ...]] [--nocolor] [--force-color] + [--exclude EXCLUDE_PATHS] [-c CONFIG_FILE] [--version] + [playbook [playbook ...]] + + positional arguments: + playbook One or more files or paths. When missing it will enable auto-detection mode. + + optional arguments: + -h, --help show this help message and exit + -L list all the rules + -f {rich,plain,rst} Format used rules output, (default: rich) + -q quieter, although not silent output + -p parseable output in the format of pep8 + --parseable-severity parseable output including severity of rule + --progressive Return success if it detects a reduction in number of violations compared with + previous git commit. This feature works only on git repository clones. + -r RULESDIR Specify custom rule directories. Add -R to keep using embedded rules from + /usr/local/lib/python3.8/site-packages/ansiblelint/rules + -R Keep default rules when using -r + --show-relpath Display path relative to CWD + -t TAGS only check rules whose id/tags match these values + -T list all the tags + -v Increase verbosity level + -x SKIP_LIST only check rules whose id/tags do not match these values + -w WARN_LIST [WARN_LIST ...] + only warn about these rules + --nocolor disable colored output + --force-color Try force colored output (relying on ansible's code) + --exclude EXCLUDE_PATHS + path to directories or files to skip. This option is repeatable. + -c CONFIG_FILE Specify configuration file to use. Defaults to ".ansible-lint" + --version show program's version number and exit + +Progressive mode +---------------- + +In order to ease tool adoption, git users can enable the progressive mode using +``--progressive`` option. This makes the linter return a success even if +some failures are found, as long the total number of violations did not +increase since the previous commit. + +As expected, this mode makes the linter run twice if it finds any violations. +The second run is performed against a temporary git working copy that contains +the previous commit. All the violations that were already present are removed +from the list and the final result is displayed. + +The most notable benefit introduced by this mode it does not prevent merging +new code while allowing developer to address historical violation at his own +speed. + +CI/CD +----- + +If execution under `Github Actions`_ is detected via the presence of +``GITHUB_ACTIONS=true`` and ``GITHUB_WORFLOW=...`` variables, the linter will +also print errors using their `annotation`_ format. + +.. _GitHub Actions: https://github.com/features/actions +.. _annotation: https://docs.github.com/en/actions/reference/workflow-commands-for-github-actions#setting-an-error-message + +Linting Playbooks and Roles +--------------------------- + +It's important to note that ``ansible-lint`` accepts a list of Ansible playbook files or a list of role directories. Starting from a directory that contains the following, the playbook file, ``playbook.yml``, or one of the role subdirectories, such as ``geerlingguy.apache``, can be passed: + +.. code-block:: + + playbook.yml + roles/ + geerlingguy.apache/ + tasks/ + handlers/ + files/ + templates/ + vars/ + defaults/ + meta/ + geerlingguy.elasticsearch/ + tasks/ + handlers/ + files/ + templates/ + vars/ + defaults/ + meta/ + +The following lints the role ``geerlingguy.apache``: + +.. code-block:: + + $ ansible-lint geerlingguy.apache + + [305] Use shell only when shell functionality is required + /Users/chouseknecht/.ansible/roles/geerlingguy.apache/tasks/main.yml:19 + Task/Handler: Get installed version of Apache. + + [502] All tasks should be named + /Users/chouseknecht/.ansible/roles/geerlingguy.apache/tasks/main.yml:29 + Task/Handler: include_vars apache-22.yml + + [502] All tasks should be named + /Users/chouseknecht/.ansible/roles/geerlingguy.apache/tasks/main.yml:32 + Task/Handler: include_vars apache-24.yml + +Here's the contents of ``playbook.yml``, which references multiples roles: + +.. code-block:: yaml + + - name: Lint multiple roles + hosts: all + tasks: + + - include_role: + name: geerlingguy.apache + + - include_role: + name: geerlingguy.elasticsearch + +The following lints ``playbook.yml``, which evaluates both the playbook and the referenced roles: + +.. code-block:: + + $ ansible-lint playbook.yml + + [305] Use shell only when shell functionality is required + /Users/chouseknecht/roles/geerlingguy.apache/tasks/main.yml:19 + Task/Handler: Get installed version of Apache. + + [502] All tasks should be named + /Users/chouseknecht/roles/geerlingguy.apache/tasks/main.yml:29 + Task/Handler: include_vars apache-22.yml + + [502] All tasks should be named + /Users/chouseknecht/roles/geerlingguy.apache/tasks/main.yml:32 + Task/Handler: include_vars apache-24.yml + + [502] All tasks should be named + /Users/chouseknecht/roles/geerlingguy.elasticsearch/tasks/main.yml:17 + Task/Handler: service state=started name=elasticsearch enabled=yes + +Since ``ansible-lint`` accepts a list of roles or playbooks, the following works as well, producing the same output as the example above: + +.. code-block:: + + $ ansible-lint geerlingguy.apache geerlingguy.elasticsearch + + [305] Use shell only when shell functionality is required + /Users/chouseknecht/roles/geerlingguy.apache/tasks/main.yml:19 + Task/Handler: Get installed version of Apache. + + [502] All tasks should be named + /Users/chouseknecht/roles/geerlingguy.apache/tasks/main.yml:29 + Task/Handler: include_vars apache-22.yml + + [502] All tasks should be named + /Users/chouseknecht/roles/geerlingguy.apache/tasks/main.yml:32 + Task/Handler: include_vars apache-24.yml + + [502] All tasks should be named + /Users/chouseknecht/roles/geerlingguy.elasticsearch/tasks/main.yml:17 + Task/Handler: service state=started name=elasticsearch enabled=yes + +Examples +-------- + +Included in ``ansible-lint/examples`` are some example playbooks with undesirable features. Running ansible-lint on them works, as demonstrated in the following: + +.. code-block:: + + $ ansible-lint examples/example.yml + + [301] Commands should not change things if nothing needs doing + examples/example.yml:9 + Task/Handler: unset variable + + [206] Variables should have spaces before and after: {{ var_name }} + examples/example.yml:10 + action: command echo {{thisvariable}} is not set in this playbook + + [301] Commands should not change things if nothing needs doing + examples/example.yml:12 + Task/Handler: trailing whitespace + + [201] Trailing whitespace + examples/example.yml:13 + action: command echo do nothing + + [401] Git checkouts must contain explicit version + examples/example.yml:15 + Task/Handler: git check + + [401] Git checkouts must contain explicit version + examples/example.yml:18 + Task/Handler: git check 2 + + [301] Commands should not change things if nothing needs doing + examples/example.yml:24 + Task/Handler: executing git through command + + [303] git used in place of git module + examples/example.yml:24 + Task/Handler: executing git through command + + [303] git used in place of git module + examples/example.yml:27 + Task/Handler: executing git through command + + [401] Git checkouts must contain explicit version + examples/example.yml:30 + Task/Handler: using git module + + [206] Variables should have spaces before and after: {{ var_name }} + examples/example.yml:34 + action: debug msg="{{item}}" + + [201] Trailing whitespace + examples/example.yml:35 + with_items: + + [403] Package installs should not use latest + examples/example.yml:39 + Task/Handler: yum latest + + [403] Package installs should not use latest + examples/example.yml:44 + Task/Handler: apt latest + + [101] Deprecated always_run + examples/example.yml:47 + Task/Handler: always run + + +If playbooks include other playbooks, or tasks, or handlers or roles, these are also handled: + +.. code-block:: + + $ ansible-lint examples/include.yml + + [301] Commands should not change things if nothing needs doing + examples/play.yml:5 + Task/Handler: a bad play + + [303] service used in place of service module + examples/play.yml:5 + Task/Handler: a bad play + + [401] Git checkouts must contain explicit version + examples/roles/bobbins/tasks/main.yml:2 + Task/Handler: test tasks + + [701] No 'galaxy_info' found + examples/roles/hello/meta/main.yml:1 + {'meta/main.yml': {'dependencies': [{'role': 'bobbins', '__line__': 3, '__file__': '/Users/akx/build/ansible-lint/examples/roles/hello/meta/main.yml'}], '__line__': 1, '__file__': '/Users/akx/build/ansible-lint/examples/roles/hello/meta/main.yml', 'skipped_rules': []}} + + [303] service used in place of service module + examples/roles/morecomplex/handlers/main.yml:1 + Task/Handler: restart service using command + + [301] Commands should not change things if nothing needs doing + examples/roles/morecomplex/tasks/main.yml:1 + Task/Handler: test bad command + + [302] mkdir used in place of argument state=directory to file module + examples/roles/morecomplex/tasks/main.yml:1 + Task/Handler: test bad command + + [301] Commands should not change things if nothing needs doing + examples/roles/morecomplex/tasks/main.yml:4 + Task/Handler: test bad command v2 + + [302] mkdir used in place of argument state=directory to file module + examples/roles/morecomplex/tasks/main.yml:4 + Task/Handler: test bad command v2 + + [301] Commands should not change things if nothing needs doing + examples/roles/morecomplex/tasks/main.yml:7 + Task/Handler: test bad local command + + [305] Use shell only when shell functionality is required + examples/roles/morecomplex/tasks/main.yml:7 + Task/Handler: test bad local command + + [504] Do not use 'local_action', use 'delegate_to: localhost' + examples/roles/morecomplex/tasks/main.yml:8 + local_action: shell touch foo + + [201] Trailing whitespace + examples/tasks/x.yml:3 + args: + + [201] Trailing whitespace + examples/tasks/x.yml:3 + args: + +.. usage-docs-inclusion-marker-end-do-not-remove + +Configuring +=========== + +.. configuring-docs-inclusion-marker-do-not-remove + +Configuration File +------------------ + +Ansible-lint supports local configuration via a ``.ansible-lint`` configuration file. Ansible-lint checks the working directory for the presence of this file and applies any configuration found there. The configuration file location can also be overridden via the ``-c path/to/file`` CLI flag. + +If a value is provided on both the command line and via a config file, the values will be merged (if a list like **exclude_paths**), or the **True** value will be preferred, in the case of something like **quiet**. + +The following values are supported, and function identically to their CLI counterparts: + +.. code-block:: yaml + + exclude_paths: + - ./my/excluded/directory/ + - ./my/other/excluded/directory/ + - ./last/excluded/directory/ + parseable: true + quiet: true + rulesdir: + - ./rule/directory/ + skip_list: + - skip_this_tag + - and_this_one_too + - skip_this_id + - '401' + tags: + - run_this_tag + use_default_rules: true + verbosity: 1 + + +Pre-commit Setup +---------------- + +To use ansible-lint with `pre-commit`_, just add the following to your local repo's ``.pre-commit-config.yaml`` file. Make sure to change **rev:** to be either a git commit sha or tag of ansible-lint containing ``hooks.yaml``. + +.. code-block:: yaml + + - repo: https://github.com/ansible/ansible-lint.git + rev: v4.1.0 + hooks: + - id: ansible-lint + files: \.(yaml|yml)$ + +.. _pre-commit: https://pre-commit.com + +.. configuring-docs-inclusion-marker-end-do-not-remove + +Rules +===== + +.. rules-docs-inclusion-marker-do-not-remove + +Specifying Rules at Runtime +--------------------------- + +By default, ``ansible-lint`` uses the rules found in ``ansible-lint/lib/ansiblelint/rules``. To override this behavior and use a custom set of rules, use the ``-r /path/to/custom-rules`` option to provide a directory path containing the custom rules. For multiple rule sets, pass multiple ``-r`` options. + +It's also possible to use the default rules, plus custom rules. This can be done by passing the ``-R`` to indicate that the default rules are to be used, along with one or more ``-r`` options. + +Using Tags to Include Rules +``````````````````````````` + +Each rule has an associated set of one or more tags. To view the list of tags for each available rule, use the ``-T`` option. + +The following shows the available tags in an example set of rules, and the rules associated with each tag: + +.. code-block:: bash + + $ ansible-lint -v -T + + behaviour ['[503]'] + bug ['[304]'] + command-shell ['[305]', '[302]', '[304]', '[306]', '[301]', '[303]'] + deprecated ['[105]', '[104]', '[103]', '[101]', '[102]'] + formatting ['[104]', '[203]', '[201]', '[204]', '[206]', '[205]', '[202]'] + idempotency ['[301]'] + idiom ['[601]', '[602]'] + metadata ['[701]', '[704]', '[703]', '[702]'] + module ['[404]', '[401]', '[403]', '[402]'] + oddity ['[501]'] + readability ['[502]'] + repeatability ['[401]', '[403]', '[402]'] + resources ['[302]', '[303]'] + safety ['[305]'] + task ['[502]', '[503]', '[504]', '[501]'] + +To run just the *idempotency* rules, for example, run the following: + +.. code-block:: bash + + $ ansible-lint -t idempotency playbook.yml + +Excluding Rules +``````````````` + +To exclude rules from the available set of rules, use the ``-x SKIP_LIST`` option. For example, the following runs all of the rules except those with the tags *readability* and *safety*: + +.. code-block:: bash + + $ ansible-lint -x readability,safety playbook.yml + +It's also possible to skip specific rules by passing the rule ID. For example, the following excludes rule *502*: + +.. code-block:: bash + + $ ansible-lint -x 502 playbook.yml + +False Positives: Skipping Rules +------------------------------- + +Some rules are a bit of a rule of thumb. Advanced *git*, *yum* or *apt* usage, for example, is typically difficult to achieve through the modules. In this case, you should mark the task so that warnings aren't produced. + +To skip a specific rule for a specific task, inside your ansible yaml add ``# noqa [rule_id]`` at the end of the line. If the rule is task-based (most are), add at the end of any line in the task. You can skip multiple rules via a space-separated list. + +.. code-block:: yaml + + - name: this would typically fire GitHasVersionRule 401 and BecomeUserWithoutBecomeRule 501 + become_user: alice # noqa 401 501 + git: src=/path/to/git/repo dest=checkout + +If the rule is line-based, ``# noqa [rule_id]`` must be at the end of the particular line to be skipped + +.. code-block:: yaml + + - name: this would typically fire LineTooLongRule 204 and VariableHasSpacesRule 206 + get_url: + url: http://example.com/really_long_path/really_long_path/really_long_path/really_long_path/really_long_path/really_long_path/file.conf # noqa 204 + dest: "{{dest_proj_path}}/foo.conf" # noqa 206 + + +It's also a good practice to comment the reasons why a task is being skipped. + +If you want skip running a rule entirely, you can use either use ``-x`` command +line argument, or add it to ``skip_list`` inside the configuration file. + +A less-preferred method of skipping is to skip all task-based rules for a task (this does not skip line-based rules). There are two mechanisms for this: the ``skip_ansible_lint`` tag works with all tasks, and the ``warn`` parameter works with the *command* or *shell* modules only. Examples: + +.. code-block:: yaml + + - name: this would typically fire CommandsInsteadOfArgumentRule 302 + command: warn=no chmod 644 X + + - name: this would typically fire CommandsInsteadOfModuleRule 303 + command: git pull --rebase + args: + warn: False + + - name: this would typically fire GitHasVersionRule 401 + git: src=/path/to/git/repo dest=checkout + tags: + - skip_ansible_lint + +Creating Custom Rules +--------------------- + +Rules are described using a class file per rule. Default rules are named *DeprecatedVariableRule.py*, etc. + +Each rule definition should have the following: + +* ID: A unique identifier +* Short description: Brief description of the rule +* Description: Behaviour the rule is looking for +* Tags: one or more tags that may be used to include or exclude the rule +* At least one of the following methods: + + * ``match`` that takes a line and returns None or False, if the line doesn't match the test, and True or a custom message, when it does. (This allows one rule to test multiple behaviours - see e.g. the *CommandsInsteadOfModulesRule*.) + * ``matchtask`` that operates on a single task or handler, such that tasks get standardized to always contain a *module* key and *module_arguments* key. Other common task modifiers, such as *when*, *with_items*, etc., are also available as keys, if present in the task. + +An example rule using ``match`` is: + +.. code-block:: python + + from ansiblelint.rules import AnsibleLintRule + + class DeprecatedVariableRule(AnsibleLintRule): + + id = 'EXAMPLE002' + shortdesc = 'Deprecated variable declarations' + description = 'Check for lines that have old style ${var} ' + \ + 'declarations' + tags = { 'deprecated' } + + def match(self, file, line): + return '${' in line + +An example rule using ``matchtask`` is: + +.. code-block:: python + + import ansiblelint.utils + from ansiblelint.rules import AnsibleLintRule + + class TaskHasTag(AnsibleLintRule): + id = 'EXAMPLE001' + shortdesc = 'Tasks must have tag' + description = 'Tasks must have tag' + tags = ['productivity'] + + def matchtask(self, file, task): + # If the task include another task or make the playbook fail + # Don't force to have a tag + if not set(task.keys()).isdisjoint(['include','fail']): + return False + + # Task should have tags + if not task.has_key('tags'): + return True + + return False + +The task argument to ``matchtask`` contains a number of keys - the critical one is *action*. The value of *task['action']* contains the module being used, and the arguments passed, both as key-value pairs and a list of other arguments (e.g. the command used with shell). + +In ansible-lint 2.0.0, *task['action']['args']* was renamed *task['action']['module_arguments']* to avoid a clash when a module actually takes args as a parameter key (e.g. ec2_tag) + +In ansible-lint 3.0.0 *task['action']['module']* was renamed *task['action']['__ansible_module__']* to avoid a clash when a module take module as an argument. As a precaution, *task['action']['module_arguments']* was renamed *task['action']['__ansible_arguments__']*. + +Packaging Custom Rules +`````````````````````` + +Ansible-lint provides a sub directory named *custom* in its built-in rules, +``/usr/lib/python3.8/site-packages/ansiblelint/rules/custom/`` for example, to +install custom rules since v4.3.1. The custom rules which are packaged as an +usual python package installed into this directory will be loaded and enabled +automatically by ansible-lint. + +To make custom rules loaded automatically, you need the followings: + +- Packaging your custom rules as an usual python package named some descriptive ones like ``ansible_lint_custom_rules_foo``. +- Make it installed into ``<ansible_lint_custom_rules_dir>/custom/<your_custom_rules_subdir>/``. + +You may accomplish the second by adding some configurations into the [options] +section of the ``setup.cfg`` of your custom rules python package like the following. + +.. code-block:: + + [options] + packages = + ansiblelint.rules.custom.<your_custom_rules_subdir> + package_dir = + ansiblelint.rules.custom.<your_custom_rules_subdir> = <your_rules_source_code_subdir> + +.. rules-docs-inclusion-marker-end-do-not-remove + +Contributing +============ + +Please read `Contribution guidelines`_ if you wish to contribute. + +Authors +======= + +ansible-lint was created by `Will Thames`_ and is now maintained as part of the `Ansible`_ by `Red Hat`_ project. + +.. _Contribution guidelines: https://ansible-lint.readthedocs.io/en/latest/contributing.html +.. _Will Thames: https://github.com/willthames +.. _Ansible: https://ansible.com +.. _Red Hat: https://redhat.com diff --git a/bindep.txt b/bindep.txt new file mode 100644 index 0000000..7a0390b --- /dev/null +++ b/bindep.txt @@ -0,0 +1,13 @@ +# This is a cross-platform list tracking distribution packages needed by tests; +# see https://pypi.org/project/bindep/ for additional information. + +bzip2 [platform:rpm] +gcc [test platform:rpm] +gcc-c++ [test platform:rpm] +libselinux-python [platform:centos-7] +python3 [test platform:rpm !platform:centos-7] +python3-devel [test platform:rpm !platform:centos-7] +python3-libselinux [test platform:rpm !platform:centos-7] +python3-netifaces [test !platform:centos-7 platform:rpm] + +openssl-devel [test platform:rpm] diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..7b05ce4 --- /dev/null +++ b/conftest.py @@ -0,0 +1,30 @@ +"""PyTest Fixtures.""" +import os + +import pytest + +from ansiblelint.constants import DEFAULT_RULESDIR +from ansiblelint.rules import RulesCollection +from ansiblelint.testing import RunFromText + + +@pytest.fixture +def default_rules_collection(): + """Return default rule collection.""" + assert os.path.isdir(DEFAULT_RULESDIR) + return RulesCollection(rulesdirs=[DEFAULT_RULESDIR]) + + +@pytest.fixture +def default_text_runner(default_rules_collection): + """Return RunFromText instance for the default set of collections.""" + return RunFromText(default_rules_collection) + + +@pytest.fixture +def rule_runner(request): + """Return runner for a specific rule class.""" + rule_class = request.param + collection = RulesCollection() + collection.register(rule_class()) + return RunFromText(collection) diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 0000000..22695c6 --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1,16 @@ +# Old compiled python stuff +*.py[co] +# package building stuff +build +# Emacs backup files... +*~ +.\#* +.doctrees +# Generated docs stuff +ansible*.xml +.buildinfo +objects.inv +.doctrees +*.min.css +_build +rst_warnings diff --git a/docs/.nojekyll b/docs/.nojekyll new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/docs/.nojekyll diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..b84f668 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,9 @@ +# Documentation source for Ansible Lint + +To build the docs, run `tox -e docs`. At the end of the build, you will +see the local location of your built docs. + +Building docs locally may not be identical to CI/CD builds. We recommend +you to create a draft PR and check the RTD PR preview page too. + +If you do not want to learn the reStructuredText format, you can also [file an issue](https://github.com/ansible/ansible-lint/issues), and let us know how we can improve our documentation. diff --git a/docs/_static/ansible-lint.svg b/docs/_static/ansible-lint.svg new file mode 100644 index 0000000..e1270a8 --- /dev/null +++ b/docs/_static/ansible-lint.svg @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> +<svg width="100%" height="100%" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"> + <g transform="matrix(1,0,0,1,-67.7268,-0.0813272)"> + <g id="ansible-lint" transform="matrix(0.103624,0,0,0.0970726,-51.3462,16.5365)"> + <rect x="1149.09" y="-169.514" width="617.619" height="659.3" style="fill:none;"/> + <g transform="matrix(-9.64401,0.0125512,-0.0123169,-10.7844,1789.35,505.457)"> + <path d="M34.404,5.547C56.248,14.75 62.901,29.008 62.864,58.451C48.638,53.766 48.041,53.62 34.295,58.418C19.563,53.502 19.058,53.483 6.355,58.386C6.392,29.206 14.197,15.017 34.404,5.547Z" style="fill:rgb(128,128,128);stroke:rgb(128,128,128);stroke-width:3.91px;"/> + </g> + <g transform="matrix(2.86444,0,0,3.05775,1016.6,-336.23)"> + <path d="M154.799,112.893L182.438,181.102L140.692,148.221L154.799,112.893ZM203.895,196.815L161.385,94.514C160.385,91.762 157.727,89.94 154.799,90.001C151.826,89.933 149.113,91.743 148.034,94.514L101.377,206.726L117.338,206.726L135.806,160.458L190.923,204.988C193.142,206.782 194.739,207.593 196.82,207.593C196.88,207.594 196.939,207.595 196.999,207.595C201.181,207.595 204.623,204.153 204.623,199.97C204.623,199.968 204.623,199.966 204.623,199.964C204.551,198.882 204.305,197.819 203.895,196.815" style="fill:white;fill-rule:nonzero;"/> + </g> + </g> + </g> +</svg> diff --git a/docs/_static/images/logo_invert.png b/docs/_static/images/logo_invert.png Binary files differnew file mode 100644 index 0000000..dfeba66 --- /dev/null +++ b/docs/_static/images/logo_invert.png diff --git a/docs/_static/theme_overrides.css b/docs/_static/theme_overrides.css new file mode 100644 index 0000000..2eea3ee --- /dev/null +++ b/docs/_static/theme_overrides.css @@ -0,0 +1,15 @@ +/* table width fix via: https://rackerlabs.github.io/docs-rackspace/tools/rtd-tables.html */ + +/* override table width restrictions */ +@media screen and (min-width: 767px) { + + .wy-table-responsive table td { + /* !important prevents the common CSS stylesheets from overriding + * this as on RTD they are loaded after this stylesheet */ + white-space: normal !important; + } + + .wy-table-responsive { + overflow: visible !important; + } +} diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..9a911ed --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,256 @@ +# -*- coding: utf-8 -*- +# +# documentation build configuration file, created by +# sphinx-quickstart on Sat Sep 27 13:23:22 2008-2009. +# +# This file is execfile()d with the current directory set to its +# containing dir. +# +# The contents of this file are pickled, so don't put values in the namespace +# that aren't pickleable (module imports are okay, they're removed +# automatically). +# +# All configuration values have a default value; values that are commented out +# serve to show the default value. +"""Documentation Configuration.""" + +import os +import sys +from pathlib import Path + +# Make in-tree extension importable in non-tox setups/envs, like RTD. +# Refs: +# https://github.com/readthedocs/readthedocs.org/issues/6311 +# https://github.com/readthedocs/readthedocs.org/issues/7182 +sys.path.insert(0, str(Path(__file__).parent.resolve())) + +# pip install sphinx_rtd_theme +# import sphinx_rtd_theme +# html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] + +# If your extensions are in another directory, add it here. If the directory +# is relative to the documentation root, use os.path.abspath to make it +# absolute, like shown here. +# sys.path.append(os.path.abspath('some/directory')) +# +sys.path.insert(0, os.path.join('ansible', 'lib')) +sys.path.append(os.path.abspath('_themes')) + +VERSION = '2.6' +AUTHOR = 'Ansible, Inc' + + +# General configuration +# --------------------- + +# Add any Sphinx extension module names here, as strings. +# They can be extensions +# coming with Sphinx (named 'sphinx.ext.*') or your custom ones. +# TEST: 'sphinxcontrib.fulltoc' +extensions = [ + 'myst_parser', + 'sphinx.ext.autodoc', + 'sphinx.ext.intersphinx', + 'sphinxcontrib.programoutput', + 'rules_table_generator_ext', # in-tree extension +] + +# Later on, add 'sphinx.ext.viewcode' to the list if you want to have +# colorized code generated too for references. + + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['.templates'] + +# The suffix of source filenames. +source_suffix = '.rst' + +# The master toctree document. +master_doc = 'index' + +# General substitutions. +project = 'Ansible Lint Documentation' +copyright = "2013-2020 Ansible, Inc" + +# The default replacements for |version| and |release|, also used in various +# other places throughout the built documents. +# +# The short X.Y version. +version = VERSION +# The full version, including alpha/beta/rc tags. +release = VERSION + +# There are two options for replacing |today|: either, you set today to some +# non-false value, then it is used: +# today = '' +# Else, today_fmt is used as the format for a strftime call. +today_fmt = '%B %d, %Y' + +# List of documents that shouldn't be included in the build. +# unused_docs = [] + +# List of directories, relative to source directories, that shouldn't be +# searched for source files. +# exclude_dirs = [] + +# A list of glob-style patterns that should be excluded when looking +# for source files. +# OBSOLETE - removing this - dharmabumstead 2018-02-06 +exclude_patterns = ['README.md'] + +# The reST default role (used for this markup: `text`) to use for all +# documents. +default_role = 'any' + +# If true, '()' will be appended to :func: etc. cross-reference text. +# add_function_parentheses = True + +# If true, the current module name will be prepended to all description +# unit titles (such as .. function::). +# add_module_names = True + +# If true, sectionauthor and moduleauthor directives will be shown in the +# output. They are ignored by default. +# show_authors = False + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' + +highlight_language = 'YAML+Jinja' + +# Substitutions, variables, entities, & shortcuts for text which do not need to link to anything. +# For titles which should be a link, use the intersphinx anchors set at the index, chapter, and +# section levels, such as qi_start_: +rst_epilog = """ +.. |acapi| replace:: *Ansible Core API Guide* +.. |acrn| replace:: *Ansible Core Release Notes* +.. |ac| replace:: Ansible Core +.. |acversion| replace:: Ansible Core Version 2.1 +.. |acversionshort| replace:: Ansible Core 2.1 +.. |versionshortest| replace:: 2.2 +.. |versiondev| replace:: 2.3 +.. |pubdate| replace:: July 19, 2016 +.. |rhel| replace:: Red Hat Enterprise Linux + +""" + + +# Options for HTML output +# ----------------------- + +html_theme_path = ['../_themes'] +html_theme = 'sphinx_ansible_theme' +html_short_title = 'Ansible Lint Documentation' + +# The style sheet to use for HTML and HTML Help pages. A file of that name +# must exist either in Sphinx' static/ path, or in one of the custom paths +# given in html_static_path. +# html_style = 'solar.css' + +# The name for this set of Sphinx documents. If None, it defaults to +# "<project> v<release> documentation". +html_title = 'Ansible Lint Documentation' + +# A shorter title for the navigation bar. Default is the same as html_title. +# html_short_title = None + +# The name of an image file (within the static path) to place at the top of +# the sidebar. +# html_logo = None + +# The name of an image file (within the static path) to use as favicon of the +# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 +# pixels large. +html_favicon = '_static/ansible-lint.svg' + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +# html_static_path = ['.static'] + +# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, +# using the given strftime format. +html_last_updated_fmt = '%b %d, %Y' + +# If true, SmartyPants will be used to convert quotes and dashes to +# typographically correct entities. +# html_use_smartypants = True + +# Custom sidebar templates, maps document names to template names. +# html_sidebars = {} + +# Additional templates that should be rendered to pages, maps page names to +# template names. +# html_additional_pages = {} + +# If false, no module index is generated. +# html_use_modindex = True + +# If false, no index is generated. +# html_use_index = True + +# If true, the index is split into individual pages for each letter. +# html_split_index = False + +# If true, the reST sources are included in the HTML build as _sources/<name>. +html_copy_source = False + +# If true, an OpenSearch description file will be output, and all pages will +# contain a <link> tag referring to it. The value of this option must be the +# base URL from which the finished HTML is served. +html_use_opensearch = 'https://ansible-lint.readthedocs.io/en/latest/' + +# If nonempty, this is the file name suffix for HTML files (e.g. ".xhtml"). +# html_file_suffix = '' + +# Output file base name for HTML help builder. +htmlhelp_basename = 'Poseidodoc' + + +# Options for LaTeX output +# ------------------------ + +# The paper size ('letter' or 'a4'). +# latex_paper_size = 'letter' + +# The font size ('10pt', '11pt' or '12pt'). +# latex_font_size = '10pt' + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, author, document class +# [howto/manual]). +latex_documents = [ + ('index', 'ansible.tex', 'Ansible 2.2 Documentation', AUTHOR, 'manual'), +] + +# The name of an image file (relative to this directory) to place at the top of +# the title page. +# latex_logo = None + +# For "manual" documents, if this is true, then toplevel headings are parts, +# not chapters. +# latex_use_parts = False + +# Additional stuff for the LaTeX preamble. +# latex_preamble = '' + +# Documents to append as an appendix to all manuals. +# latex_appendices = [] + +# If false, no module index is generated. +# latex_use_modindex = True + +autoclass_content = 'both' + +intersphinx_mapping = {'python': ('https://docs.python.org/2/', (None, '../python2-2.7.13.inv')), + 'python3': ('https://docs.python.org/3/', (None, '../python3-3.6.2.inv')), + 'jinja2': ('http://jinja.pocoo.org/docs/', (None, '../jinja2-2.9.7.inv'))} + + +# table width fix via: https://rackerlabs.github.io/docs-rackspace/tools/rtd-tables.html +html_static_path = ['_static'] +html_context = { + 'css_files': [ + '_static/theme_overrides.css', # override wide tables in RTD theme + ], +} diff --git a/docs/configuring.rst b/docs/configuring.rst new file mode 100644 index 0000000..fac77d3 --- /dev/null +++ b/docs/configuring.rst @@ -0,0 +1,14 @@ + +.. _configuring_lint: + +*********** +Configuring +*********** + +.. contents:: Topics + +This topic describes how to configure Ansible Lint + +.. include:: ../README.rst + :start-after: configuring-docs-inclusion-marker-do-not-remove + :end-before: configuring-docs-inclusion-marker-end-do-not-remove diff --git a/docs/contributing.rst b/docs/contributing.rst new file mode 100644 index 0000000..3f0d3bb --- /dev/null +++ b/docs/contributing.rst @@ -0,0 +1,50 @@ +.. include:: ../.github/CONTRIBUTING.rst + :end-before: DO-NOT-REMOVE-deps-snippet-PLACEHOLDER + +Module dependency graph +----------------------- + +Extra care should be taken when considering adding any dependency. Removing +most dependencies on Ansible internals is desired as these can change +without any warning. + +.. command-output:: pipdeptree -p ansible-lint + +.. include:: ../.github/CONTRIBUTING.rst + :start-after: DO-NOT-REMOVE-deps-snippet-PLACEHOLDER + +Adding a new rule +----------------- + +Writing a new rule is as easy as adding a single new rule, one that combines +**implementation, testing and documentation**. + +One good example is MetaTagValidRule_ which can easily be copied in order +to create a new rule by following the steps below: + +* Use a short but clear class name, which must match the filename +* Pick an unused ``id``, the first number is used to determine rule section. + Look at rules_ page and pick one that matches the best your new rule. + see which one fits best. +* Include ``experimental`` tag. Any new rule must stay as + experimental for at least two weeks until this tag is removed in next major + release. +* Update all class level variables. +* Implement linting methods needed by your rule, these are those starting with + **match** prefix. Implement only those you need. For the moment you will need + to look at how similar rules were implemented to figure out what to do. +* Update the tests. It must have at least one test and likely also a negative + match one. +* If the rule is task specific, it may be best to include a test to verify its + use inside blocks as well. +* Optionally run only the rule specific tests with a command like: + :command:`tox -e py38-ansible29 -- -k NewRule` +* Run :command:`tox` in order to run all ansible-lint tests. Adding a new rule + can break some other tests. Update them if needed. +* Run :command:`ansible-lint -L` and check that the rule description renders + correctly. +* Build the docs using :command:`tox -e docs` and check that the new rule is + displayed correctly in them. + +.. _MetaTagValidRule: https://github.com/ansible/ansible-lint/blob/master/lib/ansiblelint/rules/MetaTagValidRule.py +.. _rules: https://ansible-lint.readthedocs.io/en/latest/default_rules.html diff --git a/docs/default_rules.rst b/docs/default_rules.rst new file mode 100644 index 0000000..daf66a0 --- /dev/null +++ b/docs/default_rules.rst @@ -0,0 +1 @@ +.. ansible-lint-default-rules-list:: diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..3836198 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,47 @@ +.. _lint_documentation: + +Ansible Lint Documentation +========================== + +About Ansible Lint +`````````````````` + +Ansible Lint is a commandline tool for linting playbooks. Use it to detect behaviors and practices that could potentially +be improved. + +The tool is used by the `Ansible Galaxy project <https://github.com/ansible/galaxy/>`_ to lint and calculate quality scores +for content contributed to the `Galaxy Hub <https://galaxy.ansible.com>`_. + +The project was originally started by `@willthames <https://github.com/willthames/>`_, and has since been +transferred to the Ansible project team. + + +.. toctree:: + :maxdepth: 3 + :caption: Installing + + installing + +.. toctree:: + :maxdepth: 3 + :caption: Usage + + usage + +.. toctree:: + :maxdepth: 3 + :caption: Configuring + + configuring + +.. toctree:: + :maxdepth: 4 + :caption: Rules + + rules + default_rules + +.. toctree:: + :caption: Contributing + + contributing diff --git a/docs/installing.rst b/docs/installing.rst new file mode 100644 index 0000000..1b84abd --- /dev/null +++ b/docs/installing.rst @@ -0,0 +1,15 @@ + +.. _installing_lint: + + +********** +Installing +********** + +.. contents:: Topics + +This topic describes how to install Ansible Lint. + +.. include:: ../README.rst + :start-after: installing-docs-inclusion-marker-do-not-remove + :end-before: installing-docs-inclusion-marker-end-do-not-remove diff --git a/docs/jinja2-2.9.7.inv b/docs/jinja2-2.9.7.inv Binary files differnew file mode 100644 index 0000000..a45888b --- /dev/null +++ b/docs/jinja2-2.9.7.inv diff --git a/docs/python2-2.7.13.inv b/docs/python2-2.7.13.inv Binary files differnew file mode 100644 index 0000000..ab7587f --- /dev/null +++ b/docs/python2-2.7.13.inv diff --git a/docs/python3-3.6.2.inv b/docs/python3-3.6.2.inv Binary files differnew file mode 100644 index 0000000..1d2ed4e --- /dev/null +++ b/docs/python3-3.6.2.inv diff --git a/docs/requirements.in b/docs/requirements.in new file mode 100644 index 0000000..9ed90dd --- /dev/null +++ b/docs/requirements.in @@ -0,0 +1,5 @@ +myst-parser +pipdeptree +Sphinx +sphinx_ansible_theme +sphinxcontrib.programoutput diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 0000000..4820753 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,184 @@ +# +# This file is autogenerated by pip-compile +# To update, run: +# +# pip-compile --generate-hashes --output-file=docs/requirements.txt docs/requirements.in +# +alabaster==0.7.12 \ + --hash=sha256:446438bdcca0e05bd45ea2de1668c1d9b032e1a9154c2c259092d77031ddd359 \ + --hash=sha256:a661d72d58e6ea8a57f7a86e37d86716863ee5e92788398526d58b26a4e4dc02 \ + # via sphinx +attrs==19.3.0 \ + --hash=sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c \ + --hash=sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72 \ + # via markdown-it-py +babel==2.8.0 \ + --hash=sha256:1aac2ae2d0d8ea368fa90906567f5c08463d98ade155c0c4bfedd6a0f7160e38 \ + --hash=sha256:d670ea0b10f8b723672d3a6abeb87b565b244da220d76b4dba1b66269ec152d4 \ + # via sphinx +certifi==2020.6.20 \ + --hash=sha256:5930595817496dd21bb8dc35dad090f1c2cd0adfaf21204bf6732ca5d8ee34d3 \ + --hash=sha256:8fc0819f1f30ba15bdb34cceffb9ef04d99f420f68eb75d901e9560b8749fc41 \ + # via requests +chardet==3.0.4 \ + --hash=sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae \ + --hash=sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691 \ + # via requests +docutils==0.16 \ + --hash=sha256:0c5b78adfbf7762415433f5515cd5c9e762339e23369dbe8000d84a4bf4ab3af \ + --hash=sha256:c2de3a60e9e7d07be26b7f2b00ca0309c207e06c100f9cc2a94931fc75a478fc \ + # via myst-parser, sphinx +idna==2.10 \ + --hash=sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6 \ + --hash=sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0 \ + # via requests +imagesize==1.2.0 \ + --hash=sha256:6965f19a6a2039c7d48bca7dba2473069ff854c36ae6f19d2cde309d998228a1 \ + --hash=sha256:b1f6b5a4eab1f73479a50fb79fcf729514a900c341d8503d62a62dbc4127a2b1 \ + # via sphinx +jinja2==2.11.2 \ + --hash=sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0 \ + --hash=sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035 \ + # via sphinx +markdown-it-py==0.5.4 \ + --hash=sha256:d1782446f7fcbf2db9a1bc0430230cb879498ad6d76168d7e7c762bab04cb4ea \ + --hash=sha256:f18ec8f1c1a424ab2a9ac06b5ba87d6d2a01e450cd8678edbc71002106dd68a8 \ + # via myst-parser +markupsafe==1.1.1 \ + --hash=sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473 \ + --hash=sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161 \ + --hash=sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235 \ + --hash=sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5 \ + --hash=sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42 \ + --hash=sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff \ + --hash=sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b \ + --hash=sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1 \ + --hash=sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e \ + --hash=sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183 \ + --hash=sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66 \ + --hash=sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b \ + --hash=sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1 \ + --hash=sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15 \ + --hash=sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1 \ + --hash=sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e \ + --hash=sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b \ + --hash=sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905 \ + --hash=sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735 \ + --hash=sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d \ + --hash=sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e \ + --hash=sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d \ + --hash=sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c \ + --hash=sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21 \ + --hash=sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2 \ + --hash=sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5 \ + --hash=sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b \ + --hash=sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6 \ + --hash=sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f \ + --hash=sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f \ + --hash=sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2 \ + --hash=sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7 \ + --hash=sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be \ + # via jinja2 +myst-parser==0.12.10 \ + --hash=sha256:4612c46196e0344bb7e49dbc3deb288f9b9a88fcf6e9f210f7f3ea5bc9899bfc \ + --hash=sha256:a5311da4398869e596250d5a93b523735c3beb8bc9d3eba853223c705802043b \ + # via -r requirements.in +packaging==20.4 \ + --hash=sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8 \ + --hash=sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181 \ + # via sphinx +pipdeptree==1.0.0 \ + --hash=sha256:35a81058c9568a29c5a9569109304b25f11cd9333fa2661a4d4c2c5da0e3939d \ + --hash=sha256:5fe866a38113d28d527033ececc57b8e86df86b7c29edbacb33f41ee50f75b31 \ + --hash=sha256:a7e4f744f3ae149cf94dd5e517fae682780c4729f4a279e6fb81a928f57fea23 \ + # via -r requirements.in +pygments==2.6.1 \ + --hash=sha256:647344a061c249a3b74e230c739f434d7ea4d8b1d5f3721bc0f3558049b38f44 \ + --hash=sha256:ff7a40b4860b727ab48fad6360eb351cc1b33cbf9b15a0f689ca5353e9463324 \ + # via sphinx +pyparsing==2.4.7 \ + --hash=sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1 \ + --hash=sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b \ + # via packaging +pytz==2020.1 \ + --hash=sha256:a494d53b6d39c3c6e44c3bec237336e14305e4f29bbf800b599253057fbb79ed \ + --hash=sha256:c35965d010ce31b23eeb663ed3cc8c906275d6be1a34393a1d73a41febf4a048 \ + # via babel +pyyaml==5.3.1 \ + --hash=sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97 \ + --hash=sha256:240097ff019d7c70a4922b6869d8a86407758333f02203e0fc6ff79c5dcede76 \ + --hash=sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2 \ + --hash=sha256:69f00dca373f240f842b2931fb2c7e14ddbacd1397d57157a9b005a6a9942648 \ + --hash=sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf \ + --hash=sha256:74809a57b329d6cc0fdccee6318f44b9b8649961fa73144a98735b0aaf029f1f \ + --hash=sha256:7739fc0fa8205b3ee8808aea45e968bc90082c10aef6ea95e855e10abf4a37b2 \ + --hash=sha256:95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee \ + --hash=sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d \ + --hash=sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c \ + --hash=sha256:d13155f591e6fcc1ec3b30685d50bf0711574e2c0dfffd7644babf8b5102ca1a \ + # via myst-parser +requests==2.24.0 \ + --hash=sha256:b3559a131db72c33ee969480840fff4bb6dd111de7dd27c8ee1f820f4f00231b \ + --hash=sha256:fe75cc94a9443b9246fc7049224f75604b113c36acb93f87b80ed42c44cbb898 \ + # via sphinx +six==1.15.0 \ + --hash=sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259 \ + --hash=sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced \ + # via packaging +snowballstemmer==2.0.0 \ + --hash=sha256:209f257d7533fdb3cb73bdbd24f436239ca3b2fa67d56f6ff88e86be08cc5ef0 \ + --hash=sha256:df3bac3df4c2c01363f3dd2cfa78cce2840a79b9f1c2d2de9ce8d31683992f52 \ + # via sphinx +sphinx-ansible-theme==0.3.2 \ + --hash=sha256:250e46bc318062a2e95cc55db5dfecddb5f847f38d672d487162920f3f3ae205 \ + --hash=sha256:424ec6fbc61bc8bba3e6eb482d3ceb92e6e2d80d7e8e06599e2bbc856026feaf \ + # via -r requirements.in +sphinx-notfound-page==0.4 \ + --hash=sha256:0105a40d8a305d3e1003630d8ee99296baa08cf2a4c1ce1db8d91fbbe78f90db \ + --hash=sha256:609fd7cd7f9ea73c030f1b67a3f2bc90f60bff87b30026fbd2bcb19c7c59c484 \ + # via sphinx-ansible-theme +sphinx-rtd-theme==0.5.0 \ + --hash=sha256:22c795ba2832a169ca301cd0a083f7a434e09c538c70beb42782c073651b707d \ + --hash=sha256:373413d0f82425aaa28fb288009bf0d0964711d347763af2f1b65cafcb028c82 \ + # via sphinx-ansible-theme +sphinx==3.2.1 \ + --hash=sha256:321d6d9b16fa381a5306e5a0b76cd48ffbc588e6340059a729c6fdd66087e0e8 \ + --hash=sha256:ce6fd7ff5b215af39e2fcd44d4a321f6694b4530b6f2b2109b64d120773faea0 \ + # via -r requirements.in, myst-parser, sphinx-rtd-theme, sphinxcontrib.programoutput +sphinxcontrib-applehelp==1.0.2 \ + --hash=sha256:806111e5e962be97c29ec4c1e7fe277bfd19e9652fb1a4392105b43e01af885a \ + --hash=sha256:a072735ec80e7675e3f432fcae8610ecf509c5f1869d17e2eecff44389cdbc58 \ + # via sphinx +sphinxcontrib-devhelp==1.0.2 \ + --hash=sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e \ + --hash=sha256:ff7f1afa7b9642e7060379360a67e9c41e8f3121f2ce9164266f61b9f4b338e4 \ + # via sphinx +sphinxcontrib-htmlhelp==1.0.3 \ + --hash=sha256:3c0bc24a2c41e340ac37c85ced6dafc879ab485c095b1d65d2461ac2f7cca86f \ + --hash=sha256:e8f5bb7e31b2dbb25b9cc435c8ab7a79787ebf7f906155729338f3156d93659b \ + # via sphinx +sphinxcontrib-jsmath==1.0.1 \ + --hash=sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178 \ + --hash=sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8 \ + # via sphinx +sphinxcontrib-qthelp==1.0.3 \ + --hash=sha256:4c33767ee058b70dba89a6fc5c1892c0d57a54be67ddd3e7875a18d14cba5a72 \ + --hash=sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6 \ + # via sphinx +sphinxcontrib-serializinghtml==1.1.4 \ + --hash=sha256:eaa0eccc86e982a9b939b2b82d12cc5d013385ba5eadcc7e4fed23f4405f77bc \ + --hash=sha256:f242a81d423f59617a8e5cf16f5d4d74e28ee9a66f9e5b637a18082991db5a9a \ + # via sphinx +sphinxcontrib.programoutput==0.16 \ + --hash=sha256:0caaa216d0ad8d2cfa90a9a9dba76820e376da6e3152be28d10aedc09f82a3b0 \ + --hash=sha256:8009d1326b89cd029ee477ce32b45c58d92b8504d48811461c3117014a8f4b1e \ + # via -r requirements.in +urllib3==1.25.9 \ + --hash=sha256:3018294ebefce6572a474f0604c2021e33b3fd8006ecd11d62107a5d2a963527 \ + --hash=sha256:88206b0eb87e6d677d424843ac5209e3fb9d0190d0ee169599165ec25e9d9115 \ + # via requests + +# WARNING: The following packages were not pinned, but pip requires them to be +# pinned when the requirements file includes hashes. Consider using the --allow-unsafe flag. +# pip +# setuptools diff --git a/docs/rules.rst b/docs/rules.rst new file mode 100644 index 0000000..5a02193 --- /dev/null +++ b/docs/rules.rst @@ -0,0 +1,13 @@ +.. _lint_rules: + +***** +Rules +***** + +.. contents:: Topics + +This topic describes how to use the default Ansible Lint rules, as well as how to create and use custom rules. + +.. include:: ../README.rst + :start-after: rules-docs-inclusion-marker-do-not-remove + :end-before: rules-docs-inclusion-marker-end-do-not-remove diff --git a/docs/rules_table_generator_ext.py b/docs/rules_table_generator_ext.py new file mode 100644 index 0000000..51e2f4d --- /dev/null +++ b/docs/rules_table_generator_ext.py @@ -0,0 +1,68 @@ +#! /usr/bin/env python3 +# Requires Python 3.6+ +"""Sphinx extension for generating the rules table document.""" + +from typing import Dict, List, Union + +from sphinx.application import Sphinx +from sphinx.util.docutils import SphinxDirective +from sphinx.util.nodes import nested_parse_with_titles, nodes + +# isort: split + +from docutils import statemachine + +from ansiblelint import __version__ +from ansiblelint.constants import DEFAULT_RULESDIR +from ansiblelint.generate_docs import rules_as_rst +from ansiblelint.rules import RulesCollection + + +def _nodes_from_rst( + state: statemachine.State, + rst_source: str, +) -> List[nodes.Node]: + """Turn an RST string into a list of nodes. + + These nodes can be used in the document. + """ + node = nodes.Element() + node.document = state.document + nested_parse_with_titles( + state=state, + content=statemachine.ViewList( + statemachine.string2lines(rst_source), + source='[ansible-lint autogenerated]', + ), + node=node, + ) + return node.children + + +class AnsibleLintDefaultRulesDirective(SphinxDirective): + """Directive ``ansible-lint-default-rules-list`` definition.""" + + has_content = False + + def run(self) -> List[nodes.Node]: + """Generate a node tree in place of the directive.""" + self.env.note_reread() # rebuild the current RST doc unconditionally + + default_rules = RulesCollection([DEFAULT_RULESDIR]) + rst_rules_table = rules_as_rst(default_rules) + + return _nodes_from_rst(state=self.state, rst_source=rst_rules_table) + + +def setup(app: Sphinx) -> Dict[str, Union[bool, str]]: + """Initialize the Sphinx extension.""" + app.add_directive( + 'ansible-lint-default-rules-list', + AnsibleLintDefaultRulesDirective, + ) + + return { + 'parallel_read_safe': True, + 'parallel_write_safe': True, + 'version': __version__, + } diff --git a/docs/usage.rst b/docs/usage.rst new file mode 100644 index 0000000..8fb166e --- /dev/null +++ b/docs/usage.rst @@ -0,0 +1,15 @@ + +.. _using_lint: + + +***** +Usage +***** + +.. contents:: Topics + +This topic describes how to use ``ansible-lint``. + +.. include:: ../README.rst + :start-after: usage-docs-inclusion-marker-do-not-remove + :end-before: usage-docs-inclusion-marker-end-do-not-remove diff --git a/examples/example.yml b/examples/example.yml new file mode 100644 index 0000000..1ce910d --- /dev/null +++ b/examples/example.yml @@ -0,0 +1,52 @@ +--- +- hosts: webservers + + vars: + oldskool: "1.2.3" + bracket: "and close bracket" + + tasks: + - name: unset variable + action: command echo {{thisvariable}} is not set in this playbook + + - name: trailing whitespace + action: command echo do nothing + + - name: git check + action: git a=b c=d + + - name: git check 2 + action: git version=HEAD c=d + + - name: git check 3 + git: version=a1b2c3d4 repo=xyz bobbins=d + + - name: executing git through command + action: command git clone blah + + - name: executing git through command + action: command chdir=bobbins creates=whatever /usr/bin/git clone blah + + - name: using git module + action: git repo=blah + + - name: passing git as an argument to another task + action: debug msg="{{item}}" + with_items: + - git + - bobbins + + - name: yum latest + yum: state=latest name=httpd + + - debug: msg="task without a name" + + - name: apt latest + apt: state=latest name=apache2 + + - name: always run + debug: msg="always_run is deprecated" + always_run: true + + # empty task is currently accepted by ansible as valid code: + - diff --git a/examples/handlers/y.yml b/examples/handlers/y.yml new file mode 100644 index 0000000..fe98a7a --- /dev/null +++ b/examples/handlers/y.yml @@ -0,0 +1,2 @@ +- name: funny handler + action: service name=funny state=started force=true diff --git a/examples/include.yml b/examples/include.yml new file mode 100644 index 0000000..0e056ec --- /dev/null +++ b/examples/include.yml @@ -0,0 +1,19 @@ +--- +- hosts: bobbins + + + pre_tasks: + - include: tasks/x.yml + + roles: + - hello + - { role: morecomplex, t: z } + + tasks: + - include: tasks/x.yml + - include: tasks/x.yml y=z + + handlers: + - include: handlers/y.yml + +- include: play.yml diff --git a/examples/lineno.yml b/examples/lineno.yml new file mode 100644 index 0000000..57f879d --- /dev/null +++ b/examples/lineno.yml @@ -0,0 +1,2 @@ +- tasks: + - git: repo=hello diff --git a/examples/lots_of_warnings.yml b/examples/lots_of_warnings.yml new file mode 100644 index 0000000..38136e1 --- /dev/null +++ b/examples/lots_of_warnings.yml @@ -0,0 +1,1000 @@ +--- +# This playbook causes ansible-lint to output tons of warnings +# Enough to exceed typical stdout buffering size and thus to show the need for +# catching IOError (EPIEP) errors. + +- hosts: webservers + + tasks: + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah + - name: executing git through command + action: command git clone blah diff --git a/examples/nomatches.yml b/examples/nomatches.yml new file mode 100644 index 0000000..2cc726e --- /dev/null +++ b/examples/nomatches.yml @@ -0,0 +1,9 @@ +--- +- hosts: whatever + + tasks: + - name: hello world + action: debug msg="Hello!" + + - name: this should be fine too + action: file state=touch mode=0644 dest=./wherever diff --git a/examples/play.yml b/examples/play.yml new file mode 100644 index 0000000..63e0678 --- /dev/null +++ b/examples/play.yml @@ -0,0 +1,6 @@ +--- +- hosts: bobbins + + tasks: + - name: a bad play + action: command service blah restart diff --git a/examples/roles/bobbins/tasks/main.yml b/examples/roles/bobbins/tasks/main.yml new file mode 100644 index 0000000..8df6c6b --- /dev/null +++ b/examples/roles/bobbins/tasks/main.yml @@ -0,0 +1,3 @@ +--- +- name: test tasks + action: git a=b c=d diff --git a/examples/roles/hello/meta/main.yml b/examples/roles/hello/meta/main.yml new file mode 100644 index 0000000..b15a998 --- /dev/null +++ b/examples/roles/hello/meta/main.yml @@ -0,0 +1,3 @@ +--- +dependencies: + - role: bobbins diff --git a/examples/roles/morecomplex/handlers/main.yml b/examples/roles/morecomplex/handlers/main.yml new file mode 100644 index 0000000..3d5b393 --- /dev/null +++ b/examples/roles/morecomplex/handlers/main.yml @@ -0,0 +1,2 @@ +- name: restart service using command + command: service bar restart diff --git a/examples/roles/morecomplex/tasks/main.yml b/examples/roles/morecomplex/tasks/main.yml new file mode 100644 index 0000000..ed68394 --- /dev/null +++ b/examples/roles/morecomplex/tasks/main.yml @@ -0,0 +1,8 @@ +- name: test bad command + action: command mkdir blah + +- name: test bad command v2 + command: mkdir blah + +- name: test bad local command + local_action: shell touch foo diff --git a/examples/rules/TaskHasTag.py b/examples/rules/TaskHasTag.py new file mode 100644 index 0000000..58dfa7c --- /dev/null +++ b/examples/rules/TaskHasTag.py @@ -0,0 +1,37 @@ +"""Example implementation of a rule requiring tasks to have tags set.""" +from ansiblelint.rules import AnsibleLintRule + + +class TaskHasTag(AnsibleLintRule): + """Tasks must have tag.""" + + id = 'EXAMPLE001' + shortdesc = 'Tasks must have tag' + description = 'Tasks must have tag' + tags = ['productivity', 'tags'] + + def matchtask(self, file, task): + """Task matching method.""" + # The meta files don't have tags + if file['type'] in ["meta", "playbooks"]: + return False + + if isinstance(task, str): + return False + + # If the task include another task or make the playbook fail + # Don't force to have a tag + if not set(task.keys()).isdisjoint(['include', 'fail']): + return False + + if not set(task.keys()).isdisjoint(['include_tasks', 'fail']): + return False + + if not set(task.keys()).isdisjoint(['import_tasks', 'fail']): + return False + + # Task should have tags + if 'tags' not in task: + return True + + return False diff --git a/examples/tasks/x.yml b/examples/tasks/x.yml new file mode 100644 index 0000000..c5a4f10 --- /dev/null +++ b/examples/tasks/x.yml @@ -0,0 +1,4 @@ +- name: test include + action: funny value=clown + args: + key: value diff --git a/examples/unicode.yml b/examples/unicode.yml new file mode 100644 index 0000000..c16ce92 --- /dev/null +++ b/examples/unicode.yml @@ -0,0 +1,5 @@ +--- +- hosts: all + tasks: + - name: тест + command: uname diff --git a/hooks.yaml b/hooks.yaml new file mode 100644 index 0000000..e9131c8 --- /dev/null +++ b/hooks.yaml @@ -0,0 +1,11 @@ +--- + +# For use with pre-commit. +# See usage instructions at http://pre-commit.com + +- id: ansible-lint + name: Ansible-lint + description: This hook runs ansible-lint. + entry: ansible-lint + language: python + files: \.(yaml|yml)$ diff --git a/lib/ansiblelint/__init__.py b/lib/ansiblelint/__init__.py new file mode 100644 index 0000000..d8a4cef --- /dev/null +++ b/lib/ansiblelint/__init__.py @@ -0,0 +1,28 @@ +# Copyright (c) 2013-2014 Will Thames <will@thames.id.au> +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# 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. +"""Main ansible-lint package.""" + +from ansiblelint.rules import AnsibleLintRule +from ansiblelint.version import __version__ + +__all__ = ( + "__version__", + "AnsibleLintRule" # deprecated, import it directly from rules +) diff --git a/lib/ansiblelint/__main__.py b/lib/ansiblelint/__main__.py new file mode 100755 index 0000000..ff8d477 --- /dev/null +++ b/lib/ansiblelint/__main__.py @@ -0,0 +1,270 @@ +#!/usr/bin/env python +# Copyright (c) 2013-2014 Will Thames <will@thames.id.au> +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# 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. +"""Command line implementation.""" + +import errno +import logging +import os +import pathlib +import subprocess +import sys +from contextlib import contextmanager +from typing import TYPE_CHECKING, Any, List, Set, Type, Union + +from rich.markdown import Markdown + +from ansiblelint import cli, formatters +from ansiblelint.color import console, console_stderr +from ansiblelint.file_utils import cwd +from ansiblelint.generate_docs import rules_as_rich, rules_as_rst +from ansiblelint.rules import RulesCollection +from ansiblelint.runner import Runner +from ansiblelint.utils import get_playbooks_and_roles, get_rules_dirs + +if TYPE_CHECKING: + from argparse import Namespace + + from ansiblelint.errors import MatchError + +_logger = logging.getLogger(__name__) + +_rule_format_map = { + 'plain': str, + 'rich': rules_as_rich, + 'rst': rules_as_rst +} + + +def initialize_logger(level: int = 0) -> None: + """Set up the global logging level based on the verbosity number.""" + VERBOSITY_MAP = { + 0: logging.NOTSET, + 1: logging.INFO, + 2: logging.DEBUG + } + + handler = logging.StreamHandler() + formatter = logging.Formatter('%(levelname)-8s %(message)s') + handler.setFormatter(formatter) + logger = logging.getLogger(__package__) + logger.addHandler(handler) + # Unknown logging level is treated as DEBUG + logging_level = VERBOSITY_MAP.get(level, logging.DEBUG) + logger.setLevel(logging_level) + # Use module-level _logger instance to validate it + _logger.debug("Logging initialized to level %s", logging_level) + + +def choose_formatter_factory( + options_list: "Namespace" +) -> Type[formatters.BaseFormatter]: + """Select an output formatter based on the incoming command line arguments.""" + r: Type[formatters.BaseFormatter] = formatters.Formatter + if options_list.quiet: + r = formatters.QuietFormatter + elif options_list.parseable: + r = formatters.ParseableFormatter + elif options_list.parseable_severity: + r = formatters.ParseableSeverityFormatter + return r + + +def report_outcome(matches: List["MatchError"], options) -> int: + """Display information about how to skip found rules. + + Returns exit code, 2 if errors were found, 0 when only warnings were found. + """ + failure = False + msg = """\ +You can skip specific rules or tags by adding them to your configuration file: +```yaml +# .ansible-lint +warn_list: # or 'skip_list' to silence them completely +""" + matches_unignored = [match for match in matches if not match.ignored] + + matched_rules = {match.rule.id: match.rule for match in matches_unignored} + for id in sorted(matched_rules.keys()): + if {id, *matched_rules[id].tags}.isdisjoint(options.warn_list): + msg += f" - '{id}' # {matched_rules[id].shortdesc}\n" + failure = True + for match in matches: + if "experimental" in match.rule.tags: + msg += " - experimental # all rules tagged as experimental\n" + break + msg += "```" + + if matches and not options.quiet: + console_stderr.print(Markdown(msg)) + + if failure: + return 2 + else: + return 0 + + +def main() -> int: + """Linter CLI entry point.""" + cwd = pathlib.Path.cwd() + + options = cli.get_config(sys.argv[1:]) + + initialize_logger(options.verbosity) + _logger.debug("Options: %s", options) + + formatter_factory = choose_formatter_factory(options) + formatter = formatter_factory(cwd, options.display_relative_path) + + rulesdirs = get_rules_dirs([str(rdir) for rdir in options.rulesdir], + options.use_default_rules) + rules = RulesCollection(rulesdirs) + + if options.listrules: + console.print( + _rule_format_map[options.format](rules), + highlight=False) + return 0 + + if options.listtags: + print(rules.listtags()) + return 0 + + if isinstance(options.tags, str): + options.tags = options.tags.split(',') + + skip = set() + for s in options.skip_list: + skip.update(str(s).split(',')) + options.skip_list = frozenset(skip) + + matches = _get_matches(rules, options) + + # Assure we do not print duplicates and the order is consistent + matches = sorted(set(matches)) + + mark_as_success = False + if matches and options.progressive: + _logger.info( + "Matches found, running again on previous revision in order to detect regressions") + with _previous_revision(): + old_matches = _get_matches(rules, options) + # remove old matches from current list + matches_delta = list(set(matches) - set(old_matches)) + if len(matches_delta) == 0: + _logger.warning( + "Total violations not increased since previous " + "commit, will mark result as success. (%s -> %s)", + len(old_matches), len(matches_delta)) + mark_as_success = True + + ignored = 0 + for match in matches: + # if match is not new, mark is as ignored + if match not in matches_delta: + match.ignored = True + ignored += 1 + if ignored: + _logger.warning( + "Marked %s previously known violation(s) as ignored due to" + " progressive mode.", ignored) + + _render_matches(matches, options, formatter, cwd) + + if matches and not mark_as_success: + return report_outcome(matches, options=options) + else: + return 0 + + +def _render_matches( + matches: List, + options: "Namespace", + formatter: Any, + cwd: Union[str, pathlib.Path]): + + ignored_matches = [match for match in matches if match.ignored] + fatal_matches = [match for match in matches if not match.ignored] + # Displayed ignored matches first + if ignored_matches: + _logger.warning( + "Listing %s violation(s) marked as ignored, likely already known", + len(ignored_matches)) + for match in ignored_matches: + if match.ignored: + print(formatter.format(match, options.colored)) + if fatal_matches: + _logger.warning("Listing %s violation(s) that are fatal", len(fatal_matches)) + for match in fatal_matches: + if not match.ignored: + print(formatter.format(match, options.colored)) + + # If run under GitHub Actions we also want to emit output recognized by it. + if os.getenv('GITHUB_ACTIONS') == 'true' and os.getenv('GITHUB_WORKFLOW'): + formatter = formatters.AnnotationsFormatter(cwd, True) + for match in matches: + print(formatter.format(match)) + + +def _get_matches(rules: RulesCollection, options: "Namespace") -> list: + + if not options.playbook: + # no args triggers auto-detection mode + playbooks = get_playbooks_and_roles(options=options) + else: + playbooks = sorted(set(options.playbook)) + + matches = list() + checked_files: Set[str] = set() + for playbook in playbooks: + runner = Runner(rules, playbook, options.tags, + options.skip_list, options.exclude_paths, + options.verbosity, checked_files) + matches.extend(runner.run()) + return matches + + +@contextmanager +def _previous_revision(): + """Create or update a temporary workdir containing the previous revision.""" + worktree_dir = ".cache/old-rev" + revision = subprocess.run( + ["git", "rev-parse", "HEAD^1"], + check=True, + universal_newlines=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + ).stdout + p = pathlib.Path(worktree_dir) + p.mkdir(parents=True, exist_ok=True) + os.system(f"git worktree add -f {worktree_dir} 2>/dev/null") + with cwd(worktree_dir): + os.system(f"git checkout {revision}") + yield + + +if __name__ == "__main__": + try: + sys.exit(main()) + except IOError as exc: + if exc.errno != errno.EPIPE: + raise + except RuntimeError as e: + raise SystemExit(str(e)) diff --git a/lib/ansiblelint/cli.py b/lib/ansiblelint/cli.py new file mode 100644 index 0000000..6b39561 --- /dev/null +++ b/lib/ansiblelint/cli.py @@ -0,0 +1,219 @@ +# -*- coding: utf-8 -*- +"""CLI parser setup and helpers.""" +import argparse +import logging +import os +import sys +from pathlib import Path +from typing import List, NamedTuple + +import yaml + +from ansiblelint.constants import DEFAULT_RULESDIR, INVALID_CONFIG_RC +from ansiblelint.utils import expand_path_vars +from ansiblelint.version import __version__ + +_logger = logging.getLogger(__name__) +_PATH_VARS = ['exclude_paths', 'rulesdir', ] + + +def abspath(path: str, base_dir: str) -> str: + """Make relative path absolute relative to given directory. + + Args: + path (str): the path to make absolute + base_dir (str): the directory from which make relative paths + absolute + default_drive: Windows drive to use to make the path + absolute if none is given. + """ + if not os.path.isabs(path): + # Don't use abspath as it assumes path is relative to cwd. + # We want it relative to base_dir. + path = os.path.join(base_dir, path) + + return os.path.normpath(path) + + +def expand_to_normalized_paths(config: dict, base_dir: str = None) -> None: + # config can be None (-c /dev/null) + if not config: + return + base_dir = base_dir or os.getcwd() + for paths_var in _PATH_VARS: + if paths_var not in config: + continue # Cause we don't want to add a variable not present + + normalized_paths = [] + for path in config.pop(paths_var): + normalized_path = abspath(expand_path_vars(path), base_dir=base_dir) + + normalized_paths.append(normalized_path) + + config[paths_var] = normalized_paths + + +def load_config(config_file: str) -> dict: + config_path = os.path.abspath(config_file or '.ansible-lint') + + if config_file: + if not os.path.exists(config_path): + _logger.error("Config file not found '%s'", config_path) + sys.exit(INVALID_CONFIG_RC) + elif not os.path.exists(config_path): + # a missing default config file should not trigger an error + return {} + + try: + with open(config_path, "r") as stream: + config = yaml.safe_load(stream) + except yaml.YAMLError as e: + _logger.error(e) + sys.exit(INVALID_CONFIG_RC) + # TODO(ssbarnea): implement schema validation for config file + if isinstance(config, list): + _logger.error( + "Invalid configuration '%s', expected YAML mapping in the config file.", + config_path) + sys.exit(INVALID_CONFIG_RC) + + config_dir = os.path.dirname(config_path) + expand_to_normalized_paths(config, config_dir) + return config + + +class AbspathArgAction(argparse.Action): + def __call__(self, parser, namespace, values, option_string=None): + if isinstance(values, (str, Path)): + values = [values] + normalized_values = [Path(expand_path_vars(path)).resolve() for path in values] + previous_values = getattr(namespace, self.dest, []) + setattr(namespace, self.dest, previous_values + normalized_values) + + +def get_cli_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser() + + parser.add_argument('-L', dest='listrules', default=False, + action='store_true', help="list all the rules") + parser.add_argument('-f', dest='format', default='rich', + choices=['rich', 'plain', 'rst'], + help="Format used rules output, (default: %(default)s)") + parser.add_argument('-q', dest='quiet', + default=False, + action='store_true', + help="quieter, although not silent output") + parser.add_argument('-p', dest='parseable', + default=False, + action='store_true', + help="parseable output in the format of pep8") + parser.add_argument('--parseable-severity', dest='parseable_severity', + default=False, + action='store_true', + help="parseable output including severity of rule") + parser.add_argument('--progressive', dest='progressive', + default=False, + action='store_true', + help="Return success if it detects a reduction in number" + " of violations compared with previous git commit. This " + "feature works only in git repositories.") + parser.add_argument('-r', action=AbspathArgAction, dest='rulesdir', + default=[], type=Path, + help="Specify custom rule directories. Add -R " + f"to keep using embedded rules from {DEFAULT_RULESDIR}") + parser.add_argument('-R', action='store_true', + default=False, + dest='use_default_rules', + help="Keep default rules when using -r") + parser.add_argument('--show-relpath', dest='display_relative_path', action='store_false', + default=True, + help="Display path relative to CWD") + parser.add_argument('-t', dest='tags', + action='append', + default=[], + help="only check rules whose id/tags match these values") + parser.add_argument('-T', dest='listtags', action='store_true', + help="list all the tags") + parser.add_argument('-v', dest='verbosity', action='count', + help="Increase verbosity level", + default=0) + parser.add_argument('-x', dest='skip_list', default=[], action='append', + help="only check rules whose id/tags do not " + "match these values") + parser.add_argument('-w', dest='warn_list', default=[], action='append', + help="only warn about these rules, unless overridden in " + "config file defaults to 'experimental'") + parser.add_argument('--nocolor', dest='colored', + default=hasattr(sys.stdout, 'isatty') and sys.stdout.isatty(), + action='store_false', + help="disable colored output") + parser.add_argument('--force-color', dest='colored', + action='store_true', + help="Try force colored output (relying on ansible's code)") + parser.add_argument('--exclude', dest='exclude_paths', + action=AbspathArgAction, + type=Path, default=[], + help='path to directories or files to skip. ' + 'This option is repeatable.', + ) + parser.add_argument('-c', dest='config_file', + help='Specify configuration file to use. ' + 'Defaults to ".ansible-lint"') + parser.add_argument('--version', action='version', + version='%(prog)s {ver!s}'.format(ver=__version__), + ) + parser.add_argument(dest='playbook', nargs='*', + help="One or more files or paths. When missing it will " + " enable auto-detection mode.") + + return parser + + +def merge_config(file_config, cli_config) -> NamedTuple: + bools = ( + 'display_relative_path', + 'parseable', + 'parseable_severity', + 'quiet', + 'use_default_rules', + ) + # maps lists to their default config values + lists_map = { + 'exclude_paths': [], + 'rulesdir': [], + 'skip_list': [], + 'tags': [], + 'warn_list': ['experimental'], + } + + if not file_config: + return cli_config + + for entry in bools: + x = getattr(cli_config, entry) or file_config.get(entry, False) + setattr(cli_config, entry, x) + + for entry, default in lists_map.items(): + getattr(cli_config, entry).extend(file_config.get(entry, default)) + + if 'verbosity' in file_config: + cli_config.verbosity = (cli_config.verbosity + + file_config['verbosity']) + + return cli_config + + +def get_config(arguments: List[str]): + parser = get_cli_parser() + options = parser.parse_args(arguments) + + config = load_config(options.config_file) + + return merge_config(config, options) + + +def print_help(file=sys.stdout): + get_cli_parser().print_help(file=file) + + +# vim: et:sw=4:syntax=python:ts=4: diff --git a/lib/ansiblelint/color.py b/lib/ansiblelint/color.py new file mode 100644 index 0000000..b30a89c --- /dev/null +++ b/lib/ansiblelint/color.py @@ -0,0 +1,31 @@ +"""Console coloring and terminal support.""" +import sys +from enum import Enum + +from rich.console import Console +from rich.theme import Theme + +_theme = Theme({ + "info": "cyan", + "warning": "dim yellow", + "danger": "bold red", + "title": "yellow" +}) +console = Console(theme=_theme) +console_stderr = Console(file=sys.stderr, theme=_theme) + + +class Color(Enum): + """Color styles.""" + + reset = "0" + error_code = "1;31" # bright red + error_title = "0;31" # red + filename = "0;34" # blue + linenumber = "0;36" # cyan + line = "0;35" # purple + + +def colorize(text: str, color: Color) -> str: + """Return ANSI formated string.""" + return f"\u001b[{color.value}m{text}\u001b[{Color.reset.value}m" diff --git a/lib/ansiblelint/constants.py b/lib/ansiblelint/constants.py new file mode 100644 index 0000000..89094f9 --- /dev/null +++ b/lib/ansiblelint/constants.py @@ -0,0 +1,18 @@ +"""Constants used by AnsibleLint.""" +import os.path +import sys + +# mypy/pylint idiom for py36-py38 compatibility +# https://github.com/python/typeshed/issues/3500#issuecomment-560958608 +if sys.version_info >= (3, 8): + from typing import Literal # pylint: disable=no-name-in-module +else: + from typing_extensions import Literal + +DEFAULT_RULESDIR = os.path.join(os.path.dirname(__file__), 'rules') +CUSTOM_RULESDIR_ENVVAR = "ANSIBLE_LINT_CUSTOM_RULESDIR" + +INVALID_CONFIG_RC = 2 +ANSIBLE_FAILURE_RC = 3 + +FileType = Literal["playbook", "pre_tasks", "post_tasks"] diff --git a/lib/ansiblelint/errors.py b/lib/ansiblelint/errors.py new file mode 100644 index 0000000..8569dca --- /dev/null +++ b/lib/ansiblelint/errors.py @@ -0,0 +1,81 @@ +"""Exceptions and error representations.""" +import functools + +from ansiblelint.file_utils import normpath + + +@functools.total_ordering +class MatchError(ValueError): + """Rule violation detected during linting. + + It can be raised as Exception but also just added to the list of found + rules violations. + + Note that line argument is not considered when building hash of an + instance. + """ + + # IMPORTANT: any additional comparison protocol methods must return + # IMPORTANT: `NotImplemented` singleton to allow the check to use the + # IMPORTANT: other object's fallbacks. + # Ref: https://docs.python.org/3/reference/datamodel.html#object.__lt__ + + def __init__( + self, + message=None, + linenumber=0, + details: str = "", + filename=None, + rule=None) -> None: + """Initialize a MatchError instance.""" + super().__init__(message) + + if not (message or rule): + raise TypeError( + f'{self.__class__.__name__}() missing a ' + "required argument: one of 'message' or 'rule'", + ) + + self.message = message or getattr(rule, 'shortdesc', "") + self.linenumber = linenumber + self.details = details + self.filename = normpath(filename) if filename else None + self.rule = rule + self.ignored = False # If set it will be displayed but not counted as failure + + def __repr__(self): + """Return a MatchError instance representation.""" + formatstr = u"[{0}] ({1}) matched {2}:{3} {4}" + # note that `rule.id` can be int, str or even missing, as users + # can defined their own custom rules. + _id = getattr(self.rule, "id", "000") + + return formatstr.format(_id, self.message, + self.filename, self.linenumber, self.details) + + @property + def _hash_key(self): + # line attr is knowingly excluded, as dict is not hashable + return ( + self.filename, + self.linenumber, + str(getattr(self.rule, 'id', 0)), + self.message, + self.details, + ) + + def __lt__(self, other): + """Return whether the current object is less than the other.""" + if not isinstance(other, self.__class__): + return NotImplemented + return self._hash_key < other._hash_key + + def __hash__(self): + """Return a hash value of the MatchError instance.""" + return hash(self._hash_key) + + def __eq__(self, other): + """Identify whether the other object represents the same rule match.""" + if not isinstance(other, self.__class__): + return NotImplemented + return self.__hash__() == other.__hash__() diff --git a/lib/ansiblelint/file_utils.py b/lib/ansiblelint/file_utils.py new file mode 100644 index 0000000..f25382f --- /dev/null +++ b/lib/ansiblelint/file_utils.py @@ -0,0 +1,25 @@ +"""Utility functions related to file operations.""" +import os +from contextlib import contextmanager + + +def normpath(path) -> str: + """ + Normalize a path in order to provide a more consistent output. + + Currently it generates a relative path but in the future we may want to + make this user configurable. + """ + # convertion to string in order to allow receiving non string objects + return os.path.relpath(str(path)) + + +@contextmanager +def cwd(path): + """Context manager for temporary changing current working directory.""" + old_pwd = os.getcwd() + os.chdir(path) + try: + yield + finally: + os.chdir(old_pwd) diff --git a/lib/ansiblelint/formatters/__init__.py b/lib/ansiblelint/formatters/__init__.py new file mode 100644 index 0000000..7395183 --- /dev/null +++ b/lib/ansiblelint/formatters/__init__.py @@ -0,0 +1,167 @@ +"""Output formatters.""" +import os +from pathlib import Path +from typing import TYPE_CHECKING, Generic, TypeVar, Union + +from ansiblelint.color import Color, colorize + +if TYPE_CHECKING: + from ansiblelint.errors import MatchError + +T = TypeVar('T', bound='BaseFormatter') + + +class BaseFormatter(Generic[T]): + """Formatter of ansible-lint output. + + Base class for output formatters. + + Args: + 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: Union[str, Path], display_relative_path: bool) -> None: + """Initialize a BaseFormatter instance.""" + if isinstance(base_dir, str): + base_dir = Path(base_dir) + if base_dir: # can be None + base_dir = base_dir.absolute() + + # Required 'cause os.path.relpath() does not accept Path before 3.6 + if isinstance(base_dir, Path): + base_dir = str(base_dir) # Drop when Python 3.5 is no longer supported + + self._base_dir = base_dir if display_relative_path else None + + def _format_path(self, path: Union[str, Path]) -> str: + # Required 'cause os.path.relpath() does not accept Path before 3.6 + if isinstance(path, Path): + path = str(path) # Drop when Python 3.5 is no longer supported + + if not self._base_dir: + return path + # Use os.path.relpath 'cause Path.relative_to() misbehaves + return os.path.relpath(path, start=self._base_dir) + + def format(self, match: "MatchError", colored: bool = False) -> str: + return str(match) + + +class Formatter(BaseFormatter): + + def format(self, match: "MatchError", colored: bool = False) -> str: + formatstr = u"{0} {1}\n{2}:{3}\n{4}\n" + _id = getattr(match.rule, 'id', '000') + if colored: + return formatstr.format( + colorize(u"[{0}]".format(_id), Color.error_code), + colorize(match.message, Color.error_title), + colorize(self._format_path(match.filename or ""), Color.filename), + colorize(str(match.linenumber), Color.linenumber), + colorize(u"{0}".format(match.details), Color.line)) + else: + return formatstr.format(_id, + match.message, + match.filename or "", + match.linenumber, + match.details) + + +class QuietFormatter(BaseFormatter): + + def format(self, match: "MatchError", colored: bool = False) -> str: + formatstr = u"{0} {1}:{2}" + if colored: + return formatstr.format( + colorize(u"[{0}]".format(match.rule.id), Color.error_code), + colorize(self._format_path(match.filename or ""), Color.filename), + colorize(str(match.linenumber), Color.linenumber)) + else: + return formatstr.format(match.rule.id, self._format_path(match.filename or ""), + match.linenumber) + + +class ParseableFormatter(BaseFormatter): + + def format(self, match: "MatchError", colored: bool = False) -> str: + formatstr = u"{0}:{1}: [{2}] {3}" + if colored: + return formatstr.format( + colorize(self._format_path(match.filename or ""), Color.filename), + colorize(str(match.linenumber), Color.linenumber), + colorize(u"E{0}".format(match.rule.id), Color.error_code), + colorize(u"{0}".format(match.message), Color.error_title)) + else: + return formatstr.format(self._format_path(match.filename or ""), + match.linenumber, + "E" + match.rule.id, + match.message) + + +class AnnotationsFormatter(BaseFormatter): + # https://docs.github.com/en/actions/reference/workflow-commands-for-github-actions#setting-a-warning-message + """Formatter for emitting violations as GitHub Workflow Commands. + + These commands trigger the GHA Workflow runners platform to post violations + in a form of GitHub Checks API annotations that appear rendered in pull- + request files view. + + ::debug file={name},line={line},col={col},severity={severity}::{message} + ::warning file={name},line={line},col={col},severity={severity}::{message} + ::error file={name},line={line},col={col},severity={severity}::{message} + + Supported levels: debug, warning, error + """ + + def format(self, match: "MatchError", colored: bool = False) -> str: + """Prepare a match instance for reporting as a GitHub Actions annotation.""" + if colored: + raise ValueError('The colored mode is not supported.') + + level = self._severity_to_level(match.rule.severity) + file_path = self._format_path(match.filename or "") + line_num = match.linenumber + rule_id = match.rule.id + severity = match.rule.severity + violation_details = match.message + return ( + f"::{level} file={file_path},line={line_num},severity={severity}" + f"::[E{rule_id}] {violation_details}" + ) + + @staticmethod + def _severity_to_level(severity: str) -> str: + if severity in ['VERY_LOW', 'LOW']: + return 'warning' + elif severity in ['INFO']: + return 'debug' + # ['MEDIUM', 'HIGH', 'VERY_HIGH'] or anything else + return 'error' + + +class ParseableSeverityFormatter(BaseFormatter): + + def format(self, match: "MatchError", colored: bool = False) -> str: + formatstr = u"{0}:{1}: [{2}] [{3}] {4}" + + filename = self._format_path(match.filename or "") + linenumber = str(match.linenumber) + rule_id = u"E{0}".format(match.rule.id) + severity = match.rule.severity + message = str(match.message) + + if colored: + filename = colorize(filename, Color.filename) + linenumber = colorize(linenumber, Color.linenumber) + rule_id = colorize(rule_id, Color.error_code) + severity = colorize(severity, Color.error_code) + message = colorize(message, Color.error_title) + + return formatstr.format( + filename, + linenumber, + rule_id, + severity, + message, + ) diff --git a/lib/ansiblelint/generate_docs.py b/lib/ansiblelint/generate_docs.py new file mode 100644 index 0000000..b735b1f --- /dev/null +++ b/lib/ansiblelint/generate_docs.py @@ -0,0 +1,66 @@ +"""Utils to generate rule table .rst documentation.""" +import logging +from typing import Iterable + +from rich import box +from rich.console import render_group +from rich.markdown import Markdown +from rich.table import Table + +from ansiblelint.rules import RulesCollection + +DOC_HEADER = """ +.. _lint_default_rules: + +Default Rules +============= + +.. contents:: + :local: + +Below you can see the list of default rules Ansible Lint use to evaluate playbooks and roles: + +""" + +_logger = logging.getLogger(__name__) + + +def rules_as_rst(rules: RulesCollection) -> str: + """Return RST documentation for a list of rules.""" + r = DOC_HEADER + + for d in rules: + if not hasattr(d, 'id'): + _logger.warning( + "Rule %s skipped from being documented as it does not have an `id` attribute.", + d.__class__.__name__) + continue + + if d.id.endswith('01'): + + section = '{} Rules ({}xx)'.format( + d.tags[0].title(), + d.id[-3:-2]) + r += f'\n\n{section}\n{ "-" * len(section) }' + + title = f"{d.id}: {d.shortdesc}" + r += f"\n\n.. _{d.id}:\n\n{title}\n{'*' * len(title)}\n\n{d.description}" + + return r + + +@render_group() +def rules_as_rich(rules: RulesCollection) -> Iterable[Table]: + """Print documentation for a list of rules, returns empty string.""" + for rule in rules: + table = Table(show_header=True, header_style="title", box=box.MINIMAL) + table.add_column(rule.id, style="dim", width=16) + table.add_column(Markdown(rule.shortdesc)) + table.add_row("description", Markdown(rule.description)) + if rule.version_added: + table.add_row("version_added", rule.version_added) + if rule.tags: + table.add_row("tags", ", ".join(rule.tags)) + if rule.severity: + table.add_row("severity", rule.severity) + yield table diff --git a/lib/ansiblelint/rules/AlwaysRunRule.py b/lib/ansiblelint/rules/AlwaysRunRule.py new file mode 100644 index 0000000..8d811ff --- /dev/null +++ b/lib/ansiblelint/rules/AlwaysRunRule.py @@ -0,0 +1,33 @@ +# Copyright (c) 2017 Anth Courtney <anthcourtney@gmail.com> +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# 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 ansiblelint.rules import AnsibleLintRule + + +class AlwaysRunRule(AnsibleLintRule): + id = '101' + shortdesc = 'Deprecated always_run' + description = 'Instead of ``always_run``, use ``check_mode``' + severity = 'MEDIUM' + tags = ['deprecated', 'ANSIBLE0018'] + version_added = 'historic' + + def matchtask(self, file, task): + return 'always_run' in task diff --git a/lib/ansiblelint/rules/BecomeUserWithoutBecomeRule.py b/lib/ansiblelint/rules/BecomeUserWithoutBecomeRule.py new file mode 100644 index 0000000..e6f3259 --- /dev/null +++ b/lib/ansiblelint/rules/BecomeUserWithoutBecomeRule.py @@ -0,0 +1,80 @@ +# Copyright (c) 2016 Will Thames <will@thames.id.au> +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# 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 functools import reduce + +from ansiblelint.rules import AnsibleLintRule + + +def _get_subtasks(data): + result = [] + 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, data): + 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, data): + 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): + id = '501' + shortdesc = 'become_user requires become to work as expected' + description = '``become_user`` without ``become`` will not actually change user' + severity = 'VERY_HIGH' + tags = ['task', 'oddity', 'ANSIBLE0017'] + version_added = 'historic' + + def matchplay(self, file, data): + if file['type'] == 'playbook' and _become_user_without_become(False, data): + return ({'become_user': data}, self.shortdesc) diff --git a/lib/ansiblelint/rules/CommandHasChangesCheckRule.py b/lib/ansiblelint/rules/CommandHasChangesCheckRule.py new file mode 100644 index 0000000..26087b8 --- /dev/null +++ b/lib/ansiblelint/rules/CommandHasChangesCheckRule.py @@ -0,0 +1,45 @@ +# Copyright (c) 2016 Will Thames <will@thames.id.au> +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# 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 ansiblelint.rules import AnsibleLintRule + + +class CommandHasChangesCheckRule(AnsibleLintRule): + id = '301' + shortdesc = 'Commands should not change things if nothing needs doing' + description = ( + 'Commands should either read information (and thus set ' + '``changed_when``) or not do something if it has already been ' + 'done (using creates/removes) or only do it if another ' + 'check has a particular result (``when``)' + ) + severity = 'HIGH' + tags = ['command-shell', 'idempotency', 'ANSIBLE0012'] + version_added = 'historic' + + _commands = ['command', 'shell', 'raw'] + + def matchtask(self, file, task): + if task["__ansible_action_type__"] == 'task': + if task["action"]["__ansible_module__"] in self._commands: + return 'changed_when' not in task and \ + 'when' not in task and \ + 'creates' not in task['action'] and \ + 'removes' not in task['action'] diff --git a/lib/ansiblelint/rules/CommandsInsteadOfArgumentsRule.py b/lib/ansiblelint/rules/CommandsInsteadOfArgumentsRule.py new file mode 100644 index 0000000..f1adffa --- /dev/null +++ b/lib/ansiblelint/rules/CommandsInsteadOfArgumentsRule.py @@ -0,0 +1,65 @@ +# Copyright (c) 2013-2014 Will Thames <will@thames.id.au> +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# 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. + +import os + +from ansiblelint.rules import AnsibleLintRule +from ansiblelint.utils import get_first_cmd_arg + +try: + from ansible.module_utils.parsing.convert_bool import boolean +except ImportError: + try: + from ansible.utils.boolean import boolean + except ImportError: + try: + from ansible.utils import boolean + except ImportError: + from ansible import constants + boolean = constants.mk_boolean + + +class CommandsInsteadOfArgumentsRule(AnsibleLintRule): + id = '302' + shortdesc = 'Using command rather than an argument to e.g. file' + description = ( + 'Executing a command when there are arguments to modules ' + 'is generally a bad idea' + ) + severity = 'VERY_HIGH' + tags = ['command-shell', 'resources', 'ANSIBLE0007'] + version_added = 'historic' + + _commands = ['command', 'shell', 'raw'] + _arguments = {'chown': 'owner', 'chmod': 'mode', 'chgrp': 'group', + 'ln': 'state=link', 'mkdir': 'state=directory', + 'rmdir': 'state=absent', 'rm': 'state=absent'} + + def matchtask(self, file, task): + if task["action"]["__ansible_module__"] in self._commands: + first_cmd_arg = get_first_cmd_arg(task) + if not first_cmd_arg: + return + + executable = os.path.basename(first_cmd_arg) + if executable in self._arguments and \ + boolean(task['action'].get('warn', True)): + message = "{0} used in place of argument {1} to file module" + return message.format(executable, self._arguments[executable]) diff --git a/lib/ansiblelint/rules/CommandsInsteadOfModulesRule.py b/lib/ansiblelint/rules/CommandsInsteadOfModulesRule.py new file mode 100644 index 0000000..b19c5c2 --- /dev/null +++ b/lib/ansiblelint/rules/CommandsInsteadOfModulesRule.py @@ -0,0 +1,86 @@ +# Copyright (c) 2013-2014 Will Thames <will@thames.id.au> +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# 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. + +import os + +from ansiblelint.rules import AnsibleLintRule +from ansiblelint.utils import get_first_cmd_arg + +try: + from ansible.module_utils.parsing.convert_bool import boolean +except ImportError: + try: + from ansible.utils.boolean import boolean + except ImportError: + try: + from ansible.utils import boolean + except ImportError: + from ansible import constants + boolean = constants.mk_boolean + + +class CommandsInsteadOfModulesRule(AnsibleLintRule): + id = '303' + shortdesc = 'Using command rather than module' + description = ( + 'Executing a command when there is an Ansible module ' + 'is generally a bad idea' + ) + severity = 'HIGH' + tags = ['command-shell', 'resources', 'ANSIBLE0006'] + version_added = 'historic' + + _commands = ['command', 'shell'] + _modules = { + 'apt-get': 'apt-get', + 'chkconfig': 'service', + 'curl': 'get_url or uri', + 'git': 'git', + 'hg': 'hg', + 'letsencrypt': 'acme_certificate', + 'mktemp': 'tempfile', + 'mount': 'mount', + 'patch': 'patch', + 'rpm': 'yum or rpm_key', + 'rsync': 'synchronize', + 'sed': 'template, replace or lineinfile', + 'service': 'service', + 'supervisorctl': 'supervisorctl', + 'svn': 'subversion', + 'systemctl': 'systemd', + 'tar': 'unarchive', + 'unzip': 'unarchive', + 'wget': 'get_url or uri', + 'yum': 'yum', + } + + def matchtask(self, file, task): + if task['action']['__ansible_module__'] not in self._commands: + return + + first_cmd_arg = get_first_cmd_arg(task) + if not first_cmd_arg: + return + + executable = os.path.basename(first_cmd_arg) + if executable in self._modules and \ + boolean(task['action'].get('warn', True)): + message = '{0} used in place of {1} module' + return message.format(executable, self._modules[executable]) diff --git a/lib/ansiblelint/rules/ComparisonToEmptyStringRule.py b/lib/ansiblelint/rules/ComparisonToEmptyStringRule.py new file mode 100644 index 0000000..a43c4f7 --- /dev/null +++ b/lib/ansiblelint/rules/ComparisonToEmptyStringRule.py @@ -0,0 +1,23 @@ +# Copyright (c) 2016, Will Thames and contributors +# Copyright (c) 2018, Ansible Project + +import re + +from ansiblelint.rules import AnsibleLintRule + + +class ComparisonToEmptyStringRule(AnsibleLintRule): + id = '602' + shortdesc = "Don't compare to empty string" + description = ( + 'Use ``when: var|length > 0`` rather than ``when: var != ""`` (or ' + 'conversely ``when: var|length == 0`` rather than ``when: var == ""``)' + ) + severity = 'HIGH' + tags = ['idiom'] + version_added = 'v4.0.0' + + empty_string_compare = re.compile("[=!]= ?(\"{2}|'{2})") + + def match(self, file, line): + return self.empty_string_compare.search(line) diff --git a/lib/ansiblelint/rules/ComparisonToLiteralBoolRule.py b/lib/ansiblelint/rules/ComparisonToLiteralBoolRule.py new file mode 100644 index 0000000..46668d1 --- /dev/null +++ b/lib/ansiblelint/rules/ComparisonToLiteralBoolRule.py @@ -0,0 +1,23 @@ +# Copyright (c) 2016, Will Thames and contributors +# Copyright (c) 2018, Ansible Project + +import re + +from ansiblelint.rules import AnsibleLintRule + + +class ComparisonToLiteralBoolRule(AnsibleLintRule): + id = '601' + shortdesc = "Don't compare to literal True/False" + description = ( + 'Use ``when: var`` rather than ``when: var == True`` ' + '(or conversely ``when: not var``)' + ) + severity = 'HIGH' + tags = ['idiom'] + version_added = 'v4.0.0' + + literal_bool_compare = re.compile("[=!]= ?(True|true|False|false)") + + def match(self, file, line): + return self.literal_bool_compare.search(line) diff --git a/lib/ansiblelint/rules/DeprecatedModuleRule.py b/lib/ansiblelint/rules/DeprecatedModuleRule.py new file mode 100644 index 0000000..dc019ed --- /dev/null +++ b/lib/ansiblelint/rules/DeprecatedModuleRule.py @@ -0,0 +1,37 @@ +# Copyright (c) 2018, Ansible Project + +from ansiblelint.rules import AnsibleLintRule + + +class DeprecatedModuleRule(AnsibleLintRule): + id = '105' + shortdesc = 'Deprecated module' + description = ( + 'These are deprecated modules, some modules are kept ' + 'temporarily for backwards compatibility but usage is discouraged. ' + 'For more details see: ' + 'https://docs.ansible.com/ansible/latest/modules/list_of_all_modules.html' + ) + severity = 'HIGH' + tags = ['deprecated'] + version_added = 'v4.0.0' + + _modules = [ + 'accelerate', 'aos_asn_pool', 'aos_blueprint', 'aos_blueprint_param', + 'aos_blueprint_virtnet', 'aos_device', 'aos_external_router', + 'aos_ip_pool', 'aos_logical_device', 'aos_logical_device_map', + 'aos_login', 'aos_rack_type', 'aos_template', 'azure', 'cl_bond', + 'cl_bridge', 'cl_img_install', 'cl_interface', 'cl_interface_policy', + 'cl_license', 'cl_ports', 'cs_nic', 'docker', 'ec2_ami_find', + 'ec2_ami_search', 'ec2_remote_facts', 'ec2_vpc', 'kubernetes', + 'netscaler', 'nxos_ip_interface', 'nxos_mtu', 'nxos_portchannel', + 'nxos_switchport', 'oc', 'panos_nat_policy', 'panos_security_policy', + 'vsphere_guest', 'win_msi', 'include' + ] + + def matchtask(self, file, task): + module = task["action"]["__ansible_module__"] + if module in self._modules: + message = '{0} {1}' + return message.format(self.shortdesc, module) + return False diff --git a/lib/ansiblelint/rules/EnvVarsInCommandRule.py b/lib/ansiblelint/rules/EnvVarsInCommandRule.py new file mode 100644 index 0000000..58dba90 --- /dev/null +++ b/lib/ansiblelint/rules/EnvVarsInCommandRule.py @@ -0,0 +1,48 @@ +# Copyright (c) 2016 Will Thames <will@thames.id.au> +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# 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 ansiblelint.rules import AnsibleLintRule +from ansiblelint.utils import FILENAME_KEY, LINE_NUMBER_KEY, get_first_cmd_arg + + +class EnvVarsInCommandRule(AnsibleLintRule): + id = '304' + shortdesc = "Environment variables don't work as part of command" + description = ( + 'Environment variables should be passed to ``shell`` or ``command`` ' + 'through environment argument' + ) + severity = 'VERY_HIGH' + tags = ['command-shell', 'bug', 'ANSIBLE0014'] + version_added = 'historic' + + expected_args = ['chdir', 'creates', 'executable', 'removes', 'stdin', 'warn', + 'stdin_add_newline', 'strip_empty_ends', + 'cmd', '__ansible_module__', '__ansible_arguments__', + LINE_NUMBER_KEY, FILENAME_KEY] + + def matchtask(self, file, task): + if task["action"]["__ansible_module__"] in ['command']: + first_cmd_arg = get_first_cmd_arg(task) + if not first_cmd_arg: + return + + return any([arg not in self.expected_args for arg in task['action']] + + ["=" in first_cmd_arg]) diff --git a/lib/ansiblelint/rules/GitHasVersionRule.py b/lib/ansiblelint/rules/GitHasVersionRule.py new file mode 100644 index 0000000..f0f3680 --- /dev/null +++ b/lib/ansiblelint/rules/GitHasVersionRule.py @@ -0,0 +1,37 @@ +# Copyright (c) 2013-2014 Will Thames <will@thames.id.au> +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# 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 ansiblelint.rules import AnsibleLintRule + + +class GitHasVersionRule(AnsibleLintRule): + id = '401' + shortdesc = 'Git checkouts must contain explicit version' + description = ( + 'All version control checkouts must point to ' + 'an explicit commit or tag, not just ``latest``' + ) + severity = 'MEDIUM' + tags = ['module', 'repeatability', 'ANSIBLE0004'] + version_added = 'historic' + + def matchtask(self, file, task): + return (task['action']['__ansible_module__'] == 'git' and + task['action'].get('version', 'HEAD') == 'HEAD') diff --git a/lib/ansiblelint/rules/IncludeMissingFileRule.py b/lib/ansiblelint/rules/IncludeMissingFileRule.py new file mode 100644 index 0000000..57508fa --- /dev/null +++ b/lib/ansiblelint/rules/IncludeMissingFileRule.py @@ -0,0 +1,67 @@ +# Copyright (c) 2020, Joachim Lusiardi +# Copyright (c) 2020, Ansible Project + +import os.path + +import ansible.parsing.yaml.objects + +from ansiblelint.rules import AnsibleLintRule + + +class IncludeMissingFileRule(AnsibleLintRule): + id = '505' + shortdesc = 'referenced files must exist' + description = ( + 'All files referenced by by include / import tasks ' + 'must exist. The check excludes files with jinja2 ' + 'templates in the filename.' + ) + severity = 'MEDIUM' + tags = ['task', 'bug'] + version_added = 'v4.3.0' + + def matchplay(self, file, data): + absolute_directory = file.get('absolute_directory', None) + results = [] + + # avoid failing with a playbook having tasks: null + for task in (data.get('tasks', []) or []): + + # ignore None tasks or + # if the id of the current rule is not in list of skipped rules for this play + if not task or self.id in task.get('skipped_rules', ()): + continue + + # collect information which file was referenced for include / import + referenced_file = None + for key, val in task.items(): + if not (key.startswith('include_') or + key.startswith('import_') or + key == 'include'): + continue + if isinstance(val, ansible.parsing.yaml.objects.AnsibleMapping): + referenced_file = val.get('file', None) + else: + referenced_file = val + # take the file and skip the remaining keys + if referenced_file: + break + + if referenced_file is None or absolute_directory is None: + continue + + # make sure we have a absolute path here and check if it is a file + referenced_file = os.path.join(absolute_directory, referenced_file) + + # skip if this is a jinja2 templated reference + if '{{' in referenced_file: + continue + + # existing files do not produce any error + if os.path.isfile(referenced_file): + continue + + results.append(({'referenced_file': referenced_file}, + 'referenced missing file in %s:%i' + % (task['__file__'], task['__line__']))) + return results diff --git a/lib/ansiblelint/rules/LineTooLongRule.py b/lib/ansiblelint/rules/LineTooLongRule.py new file mode 100644 index 0000000..007857e --- /dev/null +++ b/lib/ansiblelint/rules/LineTooLongRule.py @@ -0,0 +1,19 @@ +# Copyright (c) 2016, Will Thames and contributors +# Copyright (c) 2018, Ansible Project + +from ansiblelint.rules import AnsibleLintRule + + +class LineTooLongRule(AnsibleLintRule): + id = '204' + shortdesc = 'Lines should be no longer than 160 chars' + description = ( + 'Long lines make code harder to read and ' + 'code review more difficult' + ) + severity = 'VERY_LOW' + tags = ['formatting'] + version_added = 'v4.0.0' + + def match(self, file, line): + return len(line) > 160 diff --git a/lib/ansiblelint/rules/LoadingFailureRule.py b/lib/ansiblelint/rules/LoadingFailureRule.py new file mode 100644 index 0000000..7c37498 --- /dev/null +++ b/lib/ansiblelint/rules/LoadingFailureRule.py @@ -0,0 +1,14 @@ +"""Rule definition for a failure to load a file.""" + +from ansiblelint.rules import AnsibleLintRule + + +class LoadingFailureRule(AnsibleLintRule): + """File loading failure.""" + + id = '901' + shortdesc = 'Failed to load or parse file' + description = 'Linter failed to process a YAML file, possible not an Ansible file.' + severity = 'VERY_HIGH' + tags = ['core'] + version_added = 'v4.3.0' diff --git a/lib/ansiblelint/rules/MercurialHasRevisionRule.py b/lib/ansiblelint/rules/MercurialHasRevisionRule.py new file mode 100644 index 0000000..fcfe0a8 --- /dev/null +++ b/lib/ansiblelint/rules/MercurialHasRevisionRule.py @@ -0,0 +1,37 @@ +# Copyright (c) 2013-2014 Will Thames <will@thames.id.au> +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# 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 ansiblelint.rules import AnsibleLintRule + + +class MercurialHasRevisionRule(AnsibleLintRule): + id = '402' + shortdesc = 'Mercurial checkouts must contain explicit revision' + description = ( + 'All version control checkouts must point to ' + 'an explicit commit or tag, not just ``latest``' + ) + severity = 'MEDIUM' + tags = ['module', 'repeatability', 'ANSIBLE0005'] + version_added = 'historic' + + def matchtask(self, file, task): + return (task['action']['__ansible_module__'] == 'hg' and + task['action'].get('revision', 'default') == 'default') diff --git a/lib/ansiblelint/rules/MetaChangeFromDefaultRule.py b/lib/ansiblelint/rules/MetaChangeFromDefaultRule.py new file mode 100644 index 0000000..db52db3 --- /dev/null +++ b/lib/ansiblelint/rules/MetaChangeFromDefaultRule.py @@ -0,0 +1,40 @@ +# Copyright (c) 2018, Ansible Project + +from ansiblelint.rules import AnsibleLintRule + + +class MetaChangeFromDefaultRule(AnsibleLintRule): + id = '703' + shortdesc = 'meta/main.yml default values should be changed' + field_defaults = [ + ('author', 'your name'), + ('description', 'your description'), + ('company', 'your company (optional)'), + ('license', 'license (GPLv2, CC-BY, etc)'), + ('license', 'license (GPL-2.0-or-later, MIT, etc)'), + ] + description = ( + 'meta/main.yml default values should be changed for: ``{}``'.format( + ', '.join(f[0] for f in field_defaults) + ) + ) + severity = 'HIGH' + tags = ['metadata'] + version_added = 'v4.0.0' + + def matchplay(self, file, data): + if file['type'] != 'meta': + return False + + galaxy_info = data.get('galaxy_info', None) + if not galaxy_info: + return False + + results = [] + for field, default in self.field_defaults: + value = galaxy_info.get(field, None) + if value and value == default: + results.append(({'meta/main.yml': data}, + 'Should change default metadata: %s' % field)) + + return results diff --git a/lib/ansiblelint/rules/MetaMainHasInfoRule.py b/lib/ansiblelint/rules/MetaMainHasInfoRule.py new file mode 100644 index 0000000..f05f240 --- /dev/null +++ b/lib/ansiblelint/rules/MetaMainHasInfoRule.py @@ -0,0 +1,66 @@ +# Copyright (c) 2016, Will Thames and contributors +# Copyright (c) 2018, Ansible Project + +from ansiblelint.rules import AnsibleLintRule + +META_STR_INFO = ( + 'author', + 'description' +) +META_INFO = tuple(list(META_STR_INFO) + [ + 'license', + 'min_ansible_version', + 'platforms', +]) + + +def _platform_info_errors_itr(platforms): + if not isinstance(platforms, list): + yield 'Platforms should be a list of dictionaries' + return + + for platform in platforms: + if not isinstance(platform, dict): + yield 'Platforms should be a list of dictionaries' + elif 'name' not in platform: + yield 'Platform should contain name' + + +def _galaxy_info_errors_itr(galaxy_info, + info_list=META_INFO, + str_info_list=META_STR_INFO): + for info in info_list: + ginfo = galaxy_info.get(info, False) + if ginfo: + if info in str_info_list and not isinstance(ginfo, str): + yield '{info} should be a string'.format(info=info) + elif info == 'platforms': + for err in _platform_info_errors_itr(ginfo): + yield err + else: + yield 'Role info should contain {info}'.format(info=info) + + +class MetaMainHasInfoRule(AnsibleLintRule): + id = '701' + shortdesc = 'meta/main.yml should contain relevant info' + str_info = META_STR_INFO + info = META_INFO + description = ( + 'meta/main.yml should contain: ``{}``'.format(', '.join(info)) + ) + severity = 'HIGH' + tags = ['metadata'] + version_added = 'v4.0.0' + + def matchplay(self, file, data): + if file['type'] != 'meta': + return False + + meta = {'meta/main.yml': data} + galaxy_info = data.get('galaxy_info', False) + if galaxy_info: + return [(meta, err) for err + in _galaxy_info_errors_itr(galaxy_info)] + + return [(meta, "No 'galaxy_info' found")] diff --git a/lib/ansiblelint/rules/MetaTagValidRule.py b/lib/ansiblelint/rules/MetaTagValidRule.py new file mode 100644 index 0000000..0739ca3 --- /dev/null +++ b/lib/ansiblelint/rules/MetaTagValidRule.py @@ -0,0 +1,81 @@ +# Copyright (c) 2018, Ansible Project + +import re +import sys + +from ansiblelint.rules import AnsibleLintRule + + +class MetaTagValidRule(AnsibleLintRule): + id = '702' + shortdesc = 'Tags must contain lowercase letters and digits only' + description = ( + 'Tags must contain lowercase letters and digits only, ' + 'and ``galaxy_tags`` is expected to be a list' + ) + severity = 'HIGH' + tags = ['metadata'] + version_added = 'v4.0.0' + + TAG_REGEXP = re.compile('^[a-z0-9]+$') + + def matchplay(self, file, data): + if file['type'] != 'meta': + return False + + galaxy_info = data.get('galaxy_info', None) + if not galaxy_info: + return False + + tags = [] + results = [] + + if 'galaxy_tags' in galaxy_info: + if isinstance(galaxy_info['galaxy_tags'], list): + tags += galaxy_info['galaxy_tags'] + else: + results.append(({'meta/main.yml': data}, + "Expected 'galaxy_tags' to be a list")) + + if 'categories' in galaxy_info: + results.append(({'meta/main.yml': data}, + "Use 'galaxy_tags' rather than 'categories'")) + if isinstance(galaxy_info['categories'], list): + tags += galaxy_info['categories'] + else: + results.append(({'meta/main.yml': data}, + "Expected 'categories' to be a list")) + + for tag in tags: + msg = self.shortdesc + if not isinstance(tag, str): + results.append(( + {'meta/main.yml': data}, + "Tags must be strings: '{}'".format(tag))) + continue + if not re.match(self.TAG_REGEXP, tag): + results.append(({'meta/main.yml': data}, + "{}, invalid: '{}'".format(msg, tag))) + + return results + + +META_TAG_VALID = ''' +galaxy_info: + galaxy_tags: ['database', 'my s q l', 'MYTAG'] + categories: 'my_category_not_in_a_list' +''' + +# testing code to be loaded only with pytest or when executed the rule file +if "pytest" in sys.modules: + + import pytest + + @pytest.mark.parametrize('rule_runner', (MetaTagValidRule, ), indirect=['rule_runner']) + def test_valid_tag_rule(rule_runner): + """Test rule matches.""" + results = rule_runner.run_role_meta_main(META_TAG_VALID) + assert "Use 'galaxy_tags' rather than 'categories'" in str(results) + assert "Expected 'categories' to be a list" in str(results) + assert "invalid: 'my s q l'" in str(results) + assert "invalid: 'MYTAG'" in str(results) diff --git a/lib/ansiblelint/rules/MetaVideoLinksRule.py b/lib/ansiblelint/rules/MetaVideoLinksRule.py new file mode 100644 index 0000000..aa34012 --- /dev/null +++ b/lib/ansiblelint/rules/MetaVideoLinksRule.py @@ -0,0 +1,65 @@ +# Copyright (c) 2018, Ansible Project + +import re + +from ansiblelint.rules import AnsibleLintRule + + +class MetaVideoLinksRule(AnsibleLintRule): + id = '704' + shortdesc = "meta/main.yml video_links should be formatted correctly" + description = ( + 'Items in ``video_links`` in meta/main.yml should be ' + 'dictionaries, and contain only keys ``url`` and ``title``, ' + 'and have a shared link from a supported provider' + ) + severity = 'LOW' + tags = ['metadata'] + version_added = 'v4.0.0' + + VIDEO_REGEXP = { + 'google': re.compile( + r'https://drive\.google\.com.*file/d/([0-9A-Za-z-_]+)/.*'), + 'vimeo': re.compile( + r'https://vimeo\.com/([0-9]+)'), + 'youtube': re.compile( + r'https://youtu\.be/([0-9A-Za-z-_]+)'), + } + + def matchplay(self, file, data): + if file['type'] != 'meta': + return False + + galaxy_info = data.get('galaxy_info', None) + if not galaxy_info: + return False + + video_links = galaxy_info.get('video_links', None) + if not video_links: + return False + + results = [] + + for video in video_links: + if not isinstance(video, dict): + results.append(({'meta/main.yml': data}, + "Expected item in 'video_links' to be " + "a dictionary")) + continue + + if set(video) != {'url', 'title', '__file__', '__line__'}: + results.append(({'meta/main.yml': data}, + "Expected item in 'video_links' to contain " + "only keys 'url' and 'title'")) + continue + + for name, expr in self.VIDEO_REGEXP.items(): + if expr.match(video['url']): + break + else: + msg = ("URL format '{0}' is not recognized. " + "Expected it be a shared link from Vimeo, YouTube, " + "or Google Drive.".format(video['url'])) + results.append(({'meta/main.yml': data}, msg)) + + return results diff --git a/lib/ansiblelint/rules/MissingFilePermissionsRule.py b/lib/ansiblelint/rules/MissingFilePermissionsRule.py new file mode 100644 index 0000000..bc11cc7 --- /dev/null +++ b/lib/ansiblelint/rules/MissingFilePermissionsRule.py @@ -0,0 +1,95 @@ +# Copyright (c) 2020 Sorin Sbarnea <sorin.sbarnea@gmail.com> +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# 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 ansiblelint.rules import AnsibleLintRule + +# Despite documentation mentioning 'preserve' only these modules support it: +_modules_with_preserve = ( + 'copy', + 'template', +) + + +class MissingFilePermissionsRule(AnsibleLintRule): + id = "208" + shortdesc = 'File permissions unset or incorrect' + description = ( + "Missing or unsupported mode parameter can cause unexpected file " + "permissions based " + "on version of Ansible being used. Be explicit, like ``mode: 0644`` to " + "avoid hitting this rule. Special ``preserve`` value is accepted " + f"only by {', '.join(_modules_with_preserve)} modules. " + "See https://github.com/ansible/ansible/issues/71200" + ) + severity = 'VERY_HIGH' + tags = ['unpredictability', 'experimental'] + version_added = 'v4.3.0' + + _modules = { + 'archive', + 'assemble', + 'copy', # supports preserve + 'file', + 'replace', # implicit preserve behavior but mode: preserve is invalid + 'template', # supports preserve + # 'unarchive', # disabled because .tar.gz files can have permissions inside + } + + _modules_with_create = { + 'blockinfile': False, + 'htpasswd': True, + 'ini_file': True, + 'lineinfile': False, + } + + def matchtask(self, file, task): + module = task["action"]["__ansible_module__"] + mode = task['action'].get('mode', None) + + if module not in self._modules and \ + module not in self._modules_with_create: + return False + + if mode == 'preserve' and module not in _modules_with_preserve: + return True + + if module in self._modules_with_create: + create = task["action"].get("create", self._modules_with_create[module]) + return create and mode is None + + # A file that doesn't exist cannot have a mode + if task['action'].get('state', None) == "absent": + return False + + # A symlink always has mode 0o777 + if task['action'].get('state', None) == "link": + return False + + # The file module does not create anything when state==file (default) + if module == "file" and \ + task['action'].get('state', 'file') == 'file': + return False + + # replace module is the only one that has a valid default preserve + # behavior, but we want to trigger rule if user used incorrect + # documentation and put 'preserve', which is not supported. + if module == 'replace' and mode is None: + return False + + return mode is None diff --git a/lib/ansiblelint/rules/NestedJinjaRule.py b/lib/ansiblelint/rules/NestedJinjaRule.py new file mode 100644 index 0000000..c10d4ec --- /dev/null +++ b/lib/ansiblelint/rules/NestedJinjaRule.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- +# Author: Adrián Tóth <adtoth@redhat.com> +# +# Copyright (c) 2020, Red Hat, Inc. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# 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. + +import re + +from ansiblelint.rules import AnsibleLintRule + + +class NestedJinjaRule(AnsibleLintRule): + id = '207' + shortdesc = 'Nested jinja pattern' + description = ( + "There should not be any nested jinja pattern. " + "Example (bad): ``{{ list_one + {{ list_two | max }} }}``, " + "example (good): ``{{ list_one + max(list_two) }}``" + ) + severity = 'VERY_HIGH' + tags = ['formatting'] + version_added = 'v4.3.0' + + pattern = re.compile(r"{{(?:[^{}]*)?{{") + + def matchtask(self, file, task): + + command = "".join( + str(value) + # task properties are stored in the 'action' key + for key, value in task['action'].items() + # exclude useless values of '__file__', '__ansible_module__', '__*__', etc. + if not key.startswith('__') and not key.endswith('__') + ) + + return bool(self.pattern.search(command)) diff --git a/lib/ansiblelint/rules/NoFormattingInWhenRule.py b/lib/ansiblelint/rules/NoFormattingInWhenRule.py new file mode 100644 index 0000000..a665311 --- /dev/null +++ b/lib/ansiblelint/rules/NoFormattingInWhenRule.py @@ -0,0 +1,34 @@ +from ansiblelint.rules import AnsibleLintRule + + +class NoFormattingInWhenRule(AnsibleLintRule): + id = '102' + shortdesc = 'No Jinja2 in when' + description = '``when`` lines should not include Jinja2 variables' + severity = 'HIGH' + tags = ['deprecated', 'ANSIBLE0019'] + version_added = 'historic' + + def _is_valid(self, when): + if not isinstance(when, str): + return True + return when.find('{{') == -1 and when.find('}}') == -1 + + def matchplay(self, file, play): + errors = [] + if isinstance(play, dict): + if 'roles' not in play or play['roles'] is None: + return errors + for role in play['roles']: + if self.matchtask(file, role): + errors.append(({'when': role}, + 'role "when" clause has Jinja2 templates')) + if isinstance(play, list): + for play_item in play: + sub_errors = self.matchplay(file, play_item) + if sub_errors: + errors = errors + sub_errors + return errors + + def matchtask(self, file, task): + return 'when' in task and not self._is_valid(task['when']) diff --git a/lib/ansiblelint/rules/NoTabsRule.py b/lib/ansiblelint/rules/NoTabsRule.py new file mode 100644 index 0000000..78222c8 --- /dev/null +++ b/lib/ansiblelint/rules/NoTabsRule.py @@ -0,0 +1,16 @@ +# Copyright (c) 2016, Will Thames and contributors +# Copyright (c) 2018, Ansible Project + +from ansiblelint.rules import AnsibleLintRule + + +class NoTabsRule(AnsibleLintRule): + id = '203' + shortdesc = 'Most files should not contain tabs' + description = 'Tabs can cause unexpected display issues, use spaces' + severity = 'LOW' + tags = ['formatting'] + version_added = 'v4.0.0' + + def match(self, file, line): + return '\t' in line diff --git a/lib/ansiblelint/rules/OctalPermissionsRule.py b/lib/ansiblelint/rules/OctalPermissionsRule.py new file mode 100644 index 0000000..b95c322 --- /dev/null +++ b/lib/ansiblelint/rules/OctalPermissionsRule.py @@ -0,0 +1,73 @@ +# Copyright (c) 2013-2014 Will Thames <will@thames.id.au> +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# 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 ansiblelint.rules import AnsibleLintRule + + +class OctalPermissionsRule(AnsibleLintRule): + id = '202' + shortdesc = 'Octal file permissions must contain leading zero or be a string' + description = ( + 'Numeric file permissions without leading zero can behave ' + 'in unexpected ways. See ' + 'http://docs.ansible.com/ansible/file_module.html' + ) + severity = 'VERY_HIGH' + tags = ['formatting', 'ANSIBLE0009'] + version_added = 'historic' + + _modules = ['assemble', 'copy', 'file', 'ini_file', 'lineinfile', + 'replace', 'synchronize', 'template', 'unarchive'] + + def is_invalid_permission(self, mode): + # sensible file permission modes don't + # have write bit set when read bit is + # not set and don't have execute bit set + # when user execute bit is not set. + # also, user permissions are more generous than + # group permissions and user and group permissions + # are more generous than world permissions + + other_write_without_read = (mode % 8 and mode % 8 < 4 and + not (mode % 8 == 1 and (mode >> 6) % 2 == 1)) + group_write_without_read = ((mode >> 3) % 8 and (mode >> 3) % 8 < 4 and + not ((mode >> 3) % 8 == 1 and (mode >> 6) % 2 == 1)) + user_write_without_read = ((mode >> 6) % 8 and (mode >> 6) % 8 < 4 and + not (mode >> 6) % 8 == 1) + other_more_generous_than_group = mode % 8 > (mode >> 3) % 8 + other_more_generous_than_user = mode % 8 > (mode >> 6) % 8 + group_more_generous_than_user = (mode >> 3) % 8 > (mode >> 6) % 8 + + return (other_write_without_read or + group_write_without_read or + user_write_without_read or + other_more_generous_than_group or + other_more_generous_than_user or + group_more_generous_than_user) + + def matchtask(self, file, task): + if task["action"]["__ansible_module__"] in self._modules: + mode = task['action'].get('mode', None) + + if isinstance(mode, str): + return False + + if isinstance(mode, int): + return self.is_invalid_permission(mode) diff --git a/lib/ansiblelint/rules/PackageIsNotLatestRule.py b/lib/ansiblelint/rules/PackageIsNotLatestRule.py new file mode 100644 index 0000000..9fddaf4 --- /dev/null +++ b/lib/ansiblelint/rules/PackageIsNotLatestRule.py @@ -0,0 +1,67 @@ +# Copyright (c) 2016 Will Thames <will@thames.id.au> +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# 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 ansiblelint.rules import AnsibleLintRule + + +class PackageIsNotLatestRule(AnsibleLintRule): + id = '403' + shortdesc = 'Package installs should not use latest' + description = ( + 'Package installs should use ``state=present`` ' + 'with or without a version' + ) + severity = 'VERY_LOW' + tags = ['module', 'repeatability', 'ANSIBLE0010'] + version_added = 'historic' + + _package_managers = [ + 'apk', + 'apt', + 'bower', + 'bundler', + 'dnf', + 'easy_install', + 'gem', + 'homebrew', + 'jenkins_plugin', + 'npm', + 'openbsd_package', + 'openbsd_pkg', + 'package', + 'pacman', + 'pear', + 'pip', + 'pkg5', + 'pkgutil', + 'portage', + 'slackpkg', + 'sorcery', + 'swdepot', + 'win_chocolatey', + 'yarn', + 'yum', + 'zypper', + ] + + def matchtask(self, file, task): + return (task['action']['__ansible_module__'] in self._package_managers and + not task['action'].get('version') and + task['action'].get('state') == 'latest') diff --git a/lib/ansiblelint/rules/PlaybookExtension.py b/lib/ansiblelint/rules/PlaybookExtension.py new file mode 100644 index 0000000..593e5ae --- /dev/null +++ b/lib/ansiblelint/rules/PlaybookExtension.py @@ -0,0 +1,28 @@ +# Copyright (c) 2016, Tsukinowa Inc. <info@tsukinowa.jp> +# Copyright (c) 2018, Ansible Project + +import os +from typing import List + +from ansiblelint.rules import AnsibleLintRule + + +class PlaybookExtension(AnsibleLintRule): + id = '205' + shortdesc = 'Use ".yml" or ".yaml" playbook extension' + description = 'Playbooks should have the ".yml" or ".yaml" extension' + severity = 'MEDIUM' + tags = ['formatting'] + done = [] # type: List # already noticed path list + version_added = 'v4.0.0' + + def match(self, file, text): + if file['type'] != 'playbook': + return False + + path = file['path'] + ext = os.path.splitext(path) + if ext[1] not in ['.yml', '.yaml'] and path not in self.done: + self.done.append(path) + return True + return False diff --git a/lib/ansiblelint/rules/RoleNames.py b/lib/ansiblelint/rules/RoleNames.py new file mode 100644 index 0000000..3d790b3 --- /dev/null +++ b/lib/ansiblelint/rules/RoleNames.py @@ -0,0 +1,74 @@ +# Copyright (c) 2020 Gael Chamoulaud <gchamoul@redhat.com> +# Copyright (c) 2020 Sorin Sbarnea <ssbarnea@redhat.com> +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# 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. + +import re +from pathlib import Path +from typing import List + +from ansiblelint.rules import AnsibleLintRule +from ansiblelint.utils import parse_yaml_from_file + +ROLE_NAME_REGEX = '^[a-z][a-z0-9_]+$' + + +def _remove_prefix(text, prefix): + return re.sub(r'^{0}'.format(re.escape(prefix)), '', text) + + +class RoleNames(AnsibleLintRule): + id = '106' + shortdesc = ( + "Role name {} does not match ``%s`` pattern" % ROLE_NAME_REGEX + ) + description = ( + "Role names are now limited to contain only lowercase alphanumeric " + "characters, plus '_' and start with an alpha character. See " + "`developing collections <https://docs.ansible.com/ansible/devel/dev_guide/developing_" + "collections.html#roles-directory>`_" + ) + severity = 'HIGH' + done: List[str] = [] # already noticed roles list + tags = ['deprecated'] + version_added = 'v4.3.0' + + ROLE_NAME_REGEXP = re.compile(ROLE_NAME_REGEX) + + def match(self, file, text): + path = file['path'].split("/") + if "tasks" in path: + role_name = _remove_prefix(path[path.index("tasks") - 1], "ansible-role-") + role_root = path[:path.index("tasks")] + meta = Path("/".join(role_root)) / "meta" / "main.yml" + + if meta.is_file(): + meta_data = parse_yaml_from_file(str(meta)) + if meta_data: + try: + role_name = meta_data['galaxy_info']['role_name'] + except KeyError: + pass + + if role_name in self.done: + return False + self.done.append(role_name) + if not re.match(self.ROLE_NAME_REGEXP, role_name): + return self.shortdesc.format(role_name) + return False diff --git a/lib/ansiblelint/rules/RoleRelativePath.py b/lib/ansiblelint/rules/RoleRelativePath.py new file mode 100644 index 0000000..87d7ac8 --- /dev/null +++ b/lib/ansiblelint/rules/RoleRelativePath.py @@ -0,0 +1,32 @@ +# Copyright (c) 2016, Tsukinowa Inc. <info@tsukinowa.jp> +# Copyright (c) 2018, Ansible Project + +from ansiblelint.rules import AnsibleLintRule + + +class RoleRelativePath(AnsibleLintRule): + id = '404' + shortdesc = "Doesn't need a relative path in role" + description = '``copy`` and ``template`` do not need to use relative path for ``src``' + severity = 'HIGH' + tags = ['module'] + version_added = 'v4.0.0' + + _module_to_path_folder = { + 'copy': 'files', + 'win_copy': 'files', + 'template': 'templates', + 'win_template': 'win_templates', + } + + def matchtask(self, file, task): + module = task['action']['__ansible_module__'] + if module not in self._module_to_path_folder: + return False + + if 'src' not in task['action']: + return False + + path_to_check = '../{}'.format(self._module_to_path_folder[module]) + if path_to_check in task['action']['src']: + return True diff --git a/lib/ansiblelint/rules/ShellWithoutPipefail.py b/lib/ansiblelint/rules/ShellWithoutPipefail.py new file mode 100644 index 0000000..678e5a2 --- /dev/null +++ b/lib/ansiblelint/rules/ShellWithoutPipefail.py @@ -0,0 +1,38 @@ +import re + +from ansiblelint.rules import AnsibleLintRule + + +class ShellWithoutPipefail(AnsibleLintRule): + id = '306' + shortdesc = 'Shells that use pipes should set the pipefail option' + description = ( + 'Without the pipefail option set, a shell command that ' + 'implements a pipeline can fail and still return 0. If ' + 'any part of the pipeline other than the terminal command ' + 'fails, the whole pipeline will still return 0, which may ' + 'be considered a success by Ansible. ' + 'Pipefail is available in the bash shell.' + ) + severity = 'MEDIUM' + tags = ['command-shell'] + version_added = 'v4.1.0' + + _pipefail_re = re.compile(r"^\s*set.*[+-][A-z]*o\s*pipefail") + _pipe_re = re.compile(r"(?<!\|)\|(?!\|)") + + def matchtask(self, file, task): + if task["__ansible_action_type__"] != "task": + return False + + if task["action"]["__ansible_module__"] != "shell": + return False + + if task.get("ignore_errors"): + return False + + unjinjad_cmd = self.unjinja( + ' '.join(task["action"].get("__ansible_arguments__", []))) + + return (self._pipe_re.search(unjinjad_cmd) and + not self._pipefail_re.match(unjinjad_cmd)) diff --git a/lib/ansiblelint/rules/SudoRule.py b/lib/ansiblelint/rules/SudoRule.py new file mode 100644 index 0000000..8ea554e --- /dev/null +++ b/lib/ansiblelint/rules/SudoRule.py @@ -0,0 +1,36 @@ +from ansiblelint.rules import AnsibleLintRule + + +class SudoRule(AnsibleLintRule): + id = '103' + shortdesc = 'Deprecated sudo' + description = 'Instead of ``sudo``/``sudo_user``, use ``become``/``become_user``.' + severity = 'VERY_HIGH' + tags = ['deprecated', 'ANSIBLE0008'] + version_added = 'historic' + + def _check_value(self, play_frag): + results = [] + + if isinstance(play_frag, dict): + if 'sudo' in play_frag: + results.append(({'sudo': play_frag['sudo']}, + 'Deprecated sudo feature', play_frag['__line__'])) + if 'sudo_user' in play_frag: + results.append(({'sudo_user': play_frag['sudo_user']}, + 'Deprecated sudo_user feature', play_frag['__line__'])) + if 'tasks' in play_frag: + output = self._check_value(play_frag['tasks']) + if output: + results += output + + if isinstance(play_frag, list): + for item in play_frag: + output = self._check_value(item) + if output: + results += output + + return results + + def matchplay(self, file, play): + return self._check_value(play) diff --git a/lib/ansiblelint/rules/TaskHasNameRule.py b/lib/ansiblelint/rules/TaskHasNameRule.py new file mode 100644 index 0000000..8757b03 --- /dev/null +++ b/lib/ansiblelint/rules/TaskHasNameRule.py @@ -0,0 +1,40 @@ +# Copyright (c) 2016 Will Thames <will@thames.id.au> +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# 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 ansiblelint.rules import AnsibleLintRule + + +class TaskHasNameRule(AnsibleLintRule): + id = '502' + shortdesc = 'All tasks should be named' + description = ( + 'All tasks should have a distinct name for readability ' + 'and for ``--start-at-task`` to work' + ) + severity = 'MEDIUM' + tags = ['task', 'readability', 'ANSIBLE0011'] + version_added = 'historic' + + _nameless_tasks = ['meta', 'debug', 'include_role', 'import_role', + 'include_tasks', 'import_tasks'] + + def matchtask(self, file, task): + return (not task.get('name') and + task["action"]["__ansible_module__"] not in self._nameless_tasks) diff --git a/lib/ansiblelint/rules/TaskNoLocalAction.py b/lib/ansiblelint/rules/TaskNoLocalAction.py new file mode 100644 index 0000000..294bb9d --- /dev/null +++ b/lib/ansiblelint/rules/TaskNoLocalAction.py @@ -0,0 +1,18 @@ +# Copyright (c) 2016, Tsukinowa Inc. <info@tsukinowa.jp> +# Copyright (c) 2018, Ansible Project + +from ansiblelint.rules import AnsibleLintRule + + +class TaskNoLocalAction(AnsibleLintRule): + id = '504' + shortdesc = "Do not use 'local_action', use 'delegate_to: localhost'" + description = 'Do not use ``local_action``, use ``delegate_to: localhost``' + severity = 'MEDIUM' + tags = ['task'] + version_added = 'v4.0.0' + + def match(self, file, text): + if 'local_action' in text: + return True + return False diff --git a/lib/ansiblelint/rules/TrailingWhitespaceRule.py b/lib/ansiblelint/rules/TrailingWhitespaceRule.py new file mode 100644 index 0000000..ac0f1c2 --- /dev/null +++ b/lib/ansiblelint/rules/TrailingWhitespaceRule.py @@ -0,0 +1,34 @@ +# Copyright (c) 2013-2014 Will Thames <will@thames.id.au> +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# 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 ansiblelint.rules import AnsibleLintRule + + +class TrailingWhitespaceRule(AnsibleLintRule): + id = '201' + shortdesc = 'Trailing whitespace' + description = 'There should not be any trailing whitespace' + severity = 'INFO' + tags = ['formatting', 'ANSIBLE0002'] + version_added = 'historic' + + def match(self, file, line): + line = line.replace("\r", "") + return line.rstrip() != line diff --git a/lib/ansiblelint/rules/UseCommandInsteadOfShellRule.py b/lib/ansiblelint/rules/UseCommandInsteadOfShellRule.py new file mode 100644 index 0000000..48babcf --- /dev/null +++ b/lib/ansiblelint/rules/UseCommandInsteadOfShellRule.py @@ -0,0 +1,45 @@ +# Copyright (c) 2016 Will Thames <will@thames.id.au> +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# 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 ansiblelint.rules import AnsibleLintRule + + +class UseCommandInsteadOfShellRule(AnsibleLintRule): + id = '305' + shortdesc = 'Use shell only when shell functionality is required' + description = ( + 'Shell should only be used when piping, redirecting ' + 'or chaining commands (and Ansible would be preferred ' + 'for some of those!)' + ) + severity = 'HIGH' + tags = ['command-shell', 'safety', 'ANSIBLE0013'] + version_added = 'historic' + + def matchtask(self, file, task): + # Use unjinja so that we don't match on jinja filters + # rather than pipes + if task["action"]["__ansible_module__"] == 'shell': + if 'cmd' in task['action']: + unjinjad_cmd = self.unjinja(task["action"].get("cmd", [])) + else: + unjinjad_cmd = self.unjinja( + ' '.join(task["action"].get("__ansible_arguments__", []))) + return not any([ch in unjinjad_cmd for ch in '&|<>;$\n*[]{}?`']) diff --git a/lib/ansiblelint/rules/UseHandlerRatherThanWhenChangedRule.py b/lib/ansiblelint/rules/UseHandlerRatherThanWhenChangedRule.py new file mode 100644 index 0000000..53b389d --- /dev/null +++ b/lib/ansiblelint/rules/UseHandlerRatherThanWhenChangedRule.py @@ -0,0 +1,52 @@ +# Copyright (c) 2016 Will Thames <will@thames.id.au> +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# 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 ansiblelint.rules import AnsibleLintRule + + +def _changed_in_when(item): + if not isinstance(item, str): + return False + return any(changed in item for changed in + ['.changed', '|changed', '["changed"]', "['changed']"]) + + +class UseHandlerRatherThanWhenChangedRule(AnsibleLintRule): + id = '503' + shortdesc = 'Tasks that run when changed should likely be handlers' + description = ( + 'If a task has a ``when: result.changed`` setting, it is effectively ' + 'acting as a handler' + ) + severity = 'MEDIUM' + tags = ['task', 'behaviour', 'ANSIBLE0016'] + version_added = 'historic' + + def matchtask(self, file, task): + if task["__ansible_action_type__"] != 'task': + return False + + when = task.get('when') + + if isinstance(when, list): + for item in when: + return _changed_in_when(item) + else: + return _changed_in_when(when) diff --git a/lib/ansiblelint/rules/UsingBareVariablesIsDeprecatedRule.py b/lib/ansiblelint/rules/UsingBareVariablesIsDeprecatedRule.py new file mode 100644 index 0000000..a0721ac --- /dev/null +++ b/lib/ansiblelint/rules/UsingBareVariablesIsDeprecatedRule.py @@ -0,0 +1,75 @@ +# Copyright (c) 2013-2014 Will Thames <will@thames.id.au> +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# 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. + +import os +import re + +from ansiblelint.rules import AnsibleLintRule + + +class UsingBareVariablesIsDeprecatedRule(AnsibleLintRule): + id = '104' + shortdesc = 'Using bare variables is deprecated' + description = ( + 'Using bare variables is deprecated. Update your ' + 'playbooks so that the environment value uses the full variable ' + 'syntax ``{{ your_variable }}``' + ) + severity = 'VERY_HIGH' + tags = ['deprecated', 'formatting', 'ANSIBLE0015'] + version_added = 'historic' + + _jinja = re.compile(r"{{.*}}", re.DOTALL) + _glob = re.compile('[][*?]') + + def matchtask(self, file, task): + loop_type = next((key for key in task + if key.startswith("with_")), None) + if loop_type: + if loop_type in ["with_nested", "with_together", "with_flattened", "with_filetree"]: + # These loops can either take a list defined directly in the task + # or a variable that is a list itself. When a single variable is used + # 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)): + items = [items] + for var in items: + return self._matchvar(var, task, loop_type) + elif loop_type == "with_subelements": + return self._matchvar(task[loop_type][0], task, loop_type) + elif loop_type in ["with_sequence", "with_ini", + "with_inventory_hostnames"]: + pass + else: + return self._matchvar(task[loop_type], task, loop_type) + + def _matchvar(self, varstring, task, loop_type): + if (isinstance(varstring, str) and + not self._jinja.match(varstring)): + valid = loop_type == 'with_fileglob' and bool(self._jinja.search(varstring) or + self._glob.search(varstring)) + + valid |= loop_type == 'with_filetree' and bool(self._jinja.search(varstring) or + varstring.endswith(os.sep)) + if not valid: + message = "Found a bare variable '{0}' used in a '{1}' loop." + \ + " You should use the full variable syntax ('{{{{ {0} }}}}')" + return message.format(task[loop_type], loop_type) diff --git a/lib/ansiblelint/rules/VariableHasSpacesRule.py b/lib/ansiblelint/rules/VariableHasSpacesRule.py new file mode 100644 index 0000000..dd4f441 --- /dev/null +++ b/lib/ansiblelint/rules/VariableHasSpacesRule.py @@ -0,0 +1,24 @@ +# Copyright (c) 2016, Will Thames and contributors +# Copyright (c) 2018, Ansible Project + +import re + +from ansiblelint.rules import AnsibleLintRule + + +class VariableHasSpacesRule(AnsibleLintRule): + id = '206' + shortdesc = 'Variables should have spaces before and after: {{ var_name }}' + description = 'Variables should have spaces before and after: ``{{ var_name }}``' + severity = 'LOW' + tags = ['formatting'] + version_added = 'v4.0.0' + + variable_syntax = re.compile(r"{{.*}}") + bracket_regex = re.compile(r"{{[^{' -]|[^ '}-]}}") + + def match(self, file, line): + if not self.variable_syntax.search(line): + return + line_exclude_json = re.sub(r"[^{]{'\w+': ?[^{]{.*?}}", "", line) + return self.bracket_regex.search(line_exclude_json) diff --git a/lib/ansiblelint/rules/__init__.py b/lib/ansiblelint/rules/__init__.py new file mode 100644 index 0000000..fd3e92d --- /dev/null +++ b/lib/ansiblelint/rules/__init__.py @@ -0,0 +1,254 @@ +"""All internal ansible-lint rules.""" +import glob +import importlib.util +import logging +import os +import re +from collections import defaultdict +from importlib.abc import Loader +from time import sleep +from typing import List + +import ansiblelint.utils +from ansiblelint.errors import MatchError +from ansiblelint.skip_utils import append_skipped_rules, get_rule_skips_from_line + +_logger = logging.getLogger(__name__) + + +class AnsibleLintRule(object): + + def __repr__(self) -> str: + """Return a AnsibleLintRule instance representation.""" + return self.id + ": " + self.shortdesc + + def verbose(self) -> str: + return self.id + ": " + self.shortdesc + "\n " + self.description + + id: str = "" + tags: List[str] = [] + shortdesc: str = "" + description: str = "" + version_added: str = "" + severity: str = "" + match = None + matchtask = None + matchplay = None + + @staticmethod + def unjinja(text): + text = re.sub(r"{{.+?}}", "JINJA_EXPRESSION", text) + text = re.sub(r"{%.+?%}", "JINJA_STATEMENT", text) + text = re.sub(r"{#.+?#}", "JINJA_COMMENT", text) + return text + + def matchlines(self, file, text) -> List[MatchError]: + matches: List[MatchError] = [] + if not self.match: + return matches + # arrays are 0-based, line numbers are 1-based + # so use prev_line_no as the counter + for (prev_line_no, line) in enumerate(text.split("\n")): + if line.lstrip().startswith('#'): + continue + + rule_id_list = get_rule_skips_from_line(line) + if self.id in rule_id_list: + continue + + result = self.match(file, line) + if not result: + continue + message = None + if isinstance(result, str): + message = result + m = MatchError( + message=message, + linenumber=prev_line_no + 1, + details=line, + filename=file['path'], + rule=self) + matches.append(m) + return matches + + # TODO(ssbarnea): Reduce mccabe complexity + # https://github.com/ansible/ansible-lint/issues/744 + def matchtasks(self, file: str, text: str) -> List[MatchError]: # noqa: C901 + matches: List[MatchError] = [] + if not self.matchtask: + return matches + + if file['type'] == 'meta': + return matches + + yaml = ansiblelint.utils.parse_yaml_linenumbers(text, file['path']) + if not yaml: + return matches + + yaml = append_skipped_rules(yaml, text, file['type']) + + try: + tasks = ansiblelint.utils.get_normalized_tasks(yaml, file) + except MatchError as e: + return [e] + + for task in tasks: + if self.id in task.get('skipped_rules', ()): + continue + + if 'action' not in task: + continue + result = self.matchtask(file, task) + if not result: + continue + + message = None + if isinstance(result, str): + message = result + task_msg = "Task/Handler: " + ansiblelint.utils.task_to_str(task) + m = MatchError( + message=message, + linenumber=task[ansiblelint.utils.LINE_NUMBER_KEY], + details=task_msg, + filename=file['path'], + rule=self) + matches.append(m) + return matches + + @staticmethod + def _matchplay_linenumber(play, optional_linenumber): + try: + linenumber, = optional_linenumber + except ValueError: + linenumber = play[ansiblelint.utils.LINE_NUMBER_KEY] + return linenumber + + def matchyaml(self, file: str, text: str) -> List[MatchError]: + matches: List[MatchError] = [] + if not self.matchplay: + return matches + + yaml = ansiblelint.utils.parse_yaml_linenumbers(text, file['path']) + if not yaml: + return matches + + if isinstance(yaml, dict): + yaml = [yaml] + + yaml = ansiblelint.skip_utils.append_skipped_rules(yaml, text, file['type']) + + for play in yaml: + if self.id in play.get('skipped_rules', ()): + continue + + result = self.matchplay(file, play) + if not result: + continue + + if isinstance(result, tuple): + result = [result] + + if not isinstance(result, list): + raise TypeError("{} is not a list".format(result)) + + for section, message, *optional_linenumber in result: + linenumber = self._matchplay_linenumber(play, optional_linenumber) + m = MatchError( + message=message, + linenumber=linenumber, + details=str(section), + filename=file['path'], + rule=self) + matches.append(m) + return matches + + +def load_plugins(directory: str) -> List[AnsibleLintRule]: + """Return a list of rule classes.""" + result = [] + + for pluginfile in glob.glob(os.path.join(directory, '[A-Za-z]*.py')): + + pluginname = os.path.basename(pluginfile.replace('.py', '')) + spec = importlib.util.spec_from_file_location(pluginname, pluginfile) + # https://github.com/python/typeshed/issues/2793 + if spec and isinstance(spec.loader, Loader): + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + obj = getattr(module, pluginname)() + result.append(obj) + return result + + +class RulesCollection(object): + + def __init__(self, rulesdirs=None) -> None: + """Initialize a RulesCollection instance.""" + if rulesdirs is None: + rulesdirs = [] + self.rulesdirs = ansiblelint.utils.expand_paths_vars(rulesdirs) + self.rules: List[AnsibleLintRule] = [] + for rulesdir in self.rulesdirs: + _logger.debug("Loading rules from %s", rulesdir) + self.extend(load_plugins(rulesdir)) + self.rules = sorted(self.rules, key=lambda r: r.id) + + def register(self, obj: AnsibleLintRule): + self.rules.append(obj) + + def __iter__(self): + """Return the iterator over the rules in the RulesCollection.""" + return iter(self.rules) + + def __len__(self): + """Return the length of the RulesCollection data.""" + return len(self.rules) + + def extend(self, more: List[AnsibleLintRule]) -> None: + self.rules.extend(more) + + def run(self, playbookfile, tags=set(), skip_list=frozenset()) -> List: + text = "" + matches: List = list() + + for i in range(3): + try: + with open(playbookfile['path'], mode='r', encoding='utf-8') as f: + text = f.read() + break + except IOError as e: + _logger.warning( + "Couldn't open %s - %s [try:%s]", + playbookfile['path'], + e.strerror, + i) + sleep(1) + continue + if i and not text: + return matches + + for rule in self.rules: + if not 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.matchlines(playbookfile, text)) + matches.extend(rule.matchtasks(playbookfile, text)) + matches.extend(rule.matchyaml(playbookfile, text)) + + return matches + + def __repr__(self) -> str: + """Return a RulesCollection instance representation.""" + return "\n".join([rule.verbose() + for rule in sorted(self.rules, key=lambda x: x.id)]) + + def listtags(self) -> str: + tags = defaultdict(list) + for rule in self.rules: + for tag in rule.tags: + tags[tag].append("[{0}]".format(rule.id)) + results = [] + for tag in sorted(tags): + results.append("{0} {1}".format(tag, tags[tag])) + return "\n".join(results) diff --git a/lib/ansiblelint/rules/custom/__init__.py b/lib/ansiblelint/rules/custom/__init__.py new file mode 100644 index 0000000..8c3e048 --- /dev/null +++ b/lib/ansiblelint/rules/custom/__init__.py @@ -0,0 +1 @@ +"""A placeholder package for putting custom rules under this dir.""" diff --git a/lib/ansiblelint/runner.py b/lib/ansiblelint/runner.py new file mode 100644 index 0000000..f73945f --- /dev/null +++ b/lib/ansiblelint/runner.py @@ -0,0 +1,111 @@ +"""Runner implementation.""" +import logging +import os +from typing import TYPE_CHECKING, Any, FrozenSet, Generator, List, Optional, Set + +import ansiblelint.file_utils +import ansiblelint.skip_utils +import ansiblelint.utils +from ansiblelint.errors import MatchError +from ansiblelint.rules.LoadingFailureRule import LoadingFailureRule + +if TYPE_CHECKING: + from ansiblelint.rules import RulesCollection + + +_logger = logging.getLogger(__name__) + + +class Runner(object): + """Runner class performs the linting process.""" + + def __init__( + self, + rules: "RulesCollection", + playbook: str, + tags: FrozenSet[Any] = frozenset(), + skip_list: Optional[FrozenSet[Any]] = frozenset(), + exclude_paths: List[str] = [], + verbosity: int = 0, + checked_files: Set[str] = None) -> None: + """Initialize a Runner instance.""" + self.rules = rules + self.playbooks = set() + # assume role if directory + if os.path.isdir(playbook): + self.playbooks.add((os.path.join(playbook, ''), 'role')) + self.playbook_dir = playbook + else: + self.playbooks.add((playbook, 'playbook')) + self.playbook_dir = os.path.dirname(playbook) + self.tags = tags + self.skip_list = skip_list + self._update_exclude_paths(exclude_paths) + self.verbosity = verbosity + if checked_files is None: + checked_files = set() + self.checked_files = checked_files + + def _update_exclude_paths(self, exclude_paths: List[str]) -> None: + if exclude_paths: + # These will be (potentially) relative paths + paths = ansiblelint.utils.expand_paths_vars(exclude_paths) + # Since ansiblelint.utils.find_children returns absolute paths, + # and the list of files we create in `Runner.run` can contain both + # relative and absolute paths, we need to cover both bases. + self.exclude_paths = paths + [os.path.abspath(p) for p in paths] + else: + self.exclude_paths = [] + + def is_excluded(self, file_path: str) -> bool: + """Verify if a file path should be excluded.""" + # Any will short-circuit as soon as something returns True, but will + # be poor performance for the case where the path under question is + # not excluded. + return any(file_path.startswith(path) for path in self.exclude_paths) + + def run(self) -> List[MatchError]: + """Execute the linting process.""" + files = list() + for playbook in self.playbooks: + if self.is_excluded(playbook[0]) or playbook[1] == 'role': + continue + files.append({'path': ansiblelint.file_utils.normpath(playbook[0]), + 'type': playbook[1], + # add an absolute path here, so rules are able to validate if + # referenced files exist + 'absolute_directory': os.path.dirname(playbook[0])}) + matches = set(self._emit_matches(files)) + + # remove duplicates from files list + files = [value for n, value in enumerate(files) if value not in files[:n]] + + # remove files that have already been checked + files = [x for x in files if x['path'] not in self.checked_files] + for file in files: + _logger.debug( + "Examining %s of type %s", + ansiblelint.file_utils.normpath(file['path']), + file['type']) + matches = matches.union( + self.rules.run(file, tags=set(self.tags), + skip_list=self.skip_list)) + # update list of checked files + self.checked_files.update([x['path'] for x in files]) + + return sorted(matches) + + def _emit_matches(self, files: List) -> Generator[MatchError, None, None]: + visited: Set = set() + while visited != self.playbooks: + for arg in self.playbooks - visited: + try: + for child in ansiblelint.utils.find_children(arg, self.playbook_dir): + if self.is_excluded(child['path']): + continue + self.playbooks.add((child['path'], child['type'])) + files.append(child) + except MatchError as e: + e.rule = LoadingFailureRule + yield e + visited.add(arg) diff --git a/lib/ansiblelint/skip_utils.py b/lib/ansiblelint/skip_utils.py new file mode 100644 index 0000000..c3c0a88 --- /dev/null +++ b/lib/ansiblelint/skip_utils.py @@ -0,0 +1,189 @@ +# (c) 2019–2020, Ansible by Red Hat +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# 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. + +"""Utils related to inline skipping of rules.""" +import logging +from functools import lru_cache +from itertools import product +from typing import Any, Generator, List, Sequence + +import ruamel.yaml + +from ansiblelint.constants import FileType + +INLINE_SKIP_FLAG = '# noqa ' + +_logger = logging.getLogger(__name__) + + +# playbook: Sequence currently expects only instances of one of the two +# classes below but we should consider avoiding this chimera. +# ruamel.yaml.comments.CommentedSeq +# ansible.parsing.yaml.objects.AnsibleSequence + + +def get_rule_skips_from_line(line: str) -> List: + """Return list of rule ids skipped via comment on the line of yaml.""" + _before_noqa, _noqa_marker, noqa_text = line.partition(INLINE_SKIP_FLAG) + return noqa_text.split() + + +def append_skipped_rules(pyyaml_data: str, file_text: str, file_type: FileType) -> Sequence: + """Append 'skipped_rules' to individual tasks or single metadata block. + + For a file, uses 2nd parser (ruamel.yaml) to pull comments out of + yaml subsets, check for '# noqa' skipped rules, and append any skips to the + original parser (pyyaml) data relied on by remainder of ansible-lint. + + :param pyyaml_data: file text parsed via ansible and pyyaml. + :param file_text: raw file text. + :param file_type: type of file: tasks, handlers or meta. + :returns: original pyyaml_data altered with a 'skipped_rules' list added + to individual tasks, or added to the single metadata block. + """ + try: + yaml_skip = _append_skipped_rules(pyyaml_data, file_text, file_type) + except RuntimeError: + # Notify user of skip error, do not stop, do not change exit code + _logger.error('Error trying to append skipped rules', exc_info=True) + return pyyaml_data + return yaml_skip + + +@lru_cache(maxsize=128) +def load_data(file_text: str) -> Any: + """Parse `file_text` as yaml and return parsed structure. + + This is the main culprit for slow performance, each rule asks for loading yaml again and again + ideally the `maxsize` on the decorator above MUST be great or equal total number of rules + :param file_text: raw text to parse + :return: Parsed yaml + """ + yaml = ruamel.yaml.YAML() + return yaml.load(file_text) + + +def _append_skipped_rules(pyyaml_data: Sequence, file_text: str, file_type: FileType) -> Sequence: + # parse file text using 2nd parser library + ruamel_data = load_data(file_text) + + if file_type == 'meta': + pyyaml_data[0]['skipped_rules'] = _get_rule_skips_from_yaml(ruamel_data) + return pyyaml_data + + # create list of blocks of tasks or nested tasks + if file_type in ('tasks', 'handlers'): + ruamel_task_blocks = ruamel_data + pyyaml_task_blocks = pyyaml_data + elif file_type in ('playbook', 'pre_tasks', 'post_tasks'): + try: + pyyaml_task_blocks = _get_task_blocks_from_playbook(pyyaml_data) + ruamel_task_blocks = _get_task_blocks_from_playbook(ruamel_data) + except (AttributeError, TypeError): + # TODO(awcrosby): running ansible-lint on any .yml file will + # assume it is a playbook, check needs to be added higher in the + # call stack, and can remove this except + return pyyaml_data + else: + raise RuntimeError('Unexpected file type: {}'.format(file_type)) + + # get tasks from blocks of tasks + pyyaml_tasks = _get_tasks_from_blocks(pyyaml_task_blocks) + 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): + + # ignore empty tasks + if not pyyaml_task and not ruamel_task: + continue + + if pyyaml_task.get('name') != ruamel_task.get('name'): + raise RuntimeError('Error in matching skip comment to a task') + pyyaml_task['skipped_rules'] = _get_rule_skips_from_yaml(ruamel_task) + + return pyyaml_data + + +def _get_task_blocks_from_playbook(playbook: Sequence) -> List: + """Return parts of playbook that contains tasks, and nested tasks. + + :param playbook: playbook yaml from yaml parser. + :returns: list of task dictionaries. + """ + PLAYBOOK_TASK_KEYWORDS = [ + 'tasks', + 'pre_tasks', + 'post_tasks', + 'handlers', + ] + + task_blocks = [] + for play, key in product(playbook, PLAYBOOK_TASK_KEYWORDS): + task_blocks.extend(play.get(key, [])) + return task_blocks + + +def _get_tasks_from_blocks(task_blocks: Sequence) -> Generator: + """Get list of tasks from list made of tasks and nested tasks.""" + NESTED_TASK_KEYS = [ + 'block', + 'always', + 'rescue', + ] + + def get_nested_tasks(task: Any) -> Generator[Any, None, None]: + return ( + subtask + for k in NESTED_TASK_KEYS if task and k in task + for subtask in task[k] + ) + + for task in task_blocks: + for sub_task in get_nested_tasks(task): + yield sub_task + yield task + + +def _get_rule_skips_from_yaml(yaml_input: Sequence) -> Sequence: + """Traverse yaml for comments with rule skips and return list of rules.""" + yaml_comment_obj_strs = [] + + def traverse_yaml(obj: Any) -> None: + yaml_comment_obj_strs.append(str(obj.ca.items)) + if isinstance(obj, dict): + for key, val in obj.items(): + if isinstance(val, (dict, list)): + traverse_yaml(val) + elif isinstance(obj, list): + for e in obj: + if isinstance(e, (dict, list)): + traverse_yaml(e) + else: + return + + traverse_yaml(yaml_input) + + rule_id_list = [] + for comment_obj_str in yaml_comment_obj_strs: + for line in comment_obj_str.split(r'\n'): + rule_id_list.extend(get_rule_skips_from_line(line)) + + return rule_id_list diff --git a/lib/ansiblelint/testing/__init__.py b/lib/ansiblelint/testing/__init__.py new file mode 100644 index 0000000..1ed686f --- /dev/null +++ b/lib/ansiblelint/testing/__init__.py @@ -0,0 +1,84 @@ +"""Test utils for ansible-lint.""" + +import os +import shutil +import subprocess +import sys +import tempfile +from typing import TYPE_CHECKING, Dict, List + +from ansible import __version__ as ansible_version_str + +from ansiblelint.runner import Runner + +if TYPE_CHECKING: + from ansiblelint.errors import MatchError + + +ANSIBLE_MAJOR_VERSION = tuple(map(int, ansible_version_str.split('.')[:2])) + + +class RunFromText(object): + """Use Runner on temp files created from unittest text snippets.""" + + def __init__(self, collection): + """Initialize a RunFromText instance with rules collection.""" + self.collection = collection + + def _call_runner(self, path) -> List["MatchError"]: + runner = Runner(self.collection, path) + return runner.run() + + def run_playbook(self, playbook_text): + """Lints received text as a playbook.""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".yml", prefix="playbook") as fp: + fp.write(playbook_text) + fp.flush() + results = self._call_runner(fp.name) + return results + + def run_role_tasks_main(self, tasks_main_text): + """Lints received text as tasks.""" + role_path = tempfile.mkdtemp(prefix='role_') + tasks_path = os.path.join(role_path, 'tasks') + os.makedirs(tasks_path) + with open(os.path.join(tasks_path, 'main.yml'), 'w') as fp: + fp.write(tasks_main_text) + results = self._call_runner(role_path) + shutil.rmtree(role_path) + return results + + def run_role_meta_main(self, meta_main_text): + """Lints received text as meta.""" + role_path = tempfile.mkdtemp(prefix='role_') + meta_path = os.path.join(role_path, 'meta') + os.makedirs(meta_path) + with open(os.path.join(meta_path, 'main.yml'), 'w') as fp: + fp.write(meta_main_text) + results = self._call_runner(role_path) + shutil.rmtree(role_path) + return results + + +def run_ansible_lint( + *argv: str, + cwd: str = None, + bin: str = None, + env: Dict[str, str] = None) -> subprocess.CompletedProcess: + """Run ansible-lint on a given path and returns its output.""" + if not bin: + bin = sys.executable + args = [sys.executable, "-m", "ansiblelint", *argv] + else: + args = [bin, *argv] + + return subprocess.run( + args, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + shell=False, # needed when command is a list + check=False, + cwd=cwd, + env=env, + universal_newlines=True + ) diff --git a/lib/ansiblelint/utils.py b/lib/ansiblelint/utils.py new file mode 100644 index 0000000..feac4d7 --- /dev/null +++ b/lib/ansiblelint/utils.py @@ -0,0 +1,836 @@ +# Copyright (c) 2013-2014 Will Thames <will@thames.id.au> +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# 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. +"""Generic utility helpers.""" + +import contextlib +import inspect +import logging +import os +import pprint +import subprocess +from argparse import Namespace +from collections import OrderedDict +from functools import lru_cache +from pathlib import Path +from typing import Callable, ItemsView, List, Optional, Tuple + +import yaml +from ansible import constants +from ansible.errors import AnsibleError, AnsibleParserError +from ansible.parsing.dataloader import DataLoader +from ansible.parsing.mod_args import ModuleArgsParser +from ansible.parsing.splitter import split_args +from ansible.parsing.yaml.constructor import AnsibleConstructor +from ansible.parsing.yaml.loader import AnsibleLoader +from ansible.parsing.yaml.objects import AnsibleSequence +from ansible.plugins.loader import add_all_plugin_dirs +from ansible.template import Templar +from yaml.composer import Composer +from yaml.representer import RepresenterError + +from ansiblelint.constants import ( + ANSIBLE_FAILURE_RC, CUSTOM_RULESDIR_ENVVAR, DEFAULT_RULESDIR, FileType, +) +from ansiblelint.errors import MatchError +from ansiblelint.file_utils import normpath + +# 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. +DEFAULT_VAULT_PASSWORD = 'x' + +PLAYBOOK_DIR = os.environ.get('ANSIBLE_PLAYBOOK_DIR', None) + + +_logger = logging.getLogger(__name__) + + +def parse_yaml_from_file(filepath: str) -> dict: + dl = DataLoader() + if hasattr(dl, 'set_vault_password'): + dl.set_vault_password(DEFAULT_VAULT_PASSWORD) + return dl.load_from_file(filepath) + + +def path_dwim(basedir: str, given: str) -> str: + dl = DataLoader() + dl.set_basedir(basedir) + return dl.path_dwim(given) + + +def ansible_template(basedir, varname, templatevars, **kwargs): + dl = DataLoader() + dl.set_basedir(basedir) + templar = Templar(dl, variables=templatevars) + return templar.template(varname, **kwargs) + + +LINE_NUMBER_KEY = '__line__' +FILENAME_KEY = '__file__' + +VALID_KEYS = [ + 'name', 'action', 'when', 'async', 'poll', 'notify', + 'first_available_file', 'include', 'include_tasks', 'import_tasks', 'import_playbook', + 'tags', 'register', 'ignore_errors', 'delegate_to', + 'local_action', 'transport', 'remote_user', 'sudo', + 'sudo_user', 'sudo_pass', 'when', 'connection', 'environment', 'args', 'always_run', + 'any_errors_fatal', 'changed_when', 'failed_when', 'check_mode', 'delay', + 'retries', 'until', 'su', 'su_user', 'su_pass', 'no_log', 'run_once', + 'become', 'become_user', 'become_method', FILENAME_KEY, +] + +BLOCK_NAME_TO_ACTION_TYPE_MAP = { + 'tasks': 'task', + 'handlers': 'handler', + 'pre_tasks': 'task', + 'post_tasks': 'task', + 'block': 'meta', + 'rescue': 'meta', + 'always': 'meta', +} + + +def tokenize(line): + 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 = list() + kwargs = dict() + nonkvfound = False + for arg in tokens[1:]: + if "=" in arg and not nonkvfound: + kv = arg.split("=", 1) + kwargs[kv[0]] = kv[1] + else: + nonkvfound = True + args.append(arg) + return (command, args, kwargs) + + +def _playbook_items(pb_data: dict) -> ItemsView: + if isinstance(pb_data, dict): + return pb_data.items() + elif not pb_data: + return [] + else: + return [item for play in pb_data for item in play.items()] + + +def _rebind_match_filename(filename: str, func) -> Callable: + def func_wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except MatchError as e: + e.filename = filename + raise e + return func_wrapper + + +def _set_collections_basedir(basedir: str): + # Sets the playbook directory as playbook_paths for the collection loader + try: + # Ansible 2.10+ + # noqa: # pylint:disable=cyclic-import,import-outside-toplevel + from ansible.utils.collection_loader import AnsibleCollectionConfig + + AnsibleCollectionConfig.playbook_paths = basedir + except ImportError: + # Ansible 2.8 or 2.9 + # noqa: # pylint:disable=cyclic-import,import-outside-toplevel + from ansible.utils.collection_loader import set_collection_playbook_paths + + set_collection_playbook_paths(basedir) + + +def find_children(playbook: Tuple[str, str], playbook_dir: str) -> List: + if not os.path.exists(playbook[0]): + return [] + _set_collections_basedir(playbook_dir or '.') + add_all_plugin_dirs(playbook_dir or '.') + if playbook[1] == 'role': + playbook_ds = {'roles': [{'role': playbook[0]}]} + else: + try: + playbook_ds = parse_yaml_from_file(playbook[0]) + except AnsibleError as e: + raise SystemExit(str(e)) + results = [] + basedir = os.path.dirname(playbook[0]) + items = _playbook_items(playbook_ds) + for item in items: + for child in _rebind_match_filename(playbook[0], play_children)( + basedir, item, playbook[1], playbook_dir): + if "$" in child['path'] or "{{" in child['path']: + continue + valid_tokens = list() + for token in split_args(child['path']): + if '=' in token: + break + valid_tokens.append(token) + path = ' '.join(valid_tokens) + results.append({ + 'path': path_dwim(basedir, path), + 'type': child['type'] + }) + return results + + +def template(basedir, value, vars, fail_on_undefined=False, **kwargs): + try: + value = ansible_template(os.path.abspath(basedir), value, vars, + **dict(kwargs, fail_on_undefined=fail_on_undefined)) + # Hack to skip the following exception when using to_json filter on a variable. + # I guess the filter doesn't like empty vars... + except (AnsibleError, ValueError, RepresenterError): + # templating failed, so just keep value as is. + pass + return value + + +def play_children(basedir, item, parent_type, playbook_dir): + delegate_map = { + 'tasks': _taskshandlers_children, + 'pre_tasks': _taskshandlers_children, + 'post_tasks': _taskshandlers_children, + 'block': _taskshandlers_children, + 'include': _include_children, + 'import_playbook': _include_children, + 'roles': _roles_children, + 'dependencies': _roles_children, + 'handlers': _taskshandlers_children, + 'include_tasks': _include_children, + 'import_tasks': _include_children, + } + (k, v) = item + add_all_plugin_dirs(os.path.abspath(basedir)) + + if k in delegate_map: + if v: + v = template(os.path.abspath(basedir), + v, + dict(playbook_dir=PLAYBOOK_DIR or os.path.abspath(basedir)), + fail_on_undefined=False) + return delegate_map[k](basedir, k, v, parent_type) + return [] + + +def _include_children(basedir, k, v, parent_type): + # handle special case include_tasks: name=filename.yml + if k == 'include_tasks' and isinstance(v, dict) and 'file' in v: + v = v['file'] + + # handle include: filename.yml tags=blah + (command, args, kwargs) = tokenize("{0}: {1}".format(k, v)) + + result = path_dwim(basedir, args[0]) + if not os.path.exists(result): + result = path_dwim(os.path.join(os.path.dirname(basedir)), v) + return [{'path': result, 'type': parent_type}] + + +def _taskshandlers_children(basedir, k, v, parent_type: FileType) -> List: + results = [] + for th in v: + + # ignore empty tasks, `-` + if not th: + continue + + with contextlib.suppress(LookupError): + children = _get_task_handler_children_for_tasks_or_playbooks( + th, basedir, k, parent_type, + ) + results.append(children) + continue + + if 'include_role' in th or 'import_role' in th: # lgtm [py/unreachable-statement] + th = normalize_task_v2(th) + _validate_task_handler_action_for_role(th['action']) + results.extend(_roles_children(basedir, k, [th['action'].get("name")], + parent_type, + main=th['action'].get('tasks_from', 'main'))) + continue + + if 'block' not in th: + continue + + results.extend(_taskshandlers_children(basedir, k, th['block'], parent_type)) + if 'rescue' in th: + results.extend(_taskshandlers_children(basedir, k, th['rescue'], parent_type)) + if 'always' in th: + results.extend(_taskshandlers_children(basedir, k, th['always'], parent_type)) + + return results + + +def _get_task_handler_children_for_tasks_or_playbooks( + task_handler, basedir: str, k, parent_type: FileType, +) -> dict: + """Try to get children of taskhandler for include/import tasks/playbooks.""" + child_type = k if parent_type == 'playbook' else parent_type + + task_include_keys = 'include', 'include_tasks', 'import_playbook', 'import_tasks' + for task_handler_key in task_include_keys: + + with contextlib.suppress(KeyError): + + # ignore empty tasks + if not task_handler: + continue + + return { + 'path': path_dwim(basedir, task_handler[task_handler_key]), + 'type': child_type, + } + + raise LookupError( + f'The node contains none of: {", ".join(task_include_keys)}', + ) + + +def _validate_task_handler_action_for_role(th_action: dict) -> 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(f"Failed to find required 'name' key in {module!s}") + + if not isinstance(th_action['name'], str): + raise RuntimeError( + f"Value assigned to 'name' key on '{module!s}' is not a string.", + ) + + +def _roles_children(basedir: str, k, v, parent_type: FileType, main='main') -> list: + 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': + raise SystemExit('role dict {0} does not contain a "role" ' + 'or "name" key'.format(role)) + else: + results.extend(_look_for_role_files(basedir, role, main=main)) + return results + + +def _rolepath(basedir: str, role: str) -> Optional[str]: + role_path = None + + possible_paths = [ + # if included from a playbook + path_dwim(basedir, os.path.join('roles', role)), + path_dwim(basedir, role), + # if included from roles/[role]/meta/main.yml + path_dwim( + basedir, os.path.join('..', '..', '..', 'roles', role) + ), + path_dwim(basedir, os.path.join('..', '..', role)), + ] + + if constants.DEFAULT_ROLES_PATH: + search_locations = constants.DEFAULT_ROLES_PATH + if isinstance(search_locations, str): + search_locations = search_locations.split(os.pathsep) + for loc in search_locations: + loc = os.path.expanduser(loc) + possible_paths.append(path_dwim(loc, role)) + + possible_paths.append(path_dwim(basedir, '')) + + for path_option in possible_paths: + if os.path.isdir(path_option): + role_path = path_option + break + + if role_path: + add_all_plugin_dirs(role_path) + + return role_path + + +def _look_for_role_files(basedir: str, role: str, main='main') -> list: + role_path = _rolepath(basedir, role) + if not role_path: + return [] + + results = [] + + for th in ['tasks', 'handlers', 'meta']: + current_path = os.path.join(role_path, th) + for dir, subdirs, files in os.walk(current_path): + for file in files: + file_ignorecase = file.lower() + if file_ignorecase.endswith(('.yml', '.yaml')): + thpath = os.path.join(dir, file) + results.append({'path': thpath, 'type': th}) + + return results + + +def rolename(filepath): + idx = filepath.find('roles/') + if idx < 0: + return '' + role = filepath[idx + 6:] + role = role[:role.find('/')] + return role + + +def _kv_to_dict(v): + (command, args, kwargs) = tokenize(v) + return dict(__ansible_module__=command, __ansible_arguments__=args, **kwargs) + + +def _sanitize_task(task: dict) -> dict: + """Return a stripped-off task structure compatible with new Ansible. + + This helper takes a copy of the incoming task and drops + any internally used keys from it. + """ + result = task.copy() + # task is an AnsibleMapping which inherits from OrderedDict, so we need + # to use `del` to remove unwanted keys. + for k in ['skipped_rules', FILENAME_KEY, LINE_NUMBER_KEY]: + if k in result: + del result[k] + return result + + +# FIXME: drop noqa once this function is made simpler +# Ref: https://github.com/ansible/ansible-lint/issues/744 +def normalize_task_v2(task: dict) -> dict: # noqa: C901 + """Ensure tasks have an action key and strings are converted to python objects.""" + result = dict() + if 'always_run' in task: + # FIXME(ssbarnea): Delayed import to avoid circular import + # See https://github.com/ansible/ansible-lint/issues/880 + # noqa: # pylint:disable=cyclic-import,import-outside-toplevel + from ansiblelint.rules.AlwaysRunRule import AlwaysRunRule + + raise MatchError( + rule=AlwaysRunRule, + filename=task[FILENAME_KEY], + linenumber=task[LINE_NUMBER_KEY]) + + sanitized_task = _sanitize_task(task) + mod_arg_parser = ModuleArgsParser(sanitized_task) + try: + action, arguments, result['delegate_to'] = mod_arg_parser.parse() + except AnsibleParserError as e: + try: + task_info = "%s:%s" % (task[FILENAME_KEY], task[LINE_NUMBER_KEY]) + except KeyError: + task_info = "Unknown" + pp = pprint.PrettyPrinter(indent=2) + task_pprint = pp.pformat(sanitized_task) + + _logger.critical("Couldn't parse task at %s (%s)\n%s", task_info, e.message, task_pprint) + raise SystemExit(ANSIBLE_FAILURE_RC) + + # denormalize shell -> command conversion + if '_uses_shell' in arguments: + action = 'shell' + del arguments['_uses_shell'] + + for (k, v) in list(task.items()): + if k in ('action', 'local_action', 'args', 'delegate_to') or k == action: + # we don't want to re-assign these values, which were + # determined by the ModuleArgsParser() above + continue + else: + result[k] = v + + result['action'] = dict(__ansible_module__=action) + + if '_raw_params' in arguments: + result['action']['__ansible_arguments__'] = arguments['_raw_params'].split(' ') + del arguments['_raw_params'] + else: + result['action']['__ansible_arguments__'] = list() + + if 'argv' in arguments and not result['action']['__ansible_arguments__']: + result['action']['__ansible_arguments__'] = arguments['argv'] + del arguments['argv'] + + result['action'].update(arguments) + return result + + +# FIXME: drop noqa once this function is made simpler +# Ref: https://github.com/ansible/ansible-lint/issues/744 +def normalize_task_v1(task): # noqa: C901 + result = dict() + for (k, v) in task.items(): + if k in VALID_KEYS or k.startswith('with_'): + if k == 'local_action' or k == 'action': + if not isinstance(v, dict): + v = _kv_to_dict(v) + v['__ansible_arguments__'] = v.get('__ansible_arguments__', list()) + result['action'] = v + else: + result[k] = v + else: + if isinstance(v, str): + v = _kv_to_dict(k + ' ' + v) + elif not v: + v = dict(__ansible_module__=k) + else: + if isinstance(v, dict): + v.update(dict(__ansible_module__=k)) + else: + if k == '__line__': + # Keep the line number stored + result[k] = v + continue + + else: + # Tasks that include playbooks (rather than task files) + # can get here + # https://github.com/ansible/ansible-lint/issues/138 + raise RuntimeError("Was not expecting value %s of type %s for key %s\n" + "Task: %s. Check the syntax of your playbook using " + "ansible-playbook --syntax-check" % + (str(v), type(v), k, str(task))) + v['__ansible_arguments__'] = v.get('__ansible_arguments__', list()) + result['action'] = v + if 'module' in result['action']: + # this happens when a task uses + # local_action: + # module: ec2 + # etc... + result['action']['__ansible_module__'] = result['action']['module'] + del result['action']['module'] + if 'args' in result: + result['action'].update(result.get('args')) + del result['args'] + return result + + +def normalize_task(task, filename): + 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): + name = task.get("name") + if name: + return name + action = task.get("action") + args = " ".join([u"{0}={1}".format(k, v) for (k, v) in action.items() + if k not in ["__ansible_module__", "__ansible_arguments__"]] + + action.get("__ansible_arguments__")) + return u"{0} {1}".format(action["__ansible_module__"], args) + + +def extract_from_list(blocks, candidates): + results = list() + for block in blocks: + for candidate in candidates: + if isinstance(block, dict) and candidate in block: + if isinstance(block[candidate], list): + results.extend(add_action_type(block[candidate], candidate)) + elif block[candidate] is not None: + raise RuntimeError( + "Key '%s' defined, but bad value: '%s'" % + (candidate, str(block[candidate]))) + return results + + +def add_action_type(actions, action_type): + results = list() + for action in actions: + # ignore empty task + if not action: + continue + action['__ansible_action_type__'] = BLOCK_NAME_TO_ACTION_TYPE_MAP[action_type] + results.append(action) + return results + + +def get_action_tasks(yaml, file): + tasks = list() + if file['type'] in ['tasks', 'handlers']: + tasks = add_action_type(yaml, file['type']) + else: + tasks.extend(extract_from_list(yaml, ['tasks', 'handlers', 'pre_tasks', 'post_tasks'])) + + # Add sub-elements of block/rescue/always to tasks list + tasks.extend(extract_from_list(tasks, ['block', 'rescue', 'always'])) + # Remove block/rescue/always elements from tasks list + block_rescue_always = ('block', 'rescue', 'always') + tasks[:] = [task for task in tasks if all(k not in task for k in block_rescue_always)] + + return [task for task in tasks if + set(['include', 'include_tasks', + 'import_playbook', 'import_tasks']).isdisjoint(task.keys())] + + +def get_normalized_tasks(yaml, file): + tasks = get_action_tasks(yaml, file) + res = [] + for task in tasks: + # An empty `tags` block causes `None` to be returned if + # the `or []` is not present - `task.get('tags', [])` + # does not suffice. + if 'skip_ansible_lint' in (task.get('tags') or []): + # No need to normalize_task is we are skipping it. + continue + res.append(normalize_task(task, file['path'])) + + return res + + +@lru_cache(maxsize=128) +def parse_yaml_linenumbers(data, filename): + """Parse yaml as ansible.utils.parse_yaml but with linenumbers. + + The line numbers are stored in each node's LINE_NUMBER_KEY key. + """ + def compose_node(parent, index): + # the line number where the previous token has ended (plus empty lines) + line = loader.line + node = Composer.compose_node(loader, parent, index) + node.__line__ = line + 1 + return node + + def construct_mapping(node, deep=False): + mapping = AnsibleConstructor.construct_mapping(loader, node, deep=deep) + if hasattr(node, '__line__'): + mapping[LINE_NUMBER_KEY] = node.__line__ + else: + mapping[LINE_NUMBER_KEY] = mapping._line_number + mapping[FILENAME_KEY] = filename + return mapping + + try: + kwargs = {} + if 'vault_password' in inspect.getfullargspec(AnsibleLoader.__init__).args: + kwargs['vault_password'] = DEFAULT_VAULT_PASSWORD + loader = AnsibleLoader(data, **kwargs) + loader.compose_node = compose_node + loader.construct_mapping = construct_mapping + data = loader.get_single_data() + except (yaml.parser.ParserError, yaml.scanner.ScannerError) as e: + raise SystemExit("Failed to parse YAML in %s: %s" % (filename, str(e))) + return data + + +def get_first_cmd_arg(task): + try: + if 'cmd' in task['action']: + first_cmd_arg = task['action']['cmd'].split()[0] + else: + first_cmd_arg = task['action']['__ansible_arguments__'][0] + except IndexError: + return None + return first_cmd_arg + + +def is_playbook(filename: str) -> bool: + """ + Check if the file is a playbook. + + Given a filename, it should return true if it looks like a playbook. The + function is not supposed to raise exceptions. + """ + # we assume is a playbook if we loaded a sequence of dictionaries where + # at least one of these keys is present: + playbooks_keys = { + "gather_facts", + "hosts", + "import_playbook", + "post_tasks", + "pre_tasks", + "roles" + "tasks", + } + + # makes it work with Path objects by converting them to strings + if not isinstance(filename, str): + filename = str(filename) + + try: + f = parse_yaml_from_file(filename) + except Exception as e: + _logger.warning( + "Failed to load %s with %s, assuming is not a playbook.", + filename, e) + else: + if ( + isinstance(f, AnsibleSequence) and + hasattr(f, 'keys') and + playbooks_keys.intersection(next(iter(f), {}).keys()) + ): + return True + return False + + +def get_yaml_files(options: Namespace) -> dict: + """Find all yaml files.""" + # git is preferred as it also considers .gitignore + git_command = ['git', 'ls-files', '*.yaml', '*.yml'] + _logger.info("Discovering files to lint: %s", ' '.join(git_command)) + + out = None + + try: + out = subprocess.check_output( + git_command, + stderr=subprocess.STDOUT, + universal_newlines=True + ).split() + except subprocess.CalledProcessError as exc: + _logger.warning( + "Failed to discover yaml files to lint using git: %s", + exc.output.rstrip('\n') + ) + except FileNotFoundError as exc: + if options.verbosity: + _logger.warning( + "Failed to locate command: %s", exc + ) + + if out is None: + out = [ + os.path.join(root, name) + for root, dirs, files in os.walk('.') + for name in files + if name.endswith('.yaml') or name.endswith('.yml') + ] + + return OrderedDict.fromkeys(sorted(out)) + + +# FIXME: drop noqa once this function is made simpler +# Ref: https://github.com/ansible/ansible-lint/issues/744 +def get_playbooks_and_roles(options=None) -> List[str]: # noqa: C901 + """Find roles and playbooks.""" + if options is None: + options = {} + + files = get_yaml_files(options) + + playbooks = [] + role_dirs = [] + role_internals = { + 'defaults', + 'files', + 'handlers', + 'meta', + 'tasks', + 'templates', + 'vars', + } + + # detect role in repository root: + if 'tasks/main.yml' in files or 'tasks/main.yaml' in files: + role_dirs.append('.') + + for p in map(Path, files): + + try: + for file_path in options.exclude_paths: + if str(p.resolve()).startswith(str(file_path)): + raise FileNotFoundError( + f'File {file_path} matched exclusion entry: {p}') + except FileNotFoundError as e: + _logger.debug('Ignored %s due to: %s', p, e) + continue + + if (next((i for i in p.parts if i.endswith('playbooks')), None) or + 'playbook' in p.parts[-1]): + playbooks.append(normpath(p)) + continue + + # ignore if any folder ends with _vars + if next((i for i in p.parts if i.endswith('_vars')), None): + continue + elif 'roles' in p.parts or '.' in role_dirs: + if 'tasks' in p.parts and p.parts[-1] in ['main.yaml', 'main.yml']: + role_dirs.append(str(p.parents[1])) + continue + elif role_internals.intersection(p.parts): + continue + elif 'tests' in p.parts: + playbooks.append(normpath(p)) + if 'molecule' in p.parts: + if p.parts[-1] != 'molecule.yml': + playbooks.append(normpath(p)) + continue + # hidden files are clearly not playbooks, likely config files. + if p.parts[-1].startswith('.'): + continue + + if is_playbook(str(p)): + playbooks.append(normpath(p)) + continue + + _logger.info('Unknown file type: %s', normpath(p)) + + _logger.info('Found roles: %s', ' '.join(role_dirs)) + _logger.info('Found playbooks: %s', ' '.join(playbooks)) + + return role_dirs + playbooks + + +def expand_path_vars(path: str) -> str: + """Expand the environment or ~ variables in a path string.""" + # It may be possible for function to be called with a Path object + path = str(path).strip() + path = os.path.expanduser(path) + path = os.path.expandvars(path) + return path + + +def expand_paths_vars(paths: List[str]) -> List[str]: + """Expand the environment or ~ variables in a list.""" + paths = [expand_path_vars(p) for p in paths] + return paths + + +def get_rules_dirs(rulesdir: List[str], use_default: bool) -> List[str]: + """Return a list of rules dirs.""" + default_ruledirs = [DEFAULT_RULESDIR] + default_custom_rulesdir = os.environ.get( + CUSTOM_RULESDIR_ENVVAR, os.path.join(DEFAULT_RULESDIR, "custom") + ) + custom_ruledirs = sorted( + str(rdir.resolve()) + for rdir in Path(default_custom_rulesdir).iterdir() + if rdir.is_dir() and (rdir / "__init__.py").exists() + ) + if use_default: + return rulesdir + custom_ruledirs + default_ruledirs + + return rulesdir or custom_ruledirs + default_ruledirs diff --git a/lib/ansiblelint/version.py b/lib/ansiblelint/version.py new file mode 100644 index 0000000..7bbf973 --- /dev/null +++ b/lib/ansiblelint/version.py @@ -0,0 +1,12 @@ +"""Ansible-lint version information.""" + +try: + import pkg_resources +except ImportError: + pass + + +try: + __version__ = pkg_resources.get_distribution('ansible-lint').version +except Exception: + __version__ = 'unknown' diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..3b94f98 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,37 @@ +[mypy] +python_version = 3.6 +color_output = True +error_summary = True +disallow_untyped_calls = True +; warn_redundant_casts=True + +[mypy-ansiblelint.*] +ignore_missing_imports = True + +# 3rd party ignores +[mypy-ansible] +ignore_missing_imports = True + +[mypy-ansible.*] +ignore_missing_imports = True + +[mypy-pytest] +ignore_missing_imports = True + +[mypy-packaging.version] +ignore_missing_imports = True + +[mypy-importlib_metadata] +ignore_missing_imports = True + +[mypy-rich.*] +ignore_missing_imports = True + +[mypy-ruamel.*] +ignore_missing_imports = True + +[mypy-setuptools] +ignore_missing_imports = True + +[mypy-sphinx_ansible_theme] +ignore_missing_imports = True diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..db5f0f2 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,13 @@ +[build-system] +requires = [ + "setuptools >= 42.0.0", # required by pyproject+setuptools_scm integration + "setuptools_scm[toml] >= 3.5.0", # required for "no-local-version" scheme + "setuptools_scm_git_archive >= 1.0", + "wheel", +] +build-backend = "setuptools.build_meta" + +# ATTENTION: the following section must be kept last in +# `pyproject.toml` because our CI/CD appends one line in +# the end when publishing non-tagged versions to test.pypi.org +[tool.setuptools_scm] diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..6206f8a --- /dev/null +++ b/pytest.ini @@ -0,0 +1,68 @@ +[pytest] +addopts = + # `pytest-xdist`: + -n auto + + # `pytest-mon`: + # useful for live testing with `pytest-watch` during development: + # --testmon + + --durations=10 + -v + -ra + --showlocals + --doctest-modules + --junitxml=.test-results/pytest/results.xml + + # `pytest-cov`: + --cov=ansiblelint + --cov-report term-missing:skip-covered + --cov-report xml:.test-results/pytest/cov.xml + --no-cov-on-fail + + # interpret all the target args as importables: + --pyargs + + # importable packages for test lookup: + test ansiblelint.rules +doctest_optionflags = ALLOW_UNICODE ELLIPSIS +filterwarnings = + error + + # TODO: delete the following ignores once Ansible that we support gets rid of `imp` + # Ref: https://github.com/ansible/ansible-lint/pull/734 + ignore:the imp module is deprecated in favour of importlib; see the module's documentation for alternative uses:DeprecationWarning:ansible.plugins.loader + + # TODO: delete the following ignores once Ansible gets rid of direct + # imports from `collections` + ignore:Using or importing the ABCs from 'collections' instead of from 'collections.abc' is deprecated, and in 3.8 it will stop working:DeprecationWarning + ignore:Using or importing the ABCs from 'collections' instead of from 'collections.abc' is deprecated since Python 3.3, and in 3.9 it will stop working:DeprecationWarning + ignore:Using or importing the ABCs from 'collections' instead of from 'collections.abc' is deprecated since Python 3.3,and in 3.9 it will stop working:DeprecationWarning +junit_duration_report = call +# Our github annotation parser from .github/workflows/tox.yml requires xunit1 format. Ref: +# https://github.com/shyim/junit-report-annotations-action/issues/3#issuecomment-663241378 +junit_family = xunit1 +junit_suite_name = ansible_lint_test_suite +minversion = 4.6.6 +norecursedirs = + build + dist + docs + lib/ansible_lint.egg-info + .cache + .eggs + .git + .github + .tox + *.egg +python_files = + test_*.py + # Ref: https://docs.pytest.org/en/latest/reference.html#confval-python_files + # Needed to discover legacy nose test modules: + Test*.py + # Needed to discover embedded Rule tests + *Rule.py +# Using --pyargs instead of testpath as we embed some tests +# See: https://github.com/pytest-dev/pytest/issues/6451#issuecomment-687043537 +# testpaths = +xfail_strict = true diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..3cd60a4 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,86 @@ +[aliases] +dists = clean --all sdist bdist_wheel + +[bdist_wheel] +universal = 1 + +[metadata] +name = ansible-lint +url = https://github.com/ansible/ansible-lint +project_urls = + Bug Tracker = https://github.com/ansible/ansible-lint/issues + CI: GitHub = https://github.com/ansible/ansible-lint/actions?query=workflow:gh+branch:master+event:push + Code of Conduct = https://docs.ansible.com/ansible/latest/community/code_of_conduct.html + Documentation = https://ansible-lint.readthedocs.io/en/latest/ + Mailing lists = https://docs.ansible.com/ansible/latest/community/communication.html#mailing-list-information + Source Code = https://github.com/ansible/ansible-lint +description = Checks playbooks for practices and behaviour that could potentially be improved +long_description = file: README.rst +long_description_content_type = text/x-rst +author = Will Thames +author_email = will@thames.id.au +maintainer = Ansible by Red Hat +maintainer_email = info@ansible.com +license = MIT +license_file = LICENSE +classifiers = + Development Status :: 5 - Production/Stable + + Environment :: Console + + Intended Audience :: Developers + Intended Audience :: Information Technology + Intended Audience :: System Administrators + + Operating System :: OS Independent + + License :: OSI Approved :: MIT License + + Programming Language :: Python + Programming Language :: Python :: 3 + Programming Language :: Python :: 3.6 + Programming Language :: Python :: 3.7 + Programming Language :: Python :: 3.8 + Programming Language :: Python :: Implementation + Programming Language :: Python :: Implementation :: CPython + Programming Language :: Python :: Implementation :: Jython + Programming Language :: Python :: Implementation :: PyPy + + Topic :: Software Development :: Bug Tracking + Topic :: Software Development :: Quality Assurance + Topic :: Software Development :: Testing + + Topic :: Utilities +keywords = + ansible + lint + +[options] +use_scm_version = True +python_requires = >=3.6 +package_dir = + = lib +packages = find: +zip_safe = False + +# These are required during `setup.py` run: +setup_requires = + setuptools_scm>=1.15.0 + setuptools_scm_git_archive>=1.0 + +# These are required in actual runtime: +install_requires = + ansible >= 2.8 + pyyaml + rich + ruamel.yaml >= 0.15.34,<1; python_version < "3.7" + ruamel.yaml >= 0.15.37,<1; python_version >= "3.7" + # NOTE: per issue #509 0.15.34 included in debian backports + typing-extensions; python_version < "3.8" + +[options.entry_points] +console_scripts = + ansible-lint = ansiblelint.__main__:main + +[options.packages.find] +where = lib diff --git a/setup.py b/setup.py new file mode 100755 index 0000000..b72e95c --- /dev/null +++ b/setup.py @@ -0,0 +1,9 @@ +#! /usr/bin/env python3 +"""Ansible-lint distribution package setuptools installer. + +The presence of this file ensures the support +of pip editable mode *with setuptools only*. +""" +from setuptools import setup + +__name__ == '__main__' and setup() # pylint: disable=expression-not-assigned diff --git a/test-requirements.in b/test-requirements.in new file mode 100755 index 0000000..ce938e7 --- /dev/null +++ b/test-requirements.in @@ -0,0 +1,9 @@ +#!/usr/bin/env pip-compile -q --allow-unsafe --output-file=test-requirements.txt +# Avoid using --generate-hashes as it breaks pip install from tox with: +# ERROR: In --require-hashes mode, all requirements must have their versions pinned with ==. These do not: +# ansible<2.10,>=2.9 from +pytest >= 6.0.1 +pytest-cov >= 2.10.1 +pytest-xdist >= 2.1.0 +# Needed to avoid DeprecationWarning errors in pytest: +setuptools >= 49.6.0 diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 0000000..20ddf46 --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1,24 @@ +# +# This file is autogenerated by pip-compile +# To update, run: +# +# pip-compile --allow-unsafe --output-file=test-requirements.txt ./test-requirements.in +# +apipkg==1.5 # via execnet +attrs==20.1.0 # via pytest +coverage==5.2.1 # via pytest-cov +execnet==1.7.1 # via pytest-xdist +iniconfig==1.0.1 # via pytest +packaging==20.4 # via pytest +pluggy==0.13.1 # via pytest +py==1.9.0 # via pytest, pytest-forked +pyparsing==2.4.7 # via packaging +pytest-cov==2.10.1 # via -r test-requirements.in +pytest-forked==1.3.0 # via pytest-xdist +pytest-xdist==2.1.0 # via -r test-requirements.in +pytest==6.1.2 # via -r test-requirements.in, pytest-cov, pytest-forked, pytest-xdist +six==1.15.0 # via packaging +toml==0.10.1 # via pytest + +# The following packages are considered to be unsafe in a requirements file: +setuptools==50.3.2 # via -r test-requirements.in diff --git a/test/TestAlwaysRunRule.py b/test/TestAlwaysRunRule.py new file mode 100644 index 0000000..011a258 --- /dev/null +++ b/test/TestAlwaysRunRule.py @@ -0,0 +1,24 @@ +# pylint: disable=preferred-module # FIXME: remove once migrated per GH-725 +import unittest + +from ansiblelint.rules import RulesCollection +from ansiblelint.rules.AlwaysRunRule import AlwaysRunRule +from ansiblelint.runner import Runner + + +class TestAlwaysRun(unittest.TestCase): + collection = RulesCollection() + + def setUp(self): + self.collection.register(AlwaysRunRule()) + + def test_file_positive(self): + success = 'test/always-run-success.yml' + good_runner = Runner(self.collection, success, [], [], []) + self.assertEqual([], good_runner.run()) + + def test_file_negative(self): + failure = 'test/always-run-failure.yml' + bad_runner = Runner(self.collection, failure, [], [], []) + errs = bad_runner.run() + self.assertEqual(1, len(errs)) diff --git a/test/TestAnsibleLintRule.py b/test/TestAnsibleLintRule.py new file mode 100644 index 0000000..f8bc6d1 --- /dev/null +++ b/test/TestAnsibleLintRule.py @@ -0,0 +1,7 @@ +from ansiblelint.rules import AnsibleLintRule + + +def test_unjinja(): + input = "{{ a }} {% b %} {# try to confuse parsing inside a comment { {{}} } #}" + output = "JINJA_EXPRESSION JINJA_STATEMENT JINJA_COMMENT" + assert AnsibleLintRule.unjinja(input) == output diff --git a/test/TestAnsibleSyntax.py b/test/TestAnsibleSyntax.py new file mode 100644 index 0000000..8103b61 --- /dev/null +++ b/test/TestAnsibleSyntax.py @@ -0,0 +1,16 @@ +"""Test Ansible Syntax. + +This module contains tests that validate that linter does not produce errors +when encountering what counts as valid Ansible syntax. +""" + +PB_WITH_NULL_TASKS = ''' +- hosts: all + tasks: +''' + + +def test_null_tasks(default_text_runner): + """Assure we do not fail when encountering null tasks.""" + results = default_text_runner.run_playbook(PB_WITH_NULL_TASKS) + assert not results diff --git a/test/TestBaseFormatter.py b/test/TestBaseFormatter.py new file mode 100644 index 0000000..c6ba7fb --- /dev/null +++ b/test/TestBaseFormatter.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8; -*- +from pathlib import Path + +import pytest + +from ansiblelint.formatters import BaseFormatter + + +@pytest.mark.parametrize(('base_dir', 'relative_path'), ( + (None, True), + ('/whatever', False), + (Path('/whatever'), False), +)) +@pytest.mark.parametrize('path', ('/whatever/string', Path('/whatever/string'))) +def test_base_formatter_when_base_dir(base_dir, relative_path, path): + # Given + base_formatter = BaseFormatter(base_dir, relative_path) + + # When + output_path = base_formatter._format_path(path) + + # Then + assert isinstance(output_path, str) + assert base_formatter._base_dir is None or isinstance(base_formatter._base_dir, str) + assert output_path == str(path) + + +@pytest.mark.parametrize('base_dir', ( + Path('/whatever'), + '/whatever', +)) +@pytest.mark.parametrize('path', ('/whatever/string', Path('/whatever/string'))) +def test_base_formatter_when_base_dir_is_given_and_relative_is_true(path, base_dir): + # Given + base_formatter = BaseFormatter(base_dir, True) + + # When + output_path = base_formatter._format_path(path) + + # Then + assert isinstance(output_path, str) + assert isinstance(base_formatter._base_dir, str) + assert output_path == Path(path).name + + +# vim: et:sw=4:syntax=python:ts=4: diff --git a/test/TestBecomeUserWithoutBecome.py b/test/TestBecomeUserWithoutBecome.py new file mode 100644 index 0000000..066e52f --- /dev/null +++ b/test/TestBecomeUserWithoutBecome.py @@ -0,0 +1,24 @@ +# pylint: disable=preferred-module # FIXME: remove once migrated per GH-725 +import unittest + +from ansiblelint.rules import RulesCollection +from ansiblelint.rules.BecomeUserWithoutBecomeRule import BecomeUserWithoutBecomeRule +from ansiblelint.runner import Runner + + +class TestBecomeUserWithoutBecome(unittest.TestCase): + collection = RulesCollection() + + def setUp(self): + self.collection.register(BecomeUserWithoutBecomeRule()) + + def test_file_positive(self): + success = 'test/become-user-without-become-success.yml' + good_runner = Runner(self.collection, success, [], [], []) + self.assertEqual([], good_runner.run()) + + def test_file_negative(self): + failure = 'test/become-user-without-become-failure.yml' + bad_runner = Runner(self.collection, failure, [], [], []) + errs = bad_runner.run() + self.assertEqual(3, len(errs)) diff --git a/test/TestCliRolePaths.py b/test/TestCliRolePaths.py new file mode 100644 index 0000000..a459df2 --- /dev/null +++ b/test/TestCliRolePaths.py @@ -0,0 +1,134 @@ +# pylint: disable=preferred-module # FIXME: remove once migrated per GH-725 +import os +import unittest +from pathlib import Path + +import pytest + +from ansiblelint.testing import run_ansible_lint + + +class TestCliRolePaths(unittest.TestCase): + def setUp(self): + self.local_test_dir = os.path.dirname(os.path.realpath(__file__)) + + def test_run_single_role_path_no_trailing_slash_module(self): + cwd = self.local_test_dir + role_path = 'test-role' + + result = run_ansible_lint(role_path, cwd=cwd) + self.assertIn('Use shell only when shell functionality is required', + result.stdout) + + def test_run_single_role_path_no_trailing_slash_script(self): + cwd = self.local_test_dir + role_path = 'test-role' + + result = run_ansible_lint(role_path, cwd=cwd, bin="ansible-lint") + self.assertIn('Use shell only when shell functionality is required', + result.stdout) + + def test_run_single_role_path_with_trailing_slash(self): + cwd = self.local_test_dir + role_path = 'test-role/' + + result = run_ansible_lint(role_path, cwd=cwd) + self.assertIn('Use shell only when shell functionality is required', + result.stdout) + + def test_run_multiple_role_path_no_trailing_slash(self): + cwd = self.local_test_dir + role_path = 'roles/test-role' + + result = run_ansible_lint(role_path, cwd=cwd) + self.assertIn('Use shell only when shell functionality is required', + result.stdout) + + def test_run_multiple_role_path_with_trailing_slash(self): + cwd = self.local_test_dir + role_path = 'roles/test-role/' + + result = run_ansible_lint(role_path, cwd=cwd) + self.assertIn('Use shell only when shell functionality is required', + result.stdout) + + def test_run_inside_role_dir(self): + cwd = os.path.join(self.local_test_dir, 'test-role/') + role_path = '.' + + result = run_ansible_lint(role_path, cwd=cwd) + self.assertIn('Use shell only when shell functionality is required', + result.stdout) + + def test_run_role_three_dir_deep(self): + cwd = self.local_test_dir + role_path = 'testproject/roles/test-role' + + result = run_ansible_lint(role_path, cwd=cwd) + self.assertIn('Use shell only when shell functionality is required', + result.stdout) + + def test_run_playbook(self): + """Call ansible-lint the way molecule does.""" + top_src_dir = os.path.dirname(self.local_test_dir) + cwd = os.path.join(top_src_dir, 'test/roles/test-role') + role_path = 'molecule/default/include-import-role.yml' + + env = os.environ.copy() + env['ANSIBLE_ROLES_PATH'] = os.path.dirname(cwd) + + result = run_ansible_lint(role_path, cwd=cwd, env=env) + self.assertIn('Use shell only when shell functionality is required', result.stdout) + + def test_run_role_name_invalid(self): + cwd = self.local_test_dir + role_path = 'roles/invalid-name' + + result = run_ansible_lint(role_path, cwd=cwd) + assert '106 Role name invalid-name does not match' in result.stdout + + def test_run_role_name_with_prefix(self): + cwd = self.local_test_dir + role_path = 'roles/ansible-role-foo' + + result = run_ansible_lint(role_path, cwd=cwd) + assert len(result.stdout) == 0 + assert len(result.stderr) == 0 + assert result.returncode == 0 + + def test_run_role_name_from_meta(self): + cwd = self.local_test_dir + role_path = 'roles/valid-due-to-meta' + + result = run_ansible_lint(role_path, cwd=cwd) + assert len(result.stdout) == 0 + assert len(result.stderr) == 0 + assert result.returncode == 0 + + def test_run_invalid_role_name_from_meta(self): + cwd = self.local_test_dir + role_path = 'roles/invalid_due_to_meta' + + result = run_ansible_lint(role_path, cwd=cwd) + assert '106 Role name invalid-due-to-meta does not match' in result.stdout + + +@pytest.mark.parametrize(('result', 'env'), ( + (True, { + "GITHUB_ACTIONS": "true", + "GITHUB_WORKFLOW": "foo" + }), + (False, None)), + ids=("on", "off")) +def test_run_playbook_github(result, env): + """Call ansible-lint simulating GitHub Actions environment.""" + cwd = str(Path(__file__).parent.parent.resolve()) + role_path = 'examples/example.yml' + + result_gh = run_ansible_lint(role_path, cwd=cwd, env=env) + + expected = ( + '::error file=examples/example.yml,line=47,severity=MEDIUM::[E101] ' + 'Deprecated always_run' + ) + assert (expected in result_gh.stdout) is result diff --git a/test/TestCommandHasChangesCheck.py b/test/TestCommandHasChangesCheck.py new file mode 100644 index 0000000..da0aa08 --- /dev/null +++ b/test/TestCommandHasChangesCheck.py @@ -0,0 +1,24 @@ +# pylint: disable=preferred-module # FIXME: remove once migrated per GH-725 +import unittest + +from ansiblelint.rules import RulesCollection +from ansiblelint.rules.CommandHasChangesCheckRule import CommandHasChangesCheckRule +from ansiblelint.runner import Runner + + +class TestCommandHasChangesCheck(unittest.TestCase): + collection = RulesCollection() + + def setUp(self): + self.collection.register(CommandHasChangesCheckRule()) + + def test_command_changes_positive(self): + success = 'test/command-check-success.yml' + good_runner = Runner(self.collection, success, [], [], []) + self.assertEqual([], good_runner.run()) + + def test_command_changes_negative(self): + failure = 'test/command-check-failure.yml' + bad_runner = Runner(self.collection, failure, [], [], []) + errs = bad_runner.run() + self.assertEqual(2, len(errs)) diff --git a/test/TestCommandLineInvocationSameAsConfig.py b/test/TestCommandLineInvocationSameAsConfig.py new file mode 100644 index 0000000..a5368fa --- /dev/null +++ b/test/TestCommandLineInvocationSameAsConfig.py @@ -0,0 +1,137 @@ +import os +import sys +from pathlib import Path + +import pytest + +from ansiblelint import cli + + +@pytest.fixture +def base_arguments(): + return ['../test/skiptasks.yml'] + + +@pytest.mark.parametrize(('args', 'config'), ( + (["-p"], "test/fixtures/parseable.yml"), + (["-q"], "test/fixtures/quiet.yml"), + (["-r", "test/fixtures/rules/"], + "test/fixtures/rulesdir.yml"), + (["-R", "-r", "test/fixtures/rules/"], + "test/fixtures/rulesdir-defaults.yml"), + (["-t", "skip_ansible_lint"], + "test/fixtures/tags.yml"), + (["-v"], "test/fixtures/verbosity.yml"), + (["-x", "bad_tag"], + "test/fixtures/skip-tags.yml"), + (["--exclude", "test/"], + "test/fixtures/exclude-paths.yml"), + (["--show-relpath"], + "test/fixtures/show-abspath.yml"), + ([], + "test/fixtures/show-relpath.yml"), + )) +def test_ensure_config_are_equal(base_arguments, args, config, monkeypatch): + command = base_arguments + args + cli_parser = cli.get_cli_parser() + + _real_pathlib_resolve = Path.resolve + + def _fake_pathlib_resolve(self): + try: + return _real_pathlib_resolve(self) + except FileNotFoundError: + if self != Path(args[-1]): + raise + return Path.cwd() / self + + with monkeypatch.context() as mp_ctx: + if ( + sys.version_info[:2] < (3, 6) and + args[-2:] == ["-r", "test/fixtures/rules/"] + ): + mp_ctx.setattr(Path, 'resolve', _fake_pathlib_resolve) + options = cli_parser.parse_args(command) + + file_config = cli.load_config(config) + + for key, val in file_config.items(): + if key in {'exclude_paths', 'rulesdir'}: + val = [Path(p) for p in val] + assert val == getattr(options, key) + + +def test_config_can_be_overridden(base_arguments): + no_override = cli.get_config(base_arguments + ["-t", "bad_tag"]) + + overridden = cli.get_config(base_arguments + + ["-t", "bad_tag", + "-c", "test/fixtures/tags.yml"]) + + assert no_override.tags + ["skip_ansible_lint"] == overridden.tags + + +def test_different_config_file(base_arguments): + """Ensures an alternate config_file can be used.""" + diff_config = cli.get_config(base_arguments + + ["-c", "test/fixtures/ansible-config.yml"]) + no_config = cli.get_config(base_arguments + ["-v"]) + + assert diff_config.verbosity == no_config.verbosity + + +def test_expand_path_user_and_vars_config_file(base_arguments): + """Ensure user and vars are expanded when specified as exclude_paths.""" + config1 = cli.get_config(base_arguments + + ["-c", "test/fixtures/exclude-paths-with-expands.yml"]) + config2 = cli.get_config(base_arguments + [ + "--exclude", "~/.ansible/roles", + "--exclude", "$HOME/.ansible/roles" + ]) + + assert str(config1.exclude_paths[0]) == os.path.expanduser("~/.ansible/roles") + assert str(config2.exclude_paths[0]) == os.path.expanduser("~/.ansible/roles") + assert str(config1.exclude_paths[1]) == os.path.expandvars("$HOME/.ansible/roles") + assert str(config2.exclude_paths[1]) == os.path.expandvars("$HOME/.ansible/roles") + + +def test_path_from_config_do_not_depend_on_cwd(monkeypatch): # Issue 572 + config1 = cli.load_config("test/fixtures/config-with-relative-path.yml") + monkeypatch.chdir('test') + config2 = cli.load_config("fixtures/config-with-relative-path.yml") + + assert config1['exclude_paths'].sort() == config2['exclude_paths'].sort() + + +def test_path_from_cli_depend_on_cwd(base_arguments, monkeypatch, tmp_path): + # Issue 572 + arguments = base_arguments + ["--exclude", + "test/fixtures/config-with-relative-path.yml"] + + options1 = cli.get_cli_parser().parse_args(arguments) + assert 'test/test' not in str(options1.exclude_paths[0]) + + test_dir = 'test' + if sys.version_info[:2] < (3, 6): + test_dir = tmp_path / 'test' / 'test' / 'fixtures' + test_dir.mkdir(parents=True) + (test_dir / 'config-with-relative-path.yml').write_text('') + test_dir = test_dir / '..' / '..' + monkeypatch.chdir(test_dir) + options2 = cli.get_cli_parser().parse_args(arguments) + + assert 'test/test' in str(options2.exclude_paths[0]) + + +@pytest.mark.parametrize( + "config_file", + ( + pytest.param("test/fixtures/ansible-config-invalid.yml", id="invalid"), + pytest.param("/dev/null/ansible-config-missing.yml", id="missing") + ), +) +def test_config_failure(base_arguments, config_file): + """Ensures specific config files produce error code 2.""" + with pytest.raises(SystemExit, match="^2$"): + cli.get_config(base_arguments + + ["-c", config_file]) diff --git a/test/TestComparisonToEmptyString.py b/test/TestComparisonToEmptyString.py new file mode 100644 index 0000000..cdbd0fa --- /dev/null +++ b/test/TestComparisonToEmptyString.py @@ -0,0 +1,39 @@ +# pylint: disable=preferred-module # FIXME: remove once migrated per GH-725 +import unittest + +from ansiblelint.rules import RulesCollection +from ansiblelint.rules.ComparisonToEmptyStringRule import ComparisonToEmptyStringRule +from ansiblelint.testing import RunFromText + +SUCCESS_TASKS = ''' +- name: shut down + command: /sbin/shutdown -t now + when: ansible_os_family +''' + +FAIL_TASKS = ''' +- hosts: all + tasks: + - name: shut down + command: /sbin/shutdown -t now + when: ansible_os_family == "" + - name: shut down + command: /sbin/shutdown -t now + when: ansible_os_family !="" +''' + + +class TestComparisonToEmptyStringRule(unittest.TestCase): + collection = RulesCollection() + collection.register(ComparisonToEmptyStringRule()) + + def setUp(self): + self.runner = RunFromText(self.collection) + + def test_success(self): + results = self.runner.run_role_tasks_main(SUCCESS_TASKS) + self.assertEqual(0, len(results)) + + def test_fail(self): + results = self.runner.run_playbook(FAIL_TASKS) + self.assertEqual(2, len(results)) diff --git a/test/TestComparisonToLiteralBool.py b/test/TestComparisonToLiteralBool.py new file mode 100644 index 0000000..041ca1b --- /dev/null +++ b/test/TestComparisonToLiteralBool.py @@ -0,0 +1,69 @@ +# pylint: disable=preferred-module # FIXME: remove once migrated per GH-725 +import unittest + +from ansiblelint.rules import RulesCollection +from ansiblelint.rules.ComparisonToLiteralBoolRule import ComparisonToLiteralBoolRule +from ansiblelint.testing import RunFromText + +PASS_WHEN = ''' +- name: example task + debug: + msg: test + when: my_var +''' + +PASS_WHEN_NOT_FALSE = ''' +- name: example task + debug: + msg: test + when: not my_var +''' + +PASS_WHEN_NOT_NULL = ''' +- name: example task + debug: + msg: test + when: my_var not None +''' + +FAIL_LITERAL_TRUE = ''' +- name: example task + debug: + msg: test + when: my_var == True +''' + +FAIL_LITERAL_FALSE = ''' +- name: example task + debug: + msg: test + when: my_var == false +''' + + +class TestComparisonToLiteralBoolRule(unittest.TestCase): + collection = RulesCollection() + collection.register(ComparisonToLiteralBoolRule()) + + def setUp(self): + self.runner = RunFromText(self.collection) + + def test_when(self): + results = self.runner.run_role_tasks_main(PASS_WHEN) + self.assertEqual(0, len(results)) + + def test_when_not_false(self): + results = self.runner.run_role_tasks_main(PASS_WHEN_NOT_FALSE) + self.assertEqual(0, len(results)) + + def test_when_not_null(self): + results = self.runner.run_role_tasks_main(PASS_WHEN_NOT_NULL) + self.assertEqual(0, len(results)) + + def test_literal_true(self): + results = self.runner.run_role_tasks_main(FAIL_LITERAL_TRUE) + self.assertEqual(1, len(results)) + + def test_literal_false(self): + results = self.runner.run_role_tasks_main(FAIL_LITERAL_FALSE) + self.assertEqual(1, len(results)) diff --git a/test/TestDependenciesInMeta.py b/test/TestDependenciesInMeta.py new file mode 100644 index 0000000..d272120 --- /dev/null +++ b/test/TestDependenciesInMeta.py @@ -0,0 +1,22 @@ +import pytest + +from ansiblelint.runner import Runner + + +@pytest.mark.parametrize( + 'filename', + ( + 'bitbucket', + 'galaxy', + 'github', + 'webserver', + 'gitlab', + ), +) +def test_external_dependency_is_ok(default_rules_collection, filename): + playbook_path = ( + 'test/dependency-in-meta/{filename}.yml'. + format_map(locals()) + ) + good_runner = Runner(default_rules_collection, playbook_path, [], [], []) + assert [] == good_runner.run() diff --git a/test/TestDeprecatedModule.py b/test/TestDeprecatedModule.py new file mode 100644 index 0000000..6741ed4 --- /dev/null +++ b/test/TestDeprecatedModule.py @@ -0,0 +1,32 @@ +# pylint: disable=preferred-module # FIXME: remove once migrated per GH-725 +import unittest + +import pytest + +from ansiblelint.rules import RulesCollection +from ansiblelint.rules.DeprecatedModuleRule import DeprecatedModuleRule +from ansiblelint.testing import ANSIBLE_MAJOR_VERSION, RunFromText + +MODULE_DEPRECATED = ''' +- name: task example + docker: + debug: test +''' + + +class TestDeprecatedModuleRule(unittest.TestCase): + collection = RulesCollection() + collection.register(DeprecatedModuleRule()) + + def setUp(self): + self.runner = RunFromText(self.collection) + + @pytest.mark.xfail( + ANSIBLE_MAJOR_VERSION > (2, 9), + reason='Ansible devel has changed so ansible-lint needs fixing. ' + 'Ref: https://github.com/ansible/ansible-lint/issues/675', + raises=SystemExit, strict=True, + ) + def test_module_deprecated(self): + results = self.runner.run_role_tasks_main(MODULE_DEPRECATED) + self.assertEqual(1, len(results)) diff --git a/test/TestEnvVarsInCommand.py b/test/TestEnvVarsInCommand.py new file mode 100644 index 0000000..e3a1d84 --- /dev/null +++ b/test/TestEnvVarsInCommand.py @@ -0,0 +1,90 @@ +# pylint: disable=preferred-module # FIXME: remove once migrated per GH-725 +import unittest + +from ansiblelint.rules import RulesCollection +from ansiblelint.rules.EnvVarsInCommandRule import EnvVarsInCommandRule +from ansiblelint.testing import RunFromText + +SUCCESS_PLAY_TASKS = ''' +- hosts: localhost + + tasks: + - name: actual use of environment + shell: echo $HELLO + environment: + HELLO: hello + + - name: use some key-value pairs + command: chdir=/tmp creates=/tmp/bobbins warn=no touch bobbins + + - name: commands can have flags + command: abc --xyz=def blah + + - name: commands can have equals in them + command: echo "===========" + + - name: commands with cmd + command: + cmd: + echo "-------" + + - name: command with stdin (ansible > 2.4) + command: /bin/cat + args: + stdin: "Hello, world!" + + - name: use argv to send the command as a list + command: + argv: + - /bin/echo + - Hello + - World + + - name: another use of argv + command: + args: + argv: + - echo + - testing + + - name: environment variable with shell + shell: HELLO=hello echo $HELLO + + - name: command with stdin_add_newline (ansible > 2.8) + command: /bin/cat + args: + stdin: "Hello, world!" + stdin_add_newline: false + + - name: command with strip_empty_ends (ansible > 2.8) + command: echo + args: + strip_empty_ends: false +''' + +FAIL_PLAY_TASKS = ''' +- hosts: localhost + + tasks: + - name: environment variable with command + command: HELLO=hello echo $HELLO + + - name: typo some stuff + command: cerates=/tmp/blah warn=no touch /tmp/blah +''' + + +class TestEnvVarsInCommand(unittest.TestCase): + collection = RulesCollection() + collection.register(EnvVarsInCommandRule()) + + def setUp(self): + self.runner = RunFromText(self.collection) + + def test_success(self): + results = self.runner.run_playbook(SUCCESS_PLAY_TASKS) + self.assertEqual(0, len(results)) + + def test_fail(self): + results = self.runner.run_playbook(FAIL_PLAY_TASKS) + self.assertEqual(2, len(results)) diff --git a/test/TestExamples.py b/test/TestExamples.py new file mode 100644 index 0000000..48a89d3 --- /dev/null +++ b/test/TestExamples.py @@ -0,0 +1,8 @@ +"""Assure samples produced desire outcomes.""" +from ansiblelint.runner import Runner + + +def test_example(default_rules_collection): + """example.yml is expected to have 5 match errors inside.""" + result = Runner(default_rules_collection, 'examples/example.yml', [], [], []).run() + assert len(result) == 5 diff --git a/test/TestFormatter.py b/test/TestFormatter.py new file mode 100644 index 0000000..fd4f122 --- /dev/null +++ b/test/TestFormatter.py @@ -0,0 +1,46 @@ +# Copyright (c) 2016 Will Thames <will@thames.id.au> +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# 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. +# pylint: disable=preferred-module # FIXME: remove once migrated per GH-725 +import pathlib +import unittest + +from ansiblelint.errors import MatchError +from ansiblelint.formatters import Formatter +from ansiblelint.rules import AnsibleLintRule + + +class TestFormatter(unittest.TestCase): + + def setUp(self): + self.rule = AnsibleLintRule() + self.rule.id = "TCF0001" + self.formatter = Formatter(pathlib.Path.cwd(), True) + + def test_format_coloured_string(self): + match = MatchError("message", 1, "hello", "filename.yml", self.rule) + self.formatter.format(match, True) + + def test_unicode_format_string(self): + match = MatchError(u'\U0001f427', 1, "hello", "filename.yml", self.rule) + self.formatter.format(match, False) + + def test_dict_format_line(self): + match = MatchError("xyz", 1, {'hello': 'world'}, "filename.yml", self.rule,) + self.formatter.format(match, True) diff --git a/test/TestImportIncludeRole.py b/test/TestImportIncludeRole.py new file mode 100644 index 0000000..8b581ff --- /dev/null +++ b/test/TestImportIncludeRole.py @@ -0,0 +1,103 @@ +import pytest + +from ansiblelint.runner import Runner + +ROLE_TASKS_MAIN = ''' +- name: shell instead of command + shell: echo hello world +''' + +ROLE_TASKS_WORLD = ''' +- command: echo this is a task without a name +''' + +PLAY_IMPORT_ROLE = ''' +- hosts: all + + tasks: + - import_role: + name: test-role +''' + +PLAY_IMPORT_ROLE_INCOMPLETE = ''' +- hosts: all + + tasks: + - import_role: + foo: bar +''' + +PLAY_IMPORT_ROLE_INLINE = ''' +- hosts: all + + tasks: + - import_role: name=test-role +''' + +PLAY_INCLUDE_ROLE = ''' +- hosts: all + + tasks: + - include_role: + name: test-role + tasks_from: world +''' + +PLAY_INCLUDE_ROLE_INLINE = ''' +- hosts: all + + tasks: + - include_role: name=test-role tasks_from=world +''' + + +@pytest.fixture +def playbook_path(request, tmp_path): + playbook_text = request.param + role_tasks_dir = tmp_path / 'test-role' / 'tasks' + role_tasks_dir.mkdir(parents=True) + (role_tasks_dir / 'main.yml').write_text(ROLE_TASKS_MAIN) + (role_tasks_dir / 'world.yml').write_text(ROLE_TASKS_WORLD) + play_path = tmp_path / 'playbook.yml' + play_path.write_text(playbook_text) + return str(play_path) + + +@pytest.mark.parametrize(('playbook_path', 'messages'), ( + pytest.param(PLAY_IMPORT_ROLE, + ['only when shell functionality is required'], + id='IMPORT_ROLE', + ), + pytest.param(PLAY_IMPORT_ROLE_INLINE, + ['only when shell functionality is require'], + id='IMPORT_ROLE_INLINE', + ), + pytest.param(PLAY_INCLUDE_ROLE, + ['only when shell functionality is require', + 'All tasks should be named'], + id='INCLUDE_ROLE', + ), + pytest.param(PLAY_INCLUDE_ROLE_INLINE, + ['only when shell functionality is require', + 'All tasks should be named'], + id='INCLUDE_ROLE_INLINE', + ), +), indirect=('playbook_path', )) +def test_import_role2(default_rules_collection, playbook_path, messages): + runner = Runner(default_rules_collection, playbook_path, [], [], []) + results = runner.run() + for message in messages: + assert message in str(results) + + +@pytest.mark.parametrize(('playbook_path', 'messages'), ( + pytest.param(PLAY_IMPORT_ROLE_INCOMPLETE, + ["Failed to find required 'name' key in import_role"], + id='IMPORT_ROLE_INCOMPLETE', + ), +), indirect=('playbook_path', )) +def test_invalid_import_role(default_rules_collection, playbook_path, messages): + runner = Runner(default_rules_collection, playbook_path, [], [], []) + results = runner.run() + for message in messages: + assert message in str(results) diff --git a/test/TestImportPlaybook.py b/test/TestImportPlaybook.py new file mode 100644 index 0000000..06492e3 --- /dev/null +++ b/test/TestImportPlaybook.py @@ -0,0 +1,17 @@ +"""Test ability to import playbooks.""" +from ansiblelint.runner import Runner + + +def test_task_hook_import_playbook(default_rules_collection): + """Assures import_playbook includes are recognized.""" + playbook_path = 'test/playbook-import/playbook_parent.yml' + runner = Runner(default_rules_collection, playbook_path, [], [], []) + results = runner.run() + + results_text = str(results) + assert len(runner.playbooks) == 2 + assert len(results) == 2 + # Assures we detected the issues from imported playbook + assert 'Commands should not change things' in results_text + assert '502' in results_text + assert 'All tasks should be named' in results_text diff --git a/test/TestImportWithMalformed.py b/test/TestImportWithMalformed.py new file mode 100644 index 0000000..4f5425c --- /dev/null +++ b/test/TestImportWithMalformed.py @@ -0,0 +1,65 @@ +from collections import namedtuple + +import pytest + +from ansiblelint.runner import Runner + +PlayFile = namedtuple('PlayFile', ['name', 'content']) + + +IMPORT_TASKS_MAIN = PlayFile('import-tasks-main.yml', ''' +- oops this is invalid +''') + +IMPORT_SHELL_PIP = PlayFile('import-tasks-main.yml', ''' +- shell: pip +''') + +PLAY_IMPORT_TASKS = PlayFile('playbook.yml', ''' +- hosts: all + tasks: + - import_tasks: import-tasks-main.yml +''') + + +@pytest.fixture +def play_file_path(tmp_path): + p = tmp_path / 'playbook.yml' + return str(p) + + +@pytest.fixture +def runner(play_file_path, default_rules_collection): + return Runner(default_rules_collection, play_file_path, [], [], []) + + +@pytest.fixture +def _play_files(tmp_path, request): + if request.param is None: + return + for play_file in request.param: + p = tmp_path / play_file.name + p.write_text(play_file.content) + + +@pytest.mark.parametrize( + '_play_files', + ( + pytest.param([IMPORT_SHELL_PIP, PLAY_IMPORT_TASKS], id='Import shell w/ pip'), + pytest.param( + [IMPORT_TASKS_MAIN, PLAY_IMPORT_TASKS], + id='import_tasks w/ malformed import', + marks=pytest.mark.xfail( + reason='Garbage non-tasks sequence is not being ' + 'properly processed. Ref: ' + 'https://github.com/ansible/ansible-lint/issues/707', + raises=AttributeError, + ), + ), + ), + indirect=['_play_files'] +) +@pytest.mark.usefixtures('_play_files') +def test_import_tasks_with_malformed_import(runner): + results = str(runner.run()) + assert 'only when shell functionality is required' in results diff --git a/test/TestIncludeMissFileWithRole.py b/test/TestIncludeMissFileWithRole.py new file mode 100644 index 0000000..126e095 --- /dev/null +++ b/test/TestIncludeMissFileWithRole.py @@ -0,0 +1,122 @@ +import os +from collections import namedtuple + +import pytest + +from ansiblelint.runner import Runner + +PlayFile = namedtuple('PlayFile', ['name', 'content']) + + +PLAY_IN_THE_PLACE = PlayFile('playbook.yml', u''' +- hosts: all + roles: + - include_in_the_place +''') + +PLAY_RELATIVE = PlayFile('playbook.yml', u''' +- hosts: all + roles: + - include_relative +''') + +PLAY_MISS_INCLUDE = PlayFile('playbook.yml', u''' +- hosts: all + roles: + - include_miss +''') + +PLAY_ROLE_INCLUDED_IN_THE_PLACE = PlayFile('roles/include_in_the_place/tasks/main.yml', u''' +--- +- include_tasks: included_file.yml +''') + +PLAY_ROLE_INCLUDED_RELATIVE = PlayFile('roles/include_relative/tasks/main.yml', u''' +--- +- include_tasks: tasks/included_file.yml +''') + +PLAY_ROLE_INCLUDED_MISS = PlayFile('roles/include_miss/tasks/main.yml', u''' +--- +- include_tasks: tasks/noexist_file.yml +''') + +PLAY_INCLUDED_IN_THE_PLACE = PlayFile('roles/include_in_the_place/tasks/included_file.yml', u''' +- debug: + msg: 'was found & included' +''') + +PLAY_INCLUDED_RELATIVE = PlayFile('roles/include_relative/tasks/included_file.yml', u''' +- debug: + msg: 'was found & included' +''') + + +@pytest.fixture +def play_file_path(tmp_path): + p = tmp_path / 'playbook.yml' + return str(p) + + +@pytest.fixture +def runner(play_file_path, default_rules_collection): + return Runner(default_rules_collection, play_file_path, [], [], []) + + +@pytest.fixture +def _play_files(tmp_path, request): + if request.param is None: + return + for play_file in request.param: + print(play_file.name) + p = tmp_path / play_file.name + os.makedirs(os.path.dirname(p), exist_ok=True) + p.write_text(play_file.content) + + +@pytest.mark.parametrize( + '_play_files', + ( + pytest.param([PLAY_MISS_INCLUDE, + PLAY_ROLE_INCLUDED_MISS], + id='no exist file include'), + ), + indirect=['_play_files'] +) +@pytest.mark.usefixtures('_play_files') +def test_cases_warning_message(runner, caplog): + runner.run() + noexist_message_count = 0 + + for record in caplog.records: + print(record) + if "Couldn't open" in str(record): + noexist_message_count += 1 + + assert noexist_message_count == 3 # 3 retries + + +@pytest.mark.parametrize( + '_play_files', + ( + pytest.param([PLAY_IN_THE_PLACE, + PLAY_ROLE_INCLUDED_IN_THE_PLACE, + PLAY_INCLUDED_IN_THE_PLACE], + id='in the place include'), + pytest.param([PLAY_RELATIVE, + PLAY_ROLE_INCLUDED_RELATIVE, + PLAY_INCLUDED_RELATIVE], + id='relative include') + ), + indirect=['_play_files'] +) +@pytest.mark.usefixtures('_play_files') +def test_cases_that_do_not_report(runner, caplog): + runner.run() + noexist_message_count = 0 + + for record in caplog.records: + if "Couldn't open" in str(record): + noexist_message_count += 1 + + assert noexist_message_count == 0 diff --git a/test/TestIncludeMissingFileRule.py b/test/TestIncludeMissingFileRule.py new file mode 100644 index 0000000..7248fdc --- /dev/null +++ b/test/TestIncludeMissingFileRule.py @@ -0,0 +1,88 @@ +from collections import namedtuple + +import pytest + +from ansiblelint.runner import Runner + +PlayFile = namedtuple('PlayFile', ['name', 'content']) + + +PLAY_INCLUDING_PLAIN = PlayFile('playbook.yml', u''' +- hosts: all + tasks: + - include: some_file.yml +''') + +PLAY_INCLUDING_JINJA2 = PlayFile('playbook.yml', u''' +- hosts: all + tasks: + - include: "{{ some_path }}/some_file.yml" +''') + +PLAY_INCLUDING_NOQA = PlayFile('playbook.yml', u''' +- hosts: all + tasks: + - include: some_file.yml # noqa 505 +''') + +PLAY_INCLUDED = PlayFile('some_file.yml', u''' +- debug: + msg: 'was found & included' +''') + +PLAY_HAVING_TASK = PlayFile('playbook.yml', u''' +- name: Play + hosts: all + pre_tasks: + tasks: + - name: Ping + ping: +''') + + +@pytest.fixture +def play_file_path(tmp_path): + p = tmp_path / 'playbook.yml' + return str(p) + + +@pytest.fixture +def runner(play_file_path, default_rules_collection): + return Runner(default_rules_collection, play_file_path, [], [], []) + + +@pytest.fixture +def _play_files(tmp_path, request): + if request.param is None: + return + for play_file in request.param: + p = tmp_path / play_file.name + p.write_text(play_file.content) + + +@pytest.mark.parametrize( + '_play_files', (pytest.param([PLAY_INCLUDING_PLAIN], id='referenced file missing'), ), + indirect=['_play_files'] +) +@pytest.mark.usefixtures('_play_files') +def test_include_file_missing(runner): + results = str(runner.run()) + assert 'referenced missing file in' in results + assert 'playbook.yml' in results + assert 'some_file.yml' in results + + +@pytest.mark.parametrize( + '_play_files', + ( + pytest.param([PLAY_INCLUDING_PLAIN, PLAY_INCLUDED], id='File Exists'), + pytest.param([PLAY_INCLUDING_JINJA2], id='JINJA2 in reference'), + pytest.param([PLAY_INCLUDING_NOQA], id='NOQA was used'), + pytest.param([PLAY_HAVING_TASK], id='Having a task') + ), + indirect=['_play_files'] +) +@pytest.mark.usefixtures('_play_files') +def test_cases_that_do_not_report(runner): + results = str(runner.run()) + assert 'referenced missing file in' not in results diff --git a/test/TestLineNumber.py b/test/TestLineNumber.py new file mode 100644 index 0000000..9355daa --- /dev/null +++ b/test/TestLineNumber.py @@ -0,0 +1,36 @@ +# Copyright (c) 2020 Albin Vass <albin.vass@gmail.com> +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# 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 ansiblelint.rules.SudoRule import SudoRule + +TEST_TASKLIST = """ +- debug: + msg: test + +- command: echo test + sudo: true +""" + + +def test_rule_linenumber(monkeypatch): + """Check that SudoRule offense contains a line number.""" + rule = SudoRule() + matches = rule.matchyaml(dict(path="", type='tasklist'), TEST_TASKLIST) + assert matches[0].linenumber == 5 diff --git a/test/TestLineTooLong.py b/test/TestLineTooLong.py new file mode 100644 index 0000000..be8c6f7 --- /dev/null +++ b/test/TestLineTooLong.py @@ -0,0 +1,24 @@ +# pylint: disable=preferred-module # FIXME: remove once migrated per GH-725 +import unittest + +from ansiblelint.rules import RulesCollection +from ansiblelint.rules.LineTooLongRule import LineTooLongRule +from ansiblelint.testing import RunFromText + +LONG_LINE = ''' +- name: task example + debug: + msg: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua tempor incididunt ut labore et dolore' +''' # noqa 501 + + +class TestLineTooLongRule(unittest.TestCase): + collection = RulesCollection() + collection.register(LineTooLongRule()) + + def setUp(self): + self.runner = RunFromText(self.collection) + + def test_long_line(self): + results = self.runner.run_role_tasks_main(LONG_LINE) + self.assertEqual(1, len(results)) diff --git a/test/TestLintRule.py b/test/TestLintRule.py new file mode 100644 index 0000000..7377443 --- /dev/null +++ b/test/TestLintRule.py @@ -0,0 +1,45 @@ +# Copyright (c) 2013-2014 Will Thames <will@thames.id.au> +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# 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. + +# pylint: disable=preferred-module # FIXME: remove once migrated per GH-725 +import unittest + +from .rules import EMatcherRule, UnsetVariableMatcherRule + + +class TestRule(unittest.TestCase): + + def test_rule_matching(self): + text = "" + filename = 'test/ematchtest.yml' + with open(filename) as f: + text = f.read() + ematcher = EMatcherRule.EMatcherRule() + matches = ematcher.matchlines(dict(path=filename, type='playbooks'), text) + self.assertEqual(len(matches), 3) + + def test_rule_postmatching(self): + text = "" + filename = 'test/bracketsmatchtest.yml' + with open(filename) as f: + text = f.read() + rule = UnsetVariableMatcherRule.UnsetVariableMatcherRule() + matches = rule.matchlines(dict(path=filename, type='playbooks'), text) + self.assertEqual(len(matches), 2) diff --git a/test/TestLocalContent.py b/test/TestLocalContent.py new file mode 100644 index 0000000..e78aab4 --- /dev/null +++ b/test/TestLocalContent.py @@ -0,0 +1,42 @@ +"""Test playbooks with local content.""" +import pytest + +from ansiblelint.runner import Runner + + +def test_local_collection(default_rules_collection): + """Assures local collections are found.""" + playbook_path = 'test/local-content/test-collection.yml' + runner = Runner(default_rules_collection, playbook_path, [], [], []) + results = runner.run() + + assert len(runner.playbooks) == 1 + assert len(results) == 0 + + +def test_roles_local_content(default_rules_collection): + """Assures local content in roles is found.""" + playbook_path = 'test/local-content/test-roles-success/test.yml' + runner = Runner(default_rules_collection, playbook_path, [], [], []) + results = runner.run() + + assert len(runner.playbooks) == 4 + assert len(results) == 0 + + +def test_roles_local_content_failure(default_rules_collection): + """Assures local content in roles is found, even if Ansible itself has trouble.""" + playbook_path = 'test/local-content/test-roles-failed/test.yml' + runner = Runner(default_rules_collection, playbook_path, [], [], []) + results = runner.run() + + assert len(runner.playbooks) == 4 + assert len(results) == 0 + + +def test_roles_local_content_failure_complete(default_rules_collection): + """Role with local content that is not found.""" + playbook_path = 'test/local-content/test-roles-failed-complete/test.yml' + runner = Runner(default_rules_collection, playbook_path, [], [], []) + with pytest.raises(SystemExit, match="^3$"): + runner.run() diff --git a/test/TestMatchError.py b/test/TestMatchError.py new file mode 100644 index 0000000..f17d865 --- /dev/null +++ b/test/TestMatchError.py @@ -0,0 +1,178 @@ +"""Tests for MatchError.""" + +import operator + +import pytest + +from ansiblelint.errors import MatchError +from ansiblelint.rules import AnsibleLintRule +from ansiblelint.rules.AlwaysRunRule import AlwaysRunRule +from ansiblelint.rules.BecomeUserWithoutBecomeRule import BecomeUserWithoutBecomeRule + + +class DummyTestObject: + """A dummy object for equality tests.""" + + def __repr__(self): + """Return a dummy object representation for parmetrize.""" + return '{self.__class__.__name__}()'.format(self=self) + + def __eq__(self, other): + """Report the equality check failure with any object.""" + return False + + def __ne__(self, other): + """Report the confirmation of inequality with any object.""" + return True + + +class DummySentinelTestObject: + """A dummy object for equality protocol tests with sentinel.""" + + def __eq__(self, other): + """Return sentinel as result of equality check w/ anything.""" + return 'EQ_SENTINEL' + + def __ne__(self, other): + """Return sentinel as result of inequality check w/ anything.""" + return 'NE_SENTINEL' + + def __lt__(self, other): + """Return sentinel as result of less than check w/ anything.""" + return 'LT_SENTINEL' + + def __gt__(self, other): + """Return sentinel as result of greater than chk w/ anything.""" + return 'GT_SENTINEL' + + +@pytest.mark.parametrize( + ('left_match_error', 'right_match_error'), + ( + (MatchError("foo"), MatchError("foo")), + (MatchError("a", details="foo"), MatchError("a", details="foo")), + ), +) +def test_matcherror_compare(left_match_error, right_match_error): + """Check that MatchError instances with similar attrs are equivalent.""" + assert left_match_error == right_match_error + + +class AnsibleLintRuleWithStringId(AnsibleLintRule): + id = "ANSIBLE200" + + +def test_matcherror_invalid(): + """Ensure that MatchError requires message or rule.""" + expected_err = r"^MatchError\(\) missing a required argument: one of 'message' or 'rule'$" + with pytest.raises(TypeError, match=expected_err): + MatchError() + + +@pytest.mark.parametrize( + ('left_match_error', 'right_match_error'), ( + # sorting by message + (MatchError("z"), MatchError("a")), + # filenames takes priority in sorting + (MatchError("a", filename="b"), MatchError("a", filename="a")), + # rule id 501 > rule id 101 + (MatchError(rule=BecomeUserWithoutBecomeRule), MatchError(rule=AlwaysRunRule)), + # rule id "200" > rule id 101 + (MatchError(rule=AnsibleLintRuleWithStringId), MatchError(rule=AlwaysRunRule)), + # details are taken into account + (MatchError("a", details="foo"), MatchError("a", details="bar")), + )) +class TestMatchErrorCompare: + + def test_match_error_less_than(self, left_match_error, right_match_error): + """Check 'less than' protocol implementation in MatchError.""" + assert right_match_error < left_match_error + + def test_match_error_greater_than(self, left_match_error, right_match_error): + """Check 'greater than' protocol implementation in MatchError.""" + assert left_match_error > right_match_error + + def test_match_error_not_equal(self, left_match_error, right_match_error): + """Check 'not equals' protocol implementation in MatchError.""" + assert left_match_error != right_match_error + + +@pytest.mark.parametrize( + 'other', + ( + None, + "foo", + 42, + Exception("foo"), + ), + ids=repr, +) +@pytest.mark.parametrize( + ('operation', 'operator_char'), + ( + pytest.param(operator.le, '<=', id='<='), + pytest.param(operator.gt, '>', id='>'), + ), +) +def test_matcherror_compare_no_other_fallback(other, operation, operator_char): + """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) + ) + with pytest.raises(TypeError, match=expected_error): + operation(MatchError("foo"), other) + + +@pytest.mark.parametrize( + 'other', + ( + None, + 'foo', + 42, + Exception('foo'), + DummyTestObject(), + ), + ids=repr, +) +@pytest.mark.parametrize( + ('operation', 'expected_value'), + ( + (operator.eq, False), + (operator.ne, True), + ), + ids=('==', '!=') +) +def test_matcherror_compare_with_other_fallback( + other, + operation, + expected_value, +): + """Check that MatchError comparison runs other types fallbacks.""" + assert operation(MatchError("foo"), other) is expected_value + + +@pytest.mark.parametrize( + ('operation', 'expected_value'), + ( + (operator.eq, 'EQ_SENTINEL'), + (operator.ne, 'NE_SENTINEL'), + # 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'), + ), + ids=('==', '!=', '<', '>'), +) +def test_matcherror_compare_with_dummy_sentinel(operation, expected_value): + """Check that MatchError comparison runs other types fallbacks.""" + dummy_obj = DummySentinelTestObject() + # NOTE: This assertion abuses the CPython property to cache short string + # NOTE: objects because the identity check is more presice and we don't + # NOTE: want extra operator protocol methods to influence the test. + assert operation(MatchError("foo"), dummy_obj) is expected_value diff --git a/test/TestMetaChangeFromDefault.py b/test/TestMetaChangeFromDefault.py new file mode 100644 index 0000000..911553a --- /dev/null +++ b/test/TestMetaChangeFromDefault.py @@ -0,0 +1,33 @@ +# pylint: disable=preferred-module # FIXME: remove once migrated per GH-725 +import unittest + +from ansiblelint.rules import RulesCollection +from ansiblelint.rules.MetaChangeFromDefaultRule import MetaChangeFromDefaultRule +from ansiblelint.testing import RunFromText + +DEFAULT_GALAXY_INFO = ''' +galaxy_info: + author: your name + description: your description + company: your company (optional) + license: license (GPLv2, CC-BY, etc) +''' + + +class TestMetaChangeFromDefault(unittest.TestCase): + collection = RulesCollection() + collection.register(MetaChangeFromDefaultRule()) + + def setUp(self): + self.runner = RunFromText(self.collection) + + def test_default_galaxy_info(self): + results = self.runner.run_role_meta_main(DEFAULT_GALAXY_INFO) + self.assertIn("Should change default metadata: author", + str(results)) + self.assertIn("Should change default metadata: description", + str(results)) + self.assertIn("Should change default metadata: company", + str(results)) + self.assertIn("Should change default metadata: license", + str(results)) diff --git a/test/TestMetaMainHasInfo.py b/test/TestMetaMainHasInfo.py new file mode 100644 index 0000000..757a4df --- /dev/null +++ b/test/TestMetaMainHasInfo.py @@ -0,0 +1,94 @@ +# pylint: disable=preferred-module # FIXME: remove once migrated per GH-725 +import unittest + +from ansiblelint.rules import RulesCollection +from ansiblelint.rules.MetaMainHasInfoRule import MetaMainHasInfoRule +from ansiblelint.testing import RunFromText + +NO_GALAXY_INFO = ''' +author: the author +description: this meta/main.yml has no galaxy_info +''' + +MISSING_INFO = ''' +galaxy_info: + # author: the author + description: Testing if meta contains values + company: Not applicable + + license: MIT + + # min_ansible_version: 2.5 + + platforms: + - name: Fedora + versions: + - 25 + - missing_name: No name + versions: + - 25 +''' + +BAD_TYPES = ''' +galaxy_info: + author: 007 + description: ['Testing meta'] + company: Not applicable + + license: MIT + + min_ansible_version: 2.5 + + platforms: Fedora +''' + +PLATFORMS_LIST_OF_STR = ''' +galaxy_info: + author: '007' + description: 'Testing meta' + company: Not applicable + + license: MIT + + min_ansible_version: 2.5 + + platforms: ['Fedora', 'EL'] +''' + + +class TestMetaMainHasInfo(unittest.TestCase): + collection = RulesCollection() + collection.register(MetaMainHasInfoRule()) + + def setUp(self): + self.runner = RunFromText(self.collection) + + def test_no_galaxy_info(self): + results = self.runner.run_role_meta_main(NO_GALAXY_INFO) + assert len(results) == 1 + self.assertIn("No 'galaxy_info' found", + str(results)) + + def test_missing_info(self): + results = self.runner.run_role_meta_main(MISSING_INFO) + assert len(results) == 3 + self.assertIn("Role info should contain author", + str(results)) + self.assertIn("Role info should contain min_ansible_version", + str(results)) + self.assertIn("Platform should contain name", + str(results)) + + def test_bad_types(self): + results = self.runner.run_role_meta_main(BAD_TYPES) + assert len(results) == 3 + self.assertIn("author should be a string", str(results)) + self.assertIn("description should be a string", str(results)) + self.assertIn("Platforms should be a list of dictionaries", + str(results)) + + def test_platform_list_of_str(self): + results = self.runner.run_role_meta_main(PLATFORMS_LIST_OF_STR) + assert len(results) == 1 + self.assertIn("Platforms should be a list of dictionaries", + str(results)) diff --git a/test/TestMetaVideoLinks.py b/test/TestMetaVideoLinks.py new file mode 100644 index 0000000..4d61891 --- /dev/null +++ b/test/TestMetaVideoLinks.py @@ -0,0 +1,35 @@ +# pylint: disable=preferred-module # FIXME: remove once migrated per GH-725 +import unittest + +from ansiblelint.rules import RulesCollection +from ansiblelint.rules.MetaVideoLinksRule import MetaVideoLinksRule +from ansiblelint.testing import RunFromText + +META_VIDEO_LINKS = ''' +galaxy_info: + video_links: + - url: https://youtu.be/aWmRepTSFKs + title: Proper format + - https://youtu.be/this_is_not_a_dictionary + - my_bad_key: https://youtu.be/aWmRepTSFKs + title: This has a bad key + - url: www.myvid.com/vid + title: Bad format of url +''' + + +class TestMetaVideoLinks(unittest.TestCase): + collection = RulesCollection() + collection.register(MetaVideoLinksRule()) + + def setUp(self): + self.runner = RunFromText(self.collection) + + def test_video_links(self): + results = self.runner.run_role_meta_main(META_VIDEO_LINKS) + self.assertIn("Expected item in 'video_links' to be a dictionary", + str(results)) + self.assertIn("'video_links' to contain only keys 'url' and 'title'", + str(results)) + self.assertIn("URL format 'www.myvid.com/vid' is not recognized", + str(results)) diff --git a/test/TestMissingFilePermissionsRule.py b/test/TestMissingFilePermissionsRule.py new file mode 100644 index 0000000..0a67ae1 --- /dev/null +++ b/test/TestMissingFilePermissionsRule.py @@ -0,0 +1,110 @@ +# Copyright (c) 2020 Sorin Sbarnea <sorin.sbarnea@gmail.com> +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# 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. +"""MissingFilePermissionsRule tests.""" +import pytest + +from ansiblelint.rules.MissingFilePermissionsRule import MissingFilePermissionsRule + +SUCCESS_TASKS = ''' +--- +- hosts: hosts + tasks: + - name: permissions not missing and numeric + file: + path: foo + mode: 0600 + - name: permissions missing while state is absent is fine + file: + path: foo + state: absent + - name: permissions missing while state is file (default) is fine + file: + path: foo + - name: permissions missing while state is link is fine + file: + path: foo2 + src: foo + state: link + - name: file edit when create is false + lineinfile: + path: foo + create: false + line: some content here + - name: replace should not require mode + replace: + path: foo +''' + +FAIL_TASKS = ''' +--- +- hosts: hosts + tasks: + - name: file does not allow preserve value for mode + file: + path: foo + mode: preserve + - name: permissions missing and might create file + file: + path: foo + state: touch + - name: permissions missing and might create directory + file: + path: foo + state: directory + - name: permissions needed if create is used + ini_file: + path: foo + create: true + - name: lineinfile when create is true + lineinfile: + path: foo + create: true + line: some content here + - name: replace does not allow preserve mode + replace: + path: foo + mode: preserve + - name: ini_file does not accept preserve mode + ini_file: + path: foo + create: true + mode: preserve +''' + + +@pytest.mark.parametrize('rule_runner', (MissingFilePermissionsRule, ), indirect=['rule_runner']) +def test_success(rule_runner): + """Validate that mode presence avoids hitting the rule.""" + results = rule_runner.run_playbook(SUCCESS_TASKS) + assert len(results) == 0 + + +@pytest.mark.parametrize('rule_runner', (MissingFilePermissionsRule, ), indirect=['rule_runner']) +def test_fail(rule_runner): + """Validate that missing mode triggers the rule.""" + results = rule_runner.run_playbook(FAIL_TASKS) + assert len(results) == 7 + assert results[0].linenumber == 5 + assert results[1].linenumber == 9 + assert results[2].linenumber == 13 + assert results[3].linenumber == 17 + assert results[4].linenumber == 21 + assert results[5].linenumber == 26 + assert results[6].linenumber == 30 diff --git a/test/TestNestedJinjaRule.py b/test/TestNestedJinjaRule.py new file mode 100644 index 0000000..f8367b0 --- /dev/null +++ b/test/TestNestedJinjaRule.py @@ -0,0 +1,208 @@ +# -*- coding: utf-8 -*- +# Author: Adrián Tóth <adtoth@redhat.com> +# +# Copyright (c) 2020, Red Hat, Inc. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# 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 collections import namedtuple + +import pytest + +from ansiblelint.runner import Runner + +PlayFile = namedtuple('PlayFile', ['name', 'content']) + + +FAIL_TASK_1LN = PlayFile('playbook.yml', ''' +- hosts: all + tasks: + - name: one-level nesting + set_fact: + var_one: "2*(1+2) is {{ 2 * {{ 1 + 2 }} }}" +''') + +FAIL_TASK_1LN_M = PlayFile('playbook.yml', ''' +- hosts: all + tasks: + - name: one-level multiline nesting + set_fact: + var_one_ml: > + 2*(1+2) is {{ 2 * + {{ 1 + 2 }} + }} +''') + +FAIL_TASK_2LN = PlayFile('playbook.yml', ''' +- hosts: all + tasks: + - name: two-level nesting + set_fact: + var_two: "2*(1+(3-1)) is {{ 2 * {{ 1 + {{ 3 - 1 }} }} }}" +''') + +FAIL_TASK_2LN_M = PlayFile('playbook.yml', ''' +- hosts: all + tasks: + - name: two-level multiline nesting + set_fact: + var_two_ml: > + 2*(1+(3-1)) is {{ 2 * + {{ 1 + + {{ 3 - 1 }} + }} }} +''') + +FAIL_TASK_W_5LN = PlayFile('playbook.yml', ''' +- hosts: all + tasks: + - name: five-level wild nesting + set_fact: + var_three_wld: "{{ {{ {{ {{ {{ 234 }} }} }} }} }}" +''') + +FAIL_TASK_W_5LN_M = PlayFile('playbook.yml', ''' +- hosts: all + tasks: + - name: five-level wild multiline nesting + set_fact: + var_three_wld_ml: > + {{ + {{ + {{ + {{ + {{ 234 }} + }} + }} + }} + }} +''') + +SUCCESS_TASK_P = PlayFile('playbook.yml', ''' +- hosts: all + tasks: + - name: non-nested example + set_fact: + var_one: "number for 'one' is {{ 2 * 1 }}" +''') + +SUCCESS_TASK_P_M = PlayFile('playbook.yml', ''' +- hosts: all + tasks: + - name: multiline non-nested example + set_fact: + var_one_ml: > + number for 'one' is {{ + 2 * 1 }} +''') + +SUCCESS_TASK_2P = PlayFile('playbook.yml', ''' +- hosts: all + tasks: + - name: nesting far from each other + set_fact: + var_two: "number for 'two' is {{ 2 * 1 }} and number for 'three' is {{ 4 - 1 }}" +''') + +SUCCESS_TASK_2P_M = PlayFile('playbook.yml', ''' +- hosts: all + tasks: + - name: multiline nesting far from each other + set_fact: + var_two_ml: > + number for 'two' is {{ 2 * 1 + }} and number for 'three' is {{ + 4 - 1 }} +''') + +SUCCESS_TASK_C_2P = PlayFile('playbook.yml', ''' +- hosts: all + tasks: + - name: nesting close to each other + set_fact: + var_three: "number for 'ten' is {{ 2 - 1 }}{{ 3 - 3 }}" +''') + +SUCCESS_TASK_C_2P_M = PlayFile('playbook.yml', ''' +- hosts: all + tasks: + - name: multiline nesting close to each other + set_fact: + var_three_ml: > + number for 'ten' is {{ + 2 - 1 + }}{{ 3 - 3 }} +''') + + +@pytest.fixture +def runner(tmp_path, default_rules_collection): + return Runner( + default_rules_collection, + str(tmp_path / 'playbook.yml'), + [], [], [], + ) + + +@pytest.fixture +def _playbook_file(tmp_path, request): + if request.param is None: + return + for play_file in request.param: + p = tmp_path / play_file.name + p.write_text(play_file.content) + + +@pytest.mark.parametrize( + '_playbook_file', + ( + pytest.param([FAIL_TASK_1LN], id='file includes one-level nesting'), + pytest.param([FAIL_TASK_1LN_M], id='file includes one-level multiline nesting'), + pytest.param([FAIL_TASK_2LN], id='file includes two-level nesting'), + pytest.param([FAIL_TASK_2LN_M], id='file includes two-level multiline nesting'), + pytest.param([FAIL_TASK_W_5LN], id='file includes five-level wild nesting'), + pytest.param([FAIL_TASK_W_5LN_M], id='file includes five-level wild multiline nesting'), + ), + indirect=['_playbook_file'], +) +@pytest.mark.usefixtures('_playbook_file') +def test_including_wrong_nested_jinja(runner): + rule_violations = runner.run() + assert rule_violations[0].rule.id == '207' + + +@pytest.mark.parametrize( + '_playbook_file', + ( + pytest.param([SUCCESS_TASK_P], id='file includes non-nested example'), + pytest.param([SUCCESS_TASK_P_M], id='file includes multiline non-nested example'), + pytest.param([SUCCESS_TASK_2P], id='file includes nesting far from each other'), + pytest.param([SUCCESS_TASK_2P_M], id='file includes multiline nesting far from each other'), + pytest.param([SUCCESS_TASK_C_2P], id='file includes nesting close to each other'), + pytest.param( + [SUCCESS_TASK_C_2P_M], + id='file includes multiline nesting close to each other', + ), + ), + indirect=['_playbook_file'], +) +@pytest.mark.usefixtures('_playbook_file') +def test_including_proper_nested_jinja(runner): + rule_violations = runner.run() + assert not rule_violations diff --git a/test/TestNoFormattingInWhenRule.py b/test/TestNoFormattingInWhenRule.py new file mode 100644 index 0000000..f9fde4a --- /dev/null +++ b/test/TestNoFormattingInWhenRule.py @@ -0,0 +1,24 @@ +# pylint: disable=preferred-module # FIXME: remove once migrated per GH-725 +import unittest + +from ansiblelint.rules import RulesCollection +from ansiblelint.rules.NoFormattingInWhenRule import NoFormattingInWhenRule +from ansiblelint.runner import Runner + + +class TestNoFormattingInWhenRule(unittest.TestCase): + collection = RulesCollection() + + def setUp(self): + self.collection.register(NoFormattingInWhenRule()) + + def test_file_positive(self): + success = 'test/jinja2-when-success.yml' + good_runner = Runner(self.collection, success, [], [], []) + self.assertEqual([], good_runner.run()) + + def test_file_negative(self): + failure = 'test/jinja2-when-failure.yml' + bad_runner = Runner(self.collection, failure, [], [], []) + errs = bad_runner.run() + self.assertEqual(2, len(errs)) diff --git a/test/TestOctalPermissions.py b/test/TestOctalPermissions.py new file mode 100644 index 0000000..ed8c79c --- /dev/null +++ b/test/TestOctalPermissions.py @@ -0,0 +1,112 @@ +# pylint: disable=preferred-module # FIXME: remove once migrated per GH-725 +import unittest + +from ansiblelint.rules import RulesCollection +from ansiblelint.rules.OctalPermissionsRule import OctalPermissionsRule +from ansiblelint.testing import RunFromText + +SUCCESS_TASKS = ''' +--- +- hosts: hosts + vars: + varset: varset + tasks: + - name: octal permissions test success (0600) + file: + path: foo + mode: 0600 + + - name: octal permissions test success (0000) + file: + path: foo + mode: 0000 + + - name: octal permissions test success (02000) + file: + path: bar + mode: 02000 + + - name: octal permissions test success (02751) + file: + path: bar + mode: 02751 + + - name: octal permissions test success (0777) + file: path=baz mode=0777 + + - name: octal permissions test success (0711) + file: path=baz mode=0711 + + - name: permissions test success (0777) + file: path=baz mode=u+rwx + + - name: octal permissions test success (777) + file: path=baz mode=777 + + - name: octal permissions test success (733) + file: path=baz mode=733 +''' + +FAIL_TASKS = ''' +--- +- hosts: hosts + vars: + varset: varset + tasks: + - name: octal permissions test fail (600) + file: + path: foo + mode: 600 + + - name: octal permissions test fail (710) + file: + path: foo + mode: 710 + + - name: octal permissions test fail (123) + file: + path: foo + mode: 123 + + - name: octal permissions test fail (2000) + file: + path: bar + mode: 2000 +''' + + +class TestOctalPermissionsRuleWithFile(unittest.TestCase): + + collection = RulesCollection() + VALID_MODES = [0o777, 0o775, 0o770, 0o755, 0o750, 0o711, 0o710, 0o700, + 0o666, 0o664, 0o660, 0o644, 0o640, 0o600, + 0o555, 0o551, 0o550, 0o511, 0o510, 0o500, + 0o444, 0o440, 0o400] + + INVALID_MODES = [777, 775, 770, 755, 750, 711, 710, 700, + 666, 664, 660, 644, 640, 622, 620, 600, + 555, 551, 550, # 511 == 0o777, 510 == 0o776, 500 == 0o764 + 444, 440, 400] + + def setUp(self): + self.rule = OctalPermissionsRule() + self.collection.register(self.rule) + self.runner = RunFromText(self.collection) + + def test_success(self): + results = self.runner.run_playbook(SUCCESS_TASKS) + self.assertEqual(0, len(results)) + + def test_fail(self): + results = self.runner.run_playbook(FAIL_TASKS) + self.assertEqual(4, len(results)) + + def test_valid_modes(self): + for mode in self.VALID_MODES: + self.assertFalse(self.rule.is_invalid_permission(mode), + msg="0o%o should be a valid mode" % mode) + + def test_invalid_modes(self): + for mode in self.INVALID_MODES: + self.assertTrue(self.rule.is_invalid_permission(mode), + msg="%d should be an invalid mode" % mode) diff --git a/test/TestPackageIsNotLatest.py b/test/TestPackageIsNotLatest.py new file mode 100644 index 0000000..91bf24c --- /dev/null +++ b/test/TestPackageIsNotLatest.py @@ -0,0 +1,24 @@ +# pylint: disable=preferred-module # FIXME: remove once migrated per GH-725 +import unittest + +from ansiblelint.rules import RulesCollection +from ansiblelint.rules.PackageIsNotLatestRule import PackageIsNotLatestRule +from ansiblelint.runner import Runner + + +class TestPackageIsNotLatestRule(unittest.TestCase): + collection = RulesCollection() + + def setUp(self): + self.collection.register(PackageIsNotLatestRule()) + + def test_package_not_latest_positive(self): + success = 'test/package-check-success.yml' + good_runner = Runner(self.collection, success, [], [], []) + self.assertEqual([], good_runner.run()) + + def test_package_not_latest_negative(self): + failure = 'test/package-check-failure.yml' + bad_runner = Runner(self.collection, failure, [], [], []) + errs = bad_runner.run() + self.assertEqual(3, len(errs)) diff --git a/test/TestRoleHandlers.py b/test/TestRoleHandlers.py new file mode 100644 index 0000000..9f38320 --- /dev/null +++ b/test/TestRoleHandlers.py @@ -0,0 +1,20 @@ +# pylint: disable=preferred-module # FIXME: remove once migrated per GH-725 +import unittest + +from ansiblelint.rules import RulesCollection +from ansiblelint.rules.UseHandlerRatherThanWhenChangedRule import ( + UseHandlerRatherThanWhenChangedRule, +) +from ansiblelint.runner import Runner + + +class TestRoleHandlers(unittest.TestCase): + collection = RulesCollection() + + def setUp(self): + self.collection.register(UseHandlerRatherThanWhenChangedRule()) + + def test_role_handler_positive(self): + success = 'test/role-with-handler/main.yml' + good_runner = Runner(self.collection, success, [], [], []) + self.assertEqual([], good_runner.run()) diff --git a/test/TestRoleNames.py b/test/TestRoleNames.py new file mode 100644 index 0000000..9441a50 --- /dev/null +++ b/test/TestRoleNames.py @@ -0,0 +1,82 @@ +"""Test the RoleNames rule.""" + +import pytest + +from ansiblelint.rules import RulesCollection +from ansiblelint.rules.RoleNames import RoleNames +from ansiblelint.runner import Runner + +ROLE_NAME_VALID = 'test_role' + +TASK_MINIMAL = """ +- name: Some task + ping: +""" + +ROLE_MINIMAL = { + 'tasks': { + 'main.yml': TASK_MINIMAL + } +} +ROLE_META_EMPTY = { + 'meta': { + 'main.yml': '' + } +} + +ROLE_WITH_EMPTY_META = {**ROLE_MINIMAL, **ROLE_META_EMPTY} + +PLAY_INCLUDE_ROLE = f""" +- hosts: all + roles: + - {ROLE_NAME_VALID} +""" + + +@pytest.fixture +def test_rules_collection(): + """Instantiate a roles collection for tests.""" + collection = RulesCollection() + collection.register(RoleNames()) + return collection + + +def dict_to_files(parent_dir, file_dict): + """Write a nested dict to a file and directory structure below parent_dir.""" + for file, content in file_dict.items(): + if isinstance(content, dict): + directory = parent_dir / file + directory.mkdir() + dict_to_files(directory, content) + else: + (parent_dir / file).write_text(content) + + +@pytest.fixture +def playbook_path(request, tmp_path): + """Create a playbook with a role in a temporary directory.""" + playbook_text = request.param[0] + role_name = request.param[1] + role_layout = request.param[2] + role_path = tmp_path / role_name + role_path.mkdir() + dict_to_files(role_path, role_layout) + play_path = tmp_path / 'playbook.yml' + play_path.write_text(playbook_text) + return str(play_path) + + +@pytest.mark.parametrize(('playbook_path', 'messages'), ( + pytest.param((PLAY_INCLUDE_ROLE, ROLE_NAME_VALID, ROLE_WITH_EMPTY_META), + [], + id='ROLE_EMPTY_META', + ), +), indirect=('playbook_path',)) +def test_role_name(test_rules_collection, playbook_path, messages): + """Lint a playbook and compare the expected messages with the actual messages.""" + runner = Runner(test_rules_collection, playbook_path, [], [], []) + results = runner.run() + assert len(results) == len(messages) + results_text = str(results) + for message in messages: + assert message in results_text diff --git a/test/TestRoleRelativePath.py b/test/TestRoleRelativePath.py new file mode 100644 index 0000000..6c3163b --- /dev/null +++ b/test/TestRoleRelativePath.py @@ -0,0 +1,52 @@ +# pylint: disable=preferred-module # FIXME: remove once migrated per GH-725 +import unittest + +from ansiblelint.rules import RulesCollection +from ansiblelint.rules.RoleRelativePath import RoleRelativePath +from ansiblelint.testing import RunFromText + +FAIL_TASKS = ''' +- name: template example + template: + src: ../templates/foo.j2 + dest: /etc/file.conf +- name: copy example + copy: + src: ../files/foo.conf + dest: /etc/foo.conf +- name: win_template example + win_template: + src: ../win_templates/file.conf.j2 + dest: file.conf +- name: win_copy example + win_copy: + src: ../files/foo.conf + dest: renamed-foo.conf +''' + +SUCCESS_TASKS = ''' +- name: content example with no src + copy: + content: '# This file was moved to /etc/other.conf' + dest: /etc/mine.conf +- name: content example with no src + win_copy: + content: '# This file was moved to /etc/other.conf' + dest: /etc/mine.conf +''' + + +class TestRoleRelativePath(unittest.TestCase): + collection = RulesCollection() + collection.register(RoleRelativePath()) + + def setUp(self): + self.runner = RunFromText(self.collection) + + def test_fail(self): + results = self.runner.run_role_tasks_main(FAIL_TASKS) + self.assertEqual(4, len(results)) + + def test_success(self): + results = self.runner.run_role_tasks_main(SUCCESS_TASKS) + self.assertEqual(0, len(results)) diff --git a/test/TestRuleProperties.py b/test/TestRuleProperties.py new file mode 100644 index 0000000..41f40e0 --- /dev/null +++ b/test/TestRuleProperties.py @@ -0,0 +1,11 @@ +def test_serverity_valid(default_rules_collection): + valid_severity_values = [ + 'VERY_HIGH', + 'HIGH', + 'MEDIUM', + 'LOW', + 'VERY_LOW', + 'INFO', + ] + for rule in default_rules_collection: + assert rule.severity in valid_severity_values diff --git a/test/TestRulesCollection.py b/test/TestRulesCollection.py new file mode 100644 index 0000000..52d7dc6 --- /dev/null +++ b/test/TestRulesCollection.py @@ -0,0 +1,112 @@ +# Copyright (c) 2013-2014 Will Thames <will@thames.id.au> +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# 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. + +import collections +import os + +import pytest + +from ansiblelint.rules import RulesCollection +from ansiblelint.testing import run_ansible_lint + + +@pytest.fixture +def test_rules_collection(): + return RulesCollection([os.path.abspath('./test/rules')]) + + +@pytest.fixture +def ematchtestfile(): + return dict(path='test/ematchtest.yml', type='playbook') + + +@pytest.fixture +def bracketsmatchtestfile(): + return dict(path='test/bracketsmatchtest.yml', type='playbook') + + +def test_load_collection_from_directory(test_rules_collection): + assert len(test_rules_collection) == 2 + + +def test_run_collection(test_rules_collection, ematchtestfile): + matches = test_rules_collection.run(ematchtestfile) + assert len(matches) == 3 + + +def test_tags(test_rules_collection, ematchtestfile, bracketsmatchtestfile): + matches = test_rules_collection.run(ematchtestfile, tags=['test1']) + assert len(matches) == 3 + matches = test_rules_collection.run(ematchtestfile, tags=['test2']) + assert len(matches) == 0 + matches = test_rules_collection.run(bracketsmatchtestfile, tags=['test1']) + assert len(matches) == 1 + matches = test_rules_collection.run(bracketsmatchtestfile, tags=['test2']) + assert len(matches) == 2 + + +def test_skip_tags(test_rules_collection, ematchtestfile, bracketsmatchtestfile): + matches = test_rules_collection.run(ematchtestfile, skip_list=['test1']) + assert len(matches) == 0 + matches = test_rules_collection.run(ematchtestfile, skip_list=['test2']) + assert len(matches) == 3 + matches = test_rules_collection.run(bracketsmatchtestfile, skip_list=['test1']) + assert len(matches) == 2 + matches = test_rules_collection.run(bracketsmatchtestfile, skip_list=['test2']) + assert len(matches) == 1 + + +def test_skip_id(test_rules_collection, ematchtestfile, bracketsmatchtestfile): + matches = test_rules_collection.run(ematchtestfile, skip_list=['TEST0001']) + assert len(matches) == 0 + matches = test_rules_collection.run(ematchtestfile, skip_list=['TEST0002']) + assert len(matches) == 3 + matches = test_rules_collection.run(bracketsmatchtestfile, skip_list=['TEST0001']) + assert len(matches) == 2 + matches = test_rules_collection.run(bracketsmatchtestfile, skip_list=['TEST0002']) + assert len(matches) == 1 + + +def test_skip_non_existent_id(test_rules_collection, ematchtestfile): + matches = test_rules_collection.run(ematchtestfile, skip_list=['DOESNOTEXIST']) + assert len(matches) == 3 + + +def test_no_duplicate_rule_ids(test_rules_collection): + real_rules = RulesCollection([os.path.abspath('./lib/ansiblelint/rules')]) + rule_ids = [rule.id for rule in real_rules] + assert not any(y > 1 for y in collections.Counter(rule_ids).values()) + + +def test_rich_rule_listing(): + """Test that rich list format output is rendered as a table. + + This check also offers the contract of having rule id, short and long + descriptions in the console output. + """ + rules_path = os.path.abspath('./test/rules') + result = run_ansible_lint("-r", rules_path, "-f", "rich", "-L") + assert result.returncode == 0 + + for rule in RulesCollection([rules_path]): + assert rule.id in result.stdout + assert rule.shortdesc in result.stdout + # description could wrap inside table, so we do not check full length + assert rule.description[:30] in result.stdout diff --git a/test/TestRunner.py b/test/TestRunner.py new file mode 100644 index 0000000..59740bd --- /dev/null +++ b/test/TestRunner.py @@ -0,0 +1,86 @@ +# Copyright (c) 2013-2014 Will Thames <will@thames.id.au> +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# 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. +import os + +import pytest + +from ansiblelint import formatters +from ansiblelint.cli import abspath +from ansiblelint.runner import Runner + +LOTS_OF_WARNINGS_PLAYBOOK = abspath('examples/lots_of_warnings.yml', os.getcwd()) + + +@pytest.mark.parametrize(('playbook', 'exclude', 'length'), ( + ('test/nomatchestest.yml', [], 0), + ('test/unicode.yml', [], 1), + (LOTS_OF_WARNINGS_PLAYBOOK, [LOTS_OF_WARNINGS_PLAYBOOK], 0), + ('test/block.yml', [], 0), + ('test/become.yml', [], 0), + ('test/emptytags.yml', [], 0), + ('test/contains_secrets.yml', [], 0), +)) +def test_runner(default_rules_collection, playbook, exclude, length): + runner = Runner(default_rules_collection, playbook, [], [], exclude) + + matches = runner.run() + + assert len(matches) == length + + +@pytest.mark.parametrize(('formatter_cls', 'format_kwargs'), ( + pytest.param(formatters.Formatter, {}, id='Formatter-plain'), + pytest.param(formatters.ParseableFormatter, + {'colored': True}, + id='ParseableFormatter-colored'), + pytest.param(formatters.QuietFormatter, + {'colored': True}, + id='QuietFormatter-colored'), + pytest.param(formatters.Formatter, + {'colored': True}, + id='Formatter-colored'), +)) +def test_runner_unicode_format(default_rules_collection, formatter_cls, format_kwargs): + formatter = formatter_cls(os.getcwd(), True) + runner = Runner(default_rules_collection, 'test/unicode.yml', [], [], []) + + matches = runner.run() + + formatter.format(matches[0], **format_kwargs) + + +@pytest.mark.parametrize('directory_name', ('test/', os.path.abspath('test'))) +def test_runner_with_directory(default_rules_collection, directory_name): + runner = Runner(default_rules_collection, directory_name, [], [], []) + assert list(runner.playbooks)[0][1] == 'role' + + +def test_files_not_scanned_twice(default_rules_collection): + checked_files = set() + + filename = os.path.abspath('test/common-include-1.yml') + runner = Runner(default_rules_collection, filename, [], [], [], 0, checked_files) + run1 = runner.run() + + filename = os.path.abspath('test/common-include-2.yml') + runner = Runner(default_rules_collection, filename, [], [], [], 0, checked_files) + run2 = runner.run() + + assert (len(run1) + len(run2)) == 1 diff --git a/test/TestShellWithoutPipefail.py b/test/TestShellWithoutPipefail.py new file mode 100644 index 0000000..c0c8545 --- /dev/null +++ b/test/TestShellWithoutPipefail.py @@ -0,0 +1,84 @@ +# pylint: disable=preferred-module # FIXME: remove once migrated per GH-725 +import unittest + +from ansiblelint.rules import RulesCollection +from ansiblelint.rules.ShellWithoutPipefail import ShellWithoutPipefail +from ansiblelint.testing import RunFromText + +FAIL_TASKS = ''' +--- +- hosts: localhost + become: no + tasks: + - name: pipeline without pipefail + shell: false | cat + + - name: pipeline with or and pipe, no pipefail + shell: false || true | cat + + - shell: | + df | grep '/dev' +''' + +SUCCESS_TASKS = ''' +--- +- hosts: localhost + become: no + tasks: + - name: pipeline with pipefail + shell: set -o pipefail && false | cat + + - name: pipeline with pipefail, multi-line + shell: | + set -o pipefail + false | cat + + - name: pipeline with pipefail, complex set + shell: | + set -e -x -o pipefail + false | cat + + - name: pipeline with pipefail, complex set + shell: | + set -e -x -o pipefail + false | cat + + - name: pipeline with pipefail, complex set + shell: | + set -eo pipefail + false | cat + + - name: pipeline without pipefail, ignoring errors + shell: false | cat + ignore_errors: true + + - name: non-pipeline without pipefail + shell: "true" + + - name: command without pipefail + command: "true" + + - name: shell with or + shell: + false || true + + - shell: | + set -o pipefail + df | grep '/dev' +''' + + +class TestShellWithoutPipeFail(unittest.TestCase): + collection = RulesCollection() + collection.register(ShellWithoutPipefail()) + + def setUp(self): + self.runner = RunFromText(self.collection) + + def test_fail(self): + results = self.runner.run_playbook(FAIL_TASKS) + self.assertEqual(3, len(results)) + + def test_success(self): + results = self.runner.run_playbook(SUCCESS_TASKS) + self.assertEqual(0, len(results)) diff --git a/test/TestSkipImportPlaybook.py b/test/TestSkipImportPlaybook.py new file mode 100644 index 0000000..66e8520 --- /dev/null +++ b/test/TestSkipImportPlaybook.py @@ -0,0 +1,35 @@ +import pytest + +from ansiblelint.runner import Runner + +IMPORTED_PLAYBOOK = ''' +- hosts: all + tasks: + - name: success + fail: msg="fail" + when: False +''' + +MAIN_PLAYBOOK = ''' +- hosts: all + + tasks: + - name: should be shell # noqa 305 301 + shell: echo lol + +- import_playbook: imported_playbook.yml +''' + + +@pytest.fixture +def playbook(tmp_path): + playbook_path = tmp_path / 'playbook.yml' + playbook_path.write_text(MAIN_PLAYBOOK) + (tmp_path / 'imported_playbook.yml').write_text(IMPORTED_PLAYBOOK) + return str(playbook_path) + + +def test_skip_import_playbook(default_rules_collection, playbook): + runner = Runner(default_rules_collection, playbook, [], [], []) + results = runner.run() + assert len(results) == 0 diff --git a/test/TestSkipInsideYaml.py b/test/TestSkipInsideYaml.py new file mode 100644 index 0000000..a2abde2 --- /dev/null +++ b/test/TestSkipInsideYaml.py @@ -0,0 +1,122 @@ +import pytest + +ROLE_TASKS = ''' +--- +- name: test 303 + command: git log + changed_when: False +- name: test 303 (skipped) + command: git log # noqa 303 + changed_when: False +''' + +ROLE_TASKS_WITH_BLOCK = ''' +--- +- name: bad git 1 # noqa 401 + action: git a=b c=d +- name: bad git 2 + action: git a=b c=d +- name: Block with rescue and always section + block: + - name: bad git 3 # noqa 401 + action: git a=b c=d + - name: bad git 4 + action: git a=b c=d + rescue: + - name: bad git 5 # noqa 401 + action: git a=b c=d + - name: bad git 6 + action: git a=b c=d + always: + - name: bad git 7 # noqa 401 + action: git a=b c=d + - name: bad git 8 + action: git a=b c=d +''' + +PLAYBOOK = ''' +- hosts: all + tasks: + - name: test 402 + action: hg + - name: test 402 (skipped) # noqa 402 + action: hg + + - name: test 401 and 501 + become_user: alice + action: git + - name: test 401 and 501 (skipped) # noqa 401 501 + become_user: alice + action: git + + - name: test 204 and 206 + get_url: + url: http://example.com/really_long_path/really_long_path/really_long_path/really_long_path/really_long_path/really_long_path/really_long_path/really_long_path/file.conf + dest: "{{dest_proj_path}}/foo.conf" + - name: test 204 and 206 (skipped) + get_url: + url: http://example.com/really_long_path/really_long_path/really_long_path/really_long_path/really_long_path/really_long_path/really_long_path/really_long_path/file.conf # noqa 204 + dest: "{{dest_proj_path}}/foo.conf" # noqa 206 + + - name: test 302 + command: creates=B chmod 644 A + - name: test 302 + command: warn=yes creates=B chmod 644 A + - name: test 302 (skipped via no warn) + command: warn=no creates=B chmod 644 A + - name: test 302 (skipped via skip_ansible_lint) + command: creates=B chmod 644 A + tags: + - skip_ansible_lint + + - name: test invalid action (skipped) + foo: bar + tags: + - skip_ansible_lint +''' + +ROLE_META = ''' +galaxy_info: # noqa 701 + author: your name # noqa 703 + description: missing min_ansible_version and platforms. author default not changed + license: MIT +''' + +ROLE_TASKS_WITH_BLOCK_BECOME = ''' +- hosts: + tasks: + - name: foo + become: true + block: + - name: bar + become_user: jonhdaa + command: "/etc/test.sh" +''' + + +def test_role_tasks(default_text_runner): + results = default_text_runner.run_role_tasks_main(ROLE_TASKS) + assert len(results) == 1 + + +def test_role_tasks_with_block(default_text_runner): + results = default_text_runner.run_role_tasks_main(ROLE_TASKS_WITH_BLOCK) + assert len(results) == 4 + + +@pytest.mark.parametrize( + ('playbook_src', 'results_num'), + ( + (PLAYBOOK, 7), + (ROLE_TASKS_WITH_BLOCK_BECOME, 0), + ), + ids=('generic', 'with block become inheritance'), +) +def test_playbook(default_text_runner, playbook_src, results_num): + results = default_text_runner.run_playbook(playbook_src) + assert len(results) == results_num + + +def test_role_meta(default_text_runner): + results = default_text_runner.run_role_meta_main(ROLE_META) + assert len(results) == 0 diff --git a/test/TestSkipPlaybookItems.py b/test/TestSkipPlaybookItems.py new file mode 100644 index 0000000..5564ff2 --- /dev/null +++ b/test/TestSkipPlaybookItems.py @@ -0,0 +1,99 @@ +import pytest + +PLAYBOOK_PRE_TASKS = ''' +- hosts: all + tasks: + - name: bad git 1 # noqa 401 + action: git a=b c=d + - name: bad git 2 + action: git a=b c=d + pre_tasks: + - name: bad git 3 # noqa 401 + action: git a=b c=d + - name: bad git 4 + action: git a=b c=d +''' + +PLAYBOOK_POST_TASKS = ''' +- hosts: all + tasks: + - name: bad git 1 # noqa 401 + action: git a=b c=d + - name: bad git 2 + action: git a=b c=d + post_tasks: + - name: bad git 3 # noqa 401 + action: git a=b c=d + - name: bad git 4 + action: git a=b c=d +''' + +PLAYBOOK_HANDLERS = ''' +- hosts: all + tasks: + - name: bad git 1 # noqa 401 + action: git a=b c=d + - name: bad git 2 + action: git a=b c=d + handlers: + - name: bad git 3 # noqa 401 + action: git a=b c=d + - name: bad git 4 + action: git a=b c=d +''' + +PLAYBOOK_TWO_PLAYS = ''' +- hosts: all + tasks: + - name: bad git 1 # noqa 401 + action: git a=b c=d + - name: bad git 2 + action: git a=b c=d + +- hosts: all + tasks: + - name: bad git 3 # noqa 401 + action: git a=b c=d + - name: bad git 4 + action: git a=b c=d +''' + +PLAYBOOK_WITH_BLOCK = ''' +- hosts: all + tasks: + - name: bad git 1 # noqa 401 + action: git a=b c=d + - name: bad git 2 + action: git a=b c=d + - name: Block with rescue and always section + block: + - name: bad git 3 # noqa 401 + action: git a=b c=d + - name: bad git 4 + action: git a=b c=d + rescue: + - name: bad git 5 # noqa 401 + action: git a=b c=d + - name: bad git 6 + action: git a=b c=d + always: + - name: bad git 7 # noqa 401 + action: git a=b c=d + - name: bad git 8 + action: git a=b c=d +''' + + +@pytest.mark.parametrize(('playbook', 'length'), ( + pytest.param(PLAYBOOK_PRE_TASKS, 2, id='PRE_TASKS'), + pytest.param(PLAYBOOK_POST_TASKS, 2, id='POST_TASKS'), + pytest.param(PLAYBOOK_HANDLERS, 2, id='HANDLERS'), + pytest.param(PLAYBOOK_TWO_PLAYS, 2, id='TWO_PLAYS'), + pytest.param(PLAYBOOK_WITH_BLOCK, 4, id='WITH_BLOCK'), +)) +def test_pre_tasks(default_text_runner, playbook, length): + # When + results = default_text_runner.run_playbook(playbook) + + # Then + assert len(results) == length diff --git a/test/TestSudoRule.py b/test/TestSudoRule.py new file mode 100644 index 0000000..a105854 --- /dev/null +++ b/test/TestSudoRule.py @@ -0,0 +1,67 @@ +# pylint: disable=preferred-module # FIXME: remove once migrated per GH-725 +import unittest + +from ansiblelint.rules import RulesCollection +from ansiblelint.rules.SudoRule import SudoRule +from ansiblelint.testing import RunFromText + +ROLE_2_ERRORS = ''' +- name: test + debug: + msg: 'test message' + sudo: yes + sudo_user: nobody +''' + +ROLE_0_ERRORS = ''' +- name: test + debug: + msg: 'test message' + become: yes + become_user: somebody +''' + +PLAY_4_ERRORS = ''' +- hosts: all + sudo: yes + sudo_user: somebody + tasks: + - name: test + debug: + msg: 'test message' + sudo: yes + sudo_user: nobody +''' + +PLAY_1_ERROR = ''' +- hosts: all + tasks: + - name: test + debug: + msg: 'test message' + sudo: yes +''' + + +class TestSudoRule(unittest.TestCase): + collection = RulesCollection() + collection.register(SudoRule()) + + def setUp(self): + self.runner = RunFromText(self.collection) + + def test_run_role_fail(self): + results = self.runner.run_role_tasks_main(ROLE_2_ERRORS) + self.assertEqual(2, len(results)) + + def test_run_role_pass(self): + results = self.runner.run_role_tasks_main(ROLE_0_ERRORS) + self.assertEqual(0, len(results)) + + def test_play_root_and_task_fail(self): + results = self.runner.run_playbook(PLAY_4_ERRORS) + self.assertEqual(4, len(results)) + + def test_play_task_fail(self): + results = self.runner.run_playbook(PLAY_1_ERROR) + self.assertEqual(1, len(results)) diff --git a/test/TestTaskHasName.py b/test/TestTaskHasName.py new file mode 100644 index 0000000..3a35f29 --- /dev/null +++ b/test/TestTaskHasName.py @@ -0,0 +1,24 @@ +# pylint: disable=preferred-module # FIXME: remove once migrated per GH-725 +import unittest + +from ansiblelint.rules import RulesCollection +from ansiblelint.rules.TaskHasNameRule import TaskHasNameRule +from ansiblelint.runner import Runner + + +class TestTaskHasNameRule(unittest.TestCase): + collection = RulesCollection() + + def setUp(self): + self.collection.register(TaskHasNameRule()) + + def test_file_positive(self): + success = 'test/task-has-name-success.yml' + good_runner = Runner(self.collection, success, [], [], []) + self.assertEqual([], good_runner.run()) + + def test_file_negative(self): + failure = 'test/task-has-name-failure.yml' + bad_runner = Runner(self.collection, failure, [], [], []) + errs = bad_runner.run() + self.assertEqual(2, len(errs)) diff --git a/test/TestTaskIncludes.py b/test/TestTaskIncludes.py new file mode 100644 index 0000000..a3506bd --- /dev/null +++ b/test/TestTaskIncludes.py @@ -0,0 +1,34 @@ +import pytest + +from ansiblelint.runner import Runner + + +@pytest.mark.parametrize( + ('filename', 'playbooks_count'), + ( + pytest.param('blockincludes', 4, id='block included tasks'), + pytest.param( + 'blockincludes2', 4, + id='block included tasks with rescue and always', + ), + pytest.param('taskincludes', 4, id='included tasks'), + pytest.param( + 'taskincludes_2_4_style', 4, + id='include tasks 2.4 style', + ), + pytest.param('taskimports', 4, id='import tasks 2 4 style'), + pytest.param( + 'include-in-block', 3, + id='include tasks with block include', + ), + pytest.param( + 'include-import-tasks-in-role', 4, + id='include tasks in role', + ), + ), +) +def test_included_tasks(default_rules_collection, filename, playbooks_count): + playbook_path = 'test/{filename}.yml'.format(**locals()) + runner = Runner(default_rules_collection, playbook_path, [], [], []) + runner.run() + assert len(runner.playbooks) == playbooks_count diff --git a/test/TestTaskNoLocalAction.py b/test/TestTaskNoLocalAction.py new file mode 100644 index 0000000..74be24c --- /dev/null +++ b/test/TestTaskNoLocalAction.py @@ -0,0 +1,24 @@ +# pylint: disable=preferred-module # FIXME: remove once migrated per GH-725 +import unittest + +from ansiblelint.rules import RulesCollection +from ansiblelint.rules.TaskNoLocalAction import TaskNoLocalAction +from ansiblelint.testing import RunFromText + +TASK_LOCAL_ACTION = ''' +- name: task example + local_action: + module: boto3_facts +''' + + +class TestTaskNoLocalAction(unittest.TestCase): + collection = RulesCollection() + collection.register(TaskNoLocalAction()) + + def setUp(self): + self.runner = RunFromText(self.collection) + + def test_local_action(self): + results = self.runner.run_role_tasks_main(TASK_LOCAL_ACTION) + self.assertEqual(1, len(results)) diff --git a/test/TestUseCommandInsteadOfShell.py b/test/TestUseCommandInsteadOfShell.py new file mode 100644 index 0000000..d7e44a2 --- /dev/null +++ b/test/TestUseCommandInsteadOfShell.py @@ -0,0 +1,24 @@ +# pylint: disable=preferred-module # FIXME: remove once migrated per GH-725 +import unittest + +from ansiblelint.rules import RulesCollection +from ansiblelint.rules.UseCommandInsteadOfShellRule import UseCommandInsteadOfShellRule +from ansiblelint.runner import Runner + + +class TestUseCommandInsteadOfShell(unittest.TestCase): + collection = RulesCollection() + + def setUp(self): + self.collection.register(UseCommandInsteadOfShellRule()) + + def test_file_positive(self): + success = 'test/command-instead-of-shell-success.yml' + good_runner = Runner(self.collection, success, [], [], []) + self.assertEqual([], good_runner.run()) + + def test_file_negative(self): + failure = 'test/command-instead-of-shell-failure.yml' + bad_runner = Runner(self.collection, failure, [], [], []) + errs = bad_runner.run() + self.assertEqual(2, len(errs)) diff --git a/test/TestUseHandlerRatherThanWhenChanged.py b/test/TestUseHandlerRatherThanWhenChanged.py new file mode 100644 index 0000000..5306e2f --- /dev/null +++ b/test/TestUseHandlerRatherThanWhenChanged.py @@ -0,0 +1,88 @@ +# pylint: disable=preferred-module # FIXME: remove once migrated per GH-725 +import unittest + +from ansiblelint.rules import RulesCollection +from ansiblelint.rules.UseHandlerRatherThanWhenChangedRule import ( + UseHandlerRatherThanWhenChangedRule, +) +from ansiblelint.testing import RunFromText + +SUCCESS_TASKS = ''' +- name: print helpful error message + debug: + var: result + when: result.failed + +- name: do something when hello is output + debug: + msg: why isn't this a handler + when: result.stdout == "hello" + +- name: never actually debug + debug: + var: result + when: False + +- name: Dont execute this step + debug: + msg: "debug message" + when: + - false + +- name: check when with a list + debug: + var: result + when: + - conditionA + - conditionB +''' + + +FAIL_TASKS = ''' +- name: execute command + command: echo hello + register: result + +- name: this should be a handler + debug: + msg: why isn't this a handler + when: result.changed + +- name: this should be a handler 2 + debug: + msg: why isn't this a handler + when: result|changed + +- name: this should be a handler 3 + debug: + msg: why isn't this a handler + when: result.changed == true + +- name: this should be a handler 4 + debug: + msg: why isn't this a handler + when: result['changed'] == true + +- name: this should be a handler 5 + debug: + msg: why isn't this a handler + when: + - result['changed'] == true + - another_condition +''' + + +class TestUseHandlerRatherThanWhenChanged(unittest.TestCase): + collection = RulesCollection() + collection.register(UseHandlerRatherThanWhenChangedRule()) + + def setUp(self): + self.runner = RunFromText(self.collection) + + def test_success(self): + results = self.runner.run_role_tasks_main(SUCCESS_TASKS) + self.assertEqual(0, len(results)) + + def test_fail(self): + results = self.runner.run_role_tasks_main(FAIL_TASKS) + self.assertEqual(5, len(results)) diff --git a/test/TestUsingBareVariablesIsDeprecated.py b/test/TestUsingBareVariablesIsDeprecated.py new file mode 100644 index 0000000..42c3b4d --- /dev/null +++ b/test/TestUsingBareVariablesIsDeprecated.py @@ -0,0 +1,24 @@ +# pylint: disable=preferred-module # FIXME: remove once migrated per GH-725 +import unittest + +from ansiblelint.rules import RulesCollection +from ansiblelint.rules.UsingBareVariablesIsDeprecatedRule import UsingBareVariablesIsDeprecatedRule +from ansiblelint.runner import Runner + + +class TestUsingBareVariablesIsDeprecated(unittest.TestCase): + collection = RulesCollection() + + def setUp(self): + self.collection.register(UsingBareVariablesIsDeprecatedRule()) + + def test_file_positive(self): + success = 'test/using-bare-variables-success.yml' + good_runner = Runner(self.collection, success, [], [], []) + self.assertEqual([], good_runner.run()) + + def test_file_negative(self): + failure = 'test/using-bare-variables-failure.yml' + bad_runner = Runner(self.collection, failure, [], [], []) + errs = bad_runner.run() + self.assertEqual(14, len(errs)) diff --git a/test/TestUtils.py b/test/TestUtils.py new file mode 100644 index 0000000..58824b0 --- /dev/null +++ b/test/TestUtils.py @@ -0,0 +1,317 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2013-2014 Will Thames <will@thames.id.au> +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# 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. +"""Tests for generic utilitary functions.""" + +import logging +import os +import os.path +import subprocess +import sys +from pathlib import Path + +import pytest + +from ansiblelint import cli, constants, utils +from ansiblelint.__main__ import initialize_logger +from ansiblelint.file_utils import normpath + + +@pytest.mark.parametrize(('string', 'expected_cmd', '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('action: 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'], + {'chdir': 'wxy', 'creates': 'zyx'}, + id='command_with_args'), +)) +def test_tokenize(string, expected_cmd, expected_args, expected_kwargs): + """Test that tokenize works for different input types.""" + (cmd, args, kwargs) = utils.tokenize(string) + assert cmd == expected_cmd + assert args == expected_args + assert kwargs == expected_kwargs + + +@pytest.mark.parametrize(('reference_form', 'alternate_forms'), ( + pytest.param(dict(name='hello', action='command chdir=abc echo hello world'), + (dict(name="hello", command="chdir=abc echo hello world"), ), + id='simple_command'), + pytest.param({'git': {'version': 'abc'}, 'args': {'repo': 'blah', 'dest': 'xyz'}}, + ({'git': {'version': 'abc', 'repo': 'blah', 'dest': 'xyz'}}, + {"git": 'version=abc repo=blah dest=xyz'}, + {"git": None, "args": {'repo': 'blah', 'dest': 'xyz', 'version': 'abc'}}, + ), + id='args') +)) +def test_normalize(reference_form, alternate_forms): + """Test that tasks specified differently are normalized same way.""" + normal_form = utils.normalize_task(reference_form, 'tasks.yml') + + for form in alternate_forms: + assert normal_form == utils.normalize_task(form, 'tasks.yml') + + +def test_normalize_complex_command(): + """Test that tasks specified differently are normalized same way.""" + task1 = dict(name="hello", action={'module': 'pip', + 'name': 'df', + 'editable': 'false'}) + task2 = dict(name="hello", pip={'name': 'df', + 'editable': 'false'}) + task3 = dict(name="hello", pip="name=df editable=false") + task4 = dict(name="hello", action="pip name=df editable=false") + assert utils.normalize_task(task1, 'tasks.yml') == utils.normalize_task(task2, 'tasks.yml') + assert utils.normalize_task(task2, 'tasks.yml') == utils.normalize_task(task3, 'tasks.yml') + assert utils.normalize_task(task3, 'tasks.yml') == utils.normalize_task(task4, 'tasks.yml') + + +def test_extract_from_list(): + """Check that tasks get extracted from blocks if present.""" + block = { + 'block': [{'tasks': {'name': 'hello', 'command': 'whoami'}}], + 'test_none': None, + 'test_string': 'foo', + } + blocks = [block] + + test_list = utils.extract_from_list(blocks, ['block']) + test_none = utils.extract_from_list(blocks, ['test_none']) + + assert list(block['block']) == test_list + assert list() == test_none + with pytest.raises(RuntimeError): + utils.extract_from_list(blocks, ['test_string']) + + +@pytest.mark.parametrize(('template', 'output'), ( + pytest.param('{{ playbook_dir }}', '/a/b/c', id='simple'), + pytest.param("{{ 'hello' | doesnotexist }}", + "{{ 'hello' | doesnotexist }}", + id='unknown_filter'), + pytest.param('{{ hello | to_json }}', + '{{ hello | to_json }}', + id='to_json_filter_on_undefined_variable'), + pytest.param('{{ hello | to_nice_yaml }}', + '{{ hello | to_nice_yaml }}', + id='to_nice_yaml_filter_on_undefined_variable'), +)) +def test_template(template, output): + """Verify that resolvable template vars and filters get rendered.""" + result = utils.template('/base/dir', template, dict(playbook_dir='/a/b/c')) + assert result == output + + +def test_task_to_str_unicode(): + """Ensure that extracting messages from tasks preserves Unicode.""" + task = dict(fail=dict(msg=u"unicode é ô à")) + result = utils.task_to_str(utils.normalize_task(task, 'filename.yml')) + assert result == u"fail msg=unicode é ô à" + + +@pytest.mark.parametrize('path', ( + pytest.param(Path('a/b/../'), id='pathlib.Path'), + pytest.param('a/b/../', id='str'), +)) +def test_normpath_with_path_object(path): + """Ensure that relative parent dirs are normalized in paths.""" + assert normpath(path) == "a" + + +def test_expand_path_vars(monkeypatch): + """Ensure that tilde and env vars are expanded in paths.""" + test_path = '/test/path' + monkeypatch.setenv('TEST_PATH', test_path) + assert utils.expand_path_vars('~') == os.path.expanduser('~') + assert utils.expand_path_vars('$TEST_PATH') == test_path + + +@pytest.mark.parametrize(('test_path', 'expected'), ( + 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'), +)) +def test_expand_paths_vars(test_path, expected, monkeypatch): + """Ensure that tilde and env vars are expanded in paths lists.""" + monkeypatch.setenv('TEST_PATH', '/test/path') + assert utils.expand_paths_vars([test_path]) == [expected] + + +@pytest.mark.parametrize( + ('reset_env_var', 'message_prefix'), + ( + ('PATH', + "Failed to locate command: "), + ('GIT_DIR', + "Failed to discover yaml files to lint using git: ") + ), + ids=('no Git installed', 'outside Git repository'), +) +def test_get_yaml_files_git_verbose( + reset_env_var, + message_prefix, + monkeypatch, + caplog +): + """Ensure that autodiscovery lookup failures are logged.""" + options = cli.get_config(['-v']) + initialize_logger(options.verbosity) + monkeypatch.setenv(reset_env_var, '') + utils.get_yaml_files(options) + + expected_info = ( + "ansiblelint.utils", + logging.INFO, + 'Discovering files to lint: git ls-files *.yaml *.yml') + + assert expected_info in caplog.record_tuples + assert any(m.startswith(message_prefix) for m in caplog.messages) + + +@pytest.mark.parametrize( + 'is_in_git', + (True, False), + ids=('in Git', 'outside Git'), +) +def test_get_yaml_files_silent(is_in_git, monkeypatch, capsys): + """Verify that no stderr output is displayed while discovering yaml files. + + (when the verbosity is off, regardless of the Git or Git-repo presence) + + Also checks expected number of files are detected. + """ + options = cli.get_config([]) + test_dir = Path(__file__).resolve().parent + lint_path = test_dir / 'roles' / 'test-role' + if not is_in_git: + monkeypatch.setenv('GIT_DIR', '') + + yaml_count = ( + len(list(lint_path.glob('**/*.yml'))) + len(list(lint_path.glob('**/*.yaml'))) + ) + + monkeypatch.chdir(str(lint_path)) + files = utils.get_yaml_files(options) + stderr = capsys.readouterr().err + assert not stderr, 'No stderr output is expected when the verbosity is off' + assert len(files) == yaml_count, ( + "Expected to find {yaml_count} yaml files in {lint_path}".format_map( + locals(), + ) + ) + + +def test_logger_debug(caplog): + """Test that the double verbosity arg causes logger to be DEBUG.""" + options = cli.get_config(['-vv']) + initialize_logger(options.verbosity) + + expected_info = ( + "ansiblelint.__main__", + logging.DEBUG, + 'Logging initialized to level 10', + ) + + assert expected_info in caplog.record_tuples + + +@pytest.mark.xfail +def test_cli_auto_detect(capfd): + """Test that run without arguments it will detect and lint the entire repository.""" + cmd = sys.executable, "-m", "ansiblelint", "-v", "-p", "--nocolor" + result = subprocess.run(cmd, check=False).returncode + + # We de expect to fail on our own repo due to test examples we have + # TODO(ssbarnea) replace it with exact return code once we document them + assert result != 0 + + out, err = capfd.readouterr() + + # Confirmation that it runs in auto-detect mode + assert "Discovering files to lint: git ls-files *.yaml *.yml" in err + # Expected failure to detect file type" + assert "Unknown file type: test/fixtures/unknown-type.yml" in err + # An expected rule match from our examples + assert "examples/roles/bobbins/tasks/main.yml:2: " \ + "[E401] Git checkouts must contain explicit version" in out + # assures that our .ansible-lint exclude was effective in excluding github files + assert "Unknown file type: .github/" not in out + # assures that we can parse playbooks as playbooks + assert "Unknown file type: test/test/always-run-success.yml" not in err + + +@pytest.mark.xfail +def test_is_playbook(): + """Verify that we can detect a playbook as a playbook.""" + assert utils.is_playbook("test/test/always-run-success.yml") + + +def test_auto_detect_exclude(monkeypatch): + """Verify that exclude option can be used to narrow down detection.""" + options = cli.get_config(['--exclude', 'foo']) + + def mockreturn(options): + return ['foo/playbook.yml', 'bar/playbook.yml'] + + monkeypatch.setattr(utils, 'get_yaml_files', mockreturn) + result = utils.get_playbooks_and_roles(options) + assert result == ['bar/playbook.yml'] + + +_DEFAULT_RULEDIRS = [constants.DEFAULT_RULESDIR] +_CUSTOM_RULESDIR = Path(__file__).parent / "custom_rules" +_CUSTOM_RULEDIRS = [ + str(_CUSTOM_RULESDIR / "example_inc"), + str(_CUSTOM_RULESDIR / "example_com") +] + + +@pytest.mark.parametrize(("user_ruledirs", "use_default", "expected"), ( + ([], True, _DEFAULT_RULEDIRS), + ([], False, _DEFAULT_RULEDIRS), + (_CUSTOM_RULEDIRS, True, _CUSTOM_RULEDIRS + _DEFAULT_RULEDIRS), + (_CUSTOM_RULEDIRS, False, _CUSTOM_RULEDIRS) +)) +def test_get_rules_dirs(user_ruledirs, use_default, expected): + """Test it returns expected dir lists.""" + assert utils.get_rules_dirs(user_ruledirs, use_default) == expected + + +@pytest.mark.parametrize(("user_ruledirs", "use_default", "expected"), ( + ([], True, sorted(_CUSTOM_RULEDIRS) + _DEFAULT_RULEDIRS), + ([], False, sorted(_CUSTOM_RULEDIRS) + _DEFAULT_RULEDIRS), + (_CUSTOM_RULEDIRS, True, + _CUSTOM_RULEDIRS + sorted(_CUSTOM_RULEDIRS) + _DEFAULT_RULEDIRS), + (_CUSTOM_RULEDIRS, False, _CUSTOM_RULEDIRS) +)) +def test_get_rules_dirs_with_custom_rules(user_ruledirs, use_default, expected, monkeypatch): + """Test it returns expected dir lists when custom rules exist.""" + monkeypatch.setenv(constants.CUSTOM_RULESDIR_ENVVAR, str(_CUSTOM_RULESDIR)) + assert utils.get_rules_dirs(user_ruledirs, use_default) == expected diff --git a/test/TestVariableHasSpaces.py b/test/TestVariableHasSpaces.py new file mode 100644 index 0000000..01b12c9 --- /dev/null +++ b/test/TestVariableHasSpaces.py @@ -0,0 +1,54 @@ +# pylint: disable=preferred-module # FIXME: remove once migrated per GH-725 +import unittest + +from ansiblelint.rules import RulesCollection +from ansiblelint.rules.VariableHasSpacesRule import VariableHasSpacesRule +from ansiblelint.testing import RunFromText + +TASK_VARIABLES = ''' +- name: good variable format + debug: + msg: "{{ good_format }}" +- name: good variable format + debug: + msg: "Value: {{ good_format }}" +- name: jinja escaping allowed + debug: + msg: "{{ '{{' }}" +- name: jinja escaping allowed + shell: docker info --format '{{ '{{' }}json .Swarm.LocalNodeState{{ '}}' }}' | tr -d '"' +- name: jinja whitespace control allowed + debug: + msg: | + {{ good_format }}/ + {{- good_format }} + {{- good_format -}} +- name: bad variable format + debug: + msg: "{{bad_format}}" +- name: bad variable format + debug: + msg: "Value: {{ bad_format}}" +- name: bad variable format + debug: + msg: "{{bad_format }}" +- name: not a jinja variable + debug: + msg: "test" + example: "data = ${lookup{$local_part}lsearch{/etc/aliases}}" +- name: JSON inside jinja is valid + debug: + msg: "{{ {'test': {'subtest': variable}} }}" +''' + + +class TestVariableHasSpaces(unittest.TestCase): + collection = RulesCollection() + collection.register(VariableHasSpacesRule()) + + def setUp(self): + self.runner = RunFromText(self.collection) + + def test_variable_has_spaces(self): + results = self.runner.run_role_tasks_main(TASK_VARIABLES) + self.assertEqual(3, len(results)) diff --git a/test/TestWithSkipTagId.py b/test/TestWithSkipTagId.py new file mode 100644 index 0000000..129a00d --- /dev/null +++ b/test/TestWithSkipTagId.py @@ -0,0 +1,39 @@ +# pylint: disable=preferred-module # FIXME: remove once migrated per GH-725 +import unittest + +from ansiblelint.rules import RulesCollection +from ansiblelint.rules.TrailingWhitespaceRule import TrailingWhitespaceRule +from ansiblelint.runner import Runner + + +class TestWithSkipTagId(unittest.TestCase): + collection = RulesCollection() + collection.register(TrailingWhitespaceRule()) + file = 'test/with-skip-tag-id.yml' + + def test_negative_no_param(self): + bad_runner = Runner(self.collection, self.file, [], [], []) + errs = bad_runner.run() + self.assertGreater(len(errs), 0) + + def test_negative_with_id(self): + with_id = '201' + bad_runner = Runner(self.collection, self.file, [with_id], [], []) + errs = bad_runner.run() + self.assertGreater(len(errs), 0) + + def test_negative_with_tag(self): + with_tag = 'ANSIBLE0002' + bad_runner = Runner(self.collection, self.file, [with_tag], [], []) + errs = bad_runner.run() + self.assertGreater(len(errs), 0) + + def test_positive_skip_id(self): + skip_id = '201' + good_runner = Runner(self.collection, self.file, [], [skip_id], []) + self.assertEqual([], good_runner.run()) + + def test_positive_skip_tag(self): + skip_tag = 'ANSIBLE0002' + good_runner = Runner(self.collection, self.file, [], [skip_tag], []) + self.assertEqual([], good_runner.run()) diff --git a/test/__init__.py b/test/__init__.py new file mode 100644 index 0000000..124a757 --- /dev/null +++ b/test/__init__.py @@ -0,0 +1 @@ +"""Use ansiblelint.testing instead for test reusables.""" diff --git a/test/always-run-failure.yml b/test/always-run-failure.yml new file mode 100644 index 0000000..c9ff225 --- /dev/null +++ b/test/always-run-failure.yml @@ -0,0 +1,6 @@ +- hosts: localhost + + tasks: + - name: always_run is deprecated + debug: msg="always_run is deprecated" + always_run: yes diff --git a/test/always-run-success.yml b/test/always-run-success.yml new file mode 100644 index 0000000..30559e0 --- /dev/null +++ b/test/always-run-success.yml @@ -0,0 +1,6 @@ +- hosts: localhost + + tasks: + - name: always_run is deprecated + debug: msg="always_run is deprecated" + check_mode: yes diff --git a/test/bar.txt b/test/bar.txt new file mode 100644 index 0000000..e22f90b --- /dev/null +++ b/test/bar.txt @@ -0,0 +1 @@ +Bar file diff --git a/test/become-user-without-become-failure.yml b/test/become-user-without-become-failure.yml new file mode 100644 index 0000000..a4051f0 --- /dev/null +++ b/test/become-user-without-become-failure.yml @@ -0,0 +1,26 @@ +- hosts: localhost + name: become_user without become play + become_user: root + + tasks: + - debug: + msg: hello + +- hosts: localhost + + tasks: + - name: become_user without become task + command: whoami + become_user: postgres + +- hosts: localhost + + tasks: + - name: a block with become and become_user on different tasks + block: + - name: become + become: true + command: whoami + - name: become_user + become_user: postgres + command: whoami diff --git a/test/become-user-without-become-success.yml b/test/become-user-without-become-success.yml new file mode 100644 index 0000000..a8d64da --- /dev/null +++ b/test/become-user-without-become-success.yml @@ -0,0 +1,30 @@ +- hosts: localhost + become_user: root + become: true + + tasks: + - debug: + msg: hello + +- hosts: localhost + + tasks: + - command: whoami + become_user: postgres + become: true + +- hosts: localhost + become: true + + tasks: + - name: Accepts a become from higher scope + command: whoami + become_user: postgres + +- hosts: localhost + become_user: postgres + + tasks: + - name: Accepts a become from a lower scope + command: whoami + become: true diff --git a/test/become.yml b/test/become.yml new file mode 100644 index 0000000..f2ea524 --- /dev/null +++ b/test/become.yml @@ -0,0 +1,14 @@ +- hosts: all + + tasks: + - name: clone content repository + git: + repo: '{{ archive_services_repo_url }}' + dest: '/home/www' + accept_hostkey: yes + version: master + update: no + become: yes + become_user: nobody + notify: + - restart apache2 diff --git a/test/block.yml b/test/block.yml new file mode 100644 index 0000000..1aac26e --- /dev/null +++ b/test/block.yml @@ -0,0 +1,26 @@ +--- +- hosts: all + + pre_tasks: + - { include: 'doesnotexist.yml' } + + tasks: + - block: + - name: successful debug message + debug: msg='i execute normally' + - name: failure command + command: /bin/false + changed_when: False + - name: never reached debug message + debug: msg='i never execute, cause ERROR!' + rescue: + - name: exception debug message + debug: msg='I caught an error' + - name: another failure command + command: /bin/false + changed_when: False + - name: another missed debug message + debug: msg='I also never execute :-(' + always: + - name: always reached debug message + debug: msg="this always executes" diff --git a/test/blockincludes.yml b/test/blockincludes.yml new file mode 100644 index 0000000..5bea9be --- /dev/null +++ b/test/blockincludes.yml @@ -0,0 +1,13 @@ +--- +- hosts: webservers + vars: + varset: varset + tasks: + - block: + - include: nestedincludes.yml tags=nested + - block: + - include: "{{ varnotset }}.yml" + - block: + - include: "{{ varset }}.yml" + - block: + - include: "directory with spaces/main.yml" diff --git a/test/blockincludes2.yml b/test/blockincludes2.yml new file mode 100644 index 0000000..03d7a75 --- /dev/null +++ b/test/blockincludes2.yml @@ -0,0 +1,13 @@ +--- +- hosts: webservers + vars: + varset: varset + tasks: + - block: + - include: nestedincludes.yml tags=nested + - block: + - include: "{{ varnotset }}.yml" + rescue: + - include: "{{ varset }}.yml" + always: + - include: "directory with spaces/main.yml" diff --git a/test/brackets-do-not-match-test.yml b/test/brackets-do-not-match-test.yml new file mode 100644 index 0000000..dfa5ff8 --- /dev/null +++ b/test/brackets-do-not-match-test.yml @@ -0,0 +1,22 @@ +--- +- hosts: foo + roles: + - ../../../roles/base_os + - ../../../roles/repos + - { + role: ../../../roles/openshift_master, + oo_minion_ips: "{ hostvars['localhost'].oo_minion_ips | default(['']) }}", + oo_bind_ip: "{{ hostvars[inventory_hostname].ansible_eth0.ipv4.address | default(['']) }}" + } + - ../../../roles/pods + +- name: "Set Origin specific facts on localhost (for later use)" + hosts: localhost + gather_facts: no + tasks: + - name: Setting oo_minion_ips fact on localhost + set_fact: + oo_minion_ips: "{{ hostvars + | oo_select_keys(groups['tag_env-host-type-' + oo_env + '-openshift-minion']) + | oo_collect(attribute='ansible_eth0.ipv4.address') }" + when: groups['tag_env-host-type-' + oo_env + '-openshift-minion'] is defined diff --git a/test/bracketsmatchtest.yml b/test/bracketsmatchtest.yml new file mode 100644 index 0000000..46b213d --- /dev/null +++ b/test/bracketsmatchtest.yml @@ -0,0 +1,3 @@ +val1: "{{dest}}" +val2: worry +val3: "{{victory}}" diff --git a/test/command-check-failure.yml b/test/command-check-failure.yml new file mode 100644 index 0000000..d58c09d --- /dev/null +++ b/test/command-check-failure.yml @@ -0,0 +1,11 @@ +- hosts: localhost + tasks: + - name: command without checks + command: echo blah + args: + chdir: X + + - name: shell without checks + shell: echo blah + args: + chdir: X diff --git a/test/command-check-success.yml b/test/command-check-success.yml new file mode 100644 index 0000000..24266ff --- /dev/null +++ b/test/command-check-success.yml @@ -0,0 +1,61 @@ +- hosts: localhost + tasks: + - name: command with creates check + command: echo blah + args: + creates: Z + + - name: command with removes check + command: echo blah + args: + removes: Z + + - name: command with changed_when + command: echo blah + changed_when: False + + - name: command with inline creates + command: creates=Z echo blah + + - name: command with inline removes + command: removes=Z echo blah + + - name: command with cmd + command: + cmd: + echo blah + args: + creates: Z + + - name: shell with creates check + shell: echo blah + args: + creates: Z + + - name: shell with removes check + shell: echo blah + args: + removes: Z + + - name: shell with changed_when + shell: echo blah + changed_when: False + + - name: shell with inline creates + shell: creates=Z echo blah + + - name: shell with inline removes + shell: removes=Z echo blah + + - name: shell with cmd + shell: + cmd: + echo blah + args: + creates: Z + +- hosts: localhost + handlers: + - name: restart something + command: do something + - include: included-handlers.yml diff --git a/test/command-instead-of-shell-failure.yml b/test/command-instead-of-shell-failure.yml new file mode 100644 index 0000000..7b8d829 --- /dev/null +++ b/test/command-instead-of-shell-failure.yml @@ -0,0 +1,8 @@ +--- +- hosts: localhost + tasks: + - name: shell no pipe + shell: echo hello + + - name: shell with jinja filter + shell: echo {{"hello"|upper}} diff --git a/test/command-instead-of-shell-success.yml b/test/command-instead-of-shell-success.yml new file mode 100644 index 0000000..410ff97 --- /dev/null +++ b/test/command-instead-of-shell-success.yml @@ -0,0 +1,37 @@ +- hosts: localhost + tasks: + - name: shell with pipe + shell: echo hello | true + + - name: shell with redirect + shell: echo hello > /tmp/hello + + - name: chain two shell commands + shell: echo hello && echo goodbye + + - name: run commands in succession + shell: echo hello ; echo goodbye + + - name: use variables + shell: echo $HOME $USER + + - name: use * for globbing + shell: ls foo* + + - name: use ? for globbing + shell: ls foo? + + - name: use [] for globbing + shell: ls foo[1,2,3] + + - name: use shell generator + shell: ls foo{.txt,.xml} + + - name: use backticks + shell: ls `ls foo*` + + - name: use shell with cmd + shell: + cmd: | + set -x + ls foo? diff --git a/test/common-include-1.yml b/test/common-include-1.yml new file mode 100644 index 0000000..287df85 --- /dev/null +++ b/test/common-include-1.yml @@ -0,0 +1,4 @@ +--- +- hosts: webservers + tasks: + - include: included-with-lint.yml diff --git a/test/common-include-2.yml b/test/common-include-2.yml new file mode 100644 index 0000000..287df85 --- /dev/null +++ b/test/common-include-2.yml @@ -0,0 +1,4 @@ +--- +- hosts: webservers + tasks: + - include: included-with-lint.yml diff --git a/test/contains_secrets.yml b/test/contains_secrets.yml new file mode 100644 index 0000000..aed5a0e --- /dev/null +++ b/test/contains_secrets.yml @@ -0,0 +1,14 @@ +- hosts: localhost + vars: + plain: hello123 + # just 'hello123' encrypted with 'letmein' for test purposes + secret: !vault | + $ANSIBLE_VAULT;1.1;AES256 + 63346434613163653866303630313238626164313961613935373137323639636333393338386232 + 3735313061316666343839343665383036623237353263310a623639336530383433343833653138 + 30393032393534316164613834393864616566646164363830316664623636643731383164376163 + 3736653037356435310a303533383533353739323834343637366438633766666163656330343631 + 3066 + tasks: + - name: just a debug task + debug: msg="hello world" diff --git a/test/custom_rules/__init__.py b/test/custom_rules/__init__.py new file mode 100644 index 0000000..09a0f04 --- /dev/null +++ b/test/custom_rules/__init__.py @@ -0,0 +1 @@ +"""Dummy test module.""" diff --git a/test/custom_rules/example_com/ExampleComRule.py b/test/custom_rules/example_com/ExampleComRule.py new file mode 100644 index 0000000..0ce9c1c --- /dev/null +++ b/test/custom_rules/example_com/ExampleComRule.py @@ -0,0 +1,28 @@ +# Copyright (c) 2020, Ansible Project +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# 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. +"""A dummy custom rule module #2.""" + +import ansiblelint.rules.AlwaysRunRule + + +class ExampleComRule(ansiblelint.rules.AlwaysRunRule.AlwaysRunRule): + """A dummy custom rule class.""" + + id = '100002' diff --git a/test/custom_rules/example_com/__init__.py b/test/custom_rules/example_com/__init__.py new file mode 100644 index 0000000..a633c75 --- /dev/null +++ b/test/custom_rules/example_com/__init__.py @@ -0,0 +1 @@ +"""A dummy test module #2.""" diff --git a/test/custom_rules/example_inc/CustomAlwaysRunRule.py b/test/custom_rules/example_inc/CustomAlwaysRunRule.py new file mode 100644 index 0000000..1bff62d --- /dev/null +++ b/test/custom_rules/example_inc/CustomAlwaysRunRule.py @@ -0,0 +1,28 @@ +# Copyright (c) 2020, Ansible Project +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# 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. +"""Dummy custom rule module.""" + +import ansiblelint.rules.AlwaysRunRule + + +class CustomAlwaysRunRule(ansiblelint.rules.AlwaysRunRule.AlwaysRunRule): + """Dummy custom rule class.""" + + id = '100001' diff --git a/test/custom_rules/example_inc/__init__.py b/test/custom_rules/example_inc/__init__.py new file mode 100644 index 0000000..09a0f04 --- /dev/null +++ b/test/custom_rules/example_inc/__init__.py @@ -0,0 +1 @@ +"""Dummy test module.""" diff --git a/test/dependency-in-meta/bitbucket.yml b/test/dependency-in-meta/bitbucket.yml new file mode 100644 index 0000000..b696809 --- /dev/null +++ b/test/dependency-in-meta/bitbucket.yml @@ -0,0 +1,10 @@ +--- + +dependencies: + # from Bitbucket + - src: git+http://bitbucket.org/willthames/git-ansible-galaxy + version: v1.4 + + # from Bitbucket, alternative syntax and caveats + - src: http://bitbucket.org/willthames/hg-ansible-galaxy + scm: hg diff --git a/test/dependency-in-meta/galaxy.yml b/test/dependency-in-meta/galaxy.yml new file mode 100644 index 0000000..7f2c343 --- /dev/null +++ b/test/dependency-in-meta/galaxy.yml @@ -0,0 +1,5 @@ +--- + +dependencies: + # from galaxy + - src: yatesr.timezone diff --git a/test/dependency-in-meta/github.yml b/test/dependency-in-meta/github.yml new file mode 100644 index 0000000..234c85a --- /dev/null +++ b/test/dependency-in-meta/github.yml @@ -0,0 +1,10 @@ +--- + +dependencies: + # from GitHub + - src: https://github.com/bennojoy/nginx + + # from GitHub, overriding the name and specifying a specific tag + - src: https://github.com/bennojoy/nginx + version: master + name: nginx_role diff --git a/test/dependency-in-meta/gitlab.yml b/test/dependency-in-meta/gitlab.yml new file mode 100644 index 0000000..03b741e --- /dev/null +++ b/test/dependency-in-meta/gitlab.yml @@ -0,0 +1,7 @@ +--- + +dependencies: + # from GitLab or other git-based scm + - src: git@gitlab.company.com:mygroup/ansible-base.git + scm: git + version: "0.1" # quoted, so YAML doesn't parse this as a floating-point value diff --git a/test/dependency-in-meta/webserver.yml b/test/dependency-in-meta/webserver.yml new file mode 100644 index 0000000..2209ee2 --- /dev/null +++ b/test/dependency-in-meta/webserver.yml @@ -0,0 +1,6 @@ +--- + +dependencies: + # from a webserver, where the role is packaged in a tar.gz + - src: https://some.webserver.example.com/files/master.tar.gz + name: http-role diff --git a/test/directory with spaces/main.yml b/test/directory with spaces/main.yml new file mode 100644 index 0000000..1969c6e --- /dev/null +++ b/test/directory with spaces/main.yml @@ -0,0 +1 @@ +- debug: msg="tasks in directory with spaces included" diff --git a/test/ematchtest.yml b/test/ematchtest.yml new file mode 100644 index 0000000..333526c --- /dev/null +++ b/test/ematchtest.yml @@ -0,0 +1,5 @@ +hello +nothing +exciting +is +happening diff --git a/test/emptytags.yml b/test/emptytags.yml new file mode 100644 index 0000000..26758dc --- /dev/null +++ b/test/emptytags.yml @@ -0,0 +1,7 @@ +--- +- hosts: all + + tasks: + - name: hello world + debug: msg="hello world" + tags: diff --git a/test/fixtures/ansible-config-invalid.yml b/test/fixtures/ansible-config-invalid.yml new file mode 100644 index 0000000..ca8c431 --- /dev/null +++ b/test/fixtures/ansible-config-invalid.yml @@ -0,0 +1,3 @@ +# invalid .ansible-lint config file +- foo +- bar diff --git a/test/fixtures/ansible-config.yml b/test/fixtures/ansible-config.yml new file mode 100644 index 0000000..4c94267 --- /dev/null +++ b/test/fixtures/ansible-config.yml @@ -0,0 +1,4 @@ +--- +verbosity: 1 + +# vim: et:sw=2:syntax=yaml:ts=2: diff --git a/test/fixtures/config-with-relative-path.yml b/test/fixtures/config-with-relative-path.yml new file mode 100644 index 0000000..51ac404 --- /dev/null +++ b/test/fixtures/config-with-relative-path.yml @@ -0,0 +1,5 @@ +--- +exclude_paths: +- ../test-role/ + +# vim: et:sw=2:syntax=yaml:ts=2: diff --git a/test/fixtures/exclude-paths-with-expands.yml b/test/fixtures/exclude-paths-with-expands.yml new file mode 100644 index 0000000..20c742d --- /dev/null +++ b/test/fixtures/exclude-paths-with-expands.yml @@ -0,0 +1,6 @@ +--- +exclude_paths: +- ~/.ansible/roles +- $HOME/.ansible/roles + +# vim: et:sw=2:syntax=yaml:ts=2: diff --git a/test/fixtures/exclude-paths.yml b/test/fixtures/exclude-paths.yml new file mode 100644 index 0000000..a8bb938 --- /dev/null +++ b/test/fixtures/exclude-paths.yml @@ -0,0 +1,5 @@ +--- +exclude_paths: +- ../ + +# vim: et:sw=2:syntax=yaml:ts=2: diff --git a/test/fixtures/parseable.yml b/test/fixtures/parseable.yml new file mode 100644 index 0000000..4603267 --- /dev/null +++ b/test/fixtures/parseable.yml @@ -0,0 +1,4 @@ +--- +parseable: true + +# vim: et:sw=2:syntax=yaml:ts=2: diff --git a/test/fixtures/quiet.yml b/test/fixtures/quiet.yml new file mode 100644 index 0000000..583556f --- /dev/null +++ b/test/fixtures/quiet.yml @@ -0,0 +1,4 @@ +--- +quiet: true + +# vim: et:sw=2:syntax=yaml:ts=2: diff --git a/test/fixtures/rulesdir-defaults.yml b/test/fixtures/rulesdir-defaults.yml new file mode 100644 index 0000000..9eb30c6 --- /dev/null +++ b/test/fixtures/rulesdir-defaults.yml @@ -0,0 +1,6 @@ +--- +rulesdir: +- ./rules +use_default_rules: true + +# vim: et:sw=2:syntax=yaml:ts=2: diff --git a/test/fixtures/rulesdir.yml b/test/fixtures/rulesdir.yml new file mode 100644 index 0000000..6ecd43d --- /dev/null +++ b/test/fixtures/rulesdir.yml @@ -0,0 +1,5 @@ +--- +rulesdir: +- ./rules + +# vim: et:sw=2:syntax=yaml:ts=2: diff --git a/test/fixtures/show-abspath.yml b/test/fixtures/show-abspath.yml new file mode 100644 index 0000000..1945d2e --- /dev/null +++ b/test/fixtures/show-abspath.yml @@ -0,0 +1,4 @@ +--- +display_relative_path: false + +# vim: et:sw=2:syntax=2:ts=2: diff --git a/test/fixtures/show-relpath.yml b/test/fixtures/show-relpath.yml new file mode 100644 index 0000000..7e7c3e6 --- /dev/null +++ b/test/fixtures/show-relpath.yml @@ -0,0 +1,4 @@ +--- +display_relative_path: true + +# vim: et:sw=2:syntax=2:ts=2: diff --git a/test/fixtures/skip-tags.yml b/test/fixtures/skip-tags.yml new file mode 100644 index 0000000..1f64b00 --- /dev/null +++ b/test/fixtures/skip-tags.yml @@ -0,0 +1,5 @@ +--- +skip_list: +- "bad_tag" + +# vim: et:sw=2:syntax=yaml:ts=2: diff --git a/test/fixtures/tags.yml b/test/fixtures/tags.yml new file mode 100644 index 0000000..39f444a --- /dev/null +++ b/test/fixtures/tags.yml @@ -0,0 +1,5 @@ +--- +tags: + - skip_ansible_lint + +# vim: et:sw=2:syntax=yaml:ts=2: diff --git a/test/fixtures/unknown-type.yml b/test/fixtures/unknown-type.yml new file mode 100644 index 0000000..54c6d2b --- /dev/null +++ b/test/fixtures/unknown-type.yml @@ -0,0 +1,2 @@ +--- +some: map diff --git a/test/fixtures/verbosity.yml b/test/fixtures/verbosity.yml new file mode 100644 index 0000000..4c94267 --- /dev/null +++ b/test/fixtures/verbosity.yml @@ -0,0 +1,4 @@ +--- +verbosity: 1 + +# vim: et:sw=2:syntax=yaml:ts=2: diff --git a/test/foo.txt b/test/foo.txt new file mode 100644 index 0000000..94f8f1a --- /dev/null +++ b/test/foo.txt @@ -0,0 +1 @@ +Foo file diff --git a/test/include-import-role.yml b/test/include-import-role.yml new file mode 100644 index 0000000..7050ed1 --- /dev/null +++ b/test/include-import-role.yml @@ -0,0 +1,17 @@ +- hosts: all + vars: + var_is_set: no + + tasks: + - import_role: + name: test-role + +- hosts: all + vars: + var_is_set: no + + tasks: + - include_role: + name: test-role + tasks_from: world + when: "{{ var_is_set }}" diff --git a/test/include-import-tasks-in-role.yml b/test/include-import-tasks-in-role.yml new file mode 100644 index 0000000..313fc28 --- /dev/null +++ b/test/include-import-tasks-in-role.yml @@ -0,0 +1,3 @@ +- hosts: all + roles: + - role-with-included-imported-tasks diff --git a/test/include-in-block-inner.yml b/test/include-in-block-inner.yml new file mode 100644 index 0000000..be491a1 --- /dev/null +++ b/test/include-in-block-inner.yml @@ -0,0 +1,5 @@ +--- + +- block: + - include: simpletask.yml + tags: ['foo'] diff --git a/test/include-in-block.yml b/test/include-in-block.yml new file mode 100644 index 0000000..74f1f99 --- /dev/null +++ b/test/include-in-block.yml @@ -0,0 +1,5 @@ +--- +- hosts: all + + tasks: + - include: include-in-block-inner.yml diff --git a/test/included-handlers.yml b/test/included-handlers.yml new file mode 100644 index 0000000..322b347 --- /dev/null +++ b/test/included-handlers.yml @@ -0,0 +1,6 @@ +--- +- name: restart xyz + service: name=xyz state=restarted +# see Issue #165 +- name: command handler issue 165 + command: do something diff --git a/test/included-with-lint.yml b/test/included-with-lint.yml new file mode 100644 index 0000000..d92af1a --- /dev/null +++ b/test/included-with-lint.yml @@ -0,0 +1,4 @@ +# missing a task name +- yum: + name: ansible + until: result|success diff --git a/test/includedoesnotexist.yml b/test/includedoesnotexist.yml new file mode 100644 index 0000000..b123339 --- /dev/null +++ b/test/includedoesnotexist.yml @@ -0,0 +1,3 @@ +--- +- pre_tasks: + - include: "doesnotexist.yml" diff --git a/test/jinja2-when-failure.yml b/test/jinja2-when-failure.yml new file mode 100644 index 0000000..aec7269 --- /dev/null +++ b/test/jinja2-when-failure.yml @@ -0,0 +1,10 @@ +- hosts: all + tasks: + - name: test when with jinja2 + debug: msg=text + when: "{{ false }}" + +- hosts: all + roles: + - role: test + when: "{{ '1' = '1' }}" diff --git a/test/jinja2-when-success.yml b/test/jinja2-when-success.yml new file mode 100644 index 0000000..20d3db9 --- /dev/null +++ b/test/jinja2-when-success.yml @@ -0,0 +1,8 @@ +- hosts: all + tasks: + - name: test when + debug: msg=text + when: true + - name: test when 2 + debug: msg=text2 + when: 1 = 1 diff --git a/test/local-content/README.md b/test/local-content/README.md new file mode 100644 index 0000000..2b6322a --- /dev/null +++ b/test/local-content/README.md @@ -0,0 +1,6 @@ +The reason that every roles test gets its own directory is that while they +use the same three roles, the way the tests work makes sure that when the +second one runs, the roles and their local plugins from the first test are +still known to Ansible. For that reason, their names reflect the directory +they are in to make sure that tests don't use modules/plugins found by +other tests. diff --git a/test/local-content/collections/ansible_collections/testns/testcoll/galaxy.yml b/test/local-content/collections/ansible_collections/testns/testcoll/galaxy.yml new file mode 100644 index 0000000..43dd2e9 --- /dev/null +++ b/test/local-content/collections/ansible_collections/testns/testcoll/galaxy.yml @@ -0,0 +1,3 @@ +namespace: testns +name: testcoll +version: 0.1.0 diff --git a/test/local-content/collections/ansible_collections/testns/testcoll/plugins/filter/test_filter.py b/test/local-content/collections/ansible_collections/testns/testcoll/plugins/filter/test_filter.py new file mode 100644 index 0000000..ac9e854 --- /dev/null +++ b/test/local-content/collections/ansible_collections/testns/testcoll/plugins/filter/test_filter.py @@ -0,0 +1,16 @@ +"""A filter plugin.""" + + +def a_test_filter(a, b): + """Return a string containing both a and b.""" + return '{0}:{1}'.format(a, b) + + +class FilterModule(object): + """Filter plugin.""" + + def filters(self): + """Return filters.""" + return { + 'test_filter': a_test_filter + } diff --git a/test/local-content/collections/ansible_collections/testns/testcoll/plugins/modules/test_module_2.py b/test/local-content/collections/ansible_collections/testns/testcoll/plugins/modules/test_module_2.py new file mode 100644 index 0000000..cae1a26 --- /dev/null +++ b/test/local-content/collections/ansible_collections/testns/testcoll/plugins/modules/test_module_2.py @@ -0,0 +1,14 @@ +#!/usr/bin/python +"""A module.""" + +from ansible.module_utils.basic import AnsibleModule + + +def main() -> None: + """Execute module.""" + module = AnsibleModule(dict()) + module.exit_json(msg="Hello 2!") + + +if __name__ == '__main__': + main() diff --git a/test/local-content/test-collection.yml b/test/local-content/test-collection.yml new file mode 100644 index 0000000..bc3ed1b --- /dev/null +++ b/test/local-content/test-collection.yml @@ -0,0 +1,10 @@ +--- +- name: Use module and filter plugin from local collection + hosts: localhost + tasks: + - name: Use module from local collection + testns.testcoll.test_module_2: + - name: Use filter from local collection + assert: + that: + - 1 | testns.testcoll.test_filter(2) == '1:2' diff --git a/test/local-content/test-roles-failed-complete/roles/role1/library/test_module_1_failed_complete.py b/test/local-content/test-roles-failed-complete/roles/role1/library/test_module_1_failed_complete.py new file mode 100644 index 0000000..1c63fdd --- /dev/null +++ b/test/local-content/test-roles-failed-complete/roles/role1/library/test_module_1_failed_complete.py @@ -0,0 +1,14 @@ +#!/usr/bin/python +"""A module.""" + +from ansible.module_utils.basic import AnsibleModule + + +def main() -> None: + """Execute module.""" + module = AnsibleModule(dict()) + module.exit_json(msg="Hello 1!") + + +if __name__ == '__main__': + main() diff --git a/test/local-content/test-roles-failed-complete/roles/role1/tasks/main.yml b/test/local-content/test-roles-failed-complete/roles/role1/tasks/main.yml new file mode 100644 index 0000000..680dcab --- /dev/null +++ b/test/local-content/test-roles-failed-complete/roles/role1/tasks/main.yml @@ -0,0 +1,3 @@ +--- +- name: Use local module 1 + test_module_1_failed_complete: diff --git a/test/local-content/test-roles-failed-complete/roles/role2/tasks/main.yml b/test/local-content/test-roles-failed-complete/roles/role2/tasks/main.yml new file mode 100644 index 0000000..8646f6b --- /dev/null +++ b/test/local-content/test-roles-failed-complete/roles/role2/tasks/main.yml @@ -0,0 +1,11 @@ +--- +- name: Use local module from other role that has been included before this one + # If it has not been included before, loading this role fails! + test_module_1_failed_complete: +- name: Use local module from other role that has been included before this one + # If it has not been included before, loading this role fails! + test_module_3_failed_complete: +- name: Use local test plugin + assert: + that: + - "'2' is b_test_failed_complete '12345'" 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 new file mode 100644 index 0000000..abc1049 --- /dev/null +++ b/test/local-content/test-roles-failed-complete/roles/role2/test_plugins/b_failed_complete.py @@ -0,0 +1,16 @@ +"""A test plugin.""" + + +def compatibility_in_test(a, b): + """Return True when a is contained in b.""" + return a in b + + +class TestModule: + """Test plugin.""" + + def tests(self): + """Return tests.""" + return { + 'b_test_failed_complete': compatibility_in_test, + } diff --git a/test/local-content/test-roles-failed-complete/roles/role3/library/test_module_3_failed_complete.py b/test/local-content/test-roles-failed-complete/roles/role3/library/test_module_3_failed_complete.py new file mode 100644 index 0000000..c7296be --- /dev/null +++ b/test/local-content/test-roles-failed-complete/roles/role3/library/test_module_3_failed_complete.py @@ -0,0 +1,14 @@ +#!/usr/bin/python +"""A module.""" + +from ansible.module_utils.basic import AnsibleModule + + +def main() -> None: + """Execute module.""" + module = AnsibleModule(dict()) + module.exit_json(msg="Hello 3!") + + +if __name__ == '__main__': + main() diff --git a/test/local-content/test-roles-failed-complete/roles/role3/tasks/main.yml b/test/local-content/test-roles-failed-complete/roles/role3/tasks/main.yml new file mode 100644 index 0000000..7a36734 --- /dev/null +++ b/test/local-content/test-roles-failed-complete/roles/role3/tasks/main.yml @@ -0,0 +1,3 @@ +--- +- name: Use local module 3 + test_module_3_failed_complete: diff --git a/test/local-content/test-roles-failed-complete/test.yml b/test/local-content/test-roles-failed-complete/test.yml new file mode 100644 index 0000000..1160bb5 --- /dev/null +++ b/test/local-content/test-roles-failed-complete/test.yml @@ -0,0 +1,5 @@ +--- +- name: Include role which expects module that is local to other role which is not loaded + hosts: localhost + roles: + - role2 diff --git a/test/local-content/test-roles-failed/roles/role1/library/test_module_1_failed.py b/test/local-content/test-roles-failed/roles/role1/library/test_module_1_failed.py new file mode 100644 index 0000000..1c63fdd --- /dev/null +++ b/test/local-content/test-roles-failed/roles/role1/library/test_module_1_failed.py @@ -0,0 +1,14 @@ +#!/usr/bin/python +"""A module.""" + +from ansible.module_utils.basic import AnsibleModule + + +def main() -> None: + """Execute module.""" + module = AnsibleModule(dict()) + module.exit_json(msg="Hello 1!") + + +if __name__ == '__main__': + main() diff --git a/test/local-content/test-roles-failed/roles/role1/tasks/main.yml b/test/local-content/test-roles-failed/roles/role1/tasks/main.yml new file mode 100644 index 0000000..257493a --- /dev/null +++ b/test/local-content/test-roles-failed/roles/role1/tasks/main.yml @@ -0,0 +1,3 @@ +--- +- name: Use local module 1 + test_module_1_failed: diff --git a/test/local-content/test-roles-failed/roles/role2/tasks/main.yml b/test/local-content/test-roles-failed/roles/role2/tasks/main.yml new file mode 100644 index 0000000..48daca6 --- /dev/null +++ b/test/local-content/test-roles-failed/roles/role2/tasks/main.yml @@ -0,0 +1,11 @@ +--- +- name: Use local module from other role that has been included before this one + # If it has not been included before, loading this role fails! + test_module_1_failed: +- name: Use local module from other role that has been included before this one + # If it has not been included before, loading this role fails! + test_module_3_failed: +- name: Use local test plugin + assert: + that: + - "'2' is b_test_failed '12345'" diff --git a/test/local-content/test-roles-failed/roles/role2/test_plugins/b_failed.py b/test/local-content/test-roles-failed/roles/role2/test_plugins/b_failed.py new file mode 100644 index 0000000..09a02a3 --- /dev/null +++ b/test/local-content/test-roles-failed/roles/role2/test_plugins/b_failed.py @@ -0,0 +1,16 @@ +"""A test plugin.""" + + +def compatibility_in_test(a, b): + """Return True when a is contained in b.""" + return a in b + + +class TestModule: + """Test plugin.""" + + def tests(self): + """Return tests.""" + return { + 'b_test_failed': compatibility_in_test, + } diff --git a/test/local-content/test-roles-failed/roles/role3/library/test_module_3_failed.py b/test/local-content/test-roles-failed/roles/role3/library/test_module_3_failed.py new file mode 100644 index 0000000..c7296be --- /dev/null +++ b/test/local-content/test-roles-failed/roles/role3/library/test_module_3_failed.py @@ -0,0 +1,14 @@ +#!/usr/bin/python +"""A module.""" + +from ansible.module_utils.basic import AnsibleModule + + +def main() -> None: + """Execute module.""" + module = AnsibleModule(dict()) + module.exit_json(msg="Hello 3!") + + +if __name__ == '__main__': + main() diff --git a/test/local-content/test-roles-failed/roles/role3/tasks/main.yml b/test/local-content/test-roles-failed/roles/role3/tasks/main.yml new file mode 100644 index 0000000..ad17eb0 --- /dev/null +++ b/test/local-content/test-roles-failed/roles/role3/tasks/main.yml @@ -0,0 +1,3 @@ +--- +- name: Use local module 3 + test_module_3_failed: diff --git a/test/local-content/test-roles-failed/test.yml b/test/local-content/test-roles-failed/test.yml new file mode 100644 index 0000000..08ff0f6 --- /dev/null +++ b/test/local-content/test-roles-failed/test.yml @@ -0,0 +1,7 @@ +--- +- name: Use roles with local module in wrong order, so that Ansible fails + hosts: localhost + roles: + - role2 + - role3 + - role1 diff --git a/test/local-content/test-roles-success/roles/role1/library/test_module_1_success.py b/test/local-content/test-roles-success/roles/role1/library/test_module_1_success.py new file mode 100644 index 0000000..1c63fdd --- /dev/null +++ b/test/local-content/test-roles-success/roles/role1/library/test_module_1_success.py @@ -0,0 +1,14 @@ +#!/usr/bin/python +"""A module.""" + +from ansible.module_utils.basic import AnsibleModule + + +def main() -> None: + """Execute module.""" + module = AnsibleModule(dict()) + module.exit_json(msg="Hello 1!") + + +if __name__ == '__main__': + main() diff --git a/test/local-content/test-roles-success/roles/role1/tasks/main.yml b/test/local-content/test-roles-success/roles/role1/tasks/main.yml new file mode 100644 index 0000000..ba920af --- /dev/null +++ b/test/local-content/test-roles-success/roles/role1/tasks/main.yml @@ -0,0 +1,3 @@ +--- +- name: Use local module 1 + test_module_1_success: diff --git a/test/local-content/test-roles-success/roles/role2/tasks/main.yml b/test/local-content/test-roles-success/roles/role2/tasks/main.yml new file mode 100644 index 0000000..a540cf1 --- /dev/null +++ b/test/local-content/test-roles-success/roles/role2/tasks/main.yml @@ -0,0 +1,11 @@ +--- +- name: Use local module from other role that has been included before this one + # If it has not been included before, loading this role fails! + test_module_1_success: +- name: Use local module from other role that has been included before this one + # If it has not been included before, loading this role fails! + test_module_3_success: +- name: Use local test plugin + assert: + that: + - "'2' is b_test_success '12345'" diff --git a/test/local-content/test-roles-success/roles/role2/test_plugins/b_success.py b/test/local-content/test-roles-success/roles/role2/test_plugins/b_success.py new file mode 100644 index 0000000..bcef377 --- /dev/null +++ b/test/local-content/test-roles-success/roles/role2/test_plugins/b_success.py @@ -0,0 +1,16 @@ +"""A test plugin.""" + + +def compatibility_in_test(a, b): + """Return True when a is contained in b.""" + return a in b + + +class TestModule: + """Test plugin.""" + + def tests(self): + """Return tests.""" + return { + 'b_test_success': compatibility_in_test, + } diff --git a/test/local-content/test-roles-success/roles/role3/library/test_module_3_success.py b/test/local-content/test-roles-success/roles/role3/library/test_module_3_success.py new file mode 100644 index 0000000..c7296be --- /dev/null +++ b/test/local-content/test-roles-success/roles/role3/library/test_module_3_success.py @@ -0,0 +1,14 @@ +#!/usr/bin/python +"""A module.""" + +from ansible.module_utils.basic import AnsibleModule + + +def main() -> None: + """Execute module.""" + module = AnsibleModule(dict()) + module.exit_json(msg="Hello 3!") + + +if __name__ == '__main__': + main() diff --git a/test/local-content/test-roles-success/roles/role3/tasks/main.yml b/test/local-content/test-roles-success/roles/role3/tasks/main.yml new file mode 100644 index 0000000..c77a7c8 --- /dev/null +++ b/test/local-content/test-roles-success/roles/role3/tasks/main.yml @@ -0,0 +1,3 @@ +--- +- name: Use local module 3 + test_module_3_success: diff --git a/test/local-content/test-roles-success/test.yml b/test/local-content/test-roles-success/test.yml new file mode 100644 index 0000000..df17c7d --- /dev/null +++ b/test/local-content/test-roles-success/test.yml @@ -0,0 +1,7 @@ +--- +- name: Use roles with local modules and test plugins + hosts: localhost + roles: + - role1 + - role3 + - role2 diff --git a/test/multiline-brackets-do-not-match-test.yml b/test/multiline-brackets-do-not-match-test.yml new file mode 100644 index 0000000..dfa5ff8 --- /dev/null +++ b/test/multiline-brackets-do-not-match-test.yml @@ -0,0 +1,22 @@ +--- +- hosts: foo + roles: + - ../../../roles/base_os + - ../../../roles/repos + - { + role: ../../../roles/openshift_master, + oo_minion_ips: "{ hostvars['localhost'].oo_minion_ips | default(['']) }}", + oo_bind_ip: "{{ hostvars[inventory_hostname].ansible_eth0.ipv4.address | default(['']) }}" + } + - ../../../roles/pods + +- name: "Set Origin specific facts on localhost (for later use)" + hosts: localhost + gather_facts: no + tasks: + - name: Setting oo_minion_ips fact on localhost + set_fact: + oo_minion_ips: "{{ hostvars + | oo_select_keys(groups['tag_env-host-type-' + oo_env + '-openshift-minion']) + | oo_collect(attribute='ansible_eth0.ipv4.address') }" + when: groups['tag_env-host-type-' + oo_env + '-openshift-minion'] is defined diff --git a/test/multiline-bracketsmatchtest.yml b/test/multiline-bracketsmatchtest.yml new file mode 100644 index 0000000..703f225 --- /dev/null +++ b/test/multiline-bracketsmatchtest.yml @@ -0,0 +1,22 @@ +--- +- hosts: foo + roles: + - ../../../roles/base_os + - ../../../roles/repos + - { + role: ../../../roles/openshift_master, + oo_minion_ips: "{{ hostvars['localhost'].oo_minion_ips | default(['']) }}", + oo_bind_ip: "{{ hostvars[inventory_hostname].ansible_eth0.ipv4.address | default(['']) }}" + } + - ../../../roles/pods + +- name: "Set Origin specific facts on localhost (for later use)" + hosts: localhost + gather_facts: no + tasks: + - name: Setting oo_minion_ips fact on localhost + set_fact: + oo_minion_ips: "{{ hostvars + | oo_select_keys(groups['tag_env-host-type-' + oo_env + '-openshift-minion']) + | oo_collect(attribute='ansible_eth0.ipv4.address') }}" + when: groups['tag_env-host-type-' + oo_env + '-openshift-minion'] is defined diff --git a/test/nestedincludes.yml b/test/nestedincludes.yml new file mode 100644 index 0000000..5985b54 --- /dev/null +++ b/test/nestedincludes.yml @@ -0,0 +1,2 @@ +--- +- include: simpletask.yml tags=nginx diff --git a/test/nomatchestest.yml b/test/nomatchestest.yml new file mode 100644 index 0000000..e48ef92 --- /dev/null +++ b/test/nomatchestest.yml @@ -0,0 +1,9 @@ +--- +- hosts: whatever + + tasks: + - name: hello world + action: debug msg="Hello!" + + - name: this should be fine too + action: file state=touch dest=./wherever mode=0600 diff --git a/test/norole.yml b/test/norole.yml new file mode 100644 index 0000000..3a18e94 --- /dev/null +++ b/test/norole.yml @@ -0,0 +1,5 @@ +--- +- hosts: + - localhost + roles: + - name: node diff --git a/test/norole2.yml b/test/norole2.yml new file mode 100644 index 0000000..2ee7a83 --- /dev/null +++ b/test/norole2.yml @@ -0,0 +1,5 @@ +--- +- hosts: + - localhost + roles: + - name: node diff --git a/test/package-check-failure.yml b/test/package-check-failure.yml new file mode 100644 index 0000000..8c25f3c --- /dev/null +++ b/test/package-check-failure.yml @@ -0,0 +1,14 @@ +- hosts: localhost + tasks: + - name: install ansible + yum: name=ansible state=latest + + - name: install ansible-lint + pip: name=ansible-lint + args: + state: latest + + - name: install some-package + package: + name: some-package + state: latest diff --git a/test/package-check-success.yml b/test/package-check-success.yml new file mode 100644 index 0000000..d649dc7 --- /dev/null +++ b/test/package-check-success.yml @@ -0,0 +1,15 @@ +- hosts: localhost + tasks: + - name: install ansible + yum: name=ansible-2.1.0.0 state=present + + - name: install ansible-lint + pip: name=ansible-lint + args: + state: present + version: 3.1.2 + + - name: install some-package + package: + name: some-package + state: present diff --git a/test/playbook-import/playbook_imported.yml b/test/playbook-import/playbook_imported.yml new file mode 100644 index 0000000..4dc646a --- /dev/null +++ b/test/playbook-import/playbook_imported.yml @@ -0,0 +1,9 @@ +--- +- hosts: localhost + connection: local + gather_facts: no + tasks: + - command: echo "no name" # should generate 502 + - name: Another task + debug: + msg: debug message diff --git a/test/playbook-import/playbook_parent.yml b/test/playbook-import/playbook_parent.yml new file mode 100644 index 0000000..7e8b524 --- /dev/null +++ b/test/playbook-import/playbook_parent.yml @@ -0,0 +1,3 @@ +--- +- name: Importing another playbook + import_playbook: playbook_imported.yml diff --git a/test/role-with-handler/a-role/handlers/main.yml b/test/role-with-handler/a-role/handlers/main.yml new file mode 100644 index 0000000..59ae800 --- /dev/null +++ b/test/role-with-handler/a-role/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: do anything + shell: echo merp | cat + when: + - something.changed diff --git a/test/role-with-handler/main.yml b/test/role-with-handler/main.yml new file mode 100644 index 0000000..9b37597 --- /dev/null +++ b/test/role-with-handler/main.yml @@ -0,0 +1,4 @@ +- hosts: localhost + name: foo + roles: + - { role: a-role } diff --git a/test/role-with-included-imported-tasks/tasks/imported_tasks.yml b/test/role-with-included-imported-tasks/tasks/imported_tasks.yml new file mode 100644 index 0000000..32a2b23 --- /dev/null +++ b/test/role-with-included-imported-tasks/tasks/imported_tasks.yml @@ -0,0 +1,2 @@ +- name: This is a task that should be imported + ping: diff --git a/test/role-with-included-imported-tasks/tasks/included_tasks.yml b/test/role-with-included-imported-tasks/tasks/included_tasks.yml new file mode 100644 index 0000000..37b59d2 --- /dev/null +++ b/test/role-with-included-imported-tasks/tasks/included_tasks.yml @@ -0,0 +1,2 @@ +- name: This is a task that should be included + ping: diff --git a/test/role-with-included-imported-tasks/tasks/main.yml b/test/role-with-included-imported-tasks/tasks/main.yml new file mode 100644 index 0000000..81de7cb --- /dev/null +++ b/test/role-with-included-imported-tasks/tasks/main.yml @@ -0,0 +1,6 @@ +- include_tasks: included_tasks.yml +- import_tasks: imported_tasks.yml +- include_tasks: + file: included_tasks.yml + apply: + tags: sometag diff --git a/test/roles/ansible-role-foo/tasks/main.yaml b/test/roles/ansible-role-foo/tasks/main.yaml new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/test/roles/ansible-role-foo/tasks/main.yaml diff --git a/test/roles/invalid-name/tasks/main.yaml b/test/roles/invalid-name/tasks/main.yaml new file mode 100644 index 0000000..1270837 --- /dev/null +++ b/test/roles/invalid-name/tasks/main.yaml @@ -0,0 +1,4 @@ +--- +- name: foo + debug: + msg: foo diff --git a/test/roles/invalid_due_to_meta/meta/main.yml b/test/roles/invalid_due_to_meta/meta/main.yml new file mode 100644 index 0000000..0d4b0b2 --- /dev/null +++ b/test/roles/invalid_due_to_meta/meta/main.yml @@ -0,0 +1,8 @@ +galaxy_info: + role_name: invalid-due-to-meta + author: foo + description: foo + license: MIT + platforms: + - name: foo + min_ansible_version: 2.7 diff --git a/test/roles/invalid_due_to_meta/tasks/main.yaml b/test/roles/invalid_due_to_meta/tasks/main.yaml new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/test/roles/invalid_due_to_meta/tasks/main.yaml diff --git a/test/roles/test-role/molecule/default/include-import-role.yml b/test/roles/test-role/molecule/default/include-import-role.yml new file mode 100644 index 0000000..0e1d166 --- /dev/null +++ b/test/roles/test-role/molecule/default/include-import-role.yml @@ -0,0 +1,6 @@ +--- +- name: test + gather_facts: no + hosts: all + roles: + - role: test-role diff --git a/test/roles/test-role/tasks/main.yml b/test/roles/test-role/tasks/main.yml new file mode 100644 index 0000000..53b968b --- /dev/null +++ b/test/roles/test-role/tasks/main.yml @@ -0,0 +1,2 @@ +- name: shell instead of command + shell: echo hello world diff --git a/test/roles/valid-due-to-meta/meta/main.yml b/test/roles/valid-due-to-meta/meta/main.yml new file mode 100644 index 0000000..8b8566b --- /dev/null +++ b/test/roles/valid-due-to-meta/meta/main.yml @@ -0,0 +1,8 @@ +galaxy_info: + role_name: valid_due_to_meta + author: foo + description: foo + license: MIT + platforms: + - name: foo + min_ansible_version: 2.7 diff --git a/test/roles/valid-due-to-meta/tasks/debian/main.yml b/test/roles/valid-due-to-meta/tasks/debian/main.yml new file mode 100644 index 0000000..6fa48c2 --- /dev/null +++ b/test/roles/valid-due-to-meta/tasks/debian/main.yml @@ -0,0 +1,2 @@ +# This empty task file is here to test that roles with tasks organized in subdirectories +# are handled correctly by ansible-lint. diff --git a/test/roles/valid-due-to-meta/tasks/main.yaml b/test/roles/valid-due-to-meta/tasks/main.yaml new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/test/roles/valid-due-to-meta/tasks/main.yaml diff --git a/test/rules/EMatcherRule.py b/test/rules/EMatcherRule.py new file mode 100644 index 0000000..ed65883 --- /dev/null +++ b/test/rules/EMatcherRule.py @@ -0,0 +1,12 @@ +from ansiblelint.rules import AnsibleLintRule + + +class EMatcherRule(AnsibleLintRule): + id = 'TEST0001' + description = 'This is a test rule that looks for lines ' + \ + 'containing the letter e' + shortdesc = 'The letter "e" is present' + tags = ['fake', 'dummy', 'test1'] + + def match(self, filename, line): + return "e" in line diff --git a/test/rules/UnsetVariableMatcherRule.py b/test/rules/UnsetVariableMatcherRule.py new file mode 100644 index 0000000..b4c1756 --- /dev/null +++ b/test/rules/UnsetVariableMatcherRule.py @@ -0,0 +1,12 @@ +from ansiblelint.rules import AnsibleLintRule + + +class UnsetVariableMatcherRule(AnsibleLintRule): + id = 'TEST0002' + shortdesc = 'Line contains untemplated variable' + description = 'This is a test rule that looks for lines ' + \ + 'post templating that still contain {{' + tags = ['fake', 'dummy', 'test2'] + + def match(self, filename, line): + return "{{" in line diff --git a/test/rules/__init__.py b/test/rules/__init__.py new file mode 100644 index 0000000..4a6e23f --- /dev/null +++ b/test/rules/__init__.py @@ -0,0 +1,3 @@ +"""Test rules resources.""" + +__all__ = ['UnsetVariableMatcherRule', 'EMatcherRule'] diff --git a/test/simpletask.yml b/test/simpletask.yml new file mode 100644 index 0000000..0aba042 --- /dev/null +++ b/test/simpletask.yml @@ -0,0 +1,3 @@ +--- +- name: hello world + debug: msg="Hello!" diff --git a/test/skiptasks.yml b/test/skiptasks.yml new file mode 100644 index 0000000..c6a130b --- /dev/null +++ b/test/skiptasks.yml @@ -0,0 +1,70 @@ +--- +- hosts: all + + tasks: + + - name: test 401 + action: git + + - name: test 402 + action: hg + + - name: test 303 + command: git log + changed_when: False + + - name: test 302 + command: creates=B chmod 644 A + + - name: test invalid action (skip) + foo: bar + tags: + - skip_ansible_lint + + - name: test 401 (skip) + action: git + tags: + - skip_ansible_lint + + - name: test 402 (skip) + action: hg + tags: + - skip_ansible_lint + + - name: test 303 (skip) + command: git log + tags: + - skip_ansible_lint + + - name: test 302 (skip) + command: chmod 644 A + tags: + - skip_ansible_lint + + - name: test 401 (don't warn) + command: git log + args: + warn: False + changed_when: False + + - name: test 402 (don't warn) + command: chmod 644 A + args: + warn: False + creates: B + + - name: test 402 (warn) + command: chmod 644 A + args: + warn: yes + creates: B + + - name: test 401 (don't warn single line) + command: warn=False chdir=/tmp/blah git log + changed_when: False + + - name: test 402 (don't warn single line) + command: warn=no creates=B chmod 644 A + + - name: test 402 (warn single line) + command: warn=yes creates=B chmod 644 A diff --git a/test/task-has-name-failure.yml b/test/task-has-name-failure.yml new file mode 100644 index 0000000..ce947f3 --- /dev/null +++ b/test/task-has-name-failure.yml @@ -0,0 +1,7 @@ +--- + +- hosts: all + tasks: + - command: echo "no name" + - name: + command: echo "empty name" diff --git a/test/task-has-name-success.yml b/test/task-has-name-success.yml new file mode 100644 index 0000000..b708f5a --- /dev/null +++ b/test/task-has-name-success.yml @@ -0,0 +1,9 @@ +--- + +- hosts: all + tasks: + - name: This task has a name + command: echo "Hello World" + - debug: + msg: "Hello World" + - meta: flush_handlers diff --git a/test/taskimports.yml b/test/taskimports.yml new file mode 100644 index 0000000..f21b678 --- /dev/null +++ b/test/taskimports.yml @@ -0,0 +1,9 @@ +--- +- hosts: webservers + vars: + varset: varset + tasks: + - import_tasks: nestedincludes.yml tags=nested + - import_tasks: "{{ varnotset }}.yml" + - import_tasks: "{{ varset }}.yml" + - import_tasks: "directory with spaces/main.yml" diff --git a/test/taskincludes.yml b/test/taskincludes.yml new file mode 100644 index 0000000..cba7909 --- /dev/null +++ b/test/taskincludes.yml @@ -0,0 +1,9 @@ +--- +- hosts: webservers + vars: + varset: varset + tasks: + - include: nestedincludes.yml tags=nested + - include: "{{ varnotset }}.yml" + - include: "{{ varset }}.yml" + - include: "directory with spaces/main.yml" diff --git a/test/taskincludes_2_4_style.yml b/test/taskincludes_2_4_style.yml new file mode 100644 index 0000000..f1ae9f4 --- /dev/null +++ b/test/taskincludes_2_4_style.yml @@ -0,0 +1,9 @@ +--- +- hosts: webservers + vars: + varset: varset + tasks: + - include_tasks: nestedincludes.yml tags=nested + - include_tasks: "{{ varnotset }}.yml" + - include_tasks: "{{ varset }}.yml" + - include_tasks: "directory with spaces/main.yml" diff --git a/test/test-role/tasks/main.yml b/test/test-role/tasks/main.yml new file mode 100644 index 0000000..53b968b --- /dev/null +++ b/test/test-role/tasks/main.yml @@ -0,0 +1,2 @@ +- name: shell instead of command + shell: echo hello world diff --git a/test/test-role/tasks/world.yml b/test/test-role/tasks/world.yml new file mode 100644 index 0000000..69ae661 --- /dev/null +++ b/test/test-role/tasks/world.yml @@ -0,0 +1 @@ +- command: echo this is a task without a name diff --git a/test/test/always-run-success.yml b/test/test/always-run-success.yml new file mode 100644 index 0000000..468a17c --- /dev/null +++ b/test/test/always-run-success.yml @@ -0,0 +1 @@ +- hosts: localhost diff --git a/test/testproject/roles/test-role/tasks/main.yml b/test/testproject/roles/test-role/tasks/main.yml new file mode 100644 index 0000000..53b968b --- /dev/null +++ b/test/testproject/roles/test-role/tasks/main.yml @@ -0,0 +1,2 @@ +- name: shell instead of command + shell: echo hello world diff --git a/test/unicode.yml b/test/unicode.yml new file mode 100644 index 0000000..c9204e4 --- /dev/null +++ b/test/unicode.yml @@ -0,0 +1,9 @@ +--- +- hosts: localhost + connection: local + vars: + unicode_var: a_b_cö + + tasks: + - name: bonjour, ça va? + file: state=touch dest=/tmp/naïve.yml mode=0600 diff --git a/test/using-bare-variables-failure.yml b/test/using-bare-variables-failure.yml new file mode 100644 index 0000000..865381b --- /dev/null +++ b/test/using-bare-variables-failure.yml @@ -0,0 +1,108 @@ +--- +- hosts: localhost + become: no + vars: + my_list: + - foo + - bar + + my_list2: + - 1 + - 2 + + my_list_of_dicts: + - foo: 1 + bar: 2 + - foo: 3 + bar: 4 + + my_list_of_lists: + - "{{ my_list }}" + - "{{ my_list2 }}" + + my_filenames: + - foo.txt + - bar.txt + + my_dict: + foo: bar + + tasks: + - name: with_items loop using bare variable + debug: + msg: "{{ item }}" + with_items: my_list + + - name: with_dict loop using bare variable + debug: + msg: "{{ item }}" + with_dict: my_dict + + ### Testing with_dict with a default empty dictionary + - name: with_dict loop using variable and default + debug: + msg: "{{ item.key }} - {{ item.value }}" + with_dict: uwsgi_ini | default({}) + + - name: with_nested loop using bare variable + debug: + msg: "{{ item.0 }} {{ item.1 }}" + with_nested: + - my_list + - "{{ my_list2 }}" + + - name: with_file loop using bare variable + debug: + msg: "{{ item }}" + with_file: my_list + + - name: with_fileglob loop using bare variable + debug: + msg: "{{ item }}" + with_fileglob: my_list + + - name: with_filetree loop using bare variable + debug: + msg: "{{ item }}" + with_filetree: my_list + + - name: with_together loop using bare variable + debug: + msg: "{{ item.0 }} {{ item.1 }}" + with_together: + - my_list + - "{{ my_list2 }}" + + - name: with_subelements loop using bare variable + debug: + msg: "{{ item.0 }}" + with_subelements: + - my_list_of_dicts + - bar + + - name: with_random_choice loop using bare variable + debug: + msg: "{{ item }}" + with_random_choice: my_list + + - name: with_first_found loop using bare variable + debug: + msg: "{{ item }}" + with_first_found: my_filenames + + - name: with_indexed_items loop + debug: + msg: "{{ item.0 }} {{ item.1 }}" + with_indexed_items: my_list + + - name: with_flattened loop + debug: + msg: "{{ item }}" + with_flattened: + - my_list + - my_list2 + + - name: with_flattened loop with a variable + debug: + msg: "{{ item }}" + with_flattened: my_list_of_lists diff --git a/test/using-bare-variables-success.yml b/test/using-bare-variables-success.yml new file mode 100644 index 0000000..c8f0f3f --- /dev/null +++ b/test/using-bare-variables-success.yml @@ -0,0 +1,200 @@ +--- +- hosts: localhost + become: no + vars: + my_list: + - foo + - bar + + my_list2: + - 1 + - 2 + + my_list_of_dicts: + - foo: 1 + bar: 2 + - foo: 3 + bar: 4 + + my_list_of_lists: + - "{{ my_list }}" + - "{{ my_list2 }}" + + my_filenames: + - foo.txt + - bar.txt + + my_dict: + foo: bar + + tasks: + ### Testing with_items loops + - name: with_items loop using static list + debug: + msg: "{{ item }}" + with_items: + - foo + - bar + + - name: with_items using a static hash + debug: + msg: "{{ item.key }} - {{ item.value }}" + with_items: + - { key: foo, value: 1 } + - { key: bar, value: 2 } + + - name: with_items loop using variable + debug: + msg: "{{ item }}" + with_items: "{{ my_list }}" + + ### Testing with_nested loops + - name: with_nested loop using static lists + debug: + msg: "{{ item[0] }} - {{ item[1] }}" + with_nested: + - [ 'foo', 'bar' ] + - [ '1', '2', '3' ] + + - name: with_nested loop using variable list and static + debug: + msg: "{{ item[0] }} - {{ item[1] }}" + with_nested: + - "{{ my_list }}" + - [ '1', '2', '3' ] + + ### Testing with_dict + - name: with_dict loop using variable + debug: + msg: "{{ item.key }} - {{ item.value }}" + with_dict: "{{ my_dict }}" + + ### Testing with_dict with a default empty dictionary + - name: with_dict loop using variable and default + debug: + msg: "{{ item.key }} - {{ item.value }}" + with_dict: "{{ uwsgi_ini | default({}) }}" + + ### Testing with_file + - name: with_file loop using static files list + debug: + msg: "{{ item }}" + with_file: + - foo.txt + - bar.txt + + - name: with_file loop using list of filenames + debug: + msg: "{{ item }}" + with_file: "{{ my_filenames }}" + + ### Testing with_fileglob + - name: with_fileglob loop using list of *.txt + debug: + msg: "{{ item }}" + with_fileglob: + - '*.txt' + + ### Testing non-list form of with_fileglob + - name: with_fileglob loop using single value *.txt + debug: + msg: "{{ item }}" + with_fileglob: '*.txt' + + ### Testing non-list form of with_fileglob with trailing templated pattern + - name: with_fileglob loop using templated pattern + debug: + msg: "{{ item }}" + with_fileglob: 'foo{{glob}}' + + ### Testing with_filetree + - name: with_filetree loop using list of path + debug: + msg: "{{ item }}" + with_filetree: + - path/to/dir1/ + - path/to/dir2/ + + ### Testing non-list form of with_filetree + - name: with_filetree loop using single path + debug: + msg: "{{ item }}" + with_filetree: path/to/dir/ + + ### Testing non-list form of with_filetree with trailing templated pattern + - name: with_filetree loop using templated pattern + debug: + msg: "{{ item }}" + with_filetree: 'path/to/{{ directory }}' + + ### Testing with_together + - name: with_together loop using variable lists + debug: + msg: "{{ item.0 }} - {{ item.1 }}" + with_together: + - "{{ my_list }}" + - "{{ my_list2 }}" + + - name: with_subelements loop + debug: + msg: "{{ item }}" + with_subelements: + - "{{ my_list_of_dicts }}" + - bar + + - name: with_sequence loop + debug: + msg: "{{ item }}" + with_sequence: count=2 + + - name: with_random_choice loop + debug: + msg: "{{ item }}" + with_random_choice: "{{ my_list }}" + + - name: with_first_found loop with static files list + debug: + msg: "{{ item }}" + with_first_found: + - foo.txt + - bar.txt + + - name: with_first_found loop with list of filenames + debug: + msg: "{{ item }}" + with_first_found: "{{ my_filenames }}" + + - name: with_indexed_items loop + debug: + msg: "{{ item.0 }} {{ item.1 }}" + with_indexed_items: "{{ my_list }}" + + - name: with_ini loop + debug: + msg: "{{ item }}" + with_ini: value[1-2] section=section1 file=foo.ini re=true + + - name: with_flattened loop + debug: + msg: "{{ item }}" + with_flattened: + - "{{ my_list }}" + - "{{ my_list2 }}" + + - name: with_flattened loop with a variable + debug: + msg: "{{ item }}" + with_flattened: "{{ my_list_of_lists }}" + + - name: with_flattened loop with a multiline template + debug: + msg: "{{ item }}" + with_flattened: > + {{ my_list + | union(my_list2) + | list }} + + - name: with_inventory_hostnames loop + debug: + msg: "{{ item }}" + with_inventory_hostnames: all diff --git a/test/varset.yml b/test/varset.yml new file mode 100644 index 0000000..fa28db9 --- /dev/null +++ b/test/varset.yml @@ -0,0 +1,3 @@ +- debug: msg="var was set" + +- git: repo=hello.git diff --git a/test/varunset.yml b/test/varunset.yml new file mode 100644 index 0000000..9e8a891 --- /dev/null +++ b/test/varunset.yml @@ -0,0 +1 @@ +- debug: msg="var was not set" diff --git a/test/with-skip-tag-id.yml b/test/with-skip-tag-id.yml new file mode 100644 index 0000000..12d2fb7 --- /dev/null +++ b/test/with-skip-tag-id.yml @@ -0,0 +1,6 @@ +- hosts: all + tasks: + - name: trailing whitespace on this line + git: + repo: '{{ archive_services_repo_url }}' + dest: '/home/www' diff --git a/tools/test-setup.sh b/tools/test-setup.sh new file mode 100755 index 0000000..170cd99 --- /dev/null +++ b/tools/test-setup.sh @@ -0,0 +1,17 @@ +#!/bin/bash +set -euxo pipefail +# Used by Zuul CI to perform extra bootstrapping + +# sudo used only because currently zuul default tox_executable=tox instead of +# "python3 -m tox" +# https://zuul-ci.org/docs/zuul-jobs/python-roles.html#rolevar-ensure-tox.tox_executable + +# Install pip if not already install on the system +python3 -m pip --version || { + curl https://bootstrap.pypa.io/get-pip.py -o get-pip.py + sudo python3 get-pip.py +} + +# Workaround until ensure-tox will allow upgrades +# https://review.opendev.org/#/c/690057/ +sudo python3 -m pip install -U tox @@ -0,0 +1,134 @@ +[tox] +minversion = 3.16.1 +envlist = lint,py{38,37,36}-ansible{29,28,210,devel} +isolated_build = true +requires = + setuptools >= 41.4.0 + pip >= 19.3.0 +skip_missing_interpreters = True +# `usedevelop = true` overrides `skip_install` instruction, it's unwanted +usedevelop = false + +[testenv] +description = + Run the tests with pytest under {basepython} +deps = + ansible28: ansible>=2.8,<2.9 + ansible29: ansible>=2.9,<2.10 + ansible210: ansible>=2.10.0a1,<2.11 + # Be sure we do not install old ansible from default deps + # https://github.com/ansible/ansible/issues/70705 + ansibledevel: ansible>=2.10.0a1,<2.11 + ansibledevel: ansible-base @ git+https://github.com/ansible/ansible.git + -r test-requirements.in + -c test-requirements.txt +commands = + # safety measure to assure we do not accidentaly run tests with broken dependencies + python -m pip check + {envpython} -m pytest \ + --cov "{envsitepackagesdir}/ansiblelint" \ + --junitxml "{toxworkdir}/junit.{envname}.xml" \ + {posargs:} +install_command = + {envpython} -m \ + pip install \ + {opts} \ + {packages} +passenv = + CURL_CA_BUNDLE # https proxies, https://github.com/tox-dev/tox/issues/1437 + HOME + REQUESTS_CA_BUNDLE # https proxies + SSL_CERT_FILE # https proxies +# recreate = True +setenv = + ANSIBLE_COLLECTIONS_PATHS = {envtmpdir} + COVERAGE_FILE = {env:COVERAGE_FILE:{toxworkdir}/.coverage.{envname}} + PIP_DISABLE_PIP_VERSION_CHECK = 1 +whitelist_externals = + ansibledevel: sh + +[testenv:.dev-env] +#basepython = python3 +basepython = /home/wk/.pyenv/versions/ansible-lint-py3.8.0-pyenv-venv/bin/python3 +#{[testenv]deps} +deps = +# virtualenv >= 16 +# setuptools >= 45.0.0 +isolated_build = false +skip_install = true +recreate = false +usedevelop = false + +[testenv:build-dists] +basepython = python3 +description = + Build dists with PEP 517 and save them in the dist/ dir +skip_install = true +deps = + pep517 >= 0.7.0 +commands = + {envpython} -c 'import os.path, shutil, sys; \ + dist_dir = os.path.join("{toxinidir}", "dist"); \ + os.path.isdir(dist_dir) or sys.exit(0); \ + print("Removing \{!s\} contents...".format(dist_dir), file=sys.stderr); \ + shutil.rmtree(dist_dir)' + {envpython} -m pep517.build \ + --source \ + --binary \ + --out-dir {toxinidir}/dist/ \ + {toxinidir} + +# deprecated: use more generic 'lint' instead +[testenv:flake8] +deps = {[testenv:lint]deps} +envdir = {toxworkdir}/lint +skip_install = true +commands = + python -m pre_commit run --all-files flake8 + +[testenv:lint] +basepython = python3 +deps = + pre-commit>=2.6.0 +skip_install = true +commands = + python -m pre_commit run {posargs:--all-files --hook-stage manual -v} +passenv = + {[testenv]passenv} + PRE_COMMIT_HOME + +[testenv:docs] +basepython = python3 +deps = + -r{toxinidir}/docs/requirements.in + -c{toxinidir}/docs/requirements.txt +commands = + # Build the html docs with Sphinx: + {envpython} -m sphinx \ + -j auto \ + -b html \ + --color \ + -a \ + -n \ + -W \ + -d "{temp_dir}/.doctrees" \ + . \ + "{envdir}/html" + + # Print out the output docs dir and a way to serve html: + -{envpython} -c \ + 'import pathlib; docs_dir = pathlib.Path(r"{envdir}") / "html"; index_file = docs_dir / "index.html"; '\ + 'print("\n" + "=" * 120 + f"\n\nDocumentation available under `file://\{index_file\}`\n\nTo serve docs, use `python3 -m http.server --directory \{docs_dir\} 0`\n\n" + "=" * 120)' +changedir = {toxinidir}/docs + +[testenv:metadata-validation] +basepython = python3 +description = + Verify that dists under the dist/ dir have valid metadata +depends = build-dists +deps = + twine +skip_install = true +# Ref: https://twitter.com/di_codes/status/1044358639081975813 +commands = + twine check {toxinidir}/dist/* |