summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-06-26 06:24:58 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-06-26 06:24:58 +0000
commitba233a0cbad76b4783a03893e7bf4716fbc0f0ec (patch)
treead369728c1edbe3631c8150585659078ae5d7d0b /src
parentReleasing progress-linux version 6.17.2-3~progress7.99u1. (diff)
downloadansible-lint-ba233a0cbad76b4783a03893e7bf4716fbc0f0ec.tar.xz
ansible-lint-ba233a0cbad76b4783a03893e7bf4716fbc0f0ec.zip
Merging upstream version 24.6.1.
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'src')
-rwxr-xr-xsrc/ansiblelint/__main__.py172
-rw-r--r--src/ansiblelint/_internal/rules.py33
-rw-r--r--src/ansiblelint/_mockings.py5
-rw-r--r--src/ansiblelint/app.py48
-rw-r--r--src/ansiblelint/cli.py89
-rw-r--r--src/ansiblelint/color.py1
-rw-r--r--src/ansiblelint/config.py69
-rw-r--r--src/ansiblelint/constants.py47
-rw-r--r--src/ansiblelint/data/.yamllint25
-rw-r--r--src/ansiblelint/errors.py40
-rw-r--r--src/ansiblelint/file_utils.py54
-rw-r--r--src/ansiblelint/formatters/__init__.py61
-rw-r--r--src/ansiblelint/generate_docs.py11
-rw-r--r--src/ansiblelint/loaders.py14
-rw-r--r--src/ansiblelint/logger.py13
-rw-r--r--src/ansiblelint/requirements.py28
-rw-r--r--src/ansiblelint/rules/__init__.py83
-rw-r--r--src/ansiblelint/rules/args.py27
-rw-r--r--src/ansiblelint/rules/avoid_implicit.py5
-rw-r--r--src/ansiblelint/rules/command_instead_of_module.py26
-rw-r--r--src/ansiblelint/rules/command_instead_of_shell.md4
-rw-r--r--src/ansiblelint/rules/command_instead_of_shell.py26
-rw-r--r--src/ansiblelint/rules/complexity.md19
-rw-r--r--src/ansiblelint/rules/complexity.py115
-rw-r--r--src/ansiblelint/rules/conftest.py1
-rw-r--r--src/ansiblelint/rules/deprecated_bare_vars.py12
-rw-r--r--src/ansiblelint/rules/deprecated_local_action.md4
-rw-r--r--src/ansiblelint/rules/deprecated_local_action.py88
-rw-r--r--src/ansiblelint/rules/deprecated_module.py1
-rw-r--r--src/ansiblelint/rules/empty_string_compare.py5
-rw-r--r--src/ansiblelint/rules/fqcn.md4
-rw-r--r--src/ansiblelint/rules/fqcn.py84
-rw-r--r--src/ansiblelint/rules/galaxy.md12
-rw-r--r--src/ansiblelint/rules/galaxy.py44
-rw-r--r--src/ansiblelint/rules/ignore_errors.py3
-rw-r--r--src/ansiblelint/rules/inline_env_var.py2
-rw-r--r--src/ansiblelint/rules/jinja.md6
-rw-r--r--src/ansiblelint/rules/jinja.py170
-rw-r--r--src/ansiblelint/rules/key_order.md4
-rw-r--r--src/ansiblelint/rules/key_order.py78
-rw-r--r--src/ansiblelint/rules/latest.py1
-rw-r--r--src/ansiblelint/rules/literal_compare.py6
-rw-r--r--src/ansiblelint/rules/loop_var_prefix.md28
-rw-r--r--src/ansiblelint/rules/loop_var_prefix.py6
-rw-r--r--src/ansiblelint/rules/meta_incorrect.py5
-rw-r--r--src/ansiblelint/rules/meta_no_tags.py1
-rw-r--r--src/ansiblelint/rules/meta_runtime.md46
-rw-r--r--src/ansiblelint/rules/meta_runtime.py59
-rw-r--r--src/ansiblelint/rules/meta_video_links.py6
-rw-r--r--src/ansiblelint/rules/name.md19
-rw-r--r--src/ansiblelint/rules/name.py154
-rw-r--r--src/ansiblelint/rules/no_changed_when.py6
-rw-r--r--src/ansiblelint/rules/no_free_form.md4
-rw-r--r--src/ansiblelint/rules/no_free_form.py102
-rw-r--r--src/ansiblelint/rules/no_handler.py27
-rw-r--r--src/ansiblelint/rules/no_jinja_when.md4
-rw-r--r--src/ansiblelint/rules/no_jinja_when.py57
-rw-r--r--src/ansiblelint/rules/no_log_password.md4
-rw-r--r--src/ansiblelint/rules/no_log_password.py58
-rw-r--r--src/ansiblelint/rules/no_prompting.py15
-rw-r--r--src/ansiblelint/rules/no_relative_paths.py6
-rw-r--r--src/ansiblelint/rules/no_same_owner.py6
-rw-r--r--src/ansiblelint/rules/no_tabs.py35
-rw-r--r--src/ansiblelint/rules/only_builtins.py8
-rw-r--r--src/ansiblelint/rules/package_latest.md18
-rw-r--r--src/ansiblelint/rules/package_latest.py2
-rw-r--r--src/ansiblelint/rules/partial_become.md90
-rw-r--r--src/ansiblelint/rules/partial_become.py283
-rw-r--r--src/ansiblelint/rules/playbook_extension.py4
-rw-r--r--src/ansiblelint/rules/risky_file_permissions.md2
-rw-r--r--src/ansiblelint/rules/risky_file_permissions.py5
-rw-r--r--src/ansiblelint/rules/risky_octal.py1
-rw-r--r--src/ansiblelint/rules/risky_shell_pipe.md14
-rw-r--r--src/ansiblelint/rules/risky_shell_pipe.py6
-rw-r--r--src/ansiblelint/rules/role_name.py69
-rw-r--r--src/ansiblelint/rules/run_once.py9
-rw-r--r--src/ansiblelint/rules/sanity.md11
-rw-r--r--src/ansiblelint/rules/sanity.py82
-rw-r--r--src/ansiblelint/rules/schema.py107
-rw-r--r--src/ansiblelint/rules/syntax_check.md29
-rw-r--r--src/ansiblelint/rules/syntax_check.py29
-rw-r--r--src/ansiblelint/rules/var_naming.md2
-rw-r--r--src/ansiblelint/rules/var_naming.py104
-rw-r--r--src/ansiblelint/rules/yaml.md58
-rw-r--r--src/ansiblelint/rules/yaml_rule.py48
-rw-r--r--src/ansiblelint/runner.py326
-rw-r--r--src/ansiblelint/schemas/__main__.py7
-rw-r--r--src/ansiblelint/schemas/__store__.json26
-rw-r--r--src/ansiblelint/schemas/ansible-lint-config.json13
-rw-r--r--src/ansiblelint/schemas/ansible-navigator-config.json7
-rw-r--r--src/ansiblelint/schemas/ansible.json51
-rw-r--r--src/ansiblelint/schemas/execution-environment.json3
-rw-r--r--src/ansiblelint/schemas/galaxy.json168
-rw-r--r--src/ansiblelint/schemas/inventory.json2
-rw-r--r--src/ansiblelint/schemas/main.py90
-rw-r--r--src/ansiblelint/schemas/meta.json99
-rw-r--r--src/ansiblelint/schemas/molecule.json1
-rw-r--r--src/ansiblelint/schemas/playbook.json62
-rw-r--r--src/ansiblelint/schemas/requirements.json1
-rw-r--r--src/ansiblelint/schemas/role-arg-spec.json178
-rw-r--r--src/ansiblelint/schemas/rulebook.json120
-rw-r--r--src/ansiblelint/schemas/tasks.json22
-rw-r--r--src/ansiblelint/schemas/vars.json1
-rw-r--r--src/ansiblelint/skip_utils.py12
-rw-r--r--src/ansiblelint/stats.py1
-rw-r--r--src/ansiblelint/testing/__init__.py3
-rw-r--r--src/ansiblelint/testing/fixtures.py25
-rw-r--r--src/ansiblelint/text.py8
-rw-r--r--src/ansiblelint/transformer.py50
-rw-r--r--src/ansiblelint/utils.py511
-rw-r--r--src/ansiblelint/version.py1
-rw-r--r--src/ansiblelint/yaml_utils.py295
112 files changed, 4121 insertions, 1131 deletions
diff --git a/src/ansiblelint/__main__.py b/src/ansiblelint/__main__.py
index af434d0..ca4a33b 100755
--- a/src/ansiblelint/__main__.py
+++ b/src/ansiblelint/__main__.py
@@ -30,12 +30,23 @@ import shutil
import site
import sys
from pathlib import Path
-from typing import TYPE_CHECKING, Any, Callable, TextIO
+from typing import TYPE_CHECKING, Any, TextIO
from ansible_compat.prerun import get_cache_dir
from filelock import FileLock, Timeout
from rich.markup import escape
+from ansiblelint.constants import RC, SKIP_SCHEMA_UPDATE
+
+# safety check for broken ansible core, needs to happen first
+try:
+ # pylint: disable=unused-import
+ from ansible.parsing.dataloader import DataLoader # noqa: F401
+
+except Exception as _exc: # pylint: disable=broad-exception-caught # noqa: BLE001
+ logging.fatal(_exc)
+ sys.exit(RC.INVALID_CONFIG)
+# pylint: disable=ungrouped-imports
from ansiblelint import cli
from ansiblelint._mockings import _perform_mockings_cleanup
from ansiblelint.app import get_app
@@ -53,20 +64,21 @@ from ansiblelint.config import (
log_entries,
options,
)
-from ansiblelint.constants import RC
from ansiblelint.loaders import load_ignore_txt
+from ansiblelint.runner import get_matches
from ansiblelint.skip_utils import normalize_tag
from ansiblelint.version import __version__
if TYPE_CHECKING:
# RulesCollection must be imported lazily or ansible gets imported too early.
+ from collections.abc import Callable
+
from ansiblelint.rules import RulesCollection
from ansiblelint.runner import LintResult
_logger = logging.getLogger(__name__)
-cache_dir_lock: None | FileLock = None
class LintLogHandler(logging.Handler):
@@ -107,8 +119,9 @@ def initialize_logger(level: int = 0) -> None:
_logger.debug("Logging initialized to level %s", logging_level)
-def initialize_options(arguments: list[str] | None = None) -> None:
+def initialize_options(arguments: list[str] | None = None) -> None | FileLock:
"""Load config options and store them inside options module."""
+ cache_dir_lock = None
new_options = cli.get_config(arguments or [])
new_options.cwd = pathlib.Path.cwd()
@@ -132,13 +145,13 @@ def initialize_options(arguments: list[str] | None = None) -> None:
options.cache_dir.mkdir(parents=True, exist_ok=True)
if not options.offline: # pragma: no cover
- cache_dir_lock = FileLock( # pylint: disable=redefined-outer-name
+ cache_dir_lock = FileLock(
f"{options.cache_dir}/.lock",
)
try:
cache_dir_lock.acquire(timeout=180)
except Timeout: # pragma: no cover
- _logger.error(
+ _logger.error( # noqa: TRY400
"Timeout waiting for another instance of ansible-lint to release the lock.",
)
sys.exit(RC.LOCK_TIMEOUT)
@@ -147,6 +160,8 @@ def initialize_options(arguments: list[str] | None = None) -> None:
if "ANSIBLE_DEVEL_WARNING" not in os.environ: # pragma: no branch
os.environ["ANSIBLE_DEVEL_WARNING"] = "false"
+ return cache_dir_lock
+
def _do_list(rules: RulesCollection) -> int:
# On purpose lazy-imports to avoid pre-loading Ansible
@@ -194,23 +209,85 @@ def _do_transform(result: LintResult, opts: Options) -> None:
def support_banner() -> None:
"""Display support banner when running on unsupported platform."""
- if sys.version_info < (3, 9, 0): # pragma: no cover
- prefix = "::warning::" if "GITHUB_ACTION" in os.environ else "WARNING: "
- console_stderr.print(
- f"{prefix}ansible-lint is no longer tested under Python {sys.version_info.major}.{sys.version_info.minor} and will soon require 3.9. Do not report bugs for this version.",
- style="bold red",
- )
-# pylint: disable=too-many-statements,too-many-locals
+def fix(runtime_options: Options, result: LintResult, rules: RulesCollection) -> None:
+ """Fix the linting errors.
+
+ :param options: Options object
+ :param result: LintResult object
+ """
+ match_count = len(result.matches)
+ _logger.debug("Begin fixing: %s matches", match_count)
+ ruamel_safe_version = "0.17.26"
+
+ # pylint: disable=import-outside-toplevel
+ from packaging.version import Version
+ from ruamel.yaml import __version__ as ruamel_yaml_version_str
+
+ # pylint: enable=import-outside-toplevel
+
+ if Version(ruamel_safe_version) > Version(ruamel_yaml_version_str):
+ _logger.warning(
+ "We detected use of `--fix` feature with a buggy ruamel-yaml %s library instead of >=%s, upgrade it before reporting any bugs like dropped comments.",
+ ruamel_yaml_version_str,
+ ruamel_safe_version,
+ )
+ acceptable_tags = {"all", "none", *rules.known_tags()}
+ unknown_tags = set(options.write_list).difference(acceptable_tags)
+
+ if unknown_tags:
+ _logger.error(
+ "Found invalid value(s) (%s) for --fix arguments, must be one of: %s",
+ ", ".join(unknown_tags),
+ ", ".join(acceptable_tags),
+ )
+ sys.exit(RC.INVALID_CONFIG)
+ _do_transform(result, options)
+
+ rerun = ["yaml"]
+ resolved = []
+ for idx, match in reversed(list(enumerate(result.matches))):
+ _logger.debug("Fixing: (%s of %s) %s", match_count - idx, match_count, match)
+ if match.fixed:
+ _logger.debug("Fixed, removed: %s", match)
+ result.matches.pop(idx)
+ continue
+ if match.rule.id not in rerun:
+ _logger.debug("Not rerun eligible: %s", match)
+ continue
+
+ uid = (match.rule.id, match.filename)
+ if uid in resolved:
+ _logger.debug("Previously resolved: %s", match)
+ result.matches.pop(idx)
+ continue
+ _logger.debug("Rerunning: %s", match)
+ runtime_options.tags = [match.rule.id]
+ runtime_options.lintables = [match.filename]
+ runtime_options._skip_ansible_syntax_check = True # noqa: SLF001
+ new_results = get_matches(rules, runtime_options)
+ if not new_results.matches:
+ _logger.debug("Newly resolved: %s", match)
+ result.matches.pop(idx)
+ resolved.append(uid)
+ continue
+ if match in new_results.matches:
+ _logger.debug("Still found: %s", match)
+ continue
+ _logger.debug("Fixed, removed: %s", match)
+ result.matches.pop(idx)
+
+
+# pylint: disable=too-many-locals
def main(argv: list[str] | None = None) -> int:
"""Linter CLI entry point."""
# alter PATH if needed (venv support)
- path_inject()
+ path_inject(argv[0] if argv and argv[0] else "")
if argv is None: # pragma: no cover
argv = sys.argv
- initialize_options(argv[1:])
+ cache_dir_lock = initialize_options(argv[1:])
console_options["force_terminal"] = options.colored
reconfigure(console_options)
@@ -236,7 +313,23 @@ def main(argv: list[str] | None = None) -> int:
_logger.debug("Options: %s", options)
_logger.debug("CWD: %s", Path.cwd())
- if not options.offline:
+ # checks if we have `ANSIBLE_LINT_SKIP_SCHEMA_UPDATE` set to bypass schema
+ # update. Also skip if in offline mode.
+ # env var set to skip schema refresh
+ skip_schema_update = (
+ bool(
+ int(
+ os.environ.get(
+ SKIP_SCHEMA_UPDATE,
+ "0",
+ ),
+ ),
+ )
+ or options.offline
+ or options.nodeps
+ )
+
+ if not skip_schema_update:
# pylint: disable=import-outside-toplevel
from ansiblelint.schemas.__main__ import refresh_schemas
@@ -244,7 +337,6 @@ def main(argv: list[str] | None = None) -> int:
# pylint: disable=import-outside-toplevel
from ansiblelint.rules import RulesCollection
- from ansiblelint.runner import _get_matches
if options.list_profiles:
from ansiblelint.generate_docs import profiles_as_rich
@@ -265,20 +357,7 @@ def main(argv: list[str] | None = None) -> int:
if isinstance(options.tags, str):
options.tags = options.tags.split(",") # pragma: no cover
- result = _get_matches(rules, options)
-
- if options.write_list:
- ruamel_safe_version = "0.17.26"
- from packaging.version import Version
- from ruamel.yaml import __version__ as ruamel_yaml_version_str
-
- if Version(ruamel_safe_version) > Version(ruamel_yaml_version_str):
- _logger.warning(
- "We detected use of `--write` feature with a buggy ruamel-yaml %s library instead of >=%s, upgrade it before reporting any bugs like dropped comments.",
- ruamel_yaml_version_str,
- ruamel_safe_version,
- )
- _do_transform(result, options)
+ result = get_matches(rules, options)
mark_as_success = True
@@ -292,6 +371,18 @@ def main(argv: list[str] | None = None) -> int:
for match in result.matches:
if match.tag in ignore_map[match.filename]:
match.ignored = True
+ _logger.debug("Ignored: %s", match)
+
+ if app.yamllint_config.incompatible:
+ logging.log(
+ level=logging.ERROR if options.write_list else logging.WARNING,
+ msg=app.yamllint_config.incompatible,
+ )
+
+ if options.write_list:
+ if app.yamllint_config.incompatible:
+ sys.exit(RC.INVALID_CONFIG)
+ fix(runtime_options=options, result=result, rules=rules)
app.render_matches(result.matches)
@@ -325,7 +416,7 @@ def _run_cli_entrypoint() -> None:
raise SystemExit(exc) from exc
-def path_inject() -> None:
+def path_inject(own_location: str = "") -> None:
"""Add python interpreter path to top of PATH to fix outside venv calling."""
# This make it possible to call ansible-lint that was installed inside a
# virtualenv without having to pre-activate it. Otherwise subprocess will
@@ -350,6 +441,7 @@ def path_inject() -> None:
inject_paths = []
userbase_bin_path = Path(site.getuserbase()) / "bin"
+
if (
str(userbase_bin_path) not in paths
and (userbase_bin_path / "bin" / "ansible").exists()
@@ -357,11 +449,23 @@ def path_inject() -> None:
inject_paths.append(str(userbase_bin_path))
py_path = Path(sys.executable).parent
- if str(py_path) not in paths and (py_path / "ansible").exists():
+ pipx_path = os.environ.get("PIPX_HOME", "pipx")
+ if (
+ str(py_path) not in paths
+ and (py_path / "ansible").exists()
+ and pipx_path not in str(py_path)
+ ):
inject_paths.append(str(py_path))
+ # last option, if nothing else is found, just look next to ourselves...
+ if own_location:
+ own_location = os.path.realpath(own_location)
+ parent = Path(own_location).parent
+ if (parent / "ansible").exists() and str(parent) not in paths:
+ inject_paths.append(str(parent))
+
if not os.environ.get("PYENV_VIRTUAL_ENV", None):
- if inject_paths:
+ if inject_paths and not all("pipx" in p for p in inject_paths):
print( # noqa: T201
f"WARNING: PATH altered to include {', '.join(inject_paths)} :: This is usually a sign of broken local setup, which can cause unexpected behaviors.",
file=sys.stderr,
diff --git a/src/ansiblelint/_internal/rules.py b/src/ansiblelint/_internal/rules.py
index acaf0f3..38cb835 100644
--- a/src/ansiblelint/_internal/rules.py
+++ b/src/ansiblelint/_internal/rules.py
@@ -1,4 +1,5 @@
"""Internally used rule classes."""
+
from __future__ import annotations
import inspect
@@ -9,6 +10,7 @@ from typing import TYPE_CHECKING, Any
from ansiblelint.constants import RULE_DOC_URL
if TYPE_CHECKING:
+ from ansiblelint.config import Options
from ansiblelint.errors import MatchError
from ansiblelint.file_utils import Lintable
from ansiblelint.rules import RulesCollection
@@ -44,6 +46,8 @@ class BaseRule:
link: str = ""
has_dynamic_tags: bool = False
needs_raw_task: bool = False
+ # Used to mark rules that we will never unload (internal ones)
+ unloadable: bool = False
# We use _order to sort rules and to ensure that some run before others,
# _order 0 for internal rules
# _order 1 for rules that check that data can be loaded
@@ -54,7 +58,7 @@ class BaseRule:
_collection: RulesCollection | None = None
@property
- def help(self) -> str: # noqa: A003
+ def help(self) -> str:
"""Return a help markdown string for the rule."""
if self._help is None:
self._help = ""
@@ -92,10 +96,11 @@ class BaseRule:
_logger.warning(
"Ignored exception from %s.%s while processing %s: %s",
self.__class__.__name__,
- method,
+ method.__name__,
str(file),
exc,
)
+ _logger.debug("Ignored exception details", exc_info=True)
else:
matches.extend(self.matchdir(file))
return matches
@@ -157,6 +162,26 @@ class BaseRule:
"""
return getattr(cls, "_ids", {cls.id: cls.shortdesc})
+ @property
+ def rule_config(self) -> dict[str, Any]:
+ """Retrieve rule specific configuration."""
+ rule_config = {}
+ if self.options:
+ rule_config = self.options.rules.get(self.id, {})
+ if not isinstance(rule_config, dict): # pragma: no branch
+ msg = f"Invalid rule config for {self.id}: {rule_config}"
+ raise RuntimeError(msg) # noqa: TRY004
+ return rule_config
+
+ @property
+ def options(self) -> Options | None:
+ """Used to access linter configuration."""
+ if self._collection is None:
+ msg = f"A rule ({self.id}) that is not part of a collection cannot access its configuration."
+ _logger.warning(msg)
+ return None
+ return self._collection.options
+
# pylint: enable=unused-argument
@@ -170,6 +195,7 @@ class RuntimeErrorRule(BaseRule):
tags = ["core"]
version_added = "v5.0.0"
_order = 0
+ unloadable = True
class AnsibleParserErrorRule(BaseRule):
@@ -181,6 +207,7 @@ class AnsibleParserErrorRule(BaseRule):
tags = ["core"]
version_added = "v5.0.0"
_order = 0
+ unloadable = True
class LoadingFailureRule(BaseRule):
@@ -196,6 +223,7 @@ class LoadingFailureRule(BaseRule):
_ids = {
"load-failure[not-found]": "File not found",
}
+ unloadable = True
class WarningRule(BaseRule):
@@ -207,3 +235,4 @@ class WarningRule(BaseRule):
tags = ["core", "experimental"]
version_added = "v6.8.0"
_order = 0
+ unloadable = True
diff --git a/src/ansiblelint/_mockings.py b/src/ansiblelint/_mockings.py
index e0482b7..5c2a9a7 100644
--- a/src/ansiblelint/_mockings.py
+++ b/src/ansiblelint/_mockings.py
@@ -1,4 +1,5 @@
"""Utilities for mocking ansible modules and roles."""
+
from __future__ import annotations
import contextlib
@@ -46,7 +47,7 @@ def _make_module_stub(module_name: str, options: Options) -> None:
path.mkdir(exist_ok=True, parents=True)
_write_module_stub(
filename=module_file,
- name=module_file,
+ name=module_name,
namespace=namespace,
collection=collection,
)
@@ -122,4 +123,4 @@ def _perform_mockings_cleanup(options: Options) -> None:
else:
path = options.cache_dir / "roles" / role_name
with contextlib.suppress(OSError):
- path.unlink()
+ path.rmdir()
diff --git a/src/ansiblelint/app.py b/src/ansiblelint/app.py
index 52581b3..3568f53 100644
--- a/src/ansiblelint/app.py
+++ b/src/ansiblelint/app.py
@@ -1,10 +1,12 @@
"""Application."""
+
from __future__ import annotations
import copy
import itertools
import logging
import os
+import sys
from functools import lru_cache
from pathlib import Path
from typing import TYPE_CHECKING, Any
@@ -20,6 +22,7 @@ from ansiblelint.config import PROFILES, Options, get_version_warning
from ansiblelint.config import options as default_options
from ansiblelint.constants import RC, RULE_DOC_URL
from ansiblelint.loaders import IGNORE_FILE
+from ansiblelint.requirements import Reqs
from ansiblelint.stats import SummarizedResults, TagStats
if TYPE_CHECKING:
@@ -30,6 +33,7 @@ if TYPE_CHECKING:
_logger = logging.getLogger(__package__)
+_CACHED_APP = None
class App:
@@ -46,7 +50,25 @@ class App:
self.formatter = formatter_factory(options.cwd, options.display_relative_path)
# Without require_module, our _set_collections_basedir may fail
- self.runtime = Runtime(isolated=True, require_module=True)
+ self.runtime = Runtime(
+ isolated=True,
+ require_module=True,
+ verbosity=options.verbosity,
+ )
+ self.reqs = Reqs("ansible-lint")
+ package = "ansible-core"
+ if not self.reqs.matches(
+ package,
+ str(self.runtime.version),
+ ): # pragma: no cover
+ msg = f"ansible-lint requires {package}{','.join(str(x) for x in self.reqs[package])} and current version is {self.runtime.version}"
+ logging.error(msg)
+ sys.exit(RC.INVALID_CONFIG)
+
+ # pylint: disable=import-outside-toplevel
+ from ansiblelint.yaml_utils import load_yamllint_config
+
+ self.yamllint_config = load_yamllint_config()
def render_matches(self, matches: list[MatchError]) -> None:
"""Display given matches (if they are not fixed)."""
@@ -54,7 +76,7 @@ class App:
if isinstance(
self.formatter,
- (formatters.CodeclimateJSONFormatter, formatters.SarifFormatter),
+ formatters.CodeclimateJSONFormatter | formatters.SarifFormatter,
):
# If formatter CodeclimateJSONFormatter or SarifFormatter is chosen,
# then print only the matches in JSON
@@ -205,7 +227,7 @@ class App:
ignore_file.writelines(sorted(lines))
elif matched_rules and not self.options.quiet:
console_stderr.print(
- "Read [link=https://ansible-lint.readthedocs.io/configuring/#ignoring-rules-for-entire-files]documentation[/link] for instructions on how to ignore specific rule violations.",
+ "Read [link=https://ansible.readthedocs.io/projects/lint/configuring/#ignoring-rules-for-entire-files]documentation[/link] for instructions on how to ignore specific rule violations.",
)
# Do not deprecate the old tags just yet. Why? Because it is not currently feasible
@@ -223,7 +245,7 @@ class App:
if self.options.write_list and "yaml" in self.options.skip_list:
_logger.warning(
- "You specified '--write', but no files can be modified "
+ "You specified '--fix', but no files can be modified "
"because 'yaml' is in 'skip_list'.",
)
@@ -332,7 +354,10 @@ class App:
if self.options.profile:
msg += f" Profile '{self.options.profile}' was required"
if summary.passed_profile:
- msg += f", but only '{summary.passed_profile}' profile passed."
+ if summary.passed_profile == self.options.profile:
+ msg += ", and it passed."
+ else:
+ msg += f", but '{summary.passed_profile}' profile passed."
else:
msg += "."
elif summary.passed_profile:
@@ -378,8 +403,19 @@ def _sanitize_list_options(tag_list: list[str]) -> list[str]:
@lru_cache
-def get_app(*, offline: bool | None = None) -> App:
+def get_app(*, offline: bool | None = None, cached: bool = False) -> App:
"""Return the application instance, caching the return value."""
+ # Avoids ever running the app initialization twice if cached argument
+ # is mentioned.
+ if cached:
+ if offline is not None:
+ msg = (
+ "get_app should never be called with other arguments when cached=True."
+ )
+ raise RuntimeError(msg)
+ if cached and _CACHED_APP is not None:
+ return _CACHED_APP
+
if offline is None:
offline = default_options.offline
diff --git a/src/ansiblelint/cli.py b/src/ansiblelint/cli.py
index c9178a7..ce8d9ec 100644
--- a/src/ansiblelint/cli.py
+++ b/src/ansiblelint/cli.py
@@ -1,4 +1,5 @@
"""CLI parser setup and helpers."""
+
from __future__ import annotations
import argparse
@@ -7,7 +8,7 @@ import os
import sys
from argparse import Namespace
from pathlib import Path
-from typing import TYPE_CHECKING, Any, Callable
+from typing import TYPE_CHECKING, Any
from ansiblelint.config import (
DEFAULT_KINDS,
@@ -16,7 +17,7 @@ from ansiblelint.config import (
Options,
log_entries,
)
-from ansiblelint.constants import CUSTOM_RULESDIR_ENVVAR, DEFAULT_RULESDIR, RC
+from ansiblelint.constants import CUSTOM_RULESDIR_ENVVAR, DEFAULT_RULESDIR, EPILOG, RC
from ansiblelint.file_utils import (
Lintable,
abspath,
@@ -29,7 +30,7 @@ from ansiblelint.schemas.main import validate_file_schema
from ansiblelint.yaml_utils import clean_json
if TYPE_CHECKING:
- from collections.abc import Sequence
+ from collections.abc import Callable, Sequence
_logger = logging.getLogger(__name__)
@@ -91,7 +92,7 @@ def load_config(config_file: str | None) -> tuple[dict[Any, Any], str | None]:
config = clean_json(config_lintable.data)
if not isinstance(config, dict):
msg = "Schema failed to properly validate the config file."
- raise RuntimeError(msg)
+ raise TypeError(msg)
config["config_file"] = config_path
config_dir = os.path.dirname(config_path)
expand_to_normalized_paths(config, config_dir)
@@ -134,7 +135,7 @@ class AbspathArgAction(argparse.Action):
values: str | Sequence[Any] | None,
option_string: str | None = None,
) -> None:
- if isinstance(values, (str, Path)):
+ if isinstance(values, str | Path):
values = [values]
if values:
normalized_values = [
@@ -145,7 +146,7 @@ class AbspathArgAction(argparse.Action):
class WriteArgAction(argparse.Action):
- """Argparse action to handle the --write flag with optional args."""
+ """Argparse action to handle the --fix flag with optional args."""
_default = "__default__"
@@ -174,8 +175,8 @@ class WriteArgAction(argparse.Action):
super().__init__(
option_strings=option_strings,
dest=dest,
- nargs="?", # either 0 (--write) or 1 (--write=a,b,c) argument
- const=self._default, # --write (no option) implicitly stores this
+ nargs="?", # either 0 (--fix) or 1 (--fix=a,b,c) argument
+ const=self._default, # --fix (no option) implicitly stores this
default=default,
type=type,
choices=choices,
@@ -194,8 +195,8 @@ class WriteArgAction(argparse.Action):
lintables = getattr(namespace, "lintables", None)
if not lintables and isinstance(values, str):
# args are processed in order.
- # If --write is after lintables, then that is not ambiguous.
- # But if --write comes first, then it might actually be a lintable.
+ # If --fix is after lintables, then that is not ambiguous.
+ # But if --fix comes first, then it might actually be a lintable.
maybe_lintable = Path(values)
if maybe_lintable.exists():
namespace.lintables = [values]
@@ -211,26 +212,40 @@ class WriteArgAction(argparse.Action):
setattr(namespace, self.dest, values)
@classmethod
- def merge_write_list_config(
+ def merge_fix_list_config(
cls,
from_file: list[str],
from_cli: list[str],
) -> list[str]:
- """Combine the write_list from file config with --write CLI arg.
+ """Determine the write_list value based on cli vs config.
+
+ When --fix is not passed from command line the from_cli is an empty list,
+ so we use the file.
- Handles the implicit "all" when "__default__" is present and file config is empty.
+ When from_cli is not an empty list, we ignore the from_file value.
"""
- if not from_file or "none" in from_cli:
- # --write is the same as --write=all
- return ["all" if value == cls._default else value for value in from_cli]
- # --write means use the config from the config file
- from_cli = [value for value in from_cli if value != cls._default]
- return from_file + from_cli
+ if not from_file:
+ arguments = ["all"] if from_cli == [cls._default] else from_cli
+ else:
+ arguments = from_file
+ for magic_value in ("none", "all"):
+ if magic_value in arguments and len(arguments) > 1:
+ msg = f"When passing '{magic_value}' to '--fix', you cannot pass other values."
+ raise RuntimeError(
+ msg,
+ )
+ if len(arguments) == 1 and arguments[0] == "none":
+ arguments = []
+ return arguments
def get_cli_parser() -> argparse.ArgumentParser:
"""Initialize an argument parser."""
- parser = argparse.ArgumentParser()
+ parser = argparse.ArgumentParser(
+ epilog=EPILOG,
+ # Avoid rewrapping description and epilog
+ formatter_class=argparse.RawTextHelpFormatter,
+ )
listing_group = parser.add_mutually_exclusive_group()
listing_group.add_argument(
@@ -338,22 +353,16 @@ def get_cli_parser() -> argparse.ArgumentParser:
help="Return non-zero exit code on warnings as well as errors",
)
parser.add_argument(
- "--write",
+ "--fix",
dest="write_list",
# this is a tri-state argument that takes an optional comma separated list:
action=WriteArgAction,
- help="Allow ansible-lint to reformat YAML files and run rule transforms "
- "(Reformatting YAML files standardizes spacing, quotes, etc. "
- "A rule transform can fix or simplify fixing issues identified by that rule). "
+ help="Allow ansible-lint to perform auto-fixes, including YAML reformatting. "
"You can limit the effective rule transforms (the 'write_list') by passing a "
"keywords 'all' or 'none' or a comma separated list of rule ids or rule tags. "
- "YAML reformatting happens whenever '--write' or '--write=' is used. "
- "'--write' and '--write=all' are equivalent: they allow all transforms to run. "
- "The effective list of transforms comes from 'write_list' in the config file, "
- "followed whatever '--write' args are provided on the commandline. "
- "'--write=none' resets the list of transforms to allow reformatting YAML "
- "without running any of the transforms (ie '--write=none,rule-id' will "
- "ignore write_list in the config file and only run the rule-id transform).",
+ "YAML reformatting happens whenever '--fix' or '--fix=' is used. "
+ "'--fix' and '--fix=all' are equivalent: they allow all transforms to run. "
+ "Presence of --fix in command overrides config file value.",
)
parser.add_argument(
"--show-relpath",
@@ -490,6 +499,7 @@ def merge_config(file_config: dict[Any, Any], cli_config: Options) -> Options:
"enable_list": [],
"only_builtins_allow_collections": [],
"only_builtins_allow_modules": [],
+ "supported_ansible_also": [],
# do not include "write_list" here. See special logic below.
}
@@ -506,6 +516,10 @@ def merge_config(file_config: dict[Any, Any], cli_config: Options) -> Options:
for entry, default in lists_map.items():
if not getattr(cli_config, entry, None):
setattr(cli_config, entry, default)
+ if cli_config.write_list is None:
+ cli_config.write_list = []
+ elif cli_config.write_list == [WriteArgAction._default]: # noqa: SLF001
+ cli_config.write_list = ["all"]
return cli_config
for entry in bools:
@@ -513,8 +527,8 @@ def merge_config(file_config: dict[Any, Any], cli_config: Options) -> Options:
v = getattr(cli_config, entry) or file_value
setattr(cli_config, entry, v)
- for entry, default in scalar_map.items():
- file_value = file_config.pop(entry, default)
+ for entry, default_scalar in scalar_map.items():
+ file_value = file_config.pop(entry, default_scalar)
v = getattr(cli_config, entry, None) or file_value
setattr(cli_config, entry, v)
@@ -533,7 +547,7 @@ def merge_config(file_config: dict[Any, Any], cli_config: Options) -> Options:
setattr(
cli_config,
entry,
- WriteArgAction.merge_write_list_config(
+ WriteArgAction.merge_fix_list_config(
from_file=file_config.pop(entry, []),
from_cli=getattr(cli_config, entry, []) or [],
),
@@ -557,6 +571,13 @@ def merge_config(file_config: dict[Any, Any], cli_config: Options) -> Options:
def get_config(arguments: list[str]) -> Options:
"""Extract the config based on given args."""
parser = get_cli_parser()
+ # translate deprecated options
+ for i, value in enumerate(arguments):
+ if arguments[i].startswith("--write"):
+ arguments[i] = value.replace("--write", "--fix")
+ _logger.warning(
+ "Replaced deprecated '--write' option with '--fix', change you call to avoid future regressions when we remove old option.",
+ )
options = Options(**vars(parser.parse_args(arguments)))
# docs is not document, being used for internal documentation building
diff --git a/src/ansiblelint/color.py b/src/ansiblelint/color.py
index 8f31e1c..d72d98d 100644
--- a/src/ansiblelint/color.py
+++ b/src/ansiblelint/color.py
@@ -1,4 +1,5 @@
"""Console coloring and terminal support."""
+
from __future__ import annotations
from typing import Any
diff --git a/src/ansiblelint/config.py b/src/ansiblelint/config.py
index 6164b10..ee9dea0 100644
--- a/src/ansiblelint/config.py
+++ b/src/ansiblelint/config.py
@@ -1,4 +1,5 @@
"""Store configuration options as a singleton."""
+
from __future__ import annotations
import json
@@ -67,7 +68,7 @@ DEFAULT_KINDS = [
{"requirements": "**/requirements.{yaml,yml}"}, # v2 and v1
{"playbook": "**/molecule/*/*.{yaml,yml}"}, # molecule playbooks
{"yaml": "**/{.ansible-lint,.yamllint}"},
- {"changelog": "**/changelogs/changelog.yaml"},
+ {"changelog": "**/changelogs/changelog.{yaml,yml}"},
{"yaml": "**/*.{yaml,yml}"},
{"yaml": "**/.*.{yaml,yml}"},
{"sanity-ignore-file": "**/tests/sanity/ignore-*.txt"},
@@ -98,22 +99,41 @@ BASE_KINDS = [
{"text/python": "**/*.py"},
]
+# File kinds that are recognized by ansible, used internally to force use of
+# YAML 1.1 instead of 1.2 due to ansible-core dependency on pyyaml.
+ANSIBLE_OWNED_KINDS = {
+ "handlers",
+ "galaxy",
+ "meta",
+ "meta-runtime",
+ "playbook",
+ "requirements",
+ "role-arg-spec",
+ "rulebook",
+ "tasks",
+ "vars",
+}
+
PROFILES = yaml_from_file(Path(__file__).parent / "data" / "profiles.yml")
LOOP_VAR_PREFIX = "^(__|{role}_)"
@dataclass
-class Options: # pylint: disable=too-many-instance-attributes,too-few-public-methods
+class Options: # pylint: disable=too-many-instance-attributes
"""Store ansible-lint effective configuration options."""
+ # Private attributes
+ _skip_ansible_syntax_check: bool = False
+
+ # Public attributes
cache_dir: Path | None = None
colored: bool = True
configured: bool = False
- cwd: Path = Path(".")
+ cwd: Path = Path()
display_relative_path: bool = True
exclude_paths: list[str] = field(default_factory=list)
- format: str = "brief" # noqa: A003
+ format: str = "brief"
lintables: list[str] = field(default_factory=list)
list_rules: bool = False
list_tags: bool = False
@@ -152,6 +172,27 @@ class Options: # pylint: disable=too-many-instance-attributes,too-few-public-me
version: bool = False # display version command
list_profiles: bool = False # display profiles command
ignore_file: Path | None = None
+ max_tasks: int = 100
+ max_block_depth: int = 20
+ # Refer to https://docs.ansible.com/ansible/latest/reference_appendices/release_and_maintenance.html#ansible-core-support-matrix
+ _default_supported = ["2.15.", "2.16.", "2.17."]
+ supported_ansible_also: list[str] = field(default_factory=list)
+
+ @property
+ def nodeps(self) -> bool:
+ """Returns value of nodeps feature."""
+ # We do not want this to be cached as it would affect our testings.
+ return bool(int(os.environ.get("ANSIBLE_LINT_NODEPS", "0")))
+
+ def __post_init__(self) -> None:
+ """Extra initialization logic."""
+ if self.nodeps:
+ self.offline = True
+
+ @property
+ def supported_ansible(self) -> list[str]:
+ """Returns list of ansible versions that are considered supported."""
+ return sorted([*self._default_supported, *self.supported_ansible_also])
options = Options()
@@ -166,15 +207,6 @@ collection_list: list[str] = []
log_entries: list[tuple[int, str]] = []
-def get_rule_config(rule_id: str) -> dict[str, Any]:
- """Get configurations for the rule ``rule_id``."""
- rule_config = options.rules.get(rule_id, {})
- if not isinstance(rule_config, dict): # pragma: no branch
- msg = f"Invalid rule config for {rule_id}: {rule_config}"
- raise RuntimeError(msg)
- return rule_config
-
-
@lru_cache
def ansible_collections_path() -> str:
"""Return collection path variable for current version of Ansible."""
@@ -241,7 +273,6 @@ def guess_install_method() -> str:
else:
logging.debug("Skipping %s as it is not installed.", package_name)
use_pip = False
- # pylint: disable=broad-except
except (AttributeError, ModuleNotFoundError) as exc:
# On Fedora 36, we got a AttributeError exception from pip that we want to avoid
# On NixOS, we got a ModuleNotFoundError exception from pip that we want to avoid
@@ -269,6 +300,11 @@ def get_version_warning() -> str:
# 0.1dev1 is special fallback version
if __version__ == "0.1.dev1": # pragma: no cover
return ""
+ pip = guess_install_method()
+ # If we do not know how to upgrade, we do not want to show any warnings
+ # about version.
+ if not pip:
+ return ""
msg = ""
data = {}
@@ -309,9 +345,6 @@ def get_version_warning() -> str:
msg = "[dim]You are using a pre-release version of ansible-lint.[/]"
elif current_version < new_version:
msg = f"""[warning]A new release of ansible-lint is available: [red]{current_version}[/] → [green][link={html_url}]{new_version}[/][/][/]"""
-
- pip = guess_install_method()
- if pip:
- msg += f" Upgrade by running: [info]{pip}[/]"
+ msg += f" Upgrade by running: [info]{pip}[/]"
return msg
diff --git a/src/ansiblelint/constants.py b/src/ansiblelint/constants.py
index 6b8bd12..56cf71b 100644
--- a/src/ansiblelint/constants.py
+++ b/src/ansiblelint/constants.py
@@ -1,11 +1,26 @@
"""Constants used by AnsibleLint."""
+
from enum import Enum
from pathlib import Path
from typing import Literal
DEFAULT_RULESDIR = Path(__file__).parent / "rules"
CUSTOM_RULESDIR_ENVVAR = "ANSIBLE_LINT_CUSTOM_RULESDIR"
-RULE_DOC_URL = "https://ansible-lint.readthedocs.io/rules/"
+RULE_DOC_URL = "https://ansible.readthedocs.io/projects/lint/rules/"
+SKIP_SCHEMA_UPDATE = "ANSIBLE_LINT_SKIP_SCHEMA_UPDATE"
+
+ENV_VARS_HELP = {
+ CUSTOM_RULESDIR_ENVVAR: "Used for adding another folder into the lookup path for new rules.",
+ "ANSIBLE_LINT_IGNORE_FILE": "Define it to override the name of the default ignore file `.ansible-lint-ignore`",
+ "ANSIBLE_LINT_WRITE_TMP": "Tells linter to dump fixes into different temp files instead of overriding original. Used internally for testing.",
+ SKIP_SCHEMA_UPDATE: "Tells ansible-lint to skip schema refresh.",
+ "ANSIBLE_LINT_NODEPS": "Avoids installing content dependencies and avoids performing checks that would fail when modules are not installed. Far less violations will be reported.",
+}
+
+EPILOG = (
+ "The following environment variables are also recognized but there is no guarantee that they will work in future versions:\n\n"
+ + "\n".join(f"{key}: {value}\n" for key, value in ENV_VARS_HELP.items())
+)
# Not using an IntEnum because only starting with py3.11 it will evaluate it
@@ -126,6 +141,36 @@ PLAYBOOK_TASK_KEYWORDS = [
"pre_tasks",
"post_tasks",
]
+PLAYBOOK_ROLE_KEYWORDS = [
+ "any_errors_fatal",
+ "become",
+ "become_exe",
+ "become_flags",
+ "become_method",
+ "become_user",
+ "check_mode",
+ "collections",
+ "connection",
+ "debugger",
+ "delegate_facts",
+ "delegate_to",
+ "diff",
+ "environment",
+ "ignore_errors",
+ "ignore_unreachable",
+ "module_defaults",
+ "name",
+ "role",
+ "no_log",
+ "port",
+ "remote_user",
+ "run_once",
+ "tags",
+ "throttle",
+ "timeout",
+ "vars",
+ "when",
+]
NESTED_TASK_KEYS = [
"block",
"always",
diff --git a/src/ansiblelint/data/.yamllint b/src/ansiblelint/data/.yamllint
new file mode 100644
index 0000000..6ff09f0
--- /dev/null
+++ b/src/ansiblelint/data/.yamllint
@@ -0,0 +1,25 @@
+extends: default
+rules:
+ comments:
+ # https://github.com/prettier/prettier/issues/6780
+ min-spaces-from-content: 1
+ # https://github.com/adrienverge/yamllint/issues/384
+ comments-indentation: false
+ document-start: disable
+ # 160 chars was the default used by old E204 rule, but
+ # you can easily change it or disable in your .yamllint file.
+ line-length:
+ max: 160
+ # We are adding an extra space inside braces as that's how prettier does it
+ # and we are trying not to fight other linters.
+ braces:
+ min-spaces-inside: 0 # yamllint defaults to 0
+ max-spaces-inside: 1 # yamllint defaults to 0
+ # key-duplicates:
+ # forbid-duplicated-merge-keys: true # not enabled by default
+ octal-values:
+ forbid-implicit-octal: true # yamllint defaults to false
+ forbid-explicit-octal: true # yamllint defaults to false
+ # quoted-strings:
+ # quote-type: double
+ # required: only-when-needed
diff --git a/src/ansiblelint/errors.py b/src/ansiblelint/errors.py
index c8458b8..5ee2d6f 100644
--- a/src/ansiblelint/errors.py
+++ b/src/ansiblelint/errors.py
@@ -1,4 +1,5 @@
"""Exceptions and error representations."""
+
from __future__ import annotations
import functools
@@ -6,7 +7,6 @@ from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
from ansiblelint._internal.rules import BaseRule, RuntimeErrorRule
-from ansiblelint.config import options
from ansiblelint.file_utils import Lintable
if TYPE_CHECKING:
@@ -27,15 +27,9 @@ class WarnSource:
message: str | None = None
-class StrictModeError(RuntimeError):
- """Raise when we encounter a warning in strict mode."""
-
- def __init__(
- self,
- message: str = "Warning treated as error due to --strict option.",
- ):
- """Initialize a StrictModeError instance."""
- super().__init__(message)
+@dataclass(frozen=True)
+class RuleMatchTransformMeta:
+ """Additional metadata about a match error to be used during transformation."""
# pylint: disable=too-many-instance-attributes
@@ -54,7 +48,6 @@ class MatchError(ValueError):
# order matters for these:
message: str = field(init=True, repr=False, default="")
lintable: Lintable = field(init=True, repr=False, default=Lintable(name=""))
- filename: str = field(init=True, repr=False, default="")
tag: str = field(init=True, repr=False, default="")
lineno: int = 1
@@ -65,13 +58,11 @@ class MatchError(ValueError):
rule: BaseRule = field(hash=False, default=RuntimeErrorRule())
ignored: bool = False
fixed: bool = False # True when a transform has resolved this MatchError
+ transform_meta: RuleMatchTransformMeta | None = None
def __post_init__(self) -> None:
"""Can be use by rules that can report multiple errors type, so we can still filter by them."""
- if not self.lintable and self.filename:
- self.lintable = Lintable(self.filename)
- elif self.lintable and not self.filename:
- self.filename = self.lintable.name
+ self.filename = self.lintable.name
# We want to catch accidental MatchError() which contains no useful
# information. When no arguments are passed, the '_message' field is
@@ -104,11 +95,22 @@ class MatchError(ValueError):
msg = "MatchError called incorrectly as column numbers start with 1"
raise RuntimeError(msg)
+ self.lineno += self.lintable.line_offset
+
+ # We make the lintable aware that we found a match inside it, as this
+ # can be used to skip running other rules that do require current one
+ # to pass.
+ self.lintable.matches.append(self)
+
@functools.cached_property
def level(self) -> str:
"""Return the level of the rule: error, warning or notice."""
- if not self.ignored and {self.tag, self.rule.id, *self.rule.tags}.isdisjoint(
- options.warn_list,
+ if (
+ not self.ignored
+ and self.rule.options
+ and {self.tag, self.rule.id, *self.rule.tags}.isdisjoint(
+ self.rule.options.warn_list,
+ )
):
return "error"
return "warning"
@@ -128,6 +130,10 @@ class MatchError(ValueError):
self.details,
)
+ def __str__(self) -> str:
+ """Return a MatchError instance string representation."""
+ return self.__repr__()
+
@property
def position(self) -> str:
"""Return error positioning, with column number if available."""
diff --git a/src/ansiblelint/file_utils.py b/src/ansiblelint/file_utils.py
index 15c92d2..04ce3cd 100644
--- a/src/ansiblelint/file_utils.py
+++ b/src/ansiblelint/file_utils.py
@@ -1,4 +1,5 @@
"""Utility functions related to file operations."""
+
from __future__ import annotations
import copy
@@ -16,12 +17,15 @@ import wcmatch.pathlib
import wcmatch.wcmatch
from yaml.error import YAMLError
-from ansiblelint.config import BASE_KINDS, Options, options
+from ansiblelint.app import get_app
+from ansiblelint.config import ANSIBLE_OWNED_KINDS, BASE_KINDS, Options, options
from ansiblelint.constants import CONFIG_FILENAMES, FileType, States
if TYPE_CHECKING:
from collections.abc import Iterator, Sequence
+ from ansiblelint.errors import MatchError
+
_logger = logging.getLogger(__package__)
@@ -69,9 +73,9 @@ def is_relative_to(path: Path, *other: Any) -> bool:
"""Return True if the path is relative to another path or False."""
try:
path.resolve().absolute().relative_to(*other)
- return True
except ValueError:
return False
+ return True
def normpath_path(path: str | Path) -> Path:
@@ -197,6 +201,10 @@ class Lintable:
self.exc: Exception | None = None # Stores data loading exceptions
self.parent = parent
self.explicit = False # Indicates if the file was explicitly provided or was indirectly included.
+ self.line_offset = (
+ 0 # Amount to offset line numbers by to get accurate position
+ )
+ self.matches: list[MatchError] = []
if isinstance(name, str):
name = Path(name)
@@ -219,7 +227,12 @@ class Lintable:
parts = self.path.parent.parts
if "roles" in parts:
role = self.path
- while role.parent.name != "roles" and role.name:
+ roles_path = get_app(cached=True).runtime.config.default_roles_path
+ while (
+ str(role.parent.absolute()) not in roles_path
+ and role.parent.name != "roles"
+ and role.name
+ ):
role = role.parent
if role.exists():
self.role = role.name
@@ -252,7 +265,12 @@ class Lintable:
self.parent = _guess_parent(self)
if self.kind == "yaml":
- _ = self.data # pylint: disable=pointless-statement
+ _ = self.data
+
+ def __del__(self) -> None:
+ """Clean up temporary files when the instance is cleaned up."""
+ if hasattr(self, "file"):
+ self.file.close()
def _guess_kind(self) -> None:
if self.kind == "yaml":
@@ -350,10 +368,16 @@ class Lintable:
lintable.write(force=True)
"""
- if not force and not self.updated:
+ dump_filename = self.path.expanduser().resolve()
+ if os.environ.get("ANSIBLE_LINT_WRITE_TMP", "0") == "1":
+ dump_filename = dump_filename.with_suffix(
+ f".tmp{dump_filename.suffix}",
+ )
+ elif not force and not self.updated:
# No changes to write.
return
- self.path.expanduser().resolve().write_text(
+
+ dump_filename.write_text(
self._content or "",
encoding="utf-8",
)
@@ -372,6 +396,16 @@ class Lintable:
"""Return user friendly representation of a lintable."""
return f"{self.name} ({self.kind})"
+ def is_owned_by_ansible(self) -> bool:
+ """Return true for YAML files that are managed by Ansible."""
+ return self.kind in ANSIBLE_OWNED_KINDS
+
+ def failed(self) -> bool:
+ """Return true if we already found syntax-check errors on this file."""
+ return any(
+ match.rule.id in ("syntax-check", "load-failure") for match in self.matches
+ )
+
@property
def data(self) -> Any:
"""Return loaded data representation for current file, if possible."""
@@ -396,7 +430,11 @@ class Lintable:
# pylint: disable=import-outside-toplevel
from ansiblelint.skip_utils import append_skipped_rules
- self.state = append_skipped_rules(self.state, self)
+ # pylint: disable=possibly-used-before-assignment
+ self.state = append_skipped_rules(
+ self.state,
+ self,
+ )
else:
logging.debug(
"data set to None for %s due to being '%s' (%s) kind.",
@@ -513,7 +551,7 @@ def expand_dirs_in_lintables(lintables: set[Lintable]) -> None:
for item in copy.copy(lintables):
if item.path.is_dir():
for filename in all_files:
- if filename.startswith(str(item.path)):
+ if filename.startswith((str(item.path), str(item.path.absolute()))):
lintables.add(Lintable(filename))
diff --git a/src/ansiblelint/formatters/__init__.py b/src/ansiblelint/formatters/__init__.py
index 9ddca00..187d803 100644
--- a/src/ansiblelint/formatters/__init__.py
+++ b/src/ansiblelint/formatters/__init__.py
@@ -1,4 +1,5 @@
"""Output formatters."""
+
from __future__ import annotations
import hashlib
@@ -14,6 +15,7 @@ from ansiblelint.version import __version__
if TYPE_CHECKING:
from ansiblelint.errors import MatchError
+ from ansiblelint.rules import BaseRule # type: ignore[attr-defined]
T = TypeVar("T", bound="BaseFormatter") # type: ignore[type-arg]
@@ -27,6 +29,7 @@ class BaseFormatter(Generic[T]):
----
base_dir (str|Path): reference directory against which display relative path.
display_relative_path (bool): whether to show path as relative or absolute
+
"""
def __init__(self, base_dir: str | Path, display_relative_path: bool) -> None:
@@ -143,7 +146,7 @@ class CodeclimateJSONFormatter(BaseFormatter[Any]):
"""Format a list of match errors as a JSON string."""
if not isinstance(matches, list):
msg = f"The {self.__class__} was expecting a list of MatchError."
- raise RuntimeError(msg)
+ raise TypeError(msg)
result = []
for match in matches:
@@ -210,7 +213,7 @@ class SarifFormatter(BaseFormatter[Any]):
"""Format a list of match errors as a JSON string."""
if not isinstance(matches, list):
msg = f"The {self.__class__} was expecting a list of MatchError."
- raise RuntimeError(msg)
+ raise TypeError(msg)
root_path = Path(str(self.base_dir)).as_uri()
root_path = root_path + "/" if not root_path.endswith("/") else root_path
@@ -264,7 +267,7 @@ class SarifFormatter(BaseFormatter[Any]):
"text": str(match.message),
},
"defaultConfiguration": {
- "level": self._to_sarif_level(match),
+ "level": self.get_sarif_rule_severity_level(match.rule),
},
"help": {
"text": str(match.rule.description),
@@ -275,12 +278,21 @@ class SarifFormatter(BaseFormatter[Any]):
return rule
def _to_sarif_result(self, match: MatchError) -> dict[str, Any]:
+ # https://docs.oasis-open.org/sarif/sarif/v2.1.0/errata01/os/sarif-v2.1.0-errata01-os-complete.html#_Toc141790898
+ if match.level not in ("warning", "error", "note", "none"):
+ msg = "Unexpected failure to map '%s' level to SARIF."
+ raise RuntimeError(
+ msg,
+ match.level,
+ )
+
result: dict[str, Any] = {
"ruleId": match.tag,
+ "level": self.get_sarif_result_severity_level(match),
"message": {
- "text": str(match.details)
- if str(match.details)
- else str(match.message),
+ "text": (
+ str(match.details) if str(match.details) else str(match.message)
+ ),
},
"locations": [
{
@@ -303,6 +315,37 @@ class SarifFormatter(BaseFormatter[Any]):
return result
@staticmethod
- def _to_sarif_level(match: MatchError) -> str:
- # sarif accepts only 4 levels: error, warning, note, none
- return match.level
+ def get_sarif_rule_severity_level(rule: BaseRule) -> str:
+ """General SARIF severity level for a rule.
+
+ Note: Can differ from an actual result/match severity.
+ Possible values: "none", "note", "warning", "error"
+
+ see: https://github.com/oasis-tcs/sarif-spec/blob/123e95847b13fbdd4cbe2120fa5e33355d4a042b/Schemata/sarif-schema-2.1.0.json#L1934-L1939
+ """
+ if rule.severity in ["VERY_HIGH", "HIGH"]:
+ return "error"
+
+ if rule.severity in ["MEDIUM", "LOW", "VERY_LOW"]:
+ return "warning"
+
+ if rule.severity == "INFO":
+ return "note"
+
+ return "none"
+
+ @staticmethod
+ def get_sarif_result_severity_level(match: MatchError) -> str:
+ """SARIF severity level for an actual result/match.
+
+ Possible values: "none", "note", "warning", "error"
+
+ see: https://github.com/oasis-tcs/sarif-spec/blob/123e95847b13fbdd4cbe2120fa5e33355d4a042b/Schemata/sarif-schema-2.1.0.json#L2066-L2071
+ """
+ if not match.level:
+ return "none"
+
+ if match.level in ["warning", "error"]:
+ return match.level
+
+ return "note"
diff --git a/src/ansiblelint/generate_docs.py b/src/ansiblelint/generate_docs.py
index 1498a67..6e319fb 100644
--- a/src/ansiblelint/generate_docs.py
+++ b/src/ansiblelint/generate_docs.py
@@ -1,4 +1,5 @@
"""Utils to generate rules documentation."""
+
import logging
from collections.abc import Iterable
@@ -9,7 +10,7 @@ from rich.table import Table
from ansiblelint.config import PROFILES
from ansiblelint.constants import RULE_DOC_URL
-from ansiblelint.rules import RulesCollection
+from ansiblelint.rules import RulesCollection, TransformMixin
DOC_HEADER = """
# Default Rules
@@ -27,6 +28,8 @@ def rules_as_str(rules: RulesCollection) -> RenderableType:
"""Return rules as string."""
table = Table(show_header=False, header_style="title", box=box.SIMPLE)
for rule in rules.alphabetical():
+ if issubclass(rule.__class__, TransformMixin):
+ rule.tags.insert(0, "autofix")
tag = f"[dim] ({', '.join(rule.tags)})[/dim]" if rule.tags else ""
table.add_row(
f"[link={RULE_DOC_URL}{rule.id}/]{rule.id}[/link]",
@@ -56,6 +59,12 @@ def rules_as_md(rules: RulesCollection) -> str:
result += f"\n\n## {title}\n\n**{rule.shortdesc}**\n\n{description}"
+ # Safety net for preventing us from adding autofix to rules and
+ # forgetting to mention it inside their documentation.
+ if "autofix" in rule.tags and "autofix" not in rule.description:
+ msg = f"Rule {rule.id} is invalid because it has 'autofix' tag but this ability is not documented in its description."
+ raise RuntimeError(msg)
+
return result
diff --git a/src/ansiblelint/loaders.py b/src/ansiblelint/loaders.py
index 49e38f1..c369c89 100644
--- a/src/ansiblelint/loaders.py
+++ b/src/ansiblelint/loaders.py
@@ -1,11 +1,12 @@
"""Utilities for loading various files."""
+
from __future__ import annotations
import logging
import os
-from collections import defaultdict, namedtuple
+from collections import defaultdict
from functools import partial
-from typing import TYPE_CHECKING, Any
+from typing import TYPE_CHECKING, Any, NamedTuple
import yaml
from yaml import YAMLError
@@ -19,7 +20,14 @@ except (ImportError, AttributeError):
if TYPE_CHECKING:
from pathlib import Path
-IgnoreFile = namedtuple("IgnoreFile", "default alternative")
+
+class IgnoreFile(NamedTuple):
+ """IgnoreFile n."""
+
+ default: str
+ alternative: str
+
+
IGNORE_FILE = IgnoreFile(".ansible-lint-ignore", ".config/ansible-lint-ignore.txt")
yaml_load = partial(yaml.load, Loader=FullLoader)
diff --git a/src/ansiblelint/logger.py b/src/ansiblelint/logger.py
index f0477cd..cb3bb19 100644
--- a/src/ansiblelint/logger.py
+++ b/src/ansiblelint/logger.py
@@ -1,4 +1,5 @@
"""Utils related to logging."""
+
import logging
import time
from collections.abc import Iterator
@@ -17,15 +18,3 @@ def timed_info(msg: Any, *args: Any) -> Iterator[None]:
finally:
elapsed = time.time() - start
_logger.info(msg + " (%.2fs)", *(*args, elapsed)) # noqa: G003
-
-
-def warn_or_fail(message: str) -> None:
- """Warn or fail depending on the strictness level."""
- # pylint: disable=import-outside-toplevel
- from ansiblelint.config import options
- from ansiblelint.errors import StrictModeError
-
- if options.strict:
- raise StrictModeError(message)
-
- _logger.warning(message)
diff --git a/src/ansiblelint/requirements.py b/src/ansiblelint/requirements.py
new file mode 100644
index 0000000..96381b9
--- /dev/null
+++ b/src/ansiblelint/requirements.py
@@ -0,0 +1,28 @@
+"""Utilities for checking python packages requirements."""
+
+import importlib_metadata
+from packaging.requirements import Requirement
+from packaging.specifiers import SpecifierSet
+from packaging.version import Version
+
+
+class Reqs(dict[str, SpecifierSet]):
+ """Utility class for working with package dependencies."""
+
+ reqs: dict[str, SpecifierSet]
+
+ def __init__(self, name: str = "ansible-lint") -> None:
+ """Load linter metadata requirements."""
+ for req_str in importlib_metadata.metadata(name).json["requires_dist"]:
+ req = Requirement(req_str)
+ if req.name:
+ self[req.name] = req.specifier
+
+ def matches(self, req_name: str, req_version: str | Version) -> bool:
+ """Verify if given version is matching current metadata dependencies."""
+ if req_name not in self:
+ return False
+ return all(
+ specifier.contains(str(req_version), prereleases=True)
+ for specifier in self[req_name]
+ )
diff --git a/src/ansiblelint/rules/__init__.py b/src/ansiblelint/rules/__init__.py
index acb7df1..a1743a0 100644
--- a/src/ansiblelint/rules/__init__.py
+++ b/src/ansiblelint/rules/__init__.py
@@ -1,4 +1,5 @@
"""All internal ansible-lint rules."""
+
from __future__ import annotations
import copy
@@ -23,7 +24,7 @@ from ansiblelint._internal.rules import (
WarningRule,
)
from ansiblelint.app import App, get_app
-from ansiblelint.config import PROFILES, Options, get_rule_config
+from ansiblelint.config import PROFILES, Options
from ansiblelint.config import options as default_options
from ansiblelint.constants import LINE_NUMBER_KEY, RULE_DOC_URL, SKIPPED_RULES_KEY
from ansiblelint.errors import MatchError
@@ -32,6 +33,8 @@ from ansiblelint.file_utils import Lintable, expand_paths_vars
if TYPE_CHECKING:
from ruamel.yaml.comments import CommentedMap, CommentedSeq
+ from ansiblelint.errors import RuleMatchTransformMeta
+
_logger = logging.getLogger(__name__)
match_types = {
@@ -53,11 +56,6 @@ class AnsibleLintRule(BaseRule):
"""Return rule documentation url."""
return RULE_DOC_URL + self.id + "/"
- @property
- def rule_config(self) -> dict[str, Any]:
- """Retrieve rule specific configuration."""
- return get_rule_config(self.id)
-
def get_config(self, key: str) -> Any:
"""Return a configured value for given key string."""
return self.rule_config.get(key, None)
@@ -78,6 +76,7 @@ class AnsibleLintRule(BaseRule):
details: str = "",
filename: Lintable | None = None,
tag: str = "",
+ transform_meta: RuleMatchTransformMeta | None = None,
) -> MatchError:
"""Instantiate a new MatchError."""
match = MatchError(
@@ -87,13 +86,14 @@ class AnsibleLintRule(BaseRule):
lintable=filename or Lintable(""),
rule=copy.copy(self),
tag=tag,
+ transform_meta=transform_meta,
)
# search through callers to find one of the match* methods
frame = inspect.currentframe()
match_type: str | None = None
while not match_type and frame is not None:
func_name = frame.f_code.co_name
- match_type = match_types.get(func_name, None)
+ match_type = match_types.get(func_name)
if match_type:
# add the match_type to the match
match.match_type = match_type
@@ -109,8 +109,8 @@ class AnsibleLintRule(BaseRule):
match.task = task
if not match.details:
match.details = "Task/Handler: " + ansiblelint.utils.task_to_str(task)
- if match.lineno < task[LINE_NUMBER_KEY]:
- match.lineno = task[LINE_NUMBER_KEY]
+
+ match.lineno = max(match.lineno, task[LINE_NUMBER_KEY])
def matchlines(self, file: Lintable) -> list[MatchError]:
matches: list[MatchError] = []
@@ -224,7 +224,16 @@ class AnsibleLintRule(BaseRule):
if isinstance(yaml, str):
if yaml.startswith("$ANSIBLE_VAULT"):
return []
- return [MatchError(lintable=file, rule=LoadingFailureRule())]
+ if self._collection is None:
+ msg = f"Rule {self.id} was not added to a collection."
+ raise RuntimeError(msg)
+ return [
+ # pylint: disable=E1136
+ MatchError(
+ lintable=file,
+ rule=self._collection["load-failure"],
+ ),
+ ]
if not yaml:
return matches
@@ -250,7 +259,7 @@ class AnsibleLintRule(BaseRule):
class TransformMixin:
"""A mixin for AnsibleLintRule to enable transforming files.
- If ansible-lint is started with the ``--write`` option, then the ``Transformer``
+ If ansible-lint is started with the ``--fix`` option, then the ``Transformer``
will call the ``transform()`` method for every MatchError identified if the rule
that identified it subclasses this ``TransformMixin``. Only the rule that identified
a MatchError can do transforms to fix that match.
@@ -324,7 +333,6 @@ class TransformMixin:
return target
-# pylint: disable=too-many-nested-blocks
def load_plugins(
dirs: list[str],
) -> Iterator[AnsibleLintRule]:
@@ -370,7 +378,7 @@ def load_plugins(
class RulesCollection:
"""Container for a collection of rules."""
- def __init__(
+ def __init__( # pylint: disable=too-many-arguments
self,
rulesdirs: list[str] | list[Path] | None = None,
options: Options | None = None,
@@ -388,7 +396,7 @@ class RulesCollection:
else:
self.options = options
self.profile = []
- self.app = app or get_app(offline=True)
+ self.app = app or get_app(cached=True)
if profile_name:
self.profile = PROFILES[profile_name]
@@ -405,6 +413,8 @@ class RulesCollection:
WarningRule(),
],
)
+ for rule in self.rules:
+ rule._collection = self # noqa: SLF001
for rule in load_plugins(rulesdirs_str):
self.register(rule, conditional=conditional)
self.rules = sorted(self.rules)
@@ -443,6 +453,17 @@ class RulesCollection:
"""Return the length of the RulesCollection data."""
return len(self.rules)
+ def __getitem__(self, item: Any) -> BaseRule:
+ """Return a rule from inside the collection based on its id."""
+ if not isinstance(item, str):
+ msg = f"Expected str but got {type(item)} when trying to access rule by it's id"
+ raise TypeError(msg)
+ for rule in self.rules:
+ if rule.id == item:
+ return rule
+ msg = f"Rule {item} is not present inside this collection."
+ raise ValueError(msg)
+
def extend(self, more: list[AnsibleLintRule]) -> None:
"""Combine rules."""
self.rules.extend(more)
@@ -469,7 +490,7 @@ class RulesCollection:
MatchError(
message=str(exc),
lintable=file,
- rule=LoadingFailureRule(),
+ rule=self["load-failure"],
tag=f"{LoadingFailureRule.id}[{exc.__class__.__name__.lower()}]",
),
]
@@ -482,10 +503,18 @@ class RulesCollection:
or rule.has_dynamic_tags
or not set(rule.tags).union([rule.id]).isdisjoint(tags)
):
- rule_definition = set(rule.tags)
- rule_definition.add(rule.id)
- if set(rule_definition).isdisjoint(skip_list):
- matches.extend(rule.getmatches(file))
+ if tags and set(rule.tags).union(list(rule.ids().keys())).isdisjoint(
+ tags,
+ ):
+ _logger.debug("Skipping rule %s", rule.id)
+ else:
+ _logger.debug("Running rule %s", rule.id)
+ rule_definition = set(rule.tags)
+ rule_definition.add(rule.id)
+ if set(rule_definition).isdisjoint(skip_list):
+ matches.extend(rule.getmatches(file))
+ else:
+ _logger.debug("Skipping rule %s", rule.id)
# some rules can produce matches with tags that are inside our
# skip_list, so we need to cleanse the matches
@@ -499,6 +528,15 @@ class RulesCollection:
[rule.verbose() for rule in sorted(self.rules, key=lambda x: x.id)],
)
+ def known_tags(self) -> list[str]:
+ """Return a list of known tags, without returning no sub-tags."""
+ tags = set()
+ for rule in self.rules:
+ tags.add(rule.id)
+ for tag in rule.tags:
+ tags.add(tag)
+ return sorted(tags)
+
def list_tags(self) -> str:
"""Return a string with all the tags in the RulesCollection."""
tag_desc = {
@@ -525,11 +563,10 @@ class RulesCollection:
msg = f"Rule {rule} does not have any of the required tags: {', '.join(tag_desc.keys())}"
raise RuntimeError(msg)
for tag in rule.tags:
- for id_ in rule.ids():
- tags[tag].append(id_)
+ tags[tag] = list(rule.ids())
result = "# List of tags and rules they cover\n"
for tag in sorted(tags):
- desc = tag_desc.get(tag, None)
+ desc = tag_desc.get(tag)
if desc:
result += f"{tag}: # {desc}\n"
else:
@@ -550,6 +587,8 @@ def filter_rules_with_profile(rule_col: list[BaseRule], profile: str) -> None:
included.add(rule)
extends = PROFILES[extends].get("extends", None)
for rule in rule_col.copy():
+ if rule.unloadable:
+ continue
if rule.id not in included:
_logger.debug(
"Unloading %s rule due to not being part of %s profile.",
diff --git a/src/ansiblelint/rules/args.py b/src/ansiblelint/rules/args.py
index 2acf32e..fb9f991 100644
--- a/src/ansiblelint/rules/args.py
+++ b/src/ansiblelint/rules/args.py
@@ -1,4 +1,5 @@
"""Rule definition to validate task options."""
+
from __future__ import annotations
import contextlib
@@ -8,7 +9,6 @@ import json
import logging
import re
import sys
-from functools import lru_cache
from typing import TYPE_CHECKING, Any
# pylint: disable=preferred-module
@@ -18,11 +18,11 @@ from unittest.mock import patch
# pylint: disable=reimported
import ansible.module_utils.basic as mock_ansible_module
from ansible.module_utils import basic
-from ansible.plugins.loader import PluginLoadContext, module_loader
from ansiblelint.constants import LINE_NUMBER_KEY
from ansiblelint.rules import AnsibleLintRule, RulesCollection
from ansiblelint.text import has_jinja
+from ansiblelint.utils import load_plugin
from ansiblelint.yaml_utils import clean_json
if TYPE_CHECKING:
@@ -66,12 +66,6 @@ workarounds_inject_map = {
}
-@lru_cache
-def load_module(module_name: str) -> PluginLoadContext:
- """Load plugin from module name and cache it."""
- return module_loader.find_plugin_with_context(module_name)
-
-
class ValidationPassedError(Exception):
"""Exception to be raised when validation passes."""
@@ -103,7 +97,7 @@ class ArgsRule(AnsibleLintRule):
task: Task,
file: Lintable | None = None,
) -> list[MatchError]:
- # pylint: disable=too-many-locals,too-many-return-statements
+ # pylint: disable=too-many-return-statements
results: list[MatchError] = []
module_name = task["action"]["__ansible_module_original__"]
failed_msg = None
@@ -111,7 +105,7 @@ class ArgsRule(AnsibleLintRule):
if module_name in self.module_aliases:
return []
- loaded_module = load_module(module_name)
+ loaded_module = load_plugin(module_name)
# https://github.com/ansible/ansible-lint/issues/3200
# since "ps1" modules cannot be executed on POSIX platforms, we will
@@ -150,14 +144,10 @@ class ArgsRule(AnsibleLintRule):
CustomAnsibleModule,
):
spec = importlib.util.spec_from_file_location(
- name=loaded_module.resolved_fqcn,
+ name=loaded_module.plugin_resolved_name,
location=loaded_module.plugin_resolved_path,
)
- if spec:
- assert spec.loader is not None
- module = importlib.util.module_from_spec(spec)
- spec.loader.exec_module(module)
- else:
+ if not spec:
assert file is not None
_logger.warning(
"Unable to load module %s at %s:%s for options validation",
@@ -166,6 +156,9 @@ class ArgsRule(AnsibleLintRule):
task[LINE_NUMBER_KEY],
)
return []
+ assert spec.loader is not None
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
try:
if not hasattr(module, "main"):
@@ -196,9 +189,9 @@ class ArgsRule(AnsibleLintRule):
)
sanitized_results = self._sanitize_results(results, module_name)
- return sanitized_results
except ValidationPassedError:
return []
+ return sanitized_results
# pylint: disable=unused-argument
def _sanitize_results(
diff --git a/src/ansiblelint/rules/avoid_implicit.py b/src/ansiblelint/rules/avoid_implicit.py
index 8d1fe26..d752ec7 100644
--- a/src/ansiblelint/rules/avoid_implicit.py
+++ b/src/ansiblelint/rules/avoid_implicit.py
@@ -1,4 +1,5 @@
"""Implementation of avoid-implicit rule."""
+
# https://github.com/ansible/ansible-lint/issues/2501
from __future__ import annotations
@@ -40,8 +41,8 @@ class AvoidImplicitRule(AnsibleLintRule):
# testing code to be loaded only with pytest or when executed the rule file
if "pytest" in sys.modules:
- from ansiblelint.rules import RulesCollection # pylint: disable=ungrouped-imports
- from ansiblelint.runner import Runner # pylint: disable=ungrouped-imports
+ from ansiblelint.rules import RulesCollection
+ from ansiblelint.runner import Runner
def test_template_instead_of_copy_positive() -> None:
"""Positive test for avoid-implicit."""
diff --git a/src/ansiblelint/rules/command_instead_of_module.py b/src/ansiblelint/rules/command_instead_of_module.py
index 068e430..538141b 100644
--- a/src/ansiblelint/rules/command_instead_of_module.py
+++ b/src/ansiblelint/rules/command_instead_of_module.py
@@ -1,4 +1,5 @@
"""Implementation of command-instead-of-module rule."""
+
# Copyright (c) 2013-2014 Will Thames <will@thames.id.au>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -25,7 +26,7 @@ from pathlib import Path
from typing import TYPE_CHECKING
from ansiblelint.rules import AnsibleLintRule
-from ansiblelint.utils import convert_to_boolean, get_first_cmd_arg, get_second_cmd_arg
+from ansiblelint.utils import get_first_cmd_arg, get_second_cmd_arg
if TYPE_CHECKING:
from ansiblelint.file_utils import Lintable
@@ -68,9 +69,17 @@ class CommandsInsteadOfModulesRule(AnsibleLintRule):
}
_executable_options = {
- "git": ["branch", "log", "lfs"],
- "systemctl": ["--version", "kill", "set-default", "show-environment", "status"],
- "yum": ["clean"],
+ "git": ["branch", "log", "lfs", "rev-parse"],
+ "systemctl": [
+ "--version",
+ "get-default",
+ "kill",
+ "set-default",
+ "set-property",
+ "show-environment",
+ "status",
+ ],
+ "yum": ["clean", "history", "info"],
"rpm": ["--nodeps"],
}
@@ -97,9 +106,7 @@ class CommandsInsteadOfModulesRule(AnsibleLintRule):
):
return False
- if executable in self._modules and convert_to_boolean(
- task["action"].get("warn", True),
- ):
+ if executable in self._modules:
message = "{0} used in place of {1} module"
return message.format(executable, self._modules[executable])
return False
@@ -108,8 +115,9 @@ class CommandsInsteadOfModulesRule(AnsibleLintRule):
if "pytest" in sys.modules:
import pytest
- from ansiblelint.rules import RulesCollection # pylint: disable=ungrouped-imports
- from ansiblelint.runner import Runner # pylint: disable=ungrouped-imports
+ # pylint: disable=ungrouped-imports
+ from ansiblelint.rules import RulesCollection
+ from ansiblelint.runner import Runner
@pytest.mark.parametrize(
("file", "expected"),
diff --git a/src/ansiblelint/rules/command_instead_of_shell.md b/src/ansiblelint/rules/command_instead_of_shell.md
index 0abf69d..1e64d2c 100644
--- a/src/ansiblelint/rules/command_instead_of_shell.md
+++ b/src/ansiblelint/rules/command_instead_of_shell.md
@@ -28,3 +28,7 @@ environment variable expansion or chaining multiple commands using pipes.
ansible.builtin.command: echo hello
changed_when: false
```
+
+!!! note
+
+ This rule can be automatically fixed using [`--fix`](../autofix.md) option.
diff --git a/src/ansiblelint/rules/command_instead_of_shell.py b/src/ansiblelint/rules/command_instead_of_shell.py
index 346a071..789adca 100644
--- a/src/ansiblelint/rules/command_instead_of_shell.py
+++ b/src/ansiblelint/rules/command_instead_of_shell.py
@@ -1,4 +1,5 @@
"""Implementation of command-instead-of-shell rule."""
+
# Copyright (c) 2016 Will Thames <will@thames.id.au>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -23,15 +24,18 @@ from __future__ import annotations
import sys
from typing import TYPE_CHECKING
-from ansiblelint.rules import AnsibleLintRule
+from ansiblelint.rules import AnsibleLintRule, TransformMixin
from ansiblelint.utils import get_cmd_args
if TYPE_CHECKING:
+ from ruamel.yaml.comments import CommentedMap, CommentedSeq
+
+ from ansiblelint.errors import MatchError
from ansiblelint.file_utils import Lintable
from ansiblelint.utils import Task
-class UseCommandInsteadOfShellRule(AnsibleLintRule):
+class UseCommandInsteadOfShellRule(AnsibleLintRule, TransformMixin):
"""Use shell only when shell functionality is required."""
id = "command-instead-of-shell"
@@ -62,13 +66,27 @@ class UseCommandInsteadOfShellRule(AnsibleLintRule):
return not any(ch in jinja_stripped_cmd for ch in "&|<>;$\n*[]{}?`")
return False
+ def transform(
+ self,
+ match: MatchError,
+ lintable: Lintable,
+ data: CommentedMap | CommentedSeq | str,
+ ) -> None:
+ if match.tag == "command-instead-of-shell":
+ target_task = self.seek(match.yaml_path, data)
+ for _ in range(len(target_task)):
+ k, v = target_task.popitem(False)
+ target_task["ansible.builtin.command" if "shell" in k else k] = v
+ match.fixed = True
+
# testing code to be loaded only with pytest or when executed the rule file
if "pytest" in sys.modules:
import pytest
- from ansiblelint.rules import RulesCollection # pylint: disable=ungrouped-imports
- from ansiblelint.runner import Runner # pylint: disable=ungrouped-imports
+ # pylint: disable=ungrouped-imports
+ from ansiblelint.rules import RulesCollection
+ from ansiblelint.runner import Runner
@pytest.mark.parametrize(
("file", "expected"),
diff --git a/src/ansiblelint/rules/complexity.md b/src/ansiblelint/rules/complexity.md
new file mode 100644
index 0000000..aa25a1e
--- /dev/null
+++ b/src/ansiblelint/rules/complexity.md
@@ -0,0 +1,19 @@
+# complexity
+
+This rule aims to warn about Ansible content that seems to be overly complex,
+suggesting refactoring for better readability and maintainability.
+
+## complexity[tasks]
+
+`complexity[tasks]` will be triggered if the total number of tasks inside a file
+is above 100. If encountered, you should consider using
+[`ansible.builtin.include_tasks`](https://docs.ansible.com/ansible/latest/collections/ansible/builtin/include_tasks_module.html)
+to split your tasks into smaller files.
+
+## complexity[nesting]
+
+`complexity[nesting]` will appear when a block contains too many tasks, by
+default that number is 20 but it can be changed inside the configuration file by
+defining `max_block_depth` value.
+
+ Replace nested block with an include_tasks to make code easier to maintain. Maximum block depth allowed is ...
diff --git a/src/ansiblelint/rules/complexity.py b/src/ansiblelint/rules/complexity.py
new file mode 100644
index 0000000..04d92d0
--- /dev/null
+++ b/src/ansiblelint/rules/complexity.py
@@ -0,0 +1,115 @@
+"""Implementation of limiting number of tasks."""
+
+from __future__ import annotations
+
+import re
+import sys
+from typing import TYPE_CHECKING, Any
+
+from ansiblelint.constants import LINE_NUMBER_KEY
+from ansiblelint.rules import AnsibleLintRule, RulesCollection
+
+if TYPE_CHECKING:
+ from ansiblelint.config import Options
+ from ansiblelint.errors import MatchError
+ from ansiblelint.file_utils import Lintable
+ from ansiblelint.utils import Task
+
+
+class ComplexityRule(AnsibleLintRule):
+ """Rule for limiting number of tasks inside a file."""
+
+ id = "complexity"
+ description = "There should be limited tasks executed inside any file"
+ severity = "MEDIUM"
+ tags = ["experimental", "idiom"]
+ version_added = "v6.18.0 (last update)"
+ _re_templated_inside = re.compile(r".*\{\{.*\}\}.*\w.*$")
+
+ def matchplay(self, file: Lintable, data: dict[str, Any]) -> list[MatchError]:
+ """Call matchplay for up to no_of_max_tasks inside file and return aggregate results."""
+ results: list[MatchError] = []
+
+ if file.kind != "playbook":
+ return []
+ tasks = data.get("tasks", [])
+ if not isinstance(self._collection, RulesCollection):
+ msg = "Rules cannot be run outside a rule collection."
+ raise TypeError(msg)
+ if len(tasks) > self._collection.options.max_tasks:
+ results.append(
+ self.create_matcherror(
+ message=f"Maximum tasks allowed in a play is {self._collection.options.max_tasks}.",
+ lineno=data[LINE_NUMBER_KEY],
+ tag=f"{self.id}[play]",
+ filename=file,
+ ),
+ )
+ return results
+
+ def matchtask(self, task: Task, file: Lintable | None = None) -> list[MatchError]:
+ """Check if the task is a block and count the number of items inside it."""
+ results: list[MatchError] = []
+
+ if not isinstance(self._collection, RulesCollection):
+ msg = "Rules cannot be run outside a rule collection."
+ raise TypeError(msg)
+
+ if task.action == "block/always/rescue":
+ block_depth = self.calculate_block_depth(task)
+ if block_depth > self._collection.options.max_block_depth:
+ results.append(
+ self.create_matcherror(
+ message=f"Replace nested block with an include_tasks to make code easier to maintain. Maximum block depth allowed is {self._collection.options.max_block_depth}.",
+ lineno=task[LINE_NUMBER_KEY],
+ tag=f"{self.id}[nesting]",
+ filename=file,
+ ),
+ )
+ return results
+
+ def calculate_block_depth(self, task: Task) -> int:
+ """Recursively calculate the block depth of a task."""
+ if not isinstance(task.position, str):
+ raise NotImplementedError
+ return task.position.count(".block")
+
+
+if "pytest" in sys.modules:
+ import pytest
+
+ # pylint: disable=ungrouped-imports
+ from ansiblelint.runner import Runner
+
+ @pytest.mark.parametrize(
+ ("file", "expected_results"),
+ (
+ pytest.param(
+ "examples/playbooks/rule-complexity-pass.yml",
+ [],
+ id="pass",
+ ),
+ pytest.param(
+ "examples/playbooks/rule-complexity-fail.yml",
+ ["complexity[play]", "complexity[nesting]"],
+ id="fail",
+ ),
+ ),
+ )
+ def test_complexity(
+ file: str,
+ expected_results: list[str],
+ monkeypatch: pytest.MonkeyPatch,
+ config_options: Options,
+ ) -> None:
+ """Test rule."""
+ monkeypatch.setattr(config_options, "max_tasks", 5)
+ monkeypatch.setattr(config_options, "max_block_depth", 3)
+ collection = RulesCollection(options=config_options)
+ collection.register(ComplexityRule())
+ results = Runner(file, rules=collection).run()
+
+ assert len(results) == len(expected_results)
+ for i, result in enumerate(results):
+ assert result.rule.id == ComplexityRule.id, result
+ assert result.tag == expected_results[i]
diff --git a/src/ansiblelint/rules/conftest.py b/src/ansiblelint/rules/conftest.py
index f4df7a5..5a22ffd 100644
--- a/src/ansiblelint/rules/conftest.py
+++ b/src/ansiblelint/rules/conftest.py
@@ -1,3 +1,4 @@
"""Makes pytest fixtures available."""
+
# pylint: disable=wildcard-import,unused-wildcard-import
from ansiblelint.testing.fixtures import * # noqa: F403
diff --git a/src/ansiblelint/rules/deprecated_bare_vars.py b/src/ansiblelint/rules/deprecated_bare_vars.py
index 1756e92..7b1ab08 100644
--- a/src/ansiblelint/rules/deprecated_bare_vars.py
+++ b/src/ansiblelint/rules/deprecated_bare_vars.py
@@ -27,7 +27,7 @@ import sys
from typing import TYPE_CHECKING, Any
from ansiblelint.rules import AnsibleLintRule
-from ansiblelint.text import has_glob, has_jinja
+from ansiblelint.text import has_glob, has_jinja, is_fqcn_or_name
if TYPE_CHECKING:
from ansiblelint.file_utils import Lintable
@@ -66,7 +66,7 @@ class UsingBareVariablesIsDeprecatedRule(AnsibleLintRule):
# we just need to check that one variable, and not iterate over it like
# it's a list. Otherwise, loop through and check all items.
items = task[loop_type]
- if not isinstance(items, (list, tuple)):
+ if not isinstance(items, list | tuple):
items = [items]
for var in items:
return self._matchvar(var, task, loop_type)
@@ -84,7 +84,11 @@ class UsingBareVariablesIsDeprecatedRule(AnsibleLintRule):
task: dict[str, Any],
loop_type: str,
) -> bool | str:
- if isinstance(varstring, str) and not has_jinja(varstring):
+ if (
+ isinstance(varstring, str)
+ and not has_jinja(varstring)
+ and is_fqcn_or_name(varstring)
+ ):
valid = loop_type == "with_fileglob" and bool(
has_jinja(varstring) or has_glob(varstring),
)
@@ -121,4 +125,4 @@ if "pytest" in sys.modules:
failure = "examples/playbooks/rule-deprecated-bare-vars-fail.yml"
bad_runner = Runner(failure, rules=collection)
errs = bad_runner.run()
- assert len(errs) == 12
+ assert len(errs) == 11
diff --git a/src/ansiblelint/rules/deprecated_local_action.md b/src/ansiblelint/rules/deprecated_local_action.md
index c52eb9d..68f4345 100644
--- a/src/ansiblelint/rules/deprecated_local_action.md
+++ b/src/ansiblelint/rules/deprecated_local_action.md
@@ -19,3 +19,7 @@ This rule recommends using `delegate_to: localhost` instead of the
ansible.builtin.debug:
delegate_to: localhost # <-- recommended way to run on localhost
```
+
+!!! note
+
+ This rule can be automatically fixed using [`--fix`](../autofix.md) option.
diff --git a/src/ansiblelint/rules/deprecated_local_action.py b/src/ansiblelint/rules/deprecated_local_action.py
index fc3e4ff..4e09795 100644
--- a/src/ansiblelint/rules/deprecated_local_action.py
+++ b/src/ansiblelint/rules/deprecated_local_action.py
@@ -1,19 +1,33 @@
"""Implementation for deprecated-local-action rule."""
+
# Copyright (c) 2016, Tsukinowa Inc. <info@tsukinowa.jp>
# Copyright (c) 2018, Ansible Project
from __future__ import annotations
+import copy
+import logging
+import os
import sys
+from pathlib import Path
from typing import TYPE_CHECKING
-from ansiblelint.rules import AnsibleLintRule
+from ansiblelint.rules import AnsibleLintRule, TransformMixin
+from ansiblelint.runner import get_matches
+from ansiblelint.transformer import Transformer
if TYPE_CHECKING:
+ from ruamel.yaml.comments import CommentedMap, CommentedSeq
+
+ from ansiblelint.config import Options
+ from ansiblelint.errors import MatchError
from ansiblelint.file_utils import Lintable
from ansiblelint.utils import Task
-class TaskNoLocalAction(AnsibleLintRule):
+_logger = logging.getLogger(__name__)
+
+
+class TaskNoLocalAction(AnsibleLintRule, TransformMixin):
"""Do not use 'local_action', use 'delegate_to: localhost'."""
id = "deprecated-local-action"
@@ -35,11 +49,46 @@ class TaskNoLocalAction(AnsibleLintRule):
return False
+ def transform(
+ self,
+ match: MatchError,
+ lintable: Lintable,
+ data: CommentedMap | CommentedSeq | str,
+ ) -> None:
+ if match.tag == self.id:
+ # we do not want perform a partial modification accidentally
+ original_target_task = self.seek(match.yaml_path, data)
+ target_task = copy.deepcopy(original_target_task)
+ for _ in range(len(target_task)):
+ k, v = target_task.popitem(False)
+ if k == "local_action":
+ if isinstance(v, dict):
+ module_name = v["module"]
+ target_task[module_name] = None
+ target_task["delegate_to"] = "localhost"
+ elif isinstance(v, str):
+ module_name, module_value = v.split(" ", 1)
+ target_task[module_name] = module_value
+ target_task["delegate_to"] = "localhost"
+ else:
+ _logger.debug(
+ "Ignored unexpected data inside %s transform.",
+ self.id,
+ )
+ return
+ else:
+ target_task[k] = v
+ match.fixed = True
+ original_target_task.clear()
+ original_target_task.update(target_task)
+
# testing code to be loaded only with pytest or when executed the rule file
if "pytest" in sys.modules:
- from ansiblelint.rules import RulesCollection # pylint: disable=ungrouped-imports
- from ansiblelint.runner import Runner # pylint: disable=ungrouped-imports
+ from unittest import mock
+
+ from ansiblelint.rules import RulesCollection
+ from ansiblelint.runner import Runner
def test_local_action(default_rules_collection: RulesCollection) -> None:
"""Positive test deprecated_local_action."""
@@ -50,3 +99,34 @@ if "pytest" in sys.modules:
assert len(results) == 1
assert results[0].tag == "deprecated-local-action"
+
+ @mock.patch.dict(os.environ, {"ANSIBLE_LINT_WRITE_TMP": "1"}, clear=True)
+ def test_local_action_transform(
+ config_options: Options,
+ default_rules_collection: RulesCollection,
+ ) -> None:
+ """Test transform functionality for no-log-password rule."""
+ playbook = Path("examples/playbooks/tasks/local_action.yml")
+ config_options.write_list = ["all"]
+
+ config_options.lintables = [str(playbook)]
+ runner_result = get_matches(
+ rules=default_rules_collection,
+ options=config_options,
+ )
+ transformer = Transformer(result=runner_result, options=config_options)
+ transformer.run()
+ matches = runner_result.matches
+ assert len(matches) == 3
+
+ orig_content = playbook.read_text(encoding="utf-8")
+ expected_content = playbook.with_suffix(
+ f".transformed{playbook.suffix}",
+ ).read_text(encoding="utf-8")
+ transformed_content = playbook.with_suffix(f".tmp{playbook.suffix}").read_text(
+ encoding="utf-8",
+ )
+
+ assert orig_content != transformed_content
+ assert expected_content == transformed_content
+ playbook.with_suffix(f".tmp{playbook.suffix}").unlink()
diff --git a/src/ansiblelint/rules/deprecated_module.py b/src/ansiblelint/rules/deprecated_module.py
index 03c9361..72e328f 100644
--- a/src/ansiblelint/rules/deprecated_module.py
+++ b/src/ansiblelint/rules/deprecated_module.py
@@ -1,4 +1,5 @@
"""Implementation of deprecated-module rule."""
+
# Copyright (c) 2018, Ansible Project
from __future__ import annotations
diff --git a/src/ansiblelint/rules/empty_string_compare.py b/src/ansiblelint/rules/empty_string_compare.py
index 5c7cafc..6870ed2 100644
--- a/src/ansiblelint/rules/empty_string_compare.py
+++ b/src/ansiblelint/rules/empty_string_compare.py
@@ -1,4 +1,5 @@
"""Implementation of empty-string-compare rule."""
+
# Copyright (c) 2016, Will Thames and contributors
# Copyright (c) 2018, Ansible Project
@@ -54,8 +55,8 @@ class ComparisonToEmptyStringRule(AnsibleLintRule):
# testing code to be loaded only with pytest or when executed the rule file
if "pytest" in sys.modules:
- from ansiblelint.rules import RulesCollection # pylint: disable=ungrouped-imports
- from ansiblelint.runner import Runner # pylint: disable=ungrouped-imports
+ from ansiblelint.rules import RulesCollection
+ from ansiblelint.runner import Runner
def test_rule_empty_string_compare_fail() -> None:
"""Test rule matches."""
diff --git a/src/ansiblelint/rules/fqcn.md b/src/ansiblelint/rules/fqcn.md
index 0165477..a64a324 100644
--- a/src/ansiblelint/rules/fqcn.md
+++ b/src/ansiblelint/rules/fqcn.md
@@ -87,3 +87,7 @@ structure in a backward-compatible way by adding redirects like in
# Use the FQCN for the builtin shell module.
ansible.builtin.shell: ssh ssh_user@{{ ansible_ssh_host }}
```
+
+!!! note
+
+ This rule can be automatically fixed using [`--fix`](../autofix.md) option.
diff --git a/src/ansiblelint/rules/fqcn.py b/src/ansiblelint/rules/fqcn.py
index 768fb9e..b571db3 100644
--- a/src/ansiblelint/rules/fqcn.py
+++ b/src/ansiblelint/rules/fqcn.py
@@ -1,17 +1,19 @@
"""Rule definition for usage of fully qualified collection names for builtins."""
+
from __future__ import annotations
import logging
import sys
from typing import TYPE_CHECKING, Any
-from ansible.plugins.loader import module_loader
+from ruamel.yaml.comments import CommentedSeq
from ansiblelint.constants import LINE_NUMBER_KEY
from ansiblelint.rules import AnsibleLintRule, TransformMixin
+from ansiblelint.utils import load_plugin
if TYPE_CHECKING:
- from ruamel.yaml.comments import CommentedMap, CommentedSeq
+ from ruamel.yaml.comments import CommentedMap
from ansiblelint.errors import MatchError
from ansiblelint.file_utils import Lintable
@@ -114,11 +116,16 @@ class FQCNBuiltinsRule(AnsibleLintRule, TransformMixin):
task: Task,
file: Lintable | None = None,
) -> list[MatchError]:
- result = []
+ result: list[MatchError] = []
+ if file and file.failed():
+ return result
module = task["action"]["__ansible_module_original__"]
+ if not isinstance(module, str):
+ msg = "Invalid data for module."
+ raise TypeError(msg)
if module not in self.module_aliases:
- loaded_module = module_loader.find_plugin_with_context(module)
+ loaded_module = load_plugin(module)
target = loaded_module.resolved_fqcn
self.module_aliases[module] = target
if target is None:
@@ -137,40 +144,45 @@ class FQCNBuiltinsRule(AnsibleLintRule, TransformMixin):
1,
)
if module != legacy_module:
+ if module == "ansible.builtin.include":
+ message = f"Avoid deprecated module ({module})"
+ details = "Use `ansible.builtin.include_task` or `ansible.builtin.import_tasks` instead."
+ else:
+ message = f"Use FQCN for builtin module actions ({module})."
+ details = f"Use `{module_alias}` or `{legacy_module}` instead."
result.append(
self.create_matcherror(
- message=f"Use FQCN for builtin module actions ({module}).",
- details=f"Use `{module_alias}` or `{legacy_module}` instead.",
+ message=message,
+ details=details,
filename=file,
lineno=task["__line__"],
tag="fqcn[action-core]",
),
)
- else:
- if module.count(".") < 2:
- result.append(
- self.create_matcherror(
- message=f"Use FQCN for module actions, such `{self.module_aliases[module]}`.",
- details=f"Action `{module}` is not FQCN.",
- filename=file,
- lineno=task["__line__"],
- tag="fqcn[action]",
- ),
- )
- # TODO(ssbarnea): Remove the c.g. and c.n. exceptions from here once # noqa: FIX002
- # community team is flattening these.
- # https://github.com/ansible-community/community-topics/issues/147
- elif not module.startswith("community.general.") or module.startswith(
- "community.network.",
- ):
- result.append(
- self.create_matcherror(
- message=f"You should use canonical module name `{self.module_aliases[module]}` instead of `{module}`.",
- filename=file,
- lineno=task["__line__"],
- tag="fqcn[canonical]",
- ),
- )
+ elif module.count(".") < 2:
+ result.append(
+ self.create_matcherror(
+ message=f"Use FQCN for module actions, such `{self.module_aliases[module]}`.",
+ details=f"Action `{module}` is not FQCN.",
+ filename=file,
+ lineno=task["__line__"],
+ tag="fqcn[action]",
+ ),
+ )
+ # TODO(ssbarnea): Remove the c.g. and c.n. exceptions from here once # noqa: FIX002
+ # community team is flattening these.
+ # https://github.com/ansible-community/community-topics/issues/147
+ elif not module.startswith("community.general.") or module.startswith(
+ "community.network.",
+ ):
+ result.append(
+ self.create_matcherror(
+ message=f"You should use canonical module name `{self.module_aliases[module]}` instead of `{module}`.",
+ filename=file,
+ lineno=task["__line__"],
+ tag="fqcn[canonical]",
+ ),
+ )
return result
def matchyaml(self, file: Lintable) -> list[MatchError]:
@@ -220,6 +232,8 @@ class FQCNBuiltinsRule(AnsibleLintRule, TransformMixin):
target_task = self.seek(match.yaml_path, data)
# Unfortunately, a lot of data about Ansible content gets lost here, you only get a simple dict.
# For now, just parse the error messages for the data about action names etc. and fix this later.
+ current_action = ""
+ new_action = ""
if match.tag == "fqcn[action-core]":
# split at the first bracket, cut off the last bracket and dot
current_action = match.message.split("(")[1][:-2]
@@ -233,6 +247,8 @@ class FQCNBuiltinsRule(AnsibleLintRule, TransformMixin):
current_action = match.message.split("`")[3]
new_action = match.message.split("`")[1]
for _ in range(len(target_task)):
+ if isinstance(target_task, CommentedSeq):
+ continue
k, v = target_task.popitem(False)
target_task[new_action if k == current_action else k] = v
match.fixed = True
@@ -241,7 +257,7 @@ class FQCNBuiltinsRule(AnsibleLintRule, TransformMixin):
# testing code to be loaded only with pytest or when executed the rule file
if "pytest" in sys.modules:
from ansiblelint.rules import RulesCollection
- from ansiblelint.runner import Runner # pylint: disable=ungrouped-imports
+ from ansiblelint.runner import Runner
def test_fqcn_builtin_fail() -> None:
"""Test rule matches."""
@@ -269,7 +285,7 @@ if "pytest" in sys.modules:
"""Test rule matches."""
collection = RulesCollection()
collection.register(FQCNBuiltinsRule())
- failure = "examples/collection/plugins/modules/deep/beta.py"
+ failure = "examples/.collection/plugins/modules/deep/beta.py"
results = Runner(failure, rules=collection).run()
assert len(results) == 1
assert results[0].tag == "fqcn[deep]"
@@ -279,6 +295,6 @@ if "pytest" in sys.modules:
"""Test rule does not match."""
collection = RulesCollection()
collection.register(FQCNBuiltinsRule())
- success = "examples/collection/plugins/modules/alpha.py"
+ success = "examples/.collection/plugins/modules/alpha.py"
results = Runner(success, rules=collection).run()
assert len(results) == 0
diff --git a/src/ansiblelint/rules/galaxy.md b/src/ansiblelint/rules/galaxy.md
index 61fc5c5..d719e30 100644
--- a/src/ansiblelint/rules/galaxy.md
+++ b/src/ansiblelint/rules/galaxy.md
@@ -26,6 +26,8 @@ This rule can produce messages such:
- `galaxy[tags]` - `galaxy.yaml` must have one of the required tags:
`application`, `cloud`, `database`, `infrastructure`, `linux`, `monitoring`,
`networking`, `security`, `storage`, `tools`, `windows`.
+- `galaxy[invalid-dependency-version]` = Invalid collection metadata. Dependency
+ version spec range is invalid
If you want to ignore some of the messages above, you can add any of them to the
`ignore_list`.
@@ -60,12 +62,14 @@ description: "..."
# Changelog Details
-This rule expects a `CHANGELOG.md` or `.rst` file in the collection root or a
-`changelogs/changelog.yaml` file.
+This rule expects a `CHANGELOG.md`, `CHANGELOG.rst`,
+`changelogs/changelog.yaml`, or `changelogs/changelog.yml` file in the
+collection root.
-If a `changelogs/changelog.yaml` file exists, the schema will be checked.
+If a `changelogs/changelog.yaml` or `changelogs/changelog.yml` file exists, the
+schema will be checked.
-## Minimum required changelog.yaml file
+## Minimum required changelog.yaml/changelog.yml file
```yaml
# changelog.yaml
diff --git a/src/ansiblelint/rules/galaxy.py b/src/ansiblelint/rules/galaxy.py
index 2f627f5..e9b21d3 100644
--- a/src/ansiblelint/rules/galaxy.py
+++ b/src/ansiblelint/rules/galaxy.py
@@ -1,11 +1,12 @@
"""Implementation of GalaxyRule."""
+
from __future__ import annotations
import sys
from functools import total_ordering
from typing import TYPE_CHECKING, Any
-from ansiblelint.constants import LINE_NUMBER_KEY
+from ansiblelint.constants import FILENAME_KEY, LINE_NUMBER_KEY
from ansiblelint.rules import AnsibleLintRule
if TYPE_CHECKING:
@@ -27,6 +28,7 @@ class GalaxyRule(AnsibleLintRule):
"galaxy[version-missing]": "galaxy.yaml should have version tag.",
"galaxy[version-incorrect]": "collection version should be greater than or equal to 1.0.0",
"galaxy[no-runtime]": "meta/runtime.yml file not found.",
+ "galaxy[invalid-dependency-version]": "Invalid collection metadata. Dependency version spec range is invalid",
}
def matchplay(self, file: Lintable, data: dict[str, Any]) -> list[MatchError]:
@@ -39,6 +41,7 @@ class GalaxyRule(AnsibleLintRule):
"application",
"cloud",
"database",
+ "eda",
"infrastructure",
"linux",
"monitoring",
@@ -55,6 +58,7 @@ class GalaxyRule(AnsibleLintRule):
changelog_found = 0
changelog_paths = [
base_path / "changelogs" / "changelog.yaml",
+ base_path / "changelogs" / "changelog.yml",
base_path / "CHANGELOG.rst",
base_path / "CHANGELOG.md",
]
@@ -62,8 +66,21 @@ class GalaxyRule(AnsibleLintRule):
for path in changelog_paths:
if path.is_file():
changelog_found = 1
-
- galaxy_tag_list = data.get("tags", None)
+ galaxy_tag_list = data.get("tags")
+ collection_deps = data.get("dependencies")
+ if collection_deps:
+ for dep, ver in collection_deps.items():
+ if (
+ dep not in [LINE_NUMBER_KEY, FILENAME_KEY]
+ and len(str(ver).strip()) == 0
+ ):
+ results.append(
+ self.create_matcherror(
+ message=f"Invalid collection metadata. Dependency version spec range is invalid for '{dep}'.",
+ tag="galaxy[invalid-dependency-version]",
+ filename=file,
+ ),
+ )
# Changelog Check - building off Galaxy rule as there is no current way to check
# for a nonexistent file
@@ -108,7 +125,6 @@ class GalaxyRule(AnsibleLintRule):
results.append(
self.create_matcherror(
message="collection version should be greater than or equal to 1.0.0",
- # pylint: disable=protected-access
lineno=version._line_number, # noqa: SLF001
tag="galaxy[version-incorrect]",
filename=file,
@@ -154,7 +170,7 @@ class Version:
def _coerce(other: object) -> Version:
if isinstance(other, str):
other = Version(other)
- if isinstance(other, (int, float)):
+ if isinstance(other, int | float):
other = Version(str(other))
if isinstance(other, Version):
return other
@@ -172,7 +188,7 @@ if "pytest" in sys.modules:
"""Positive test for collection version in galaxy."""
collection = RulesCollection()
collection.register(GalaxyRule())
- success = "examples/collection/galaxy.yml"
+ success = "examples/.collection/galaxy.yml"
good_runner = Runner(success, rules=collection)
assert [] == good_runner.run()
@@ -189,7 +205,7 @@ if "pytest" in sys.modules:
"""Test for no collection version in galaxy."""
collection = RulesCollection()
collection.register(GalaxyRule())
- failure = "examples/no_collection_version/galaxy.yml"
+ failure = "examples/.no_collection_version/galaxy.yml"
bad_runner = Runner(failure, rules=collection)
errs = bad_runner.run()
assert len(errs) == 1
@@ -222,17 +238,25 @@ if "pytest" in sys.modules:
id="pass",
),
pytest.param(
- "examples/collection/galaxy.yml",
+ "examples/.collection/galaxy.yml",
["schema[galaxy]"],
id="schema",
),
pytest.param(
- "examples/no_changelog/galaxy.yml",
+ "examples/.invalid_dependencies/galaxy.yml",
+ [
+ "galaxy[invalid-dependency-version]",
+ "galaxy[invalid-dependency-version]",
+ ],
+ id="invalid-dependency-version",
+ ),
+ pytest.param(
+ "examples/.no_changelog/galaxy.yml",
["galaxy[no-changelog]"],
id="no-changelog",
),
pytest.param(
- "examples/no_collection_version/galaxy.yml",
+ "examples/.no_collection_version/galaxy.yml",
["schema[galaxy]", "galaxy[version-missing]"],
id="no-collection-version",
),
diff --git a/src/ansiblelint/rules/ignore_errors.py b/src/ansiblelint/rules/ignore_errors.py
index 4144f2d..29f0408 100644
--- a/src/ansiblelint/rules/ignore_errors.py
+++ b/src/ansiblelint/rules/ignore_errors.py
@@ -1,4 +1,5 @@
"""IgnoreErrorsRule used with ansible-lint."""
+
from __future__ import annotations
import sys
@@ -44,7 +45,7 @@ if "pytest" in sys.modules:
import pytest
if TYPE_CHECKING:
- from ansiblelint.testing import RunFromText # pylint: disable=ungrouped-imports
+ from ansiblelint.testing import RunFromText
IGNORE_ERRORS_TRUE = """
- hosts: all
diff --git a/src/ansiblelint/rules/inline_env_var.py b/src/ansiblelint/rules/inline_env_var.py
index f578fb7..1f0747e 100644
--- a/src/ansiblelint/rules/inline_env_var.py
+++ b/src/ansiblelint/rules/inline_env_var.py
@@ -1,4 +1,5 @@
"""Implementation of inside-env-var rule."""
+
# Copyright (c) 2016 Will Thames <will@thames.id.au>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -48,7 +49,6 @@ class EnvVarsInCommandRule(AnsibleLintRule):
"executable",
"removes",
"stdin",
- "warn",
"stdin_add_newline",
"strip_empty_ends",
"cmd",
diff --git a/src/ansiblelint/rules/jinja.md b/src/ansiblelint/rules/jinja.md
index 8e1732e..e4720d7 100644
--- a/src/ansiblelint/rules/jinja.md
+++ b/src/ansiblelint/rules/jinja.md
@@ -12,7 +12,7 @@ version can report:
As jinja2 syntax is closely following Python one we aim to follow
[black](https://black.readthedocs.io/en/stable/) formatting rules. If you are
-curious how black would reformat a small sniped feel free to visit
+curious how black would reformat a small snippet feel free to visit
[online black formatter](https://black.vercel.app/) site. Keep in mind to not
include the entire jinja2 template, so instead of `{{ 1+2==3 }}`, do paste
only `1+2==3`.
@@ -53,3 +53,7 @@ In its current form, this rule presents the following limitations:
does not support tilde as a binary operator. Example: `{{ a ~ b }}`.
- Jinja2 blocks that use dot notation with numbers are ignored because python
and black do not allow it. Example: `{{ foo.0.bar }}`
+
+!!! note
+
+ This rule can be automatically fixed using [`--fix`](../autofix.md) option.
diff --git a/src/ansiblelint/rules/jinja.py b/src/ansiblelint/rules/jinja.py
index 08254bc..ff124a8 100644
--- a/src/ansiblelint/rules/jinja.py
+++ b/src/ansiblelint/rules/jinja.py
@@ -1,28 +1,35 @@
"""Rule for checking content of jinja template strings."""
+
from __future__ import annotations
import logging
+import os
import re
import sys
-from collections import namedtuple
+from dataclasses import dataclass
from pathlib import Path
-from typing import TYPE_CHECKING, Any
+from typing import TYPE_CHECKING, Any, NamedTuple
import black
import jinja2
-from ansible.errors import AnsibleError, AnsibleParserError
+from ansible.errors import AnsibleError, AnsibleFilterError, AnsibleParserError
from ansible.parsing.yaml.objects import AnsibleUnicode
from jinja2.exceptions import TemplateSyntaxError
from ansiblelint.constants import LINE_NUMBER_KEY
+from ansiblelint.errors import RuleMatchTransformMeta
from ansiblelint.file_utils import Lintable
-from ansiblelint.rules import AnsibleLintRule
+from ansiblelint.rules import AnsibleLintRule, TransformMixin
+from ansiblelint.runner import get_matches
from ansiblelint.skip_utils import get_rule_skips_from_line
from ansiblelint.text import has_jinja
from ansiblelint.utils import parse_yaml_from_file, template
from ansiblelint.yaml_utils import deannotate, nested_items_path
if TYPE_CHECKING:
+ from ruamel.yaml.comments import CommentedMap, CommentedSeq
+
+ from ansiblelint.config import Options
from ansiblelint.errors import MatchError
from ansiblelint.utils import Task
@@ -30,7 +37,14 @@ if TYPE_CHECKING:
_logger = logging.getLogger(__package__)
KEYWORDS_WITH_IMPLICIT_TEMPLATE = ("changed_when", "failed_when", "until", "when")
-Token = namedtuple("Token", "lineno token_type value")
+
+class Token(NamedTuple):
+ """Token."""
+
+ lineno: int
+ token_type: str
+ value: str
+
ignored_re = re.compile(
"|".join( # noqa: FLY002
@@ -53,7 +67,27 @@ ignored_re = re.compile(
)
-class JinjaRule(AnsibleLintRule):
+@dataclass(frozen=True)
+class JinjaRuleTMetaSpacing(RuleMatchTransformMeta):
+ """JinjaRule transform metadata.
+
+ :param key: Key or index within the task
+ :param value: Value of the key
+ :param path: Path to the key
+ :param fixed: Value with spacing fixed
+ """
+
+ key: str | int
+ value: str | int
+ path: tuple[str | int, ...]
+ fixed: str
+
+ def __str__(self) -> str:
+ """Return string representation."""
+ return f"{self.key}={self.value} at {self.path} fixed to {self.fixed}"
+
+
+class JinjaRule(AnsibleLintRule, TransformMixin):
"""Rule that looks inside jinja2 templates."""
id = "jinja"
@@ -94,11 +128,13 @@ class JinjaRule(AnsibleLintRule):
if isinstance(v, str):
try:
template(
- basedir=file.path.parent if file else Path("."),
+ basedir=file.path.parent if file else Path(),
value=v,
variables=deannotate(task.get("vars", {})),
fail_on_error=True, # we later decide which ones to ignore or not
)
+ except AnsibleFilterError:
+ bypass = True
# ValueError RepresenterError
except AnsibleError as exc:
bypass = False
@@ -111,7 +147,7 @@ class JinjaRule(AnsibleLintRule):
)
if ignored_re.search(orig_exc_message) or isinstance(
orig_exc,
- AnsibleParserError,
+ AnsibleParserError | TypeError,
):
# An unhandled exception occurred while running the lookup plugin 'template'. Error was a <class 'ansible.errors.AnsibleError'>, original message: the template file ... could not be found for the lookup. the template file ... could not be found for the lookup
@@ -119,7 +155,7 @@ class JinjaRule(AnsibleLintRule):
# AnsibleError(TemplateSyntaxError): template error while templating string: Could not load "ipwrap": 'Invalid plugin FQCN (ansible.netcommon.ipwrap): unable to locate collection ansible.netcommon'. String: Foo {{ buildset_registry.host | ipwrap }}. Could not load "ipwrap": 'Invalid plugin FQCN (ansible.netcommon.ipwrap): unable to locate collection ansible.netcommon'
bypass = True
elif (
- isinstance(orig_exc, (AnsibleError, TemplateSyntaxError))
+ isinstance(orig_exc, AnsibleError | TemplateSyntaxError)
and match
):
error = match.group("error")
@@ -166,6 +202,12 @@ class JinjaRule(AnsibleLintRule):
details=details,
filename=file,
tag=f"{self.id}[{tag}]",
+ transform_meta=JinjaRuleTMetaSpacing(
+ key=key,
+ value=v,
+ path=tuple(path),
+ fixed=reformatted,
+ ),
),
)
except Exception as exc:
@@ -181,7 +223,6 @@ class JinjaRule(AnsibleLintRule):
if str(file.kind) == "vars":
data = parse_yaml_from_file(str(file.path))
- # pylint: disable=unused-variable
for key, v, _path in nested_items_path(data):
if isinstance(v, AnsibleUnicode):
reformatted, details, tag = self.check_whitespace(
@@ -249,7 +290,7 @@ class JinjaRule(AnsibleLintRule):
last_value = value
return result
- # pylint: disable=too-many-statements,too-many-locals
+ # pylint: disable=too-many-locals
def check_whitespace(
self,
text: str,
@@ -327,7 +368,7 @@ class JinjaRule(AnsibleLintRule):
# process expression
# pylint: disable=unsupported-membership-test
if isinstance(expr_str, str) and "\n" in expr_str:
- raise NotImplementedError
+ raise NotImplementedError # noqa: TRY301
leading_spaces = " " * (len(expr_str) - len(expr_str.lstrip()))
expr_str = leading_spaces + blacken(expr_str.lstrip())
if tokens[
@@ -348,7 +389,6 @@ class JinjaRule(AnsibleLintRule):
except jinja2.exceptions.TemplateSyntaxError as exc:
return "", str(exc.message), "invalid"
- # https://github.com/PyCQA/pylint/issues/7433 - py311 only
# pylint: disable=c-extension-no-member
except (NotImplementedError, black.parsing.InvalidInput) as exc:
# black is not able to recognize all valid jinja2 templates, so we
@@ -370,6 +410,68 @@ class JinjaRule(AnsibleLintRule):
)
return reformatted, details, "spacing"
+ def transform(
+ self: JinjaRule,
+ match: MatchError,
+ lintable: Lintable,
+ data: CommentedMap | CommentedSeq | str,
+ ) -> None:
+ """Transform jinja2 errors.
+
+ :param match: MatchError instance
+ :param lintable: Lintable instance
+ :param data: data to transform
+ """
+ if match.tag == "jinja[spacing]":
+ self._transform_spacing(match, data)
+
+ def _transform_spacing(
+ self: JinjaRule,
+ match: MatchError,
+ data: CommentedMap | CommentedSeq | str,
+ ) -> None:
+ """Transform jinja2 spacing errors.
+
+ The match error was found on a normalized task so we cannot compare the path
+ instead we only compare the key and value, if the task has 2 identical keys with the
+ exact same jinja spacing issue, we may transform them out of order
+
+ :param match: MatchError instance
+ :param data: data to transform
+ """
+ if not isinstance(match.transform_meta, JinjaRuleTMetaSpacing):
+ return
+ if isinstance(data, str):
+ return
+
+ obj = self.seek(match.yaml_path, data)
+ if obj is None:
+ return
+
+ ignored_keys = ("block", "ansible.builtin.block", "ansible.legacy.block")
+ for key, value, path in nested_items_path(
+ data_collection=obj,
+ ignored_keys=ignored_keys,
+ ):
+ if key == match.transform_meta.key and value == match.transform_meta.value:
+ if not path:
+ continue
+ for pth in path[:-1]:
+ try:
+ obj = obj[pth]
+ except (KeyError, TypeError) as exc:
+ err = f"Unable to transform {match.transform_meta}: {exc}"
+ _logger.error(err) # noqa: TRY400
+ return
+ try:
+ obj[path[-1]][key] = match.transform_meta.fixed
+ match.fixed = True
+
+ except (KeyError, TypeError) as exc:
+ err = f"Unable to transform {match.transform_meta}: {exc}"
+ _logger.error(err) # noqa: TRY400
+ return
+
def blacken(text: str) -> str:
"""Format Jinja2 template using black."""
@@ -380,10 +482,14 @@ def blacken(text: str) -> str:
if "pytest" in sys.modules:
+ from unittest import mock
+
import pytest
- from ansiblelint.rules import RulesCollection # pylint: disable=ungrouped-imports
- from ansiblelint.runner import Runner # pylint: disable=ungrouped-imports
+ # pylint: disable=ungrouped-imports
+ from ansiblelint.rules import RulesCollection
+ from ansiblelint.runner import Runner
+ from ansiblelint.transformer import Transformer
@pytest.fixture(name="error_expected_lines")
def fixture_error_expected_lines() -> list[int]:
@@ -725,6 +831,38 @@ if "pytest" in sys.modules:
errs = Runner(success, rules=collection).run()
assert len(errs) == 0
+ @mock.patch.dict(os.environ, {"ANSIBLE_LINT_WRITE_TMP": "1"}, clear=True)
+ def test_jinja_transform(
+ config_options: Options,
+ default_rules_collection: RulesCollection,
+ ) -> None:
+ """Test transform functionality for jinja rule."""
+ playbook = Path("examples/playbooks/rule-jinja-before.yml")
+ config_options.write_list = ["all"]
+
+ config_options.lintables = [str(playbook)]
+ runner_result = get_matches(
+ rules=default_rules_collection,
+ options=config_options,
+ )
+ transformer = Transformer(result=runner_result, options=config_options)
+ transformer.run()
+
+ matches = runner_result.matches
+ assert len(matches) == 2
+
+ orig_content = playbook.read_text(encoding="utf-8")
+ expected_content = playbook.with_suffix(
+ f".transformed{playbook.suffix}",
+ ).read_text(encoding="utf-8")
+ transformed_content = playbook.with_suffix(f".tmp{playbook.suffix}").read_text(
+ encoding="utf-8",
+ )
+
+ assert orig_content != transformed_content
+ assert expected_content == transformed_content
+ playbook.with_suffix(f".tmp{playbook.suffix}").unlink()
+
def _get_error_line(task: dict[str, Any], path: list[str | int]) -> int:
"""Return error line number."""
@@ -736,5 +874,5 @@ def _get_error_line(task: dict[str, Any], path: list[str | int]) -> int:
line = ctx[LINE_NUMBER_KEY]
if not isinstance(line, int):
msg = "Line number is not an integer"
- raise RuntimeError(msg)
+ raise TypeError(msg)
return line
diff --git a/src/ansiblelint/rules/key_order.md b/src/ansiblelint/rules/key_order.md
index 378d8a5..bcef36a 100644
--- a/src/ansiblelint/rules/key_order.md
+++ b/src/ansiblelint/rules/key_order.md
@@ -61,3 +61,7 @@ we concluded that the block keys must be the last ones.
Another common practice was to put `tags` as the last property. Still, for the
same reasons, we decided that they should not be put after block keys either.
+
+!!! note
+
+ This rule can be automatically fixed using [`--fix`](../autofix.md) option.
diff --git a/src/ansiblelint/rules/key_order.py b/src/ansiblelint/rules/key_order.py
index 897da64..0c0a2f1 100644
--- a/src/ansiblelint/rules/key_order.py
+++ b/src/ansiblelint/rules/key_order.py
@@ -1,14 +1,19 @@
"""All tasks should be have name come first."""
+
from __future__ import annotations
import functools
import sys
-from typing import TYPE_CHECKING
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Any
-from ansiblelint.rules import AnsibleLintRule
+from ansiblelint.constants import ANNOTATION_KEYS, LINE_NUMBER_KEY
+from ansiblelint.errors import MatchError, RuleMatchTransformMeta
+from ansiblelint.rules import AnsibleLintRule, TransformMixin
if TYPE_CHECKING:
- from ansiblelint.errors import MatchError
+ from ruamel.yaml.comments import CommentedMap, CommentedSeq
+
from ansiblelint.file_utils import Lintable
from ansiblelint.utils import Task
@@ -46,7 +51,21 @@ def task_property_sorter(property1: str, property2: str) -> int:
return (v_1 > v_2) - (v_1 < v_2)
-class KeyOrderRule(AnsibleLintRule):
+@dataclass(frozen=True)
+class KeyOrderTMeta(RuleMatchTransformMeta):
+ """Key Order transform metadata.
+
+ :param fixed: tuple with updated key order
+ """
+
+ fixed: tuple[str | int, ...]
+
+ def __str__(self) -> str:
+ """Return string representation."""
+ return f"Fixed to {self.fixed}"
+
+
+class KeyOrderRule(AnsibleLintRule, TransformMixin):
"""Ensure specific order of keys in mappings."""
id = "key-order"
@@ -59,6 +78,25 @@ class KeyOrderRule(AnsibleLintRule):
"key-order[task]": "You can improve the task key order",
}
+ def matchplay(self, file: Lintable, data: dict[str, Any]) -> list[MatchError]:
+ """Return matches found for a specific play (entry in playbook)."""
+ result: list[MatchError] = []
+ if file.kind != "playbook":
+ return result
+ keys = [str(key) for key, val in data.items() if key not in ANNOTATION_KEYS]
+ sorted_keys = sorted(keys, key=functools.cmp_to_key(task_property_sorter))
+ if keys != sorted_keys:
+ result.append(
+ self.create_matcherror(
+ f"You can improve the play key order to: {', '.join(sorted_keys)}",
+ filename=file,
+ tag=f"{self.id}[play]",
+ lineno=data[LINE_NUMBER_KEY],
+ transform_meta=KeyOrderTMeta(fixed=tuple(sorted_keys)),
+ ),
+ )
+ return result
+
def matchtask(
self,
task: Task,
@@ -66,7 +104,7 @@ class KeyOrderRule(AnsibleLintRule):
) -> list[MatchError]:
result = []
raw_task = task["__raw_task__"]
- keys = [key for key in raw_task if not key.startswith("_")]
+ keys = [str(key) for key in raw_task if not key.startswith("_")]
sorted_keys = sorted(keys, key=functools.cmp_to_key(task_property_sorter))
if keys != sorted_keys:
result.append(
@@ -74,17 +112,43 @@ class KeyOrderRule(AnsibleLintRule):
f"You can improve the task key order to: {', '.join(sorted_keys)}",
filename=file,
tag="key-order[task]",
+ transform_meta=KeyOrderTMeta(fixed=tuple(sorted_keys)),
),
)
return result
+ def transform(
+ self,
+ match: MatchError,
+ lintable: Lintable,
+ data: CommentedMap | CommentedSeq | str,
+ ) -> None:
+ if not isinstance(match.transform_meta, KeyOrderTMeta):
+ return
+
+ if match.tag == f"{self.id}[play]":
+ play = self.seek(match.yaml_path, data)
+ for key in match.transform_meta.fixed:
+ # other transformation might change the key
+ if key in play:
+ play[key] = play.pop(key)
+ match.fixed = True
+ if match.tag == f"{self.id}[task]":
+ task = self.seek(match.yaml_path, data)
+ for key in match.transform_meta.fixed:
+ # other transformation might change the key
+ if key in task:
+ task[key] = task.pop(key)
+ match.fixed = True
+
# testing code to be loaded only with pytest or when executed the rule file
if "pytest" in sys.modules:
import pytest
- from ansiblelint.rules import RulesCollection # pylint: disable=ungrouped-imports
- from ansiblelint.runner import Runner # pylint: disable=ungrouped-imports
+ # pylint: disable=ungrouped-imports
+ from ansiblelint.rules import RulesCollection
+ from ansiblelint.runner import Runner
@pytest.mark.parametrize(
("test_file", "failures"),
diff --git a/src/ansiblelint/rules/latest.py b/src/ansiblelint/rules/latest.py
index 0838feb..ef57b94 100644
--- a/src/ansiblelint/rules/latest.py
+++ b/src/ansiblelint/rules/latest.py
@@ -1,4 +1,5 @@
"""Implementation of latest rule."""
+
from __future__ import annotations
from typing import TYPE_CHECKING
diff --git a/src/ansiblelint/rules/literal_compare.py b/src/ansiblelint/rules/literal_compare.py
index 1129d1d..151398a 100644
--- a/src/ansiblelint/rules/literal_compare.py
+++ b/src/ansiblelint/rules/literal_compare.py
@@ -1,4 +1,5 @@
"""Implementation of the literal-compare rule."""
+
# Copyright (c) 2016, Will Thames and contributors
# Copyright (c) 2018-2021, Ansible Project
@@ -55,8 +56,9 @@ class ComparisonToLiteralBoolRule(AnsibleLintRule):
if "pytest" in sys.modules:
import pytest
- from ansiblelint.rules import RulesCollection # pylint: disable=ungrouped-imports
- from ansiblelint.runner import Runner # pylint: disable=ungrouped-imports
+ # pylint: disable=ungrouped-imports
+ from ansiblelint.rules import RulesCollection
+ from ansiblelint.runner import Runner
@pytest.mark.parametrize(
("test_file", "failures"),
diff --git a/src/ansiblelint/rules/loop_var_prefix.md b/src/ansiblelint/rules/loop_var_prefix.md
index 33adbd7..5d1b9b0 100644
--- a/src/ansiblelint/rules/loop_var_prefix.md
+++ b/src/ansiblelint/rules/loop_var_prefix.md
@@ -1,15 +1,15 @@
# loop-var-prefix
-This rule avoids conflicts with nested looping tasks by configuring a variable
-prefix with `loop_var`. Ansible sets `item` as the loop variable. You can use
-`loop_var` to specify a prefix for loop variables and ensure they are unique to
-each task.
+This rule avoids conflicts with nested looping tasks by enforcing an individual
+variable name in loops. Ansible defaults to `item` as the loop variable. You can
+use `loop_var` to rename it. Optionally require a prefix on the variable name.
+The prefix can be configured via the `<loop_var_prefix>` setting.
This rule can produce the following messages:
- `loop-var-prefix[missing]` - Replace any unsafe implicit `item` loop variable
- by adding `loop_var: <loop_var_prefix>...`.
-- `loop-var-prefix[wrong]` - Ensure loop variables start with
+ by adding `loop_var: <variable_name>...`.
+- `loop-var-prefix[wrong]` - Ensure the loop variable starts with
`<loop_var_prefix>`.
This rule originates from the [Naming parameters section of Ansible Best
@@ -41,20 +41,20 @@ enable_list:
- name: Example playbook
hosts: localhost
tasks:
- - name: Does not set a prefix for loop variables.
+ - name: Does not set a variable name for loop variables.
ansible.builtin.debug:
- var: item
+ var: item # <- When in a nested loop, "item" is ambiguous
loop:
- foo
- - bar # <- These items do not have a unique prefix.
- - name: Sets a prefix that is not unique.
+ - bar
+ - name: Sets a variable name that doesn't start with <loop_var_prefix>.
ansible.builtin.debug:
var: zz_item
loop:
- foo
- bar
loop_control:
- loop_var: zz_item # <- This prefix is not unique.
+ loop_var: zz_item # <- zz is not the role name so the prefix is wrong
```
## Correct Code
@@ -64,14 +64,14 @@ enable_list:
- name: Example playbook
hosts: localhost
tasks:
- - name: Sets a unique prefix for loop variables.
+ - name: Sets a unique variable_name with role as prefix for loop variables.
ansible.builtin.debug:
- var: zz_item
+ var: myrole_item
loop:
- foo
- bar
loop_control:
- loop_var: my_prefix # <- Specifies a unique prefix for loop variables.
+ loop_var: myrole_item # <- Unique variable name with role as prefix
```
[cop314]:
diff --git a/src/ansiblelint/rules/loop_var_prefix.py b/src/ansiblelint/rules/loop_var_prefix.py
index 8f1bb56..9f7a2ca 100644
--- a/src/ansiblelint/rules/loop_var_prefix.py
+++ b/src/ansiblelint/rules/loop_var_prefix.py
@@ -1,4 +1,5 @@
"""Optional Ansible-lint rule to enforce use of prefix on role loop vars."""
+
from __future__ import annotations
import re
@@ -81,8 +82,9 @@ Looping inside roles has the risk of clashing with loops from user-playbooks.\
if "pytest" in sys.modules:
import pytest
- from ansiblelint.rules import RulesCollection # pylint: disable=ungrouped-imports
- from ansiblelint.runner import Runner # pylint: disable=ungrouped-imports
+ # pylint: disable=ungrouped-imports
+ from ansiblelint.rules import RulesCollection
+ from ansiblelint.runner import Runner
@pytest.mark.parametrize(
("test_file", "failures"),
diff --git a/src/ansiblelint/rules/meta_incorrect.py b/src/ansiblelint/rules/meta_incorrect.py
index 4252254..ed8d8d9 100644
--- a/src/ansiblelint/rules/meta_incorrect.py
+++ b/src/ansiblelint/rules/meta_incorrect.py
@@ -1,4 +1,5 @@
"""Implementation of meta-incorrect rule."""
+
# Copyright (c) 2018, Ansible Project
from __future__ import annotations
@@ -56,8 +57,8 @@ class MetaChangeFromDefaultRule(AnsibleLintRule):
if "pytest" in sys.modules:
- from ansiblelint.rules import RulesCollection # pylint: disable=ungrouped-imports
- from ansiblelint.runner import Runner # pylint: disable=ungrouped-imports
+ from ansiblelint.rules import RulesCollection
+ from ansiblelint.runner import Runner
def test_default_galaxy_info(
default_rules_collection: RulesCollection,
diff --git a/src/ansiblelint/rules/meta_no_tags.py b/src/ansiblelint/rules/meta_no_tags.py
index c27a30e..3e9b636 100644
--- a/src/ansiblelint/rules/meta_no_tags.py
+++ b/src/ansiblelint/rules/meta_no_tags.py
@@ -1,4 +1,5 @@
"""Implementation of meta-no-tags rule."""
+
from __future__ import annotations
import re
diff --git a/src/ansiblelint/rules/meta_runtime.md b/src/ansiblelint/rules/meta_runtime.md
index 6ed6f17..3e05c59 100644
--- a/src/ansiblelint/rules/meta_runtime.md
+++ b/src/ansiblelint/rules/meta_runtime.md
@@ -1,26 +1,21 @@
# meta-runtime
-This rule checks the meta/runtime.yml `requires_ansible` key against the list of currently supported versions of ansible-core.
-
-This rule can produce messages such:
-
-- `requires_ansible` key must be set to a supported version.
-
-Currently supported versions of ansible-core are:
-
-- `2.9.10`
-- `2.11.x`
-- `2.12.x`
-- `2.13.x`
-- `2.14.x`
-- `2.15.x`
-- `2.16.x` (in development)
+This rule checks the meta/runtime.yml `requires_ansible` key against the list of
+currently supported versions of ansible-core.
This rule can produce messages such as:
-- `meta-runtime[unsupported-version]` - `requires_ansible` key must contain a supported version, shown in the list above.
-- `meta-runtime[invalid-version]` - `requires_ansible` key must be a valid version identifier.
+- `meta-runtime[unsupported-version]` - `requires_ansible` key must refer to a
+ currently supported version such as: >=2.14.0, >=2.15.0, >=2.16.0
+- `meta-runtime[invalid-version]` - `requires_ansible` is not a valid
+ requirement specification
+Please note that the linter will allow only a full version of Ansible such
+`2.16.0` and not allow their short form, like `2.16`. This is a safety measure
+for asking authors to mention an explicit version that they tested with. Over
+the years we spotted multiple problems caused by the use of the short versions,
+users ended up trying an outdated version that was never tested against by the
+collection maintainer.
## Problematic code
@@ -30,11 +25,10 @@ This rule can produce messages such as:
requires_ansible: ">=2.9"
```
-
```yaml
# runtime.yml
---
-requires_ansible: "2.9"
+requires_ansible: "2.15"
```
## Correct code
@@ -42,5 +36,17 @@ requires_ansible: "2.9"
```yaml
# runtime.yml
---
-requires_ansible: ">=2.9.10"
+requires_ansible: ">=2.15.0"
+```
+
+## Configuration
+
+In addition to the internal list of supported Ansible versions, users can
+configure additional values. This allows those that want to maintain content
+that requires a version of ansible-core that is already out of support.
+
+```yaml
+# Also recognize these versions of Ansible as supported:
+supported_ansible_also:
+ - "2.14"
```
diff --git a/src/ansiblelint/rules/meta_runtime.py b/src/ansiblelint/rules/meta_runtime.py
index fed7121..3df2826 100644
--- a/src/ansiblelint/rules/meta_runtime.py
+++ b/src/ansiblelint/rules/meta_runtime.py
@@ -1,4 +1,5 @@
"""Implementation of meta-runtime rule."""
+
from __future__ import annotations
import sys
@@ -22,17 +23,15 @@ class CheckRequiresAnsibleVersion(AnsibleLintRule):
id = "meta-runtime"
description = (
"The ``requires_ansible`` key in runtime.yml must specify "
- "a supported platform version of ansible-core and be a valid version."
+ "a supported platform version of ansible-core and be a valid version value "
+ "in x.y.z format."
)
severity = "VERY_HIGH"
tags = ["metadata"]
version_added = "v6.11.0 (last update)"
- # Refer to https://access.redhat.com/support/policy/updates/ansible-automation-platform
- # Also add devel to this list
- supported_ansible = ["2.9.10", "2.11.", "2.12.", "2.13.", "2.14.", "2.15.", "2.16."]
_ids = {
- "meta-runtime[unsupported-version]": "requires_ansible key must be set to a supported version.",
+ "meta-runtime[unsupported-version]": "'requires_ansible' key must refer to a currently supported version",
"meta-runtime[invalid-version]": "'requires_ansible' is not a valid requirement specification",
}
@@ -47,22 +46,26 @@ class CheckRequiresAnsibleVersion(AnsibleLintRule):
if file.kind != "meta-runtime":
return []
- version_required = file.data.get("requires_ansible", None)
+ requires_ansible = file.data.get("requires_ansible", None)
- if version_required:
- if not any(
- version in version_required for version in self.supported_ansible
+ if requires_ansible:
+ if self.options and not any(
+ version in requires_ansible
+ for version in self.options.supported_ansible
):
+ supported_ansible = [f">={x}0" for x in self.options.supported_ansible]
+ msg = f"'requires_ansible' key must refer to a currently supported version such as: {', '.join(supported_ansible)}"
+
results.append(
self.create_matcherror(
- message="requires_ansible key must be set to a supported version.",
+ message=msg,
tag="meta-runtime[unsupported-version]",
filename=file,
),
)
try:
- SpecifierSet(version_required)
+ SpecifierSet(requires_ansible)
except ValueError:
results.append(
self.create_matcherror(
@@ -79,17 +82,18 @@ class CheckRequiresAnsibleVersion(AnsibleLintRule):
if "pytest" in sys.modules:
import pytest
- from ansiblelint.rules import RulesCollection # pylint: disable=ungrouped-imports
- from ansiblelint.runner import Runner # pylint: disable=ungrouped-imports
+ # pylint: disable=ungrouped-imports
+ from ansiblelint.rules import RulesCollection
+ from ansiblelint.runner import Runner
@pytest.mark.parametrize(
("test_file", "failures", "tags"),
(
pytest.param(
- "examples/meta_runtime_version_checks/pass/meta/runtime.yml",
+ "examples/meta_runtime_version_checks/pass_0/meta/runtime.yml",
0,
"meta-runtime[unsupported-version]",
- id="pass",
+ id="pass0",
),
pytest.param(
"examples/meta_runtime_version_checks/fail_0/meta/runtime.yml",
@@ -111,16 +115,37 @@ if "pytest" in sys.modules:
),
),
)
- def test_meta_supported_version(
+ def test_default_meta_supported_version(
default_rules_collection: RulesCollection,
test_file: str,
failures: int,
tags: str,
) -> None:
- """Test rule matches."""
+ """Test for default supported ansible versions."""
default_rules_collection.register(CheckRequiresAnsibleVersion())
results = Runner(test_file, rules=default_rules_collection).run()
for result in results:
assert result.rule.id == CheckRequiresAnsibleVersion().id
assert result.tag == tags
assert len(results) == failures
+
+ @pytest.mark.parametrize(
+ ("test_file", "failures"),
+ (
+ pytest.param(
+ "examples/meta_runtime_version_checks/pass_1/meta/runtime.yml",
+ 0,
+ id="pass1",
+ ),
+ ),
+ )
+ def test_added_meta_supported_version(
+ default_rules_collection: RulesCollection,
+ test_file: str,
+ failures: int,
+ ) -> None:
+ """Test for added supported ansible versions in the config."""
+ default_rules_collection.register(CheckRequiresAnsibleVersion())
+ default_rules_collection.options.supported_ansible_also = ["2.9"]
+ results = Runner(test_file, rules=default_rules_collection).run()
+ assert len(results) == failures
diff --git a/src/ansiblelint/rules/meta_video_links.py b/src/ansiblelint/rules/meta_video_links.py
index 5d4941a..fa19cc6 100644
--- a/src/ansiblelint/rules/meta_video_links.py
+++ b/src/ansiblelint/rules/meta_video_links.py
@@ -1,4 +1,5 @@
"""Implementation of meta-video-links rule."""
+
# Copyright (c) 2018, Ansible Project
from __future__ import annotations
@@ -86,8 +87,9 @@ class MetaVideoLinksRule(AnsibleLintRule):
if "pytest" in sys.modules:
import pytest
- from ansiblelint.rules import RulesCollection # pylint: disable=ungrouped-imports
- from ansiblelint.runner import Runner # pylint: disable=ungrouped-imports
+ # pylint: disable=ungrouped-imports
+ from ansiblelint.rules import RulesCollection
+ from ansiblelint.runner import Runner
@pytest.mark.parametrize(
("test_file", "failures"),
diff --git a/src/ansiblelint/rules/name.md b/src/ansiblelint/rules/name.md
index 9df4213..0c5080a 100644
--- a/src/ansiblelint/rules/name.md
+++ b/src/ansiblelint/rules/name.md
@@ -21,12 +21,21 @@ If you want to ignore some of the messages above, you can add any of them to the
## name[prefix]
-This rule applies only to included task files that are not named `main.yml`. It
-suggests adding the stem of the file as a prefix to the task name.
+This rule applies only to included task files that are not named `main.yml` or
+are embedded within subdirectories. It suggests adding the stems of the file
+path as a prefix to the task name.
For example, if you have a task named `Restart server` inside a file named
`tasks/deploy.yml`, this rule suggests renaming it to `deploy | Restart server`,
-so it would be easier to identify where it comes from.
+so it would be easier to identify where it comes from. If the file was named
+`tasks/main.yml`, then the rule would have no effect.
+
+For task files that are embedded within subdirectories, these subdirectories
+will also be appended as part of the prefix. For example, if you have a task
+named `Terminate server` inside a file named `tasks/foo/destroy.yml`, this rule
+suggests renaming it to `foo | destroy | Terminate server`. If the file was
+named `tasks/foo/main.yml` then the rule would recommend renaming the task to
+`foo | main | Terminate server`.
For the moment, this sub-rule is just an **opt-in**, so you need to add it to
your `enable_list` to activate it.
@@ -59,3 +68,7 @@ your `enable_list` to activate it.
- name: Create placeholder file
ansible.builtin.command: touch /tmp/.placeholder
```
+
+!!! note
+
+ `name[casing]` can be automatically fixed using [`--fix`](../autofix.md) option.
diff --git a/src/ansiblelint/rules/name.py b/src/ansiblelint/rules/name.py
index 41ce5cb..b814a41 100644
--- a/src/ansiblelint/rules/name.py
+++ b/src/ansiblelint/rules/name.py
@@ -1,19 +1,23 @@
"""Implementation of NameRule."""
+
from __future__ import annotations
import re
import sys
-from copy import deepcopy
from typing import TYPE_CHECKING, Any
+import wcmatch.pathlib
+import wcmatch.wcmatch
+
from ansiblelint.constants import LINE_NUMBER_KEY
+from ansiblelint.file_utils import Lintable
from ansiblelint.rules import AnsibleLintRule, TransformMixin
if TYPE_CHECKING:
from ruamel.yaml.comments import CommentedMap, CommentedSeq
+ from ansiblelint.config import Options
from ansiblelint.errors import MatchError
- from ansiblelint.file_utils import Lintable
from ansiblelint.utils import Task
@@ -39,9 +43,11 @@ class NameRule(AnsibleLintRule, TransformMixin):
def matchplay(self, file: Lintable, data: dict[str, Any]) -> list[MatchError]:
"""Return matches found for a specific play (entry in playbook)."""
- results = []
+ results: list[MatchError] = []
if file.kind != "playbook":
return []
+ if file.failed():
+ return results
if "name" not in data:
return [
self.create_matcherror(
@@ -65,7 +71,9 @@ class NameRule(AnsibleLintRule, TransformMixin):
task: Task,
file: Lintable | None = None,
) -> list[MatchError]:
- results = []
+ results: list[MatchError] = []
+ if file and file.failed():
+ return results
name = task.get("name")
if not name:
results.append(
@@ -84,6 +92,7 @@ class NameRule(AnsibleLintRule, TransformMixin):
lineno=task[LINE_NUMBER_KEY],
),
)
+
return results
def _prefix_check(
@@ -120,10 +129,15 @@ class NameRule(AnsibleLintRule, TransformMixin):
# stage one check prefix
effective_name = name
if self._collection and lintable:
- prefix = self._collection.options.task_name_prefix.format(
- stem=lintable.path.stem,
- )
- if lintable.kind == "tasks" and lintable.path.stem != "main":
+ full_stem = self._find_full_stem(lintable)
+ stems = [
+ self._collection.options.task_name_prefix.format(stem=stem)
+ for stem in wcmatch.pathlib.PurePath(
+ full_stem,
+ ).parts
+ ]
+ prefix = "".join(stems)
+ if lintable.kind == "tasks" and full_stem != "main":
if not name.startswith(prefix):
# For the moment in order to raise errors this rule needs to be
# enabled manually. Still, we do allow use of prefixes even without
@@ -165,6 +179,38 @@ class NameRule(AnsibleLintRule, TransformMixin):
)
return results
+ def _find_full_stem(self, lintable: Lintable) -> str:
+ lintable_dir = wcmatch.pathlib.PurePath(lintable.dir)
+ stem = lintable.path.stem
+ kind = str(lintable.kind)
+
+ stems = [lintable_dir.name]
+ lintable_dir = lintable_dir.parent
+ pathex = lintable_dir / stem
+ glob = ""
+
+ if self.options:
+ for entry in self.options.kinds:
+ for key, value in entry.items():
+ if kind == key:
+ glob = value
+
+ while pathex.globmatch(
+ glob,
+ flags=(
+ wcmatch.pathlib.GLOBSTAR
+ | wcmatch.pathlib.BRACE
+ | wcmatch.pathlib.DOTGLOB
+ ),
+ ):
+ stems.insert(0, lintable_dir.name)
+ lintable_dir = lintable_dir.parent
+ pathex = lintable_dir / stem
+
+ if stems[0].startswith(kind):
+ del stems[0]
+ return str(wcmatch.pathlib.PurePath(*stems, stem))
+
def transform(
self,
match: MatchError,
@@ -172,17 +218,44 @@ class NameRule(AnsibleLintRule, TransformMixin):
data: CommentedMap | CommentedSeq | str,
) -> None:
if match.tag == "name[casing]":
+
+ def update_task_name(task_name: str) -> str:
+ """Capitalize the first work of the task name."""
+ # Not using capitalize(), since that rewrites the rest of the name to lower case
+ if "|" in task_name: # if using prefix
+ [file_name, update_task_name] = task_name.split("|")
+ return f"{file_name.strip()} | {update_task_name.strip()[:1].upper()}{update_task_name.strip()[1:]}"
+
+ return f"{task_name[:1].upper()}{task_name[1:]}"
+
target_task = self.seek(match.yaml_path, data)
- # Not using capitalize(), since that rewrites the rest of the name to lower case
- target_task[
- "name"
- ] = f"{target_task['name'][:1].upper()}{target_task['name'][1:]}"
- match.fixed = True
+ orig_task_name = target_task.get("name", None)
+ # pylint: disable=too-many-nested-blocks
+ if orig_task_name:
+ updated_task_name = update_task_name(orig_task_name)
+ for item in data:
+ if isinstance(item, dict) and "tasks" in item:
+ for task in item["tasks"]:
+ # We want to rewrite task names in the notify keyword, but
+ # if there isn't a notify section, there's nothing to do.
+ if "notify" not in task:
+ continue
+
+ if (
+ isinstance(task["notify"], str)
+ and orig_task_name == task["notify"]
+ ):
+ task["notify"] = updated_task_name
+ elif isinstance(task["notify"], list):
+ for idx in range(len(task["notify"])):
+ if orig_task_name == task["notify"][idx]:
+ task["notify"][idx] = updated_task_name
+
+ target_task["name"] = updated_task_name
+ match.fixed = True
if "pytest" in sys.modules:
- from ansiblelint.config import options
- from ansiblelint.file_utils import Lintable # noqa: F811
from ansiblelint.rules import RulesCollection
from ansiblelint.runner import Runner
@@ -203,11 +276,23 @@ if "pytest" in sys.modules:
errs = bad_runner.run()
assert len(errs) == 5
- def test_name_prefix_negative() -> None:
+ def test_name_prefix_positive(config_options: Options) -> None:
+ """Positive test for name[prefix]."""
+ config_options.enable_list = ["name[prefix]"]
+ collection = RulesCollection(options=config_options)
+ collection.register(NameRule())
+ success = Lintable(
+ "examples/playbooks/tasks/main.yml",
+ kind="tasks",
+ )
+ good_runner = Runner(success, rules=collection)
+ results = good_runner.run()
+ assert len(results) == 0
+
+ def test_name_prefix_negative(config_options: Options) -> None:
"""Negative test for name[missing]."""
- custom_options = deepcopy(options)
- custom_options.enable_list = ["name[prefix]"]
- collection = RulesCollection(options=custom_options)
+ config_options.enable_list = ["name[prefix]"]
+ collection = RulesCollection(options=config_options)
collection.register(NameRule())
failure = Lintable(
"examples/playbooks/tasks/rule-name-prefix-fail.yml",
@@ -221,6 +306,36 @@ if "pytest" in sys.modules:
assert results[1].tag == "name[prefix]"
assert results[2].tag == "name[prefix]"
+ def test_name_prefix_negative_2(config_options: Options) -> None:
+ """Negative test for name[prefix]."""
+ config_options.enable_list = ["name[prefix]"]
+ collection = RulesCollection(options=config_options)
+ collection.register(NameRule())
+ failure = Lintable(
+ "examples/playbooks/tasks/partial_prefix/foo.yml",
+ kind="tasks",
+ )
+ bad_runner = Runner(failure, rules=collection)
+ results = bad_runner.run()
+ assert len(results) == 2
+ assert results[0].tag == "name[prefix]"
+ assert results[1].tag == "name[prefix]"
+
+ def test_name_prefix_negative_3(config_options: Options) -> None:
+ """Negative test for name[prefix]."""
+ config_options.enable_list = ["name[prefix]"]
+ collection = RulesCollection(options=config_options)
+ collection.register(NameRule())
+ failure = Lintable(
+ "examples/playbooks/tasks/partial_prefix/main.yml",
+ kind="tasks",
+ )
+ bad_runner = Runner(failure, rules=collection)
+ results = bad_runner.run()
+ assert len(results) == 2
+ assert results[0].tag == "name[prefix]"
+ assert results[1].tag == "name[prefix]"
+
def test_rule_name_lowercase() -> None:
"""Negative test for a task that starts with lowercase."""
collection = RulesCollection()
@@ -255,6 +370,5 @@ if "pytest" in sys.modules:
def test_when_no_lintable() -> None:
"""Test when lintable is None."""
name_rule = NameRule()
- # pylint: disable=protected-access
result = name_rule._prefix_check("Foo", None, 1) # noqa: SLF001
assert len(result) == 0
diff --git a/src/ansiblelint/rules/no_changed_when.py b/src/ansiblelint/rules/no_changed_when.py
index 28ba427..e71934d 100644
--- a/src/ansiblelint/rules/no_changed_when.py
+++ b/src/ansiblelint/rules/no_changed_when.py
@@ -1,4 +1,5 @@
"""Implementation of the no-changed-when rule."""
+
# Copyright (c) 2016 Will Thames <will@thames.id.au>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -75,8 +76,9 @@ class CommandHasChangesCheckRule(AnsibleLintRule):
if "pytest" in sys.modules:
import pytest
- from ansiblelint.rules import RulesCollection # pylint: disable=ungrouped-imports
- from ansiblelint.runner import Runner # pylint: disable=ungrouped-imports
+ # pylint: disable=ungrouped-imports
+ from ansiblelint.rules import RulesCollection
+ from ansiblelint.runner import Runner
@pytest.mark.parametrize(
("file", "expected"),
diff --git a/src/ansiblelint/rules/no_free_form.md b/src/ansiblelint/rules/no_free_form.md
index 0ffc0ac..ae05d0f 100644
--- a/src/ansiblelint/rules/no_free_form.md
+++ b/src/ansiblelint/rules/no_free_form.md
@@ -56,3 +56,7 @@ This rule can produce messages as:
executable: /bin/bash # <-- explicit is better
changed_when: false
```
+
+!!! note
+
+ This rule can be automatically fixed using [`--fix`](../autofix.md) option.
diff --git a/src/ansiblelint/rules/no_free_form.py b/src/ansiblelint/rules/no_free_form.py
index e89333b..13489ef 100644
--- a/src/ansiblelint/rules/no_free_form.py
+++ b/src/ansiblelint/rules/no_free_form.py
@@ -1,20 +1,25 @@
"""Implementation of NoFreeFormRule."""
+
from __future__ import annotations
+import functools
import re
import sys
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, Any
from ansiblelint.constants import INCLUSION_ACTION_NAMES, LINE_NUMBER_KEY
-from ansiblelint.rules import AnsibleLintRule
+from ansiblelint.rules import AnsibleLintRule, TransformMixin
+from ansiblelint.rules.key_order import task_property_sorter
if TYPE_CHECKING:
+ from ruamel.yaml.comments import CommentedMap, CommentedSeq
+
from ansiblelint.errors import MatchError
from ansiblelint.file_utils import Lintable
from ansiblelint.utils import Task
-class NoFreeFormRule(AnsibleLintRule):
+class NoFreeFormRule(AnsibleLintRule, TransformMixin):
"""Rule for detecting discouraged free-form syntax for action modules."""
id = "no-free-form"
@@ -75,7 +80,7 @@ class NoFreeFormRule(AnsibleLintRule):
"win_command",
"win_shell",
):
- if self.cmd_shell_re.match(action_value):
+ if self.cmd_shell_re.search(action_value):
fail = True
else:
fail = True
@@ -89,12 +94,97 @@ class NoFreeFormRule(AnsibleLintRule):
)
return results
+ def transform(
+ self,
+ match: MatchError,
+ lintable: Lintable,
+ data: CommentedMap | CommentedSeq | str,
+ ) -> None:
+ if "no-free-form" in match.tag:
+ task = self.seek(match.yaml_path, data)
+
+ def filter_values(
+ val: str,
+ filter_key: str,
+ filter_dict: dict[str, Any],
+ ) -> str:
+ """Pull out key=value pairs from a string and set them in filter_dict.
+
+ Returns unmatched strings.
+ """
+ if filter_key not in val:
+ return val
+
+ extra = ""
+ [k, v] = val.split(filter_key, 1)
+ if " " in k:
+ extra, k = k.rsplit(" ", 1)
+
+ if v[0] in "\"'":
+ # Keep quoted strings together
+ quote = v[0]
+ _, v, remainder = v.split(quote, 2)
+ v = f"{quote}{v}{quote}"
+ else:
+ try:
+ v, remainder = v.split(" ", 1)
+ except ValueError:
+ remainder = ""
+
+ filter_dict[k] = v
+
+ extra = " ".join(
+ (extra, filter_values(remainder, filter_key, filter_dict)),
+ )
+ return extra.strip()
+
+ if match.tag == "no-free-form":
+ module_opts: dict[str, Any] = {}
+ for _ in range(len(task)):
+ k, v = task.popitem(False)
+ # identify module as key and process its value
+ if len(k.split(".")) == 3 and isinstance(v, str):
+ cmd = filter_values(v, "=", module_opts)
+ if cmd:
+ module_opts["cmd"] = cmd
+
+ sorted_module_opts = {}
+ for key in sorted(
+ module_opts.keys(),
+ key=functools.cmp_to_key(task_property_sorter),
+ ):
+ sorted_module_opts[key] = module_opts[key]
+
+ task[k] = sorted_module_opts
+ else:
+ task[k] = v
+
+ match.fixed = True
+ elif match.tag == "no-free-form[raw]":
+ exec_key_val: dict[str, Any] = {}
+ for _ in range(len(task)):
+ k, v = task.popitem(False)
+ if isinstance(v, str) and "executable" in v:
+ # Filter the executable and other parts from the string
+ task[k] = " ".join(
+ [
+ item
+ for item in v.split(" ")
+ if filter_values(item, "=", exec_key_val)
+ ],
+ )
+ task["args"] = exec_key_val
+ else:
+ task[k] = v
+ match.fixed = True
+
if "pytest" in sys.modules:
import pytest
- from ansiblelint.rules import RulesCollection # pylint: disable=ungrouped-imports
- from ansiblelint.runner import Runner # pylint: disable=ungrouped-imports
+ # pylint: disable=ungrouped-imports
+ from ansiblelint.rules import RulesCollection
+ from ansiblelint.runner import Runner
@pytest.mark.parametrize(
("file", "expected"),
diff --git a/src/ansiblelint/rules/no_handler.py b/src/ansiblelint/rules/no_handler.py
index 380fd61..ae8f820 100644
--- a/src/ansiblelint/rules/no_handler.py
+++ b/src/ansiblelint/rules/no_handler.py
@@ -69,25 +69,27 @@ class UseHandlerRatherThanWhenChangedRule(AnsibleLintRule):
task: Task,
file: Lintable | None = None,
) -> bool | str:
- if task["__ansible_action_type__"] != "task":
+ if task["__ansible_action_type__"] != "task" or task.is_handler():
return False
when = task.get("when")
+ result = False
if isinstance(when, list):
- if len(when) > 1:
- return False
- return _changed_in_when(when[0])
- if isinstance(when, str):
- return _changed_in_when(when)
- return False
+ if len(when) <= 1:
+ result = _changed_in_when(when[0])
+ elif isinstance(when, str):
+ result = _changed_in_when(when)
+ return result
if "pytest" in sys.modules:
import pytest
- from ansiblelint.rules import RulesCollection # pylint: disable=ungrouped-imports
- from ansiblelint.runner import Runner # pylint: disable=ungrouped-imports
+ # pylint: disable=ungrouped-imports
+ from ansiblelint.rules import RulesCollection
+ from ansiblelint.runner import Runner
+ from ansiblelint.testing import run_ansible_lint
@pytest.mark.parametrize(
("test_file", "failures"),
@@ -106,3 +108,10 @@ if "pytest" in sys.modules:
assert len(results) == failures
for result in results:
assert result.tag == "no-handler"
+
+ def test_role_with_handler() -> None:
+ """Test role with handler."""
+ role_path = "examples/roles/role_with_handler"
+
+ results = run_ansible_lint("-v", role_path)
+ assert "no-handler" not in results.stdout
diff --git a/src/ansiblelint/rules/no_jinja_when.md b/src/ansiblelint/rules/no_jinja_when.md
index 702e807..5a2c736 100644
--- a/src/ansiblelint/rules/no_jinja_when.md
+++ b/src/ansiblelint/rules/no_jinja_when.md
@@ -30,3 +30,7 @@ anti-pattern and does not produce expected results.
ansible.builtin.command: /sbin/shutdown -t now
when: ansible_facts['os_family'] == "Debian" # <- Uses facts in a conditional statement.
```
+
+!!! note
+
+ This rule can be automatically fixed using [`--fix`](../autofix.md) option.
diff --git a/src/ansiblelint/rules/no_jinja_when.py b/src/ansiblelint/rules/no_jinja_when.py
index 807081d..a5fc030 100644
--- a/src/ansiblelint/rules/no_jinja_when.py
+++ b/src/ansiblelint/rules/no_jinja_when.py
@@ -1,19 +1,23 @@
"""Implementation of no-jinja-when rule."""
+
from __future__ import annotations
+import re
import sys
from typing import TYPE_CHECKING, Any
from ansiblelint.constants import LINE_NUMBER_KEY
-from ansiblelint.rules import AnsibleLintRule
+from ansiblelint.rules import AnsibleLintRule, TransformMixin
if TYPE_CHECKING:
+ from ruamel.yaml.comments import CommentedMap, CommentedSeq
+
from ansiblelint.errors import MatchError
from ansiblelint.file_utils import Lintable
from ansiblelint.utils import Task
-class NoFormattingInWhenRule(AnsibleLintRule):
+class NoFormattingInWhenRule(AnsibleLintRule, TransformMixin):
"""No Jinja2 in when."""
id = "no-jinja-when"
@@ -44,19 +48,19 @@ class NoFormattingInWhenRule(AnsibleLintRule):
if isinstance(data, dict):
if "roles" not in data or data["roles"] is None:
return errors
- for role in data["roles"]:
+ errors = [
+ self.create_matcherror(
+ details=str({"when": role}),
+ filename=file,
+ lineno=role[LINE_NUMBER_KEY],
+ )
+ for role in data["roles"]
if (
isinstance(role, dict)
and "when" in role
and not self._is_valid(role["when"])
- ):
- errors.append(
- self.create_matcherror(
- details=str({"when": role}),
- filename=file,
- lineno=role[LINE_NUMBER_KEY],
- ),
- )
+ )
+ ]
return errors
def matchtask(
@@ -66,6 +70,37 @@ class NoFormattingInWhenRule(AnsibleLintRule):
) -> bool | str:
return "when" in task.raw_task and not self._is_valid(task.raw_task["when"])
+ def transform(
+ self,
+ match: MatchError,
+ lintable: Lintable,
+ data: CommentedMap | CommentedSeq | str,
+ ) -> None:
+ if match.tag == self.id:
+ task = self.seek(match.yaml_path, data)
+ key_to_check = ("when", "changed_when", "failed_when")
+ for _ in range(len(task)):
+ k, v = task.popitem(False)
+ if k == "roles" and isinstance(v, list):
+ transform_for_roles(v, key_to_check=key_to_check)
+ elif k in key_to_check:
+ v = re.sub(r"{{ (.*?) }}", r"\1", v)
+ task[k] = v
+ match.fixed = True
+
+
+def transform_for_roles(v: list[Any], key_to_check: tuple[str, ...]) -> None:
+ """Additional transform logic in case of roles."""
+ for idx, new_dict in enumerate(v):
+ for new_key, new_value in new_dict.items():
+ if new_key in key_to_check:
+ if isinstance(new_value, list):
+ for index, nested_value in enumerate(new_value):
+ new_value[index] = re.sub(r"{{ (.*?) }}", r"\1", nested_value)
+ v[idx][new_key] = new_value
+ if isinstance(new_value, str):
+ v[idx][new_key] = re.sub(r"{{ (.*?) }}", r"\1", new_value)
+
if "pytest" in sys.modules:
# Tests for no-jinja-when rule.
diff --git a/src/ansiblelint/rules/no_log_password.md b/src/ansiblelint/rules/no_log_password.md
index 579dd11..3629ef6 100644
--- a/src/ansiblelint/rules/no_log_password.md
+++ b/src/ansiblelint/rules/no_log_password.md
@@ -43,3 +43,7 @@ Explicitly adding `no_log: true` prevents accidentally exposing secrets.
- wow
no_log: true # <- Sets the no_log attribute to a non-false value.
```
+
+!!! note
+
+ This rule can be automatically fixed using [`--fix`](../autofix.md) option.
diff --git a/src/ansiblelint/rules/no_log_password.py b/src/ansiblelint/rules/no_log_password.py
index 7cc7439..c3f6d34 100644
--- a/src/ansiblelint/rules/no_log_password.py
+++ b/src/ansiblelint/rules/no_log_password.py
@@ -15,17 +15,25 @@
"""NoLogPasswordsRule used with ansible-lint."""
from __future__ import annotations
+import os
import sys
+from pathlib import Path
from typing import TYPE_CHECKING
-from ansiblelint.rules import AnsibleLintRule
+from ansiblelint.rules import AnsibleLintRule, RulesCollection, TransformMixin
+from ansiblelint.runner import get_matches
+from ansiblelint.transformer import Transformer
from ansiblelint.utils import Task, convert_to_boolean
if TYPE_CHECKING:
+ from ruamel.yaml.comments import CommentedMap, CommentedSeq
+
+ from ansiblelint.config import Options
+ from ansiblelint.errors import MatchError
from ansiblelint.file_utils import Lintable
-class NoLogPasswordsRule(AnsibleLintRule):
+class NoLogPasswordsRule(AnsibleLintRule, TransformMixin):
"""Password should not be logged."""
id = "no-log-password"
@@ -72,12 +80,26 @@ class NoLogPasswordsRule(AnsibleLintRule):
has_password and not convert_to_boolean(no_log) and len(has_loop) > 0,
)
+ def transform(
+ self,
+ match: MatchError,
+ lintable: Lintable,
+ data: CommentedMap | CommentedSeq | str,
+ ) -> None:
+ if match.tag == self.id:
+ task = self.seek(match.yaml_path, data)
+ task["no_log"] = True
+
+ match.fixed = True
+
if "pytest" in sys.modules:
+ from unittest import mock
+
import pytest
if TYPE_CHECKING:
- from ansiblelint.testing import RunFromText # pylint: disable=ungrouped-imports
+ from ansiblelint.testing import RunFromText
NO_LOG_UNUSED = """
- name: Test
@@ -304,3 +326,33 @@ if "pytest" in sys.modules:
"""The task does not actually lock the user."""
results = rule_runner.run_playbook(PASSWORD_LOCK_FALSE)
assert len(results) == 0
+
+ @mock.patch.dict(os.environ, {"ANSIBLE_LINT_WRITE_TMP": "1"}, clear=True)
+ def test_no_log_password_transform(
+ config_options: Options,
+ ) -> None:
+ """Test transform functionality for no-log-password rule."""
+ playbook = Path("examples/playbooks/transform-no-log-password.yml")
+ config_options.write_list = ["all"]
+ rules = RulesCollection(options=config_options)
+ rules.register(NoLogPasswordsRule())
+
+ config_options.lintables = [str(playbook)]
+ runner_result = get_matches(rules=rules, options=config_options)
+ transformer = Transformer(result=runner_result, options=config_options)
+ transformer.run()
+
+ matches = runner_result.matches
+ assert len(matches) == 2
+
+ orig_content = playbook.read_text(encoding="utf-8")
+ expected_content = playbook.with_suffix(
+ f".transformed{playbook.suffix}",
+ ).read_text(encoding="utf-8")
+ transformed_content = playbook.with_suffix(f".tmp{playbook.suffix}").read_text(
+ encoding="utf-8",
+ )
+
+ assert orig_content != transformed_content
+ assert expected_content == transformed_content
+ playbook.with_suffix(f".tmp{playbook.suffix}").unlink()
diff --git a/src/ansiblelint/rules/no_prompting.py b/src/ansiblelint/rules/no_prompting.py
index 6622771..c5d11d8 100644
--- a/src/ansiblelint/rules/no_prompting.py
+++ b/src/ansiblelint/rules/no_prompting.py
@@ -1,4 +1,5 @@
"""Implementation of no-prompting rule."""
+
from __future__ import annotations
import sys
@@ -8,6 +9,7 @@ from ansiblelint.constants import LINE_NUMBER_KEY
from ansiblelint.rules import AnsibleLintRule
if TYPE_CHECKING:
+ from ansiblelint.config import Options
from ansiblelint.errors import MatchError
from ansiblelint.file_utils import Lintable
from ansiblelint.utils import Task
@@ -32,7 +34,7 @@ class NoPromptingRule(AnsibleLintRule):
if file.kind != "playbook": # pragma: no cover
return []
- vars_prompt = data.get("vars_prompt", None)
+ vars_prompt = data.get("vars_prompt")
if not vars_prompt:
return []
return [
@@ -60,15 +62,14 @@ class NoPromptingRule(AnsibleLintRule):
if "pytest" in sys.modules:
- from ansiblelint.config import options
- from ansiblelint.rules import RulesCollection # pylint: disable=ungrouped-imports
- from ansiblelint.runner import Runner # pylint: disable=ungrouped-imports
+ from ansiblelint.rules import RulesCollection
+ from ansiblelint.runner import Runner
- def test_no_prompting_fail() -> None:
+ def test_no_prompting_fail(config_options: Options) -> None:
"""Negative test for no-prompting."""
# For testing we want to manually enable opt-in rules
- options.enable_list = ["no-prompting"]
- rules = RulesCollection(options=options)
+ config_options.enable_list = ["no-prompting"]
+ rules = RulesCollection(options=config_options)
rules.register(NoPromptingRule())
results = Runner("examples/playbooks/rule-no-prompting.yml", rules=rules).run()
assert len(results) == 2
diff --git a/src/ansiblelint/rules/no_relative_paths.py b/src/ansiblelint/rules/no_relative_paths.py
index 470b1b8..de22641 100644
--- a/src/ansiblelint/rules/no_relative_paths.py
+++ b/src/ansiblelint/rules/no_relative_paths.py
@@ -1,4 +1,5 @@
"""Implementation of no-relative-paths rule."""
+
# Copyright (c) 2016, Tsukinowa Inc. <info@tsukinowa.jp>
# Copyright (c) 2018, Ansible Project
@@ -53,8 +54,9 @@ class RoleRelativePath(AnsibleLintRule):
if "pytest" in sys.modules:
import pytest
- from ansiblelint.rules import RulesCollection # pylint: disable=ungrouped-imports
- from ansiblelint.runner import Runner # pylint: disable=ungrouped-imports
+ # pylint: disable=ungrouped-imports
+ from ansiblelint.rules import RulesCollection
+ from ansiblelint.runner import Runner
@pytest.mark.parametrize(
("test_file", "failures"),
diff --git a/src/ansiblelint/rules/no_same_owner.py b/src/ansiblelint/rules/no_same_owner.py
index 021900e..23290e0 100644
--- a/src/ansiblelint/rules/no_same_owner.py
+++ b/src/ansiblelint/rules/no_same_owner.py
@@ -1,4 +1,5 @@
"""Optional rule for avoiding keeping owner/group when transferring files."""
+
from __future__ import annotations
import re
@@ -84,8 +85,9 @@ should not be preserved when transferring files between them.
if "pytest" in sys.modules:
import pytest
- from ansiblelint.rules import RulesCollection # pylint: disable=ungrouped-imports
- from ansiblelint.runner import Runner # pylint: disable=ungrouped-imports
+ # pylint: disable=ungrouped-imports
+ from ansiblelint.rules import RulesCollection
+ from ansiblelint.runner import Runner
@pytest.mark.parametrize(
("test_file", "failures"),
diff --git a/src/ansiblelint/rules/no_tabs.py b/src/ansiblelint/rules/no_tabs.py
index c53f1bb..2614a1a 100644
--- a/src/ansiblelint/rules/no_tabs.py
+++ b/src/ansiblelint/rules/no_tabs.py
@@ -1,4 +1,5 @@
"""Implementation of no-tabs rule."""
+
# Copyright (c) 2016, Will Thames and contributors
# Copyright (c) 2018, Ansible Project
from __future__ import annotations
@@ -7,6 +8,7 @@ import sys
from typing import TYPE_CHECKING
from ansiblelint.rules import AnsibleLintRule
+from ansiblelint.text import has_jinja
from ansiblelint.yaml_utils import nested_items_path
if TYPE_CHECKING:
@@ -27,6 +29,10 @@ class NoTabsRule(AnsibleLintRule):
("lineinfile", "insertbefore"),
("lineinfile", "regexp"),
("lineinfile", "line"),
+ ("win_lineinfile", "insertafter"),
+ ("win_lineinfile", "insertbefore"),
+ ("win_lineinfile", "regexp"),
+ ("win_lineinfile", "line"),
("ansible.builtin.lineinfile", "insertafter"),
("ansible.builtin.lineinfile", "insertbefore"),
("ansible.builtin.lineinfile", "regexp"),
@@ -35,6 +41,10 @@ class NoTabsRule(AnsibleLintRule):
("ansible.legacy.lineinfile", "insertbefore"),
("ansible.legacy.lineinfile", "regexp"),
("ansible.legacy.lineinfile", "line"),
+ ("community.windows.win_lineinfile", "insertafter"),
+ ("community.windows.win_lineinfile", "insertbefore"),
+ ("community.windows.win_lineinfile", "regexp"),
+ ("community.windows.win_lineinfile", "line"),
]
def matchtask(
@@ -44,17 +54,22 @@ class NoTabsRule(AnsibleLintRule):
) -> bool | str:
action = task["action"]["__ansible_module__"]
for k, v, _ in nested_items_path(task):
- if isinstance(k, str) and "\t" in k:
+ if isinstance(k, str) and "\t" in k and not has_jinja(k):
return True
- if isinstance(v, str) and "\t" in v and (action, k) not in self.allow_list:
+ if (
+ isinstance(v, str)
+ and "\t" in v
+ and (action, k) not in self.allow_list
+ and not has_jinja(v)
+ ):
return True
return False
# testing code to be loaded only with pytest or when executed the rule file
if "pytest" in sys.modules:
- from ansiblelint.rules import RulesCollection # pylint: disable=ungrouped-imports
- from ansiblelint.runner import Runner # pylint: disable=ungrouped-imports
+ from ansiblelint.rules import RulesCollection
+ from ansiblelint.runner import Runner
def test_no_tabs_rule(default_rules_collection: RulesCollection) -> None:
"""Test rule matches."""
@@ -62,6 +77,12 @@ if "pytest" in sys.modules:
"examples/playbooks/rule-no-tabs.yml",
rules=default_rules_collection,
).run()
- assert results[0].lineno == 10
- assert results[0].message == NoTabsRule().shortdesc
- assert len(results) == 2
+ expected_results = [
+ (10, NoTabsRule().shortdesc),
+ (13, NoTabsRule().shortdesc),
+ ]
+ for i, expected in enumerate(expected_results):
+ assert len(results) >= i + 1
+ assert results[i].lineno == expected[0]
+ assert results[i].message == expected[1]
+ assert len(results) == len(expected), results
diff --git a/src/ansiblelint/rules/only_builtins.py b/src/ansiblelint/rules/only_builtins.py
index 78ad93a..3757af8 100644
--- a/src/ansiblelint/rules/only_builtins.py
+++ b/src/ansiblelint/rules/only_builtins.py
@@ -1,11 +1,11 @@
"""Rule definition for usage of builtin actions only."""
+
from __future__ import annotations
import os
import sys
from typing import TYPE_CHECKING
-from ansiblelint.config import options
from ansiblelint.rules import AnsibleLintRule
from ansiblelint.rules.fqcn import builtins
from ansiblelint.skip_utils import is_nested_task
@@ -33,9 +33,11 @@ class OnlyBuiltinsRule(AnsibleLintRule):
allowed_collections = [
"ansible.builtin",
"ansible.legacy",
- *options.only_builtins_allow_collections,
]
- allowed_modules = builtins + options.only_builtins_allow_modules
+ allowed_modules = builtins
+ if self.options:
+ allowed_collections += self.options.only_builtins_allow_collections
+ allowed_modules += self.options.only_builtins_allow_modules
is_allowed = (
any(module.startswith(f"{prefix}.") for prefix in allowed_collections)
diff --git a/src/ansiblelint/rules/package_latest.md b/src/ansiblelint/rules/package_latest.md
index c7e0d82..c965548 100644
--- a/src/ansiblelint/rules/package_latest.md
+++ b/src/ansiblelint/rules/package_latest.md
@@ -7,7 +7,7 @@ In production environments, you should set `state` to `present` and specify a ta
Setting `state` to `latest` not only installs software, it performs an update and installs additional packages.
This can result in performance degradation or loss of service.
-If you do want to update packages to the latest version, you should also set the `update_only` parameter to `true` to avoid installing additional packages.
+If you do want to update packages to the latest version, you should also set the `update_only` or `only_upgrade` parameter to `true` based on package manager to avoid installing additional packages.
## Problematic Code
@@ -32,11 +32,17 @@ If you do want to update packages to the latest version, you should also set the
name: some-package
state: latest # <- Installs the latest package.
- - name: Install Ansible with update_only to false
+ - name: Install sudo with update_only to false
ansible.builtin.yum:
name: sudo
state: latest
update_only: false # <- Updates and installs packages.
+
+ - name: Install sudo with only_upgrade to false
+ ansible.builtin.apt:
+ name: sudo
+ state: latest
+ only_upgrade: false # <- Upgrades and installs packages
```
## Correct Code
@@ -63,9 +69,15 @@ If you do want to update packages to the latest version, you should also set the
name: some-package
state: present # <- Ensures the package is installed.
- - name: Update Ansible with update_only to true
+ - name: Update sudo with update_only to true
ansible.builtin.yum:
name: sudo
state: latest
update_only: true # <- Updates but does not install additional packages.
+
+ - name: Install sudo with only_upgrade to true
+ ansible.builtin.apt:
+ name: sudo
+ state: latest
+ only_upgrade: true # <- Upgrades but does not install additional packages.
```
diff --git a/src/ansiblelint/rules/package_latest.py b/src/ansiblelint/rules/package_latest.py
index a00a540..9c8ce3c 100644
--- a/src/ansiblelint/rules/package_latest.py
+++ b/src/ansiblelint/rules/package_latest.py
@@ -1,4 +1,5 @@
"""Implementations of the package-latest rule."""
+
# Copyright (c) 2016 Will Thames <will@thames.id.au>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -79,5 +80,6 @@ class PackageIsNotLatestRule(AnsibleLintRule):
task["action"]["__ansible_module__"] in self._package_managers
and not task["action"].get("version")
and not task["action"].get("update_only")
+ and not task["action"].get("only_upgrade")
and task["action"].get("state") == "latest"
)
diff --git a/src/ansiblelint/rules/partial_become.md b/src/ansiblelint/rules/partial_become.md
index 01f9dae..672ef96 100644
--- a/src/ansiblelint/rules/partial_become.md
+++ b/src/ansiblelint/rules/partial_become.md
@@ -5,6 +5,13 @@ This rule checks that privilege escalation is activated when changing users.
To perform an action as a different user with the `become_user` directive, you
must set `become: true`.
+This rule can produce the following messages:
+
+- `partial-become[play]`: become_user requires become to work as expected, at
+ play level.
+- `partial-become[task]`: become_user requires become to work as expected, at
+ task level.
+
!!! warning
While Ansible inherits have of `become` and `become_user` from upper levels,
@@ -19,12 +26,13 @@ must set `become: true`.
---
- name: Example playbook
hosts: localhost
+ become: true # <- Activates privilege escalation.
tasks:
- name: Start the httpd service as the apache user
ansible.builtin.service:
name: httpd
state: started
- become_user: apache # <- Does not change the user because "become: true" is not set.
+ become_user: apache # <- Does not change the user because "become: true" is not set.
```
## Correct Code
@@ -37,6 +45,82 @@ must set `become: true`.
ansible.builtin.service:
name: httpd
state: started
- become: true # <- Activates privilege escalation.
- become_user: apache # <- Changes the user with the desired privileges.
+ become: true # <- Activates privilege escalation.
+ become_user: apache # <- Changes the user with the desired privileges.
+
+# Stand alone playbook alternative, applies to all tasks
+
+- name: Example playbook
+ hosts: localhost
+ become: true # <- Activates privilege escalation.
+ become_user: apache # <- Changes the user with the desired privileges.
+ tasks:
+ - name: Start the httpd service as the apache user
+ ansible.builtin.service:
+ name: httpd
+ state: started
+```
+
+## Problematic Code
+
+```yaml
+---
+- name: Example playbook 1
+ hosts: localhost
+ become: true # <- Activates privilege escalation.
+ tasks:
+ - name: Include a task file
+ ansible.builtin.include_tasks: tasks.yml
```
+
+```yaml
+---
+- name: Example playbook 2
+ hosts: localhost
+ tasks:
+ - name: Include a task file
+ ansible.builtin.include_tasks: tasks.yml
+```
+
+```yaml
+# tasks.yml
+- name: Start the httpd service as the apache user
+ ansible.builtin.service:
+ name: httpd
+ state: started
+ become_user: apache # <- Does not change the user because "become: true" is not set.
+```
+
+## Correct Code
+
+```yaml
+---
+- name: Example playbook 1
+ hosts: localhost
+ tasks:
+ - name: Include a task file
+ ansible.builtin.include_tasks: tasks.yml
+```
+
+```yaml
+---
+- name: Example playbook 2
+ hosts: localhost
+ tasks:
+ - name: Include a task file
+ ansible.builtin.include_tasks: tasks.yml
+```
+
+```yaml
+# tasks.yml
+- name: Start the httpd service as the apache user
+ ansible.builtin.service:
+ name: httpd
+ state: started
+ become: true # <- Activates privilege escalation.
+ become_user: apache # <- Does not change the user because "become: true" is not set.
+```
+
+!!! note
+
+ This rule can be automatically fixed using [`--fix`](../autofix.md) option.
diff --git a/src/ansiblelint/rules/partial_become.py b/src/ansiblelint/rules/partial_become.py
index d14c06f..879b186 100644
--- a/src/ansiblelint/rules/partial_become.py
+++ b/src/ansiblelint/rules/partial_become.py
@@ -1,4 +1,5 @@
"""Implementation of partial-become rule."""
+
# Copyright (c) 2016 Will Thames <will@thames.id.au>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -21,115 +22,231 @@
from __future__ import annotations
import sys
-from functools import reduce
from typing import TYPE_CHECKING, Any
+from ruamel.yaml.comments import CommentedMap, CommentedSeq
+
from ansiblelint.constants import LINE_NUMBER_KEY
-from ansiblelint.rules import AnsibleLintRule
+from ansiblelint.rules import AnsibleLintRule, TransformMixin
if TYPE_CHECKING:
+ from collections.abc import Iterator
+
from ansiblelint.errors import MatchError
from ansiblelint.file_utils import Lintable
+ from ansiblelint.utils import Task
-def _get_subtasks(data: dict[str, Any]) -> list[Any]:
- result: list[Any] = []
- block_names = [
- "tasks",
- "pre_tasks",
- "post_tasks",
- "handlers",
- "block",
- "always",
- "rescue",
- ]
- for name in block_names:
- if data and name in data:
- result += data[name] or []
- return result
-
-
-def _nested_search(term: str, data: dict[str, Any]) -> Any:
- if data and term in data:
- return True
- return reduce(
- (lambda x, y: x or _nested_search(term, y)),
- _get_subtasks(data),
- False,
- )
-
-
-def _become_user_without_become(becomeuserabove: bool, data: dict[str, Any]) -> Any:
- if "become" in data:
- # If become is in lineage of tree then correct
- return False
- if "become_user" in data and _nested_search("become", data):
- # If 'become_user' on tree and become somewhere below
- # we must check for a case of a second 'become_user' without a
- # 'become' in its lineage
- subtasks = _get_subtasks(data)
- return reduce(
- (lambda x, y: x or _become_user_without_become(False, y)),
- subtasks,
- False,
- )
- if _nested_search("become_user", data):
- # Keep searching down if 'become_user' exists in the tree below current task
- subtasks = _get_subtasks(data)
- return len(subtasks) == 0 or reduce(
- (
- lambda x, y: x
- or _become_user_without_become(
- becomeuserabove or "become_user" in data,
- y,
- )
- ),
- subtasks,
- False,
- )
- # If at bottom of tree, flag up if 'become_user' existed in the lineage of the tree and
- # 'become' was not. This is an error if any lineage has a 'become_user' but no become
- return becomeuserabove
-
-
-class BecomeUserWithoutBecomeRule(AnsibleLintRule):
- """become_user requires become to work as expected."""
+class BecomeUserWithoutBecomeRule(AnsibleLintRule, TransformMixin):
+ """``become_user`` should have a corresponding ``become`` at the play or task level."""
id = "partial-become"
- description = "``become_user`` without ``become`` will not actually change user"
+ description = "``become_user`` should have a corresponding ``become`` at the play or task level."
severity = "VERY_HIGH"
tags = ["unpredictability"]
version_added = "historic"
- def matchplay(self, file: Lintable, data: dict[str, Any]) -> list[MatchError]:
- if file.kind == "playbook":
- result = _become_user_without_become(False, data)
- if result:
- return [
- self.create_matcherror(
- message=self.shortdesc,
- filename=file,
- lineno=data[LINE_NUMBER_KEY],
- ),
- ]
- return []
+ def matchplay(
+ self: BecomeUserWithoutBecomeRule,
+ file: Lintable,
+ data: dict[str, Any],
+ ) -> list[MatchError]:
+ """Match become_user without become in play.
+
+ :param file: The file to lint.
+ :param data: The data to lint (play)
+ :returns: A list of errors.
+ """
+ if file.kind != "playbook":
+ return []
+ errors = []
+ partial = "become_user" in data and "become" not in data
+ if partial:
+ error = self.create_matcherror(
+ message=self.shortdesc,
+ filename=file,
+ tag=f"{self.id}[play]",
+ lineno=data[LINE_NUMBER_KEY],
+ )
+ errors.append(error)
+ return errors
+
+ def matchtask(
+ self: BecomeUserWithoutBecomeRule,
+ task: Task,
+ file: Lintable | None = None,
+ ) -> list[MatchError]:
+ """Match become_user without become in task.
+
+ :param task: The task to lint.
+ :param file: The file to lint.
+ :returns: A list of errors.
+ """
+ data = task.normalized_task
+ errors = []
+ partial = "become_user" in data and "become" not in data
+ if partial:
+ error = self.create_matcherror(
+ message=self.shortdesc,
+ filename=file,
+ tag=f"{self.id}[task]",
+ lineno=task[LINE_NUMBER_KEY],
+ )
+ errors.append(error)
+ return errors
+
+ def _dive(self: BecomeUserWithoutBecomeRule, data: CommentedSeq) -> Iterator[Any]:
+ """Dive into the data and yield each item.
+
+ :param data: The data to dive into.
+ :yield: Each item in the data.
+ """
+ for item in data:
+ for nested in ("block", "rescue", "always"):
+ if nested in item:
+ yield from self._dive(item[nested])
+ yield item
+
+ def transform(
+ self: BecomeUserWithoutBecomeRule,
+ match: MatchError,
+ lintable: Lintable,
+ data: CommentedMap | CommentedSeq | str,
+ ) -> None:
+ """Transform the data.
+
+ :param match: The match to transform.
+ :param lintable: The file to transform.
+ :param data: The data to transform.
+ """
+ if not isinstance(data, CommentedSeq):
+ return
+
+ obj = self.seek(match.yaml_path, data)
+ if "become" in obj and "become_user" in obj:
+ match.fixed = True
+ return
+ if "become" not in obj and "become_user" not in obj:
+ match.fixed = True
+ return
+
+ self._transform_plays(plays=data)
+
+ if "become" in obj and "become_user" in obj:
+ match.fixed = True
+ return
+ if "become" not in obj and "become_user" not in obj:
+ match.fixed = True
+ return
+
+ def is_ineligible_for_transform(
+ self: BecomeUserWithoutBecomeRule,
+ data: CommentedMap,
+ ) -> bool:
+ """Check if the data is eligible for transformation.
+
+ :param data: The data to check.
+ :returns: True if ineligible, False otherwise.
+ """
+ if any("include" in key for key in data):
+ return True
+ if "notify" in data:
+ return True
+ return False
+
+ def _transform_plays(self, plays: CommentedSeq) -> None:
+ """Transform the plays.
+
+ :param plays: The plays to transform.
+ """
+ for play in plays:
+ self._transform_play(play=play)
+
+ def _transform_play(self, play: CommentedMap) -> None:
+ """Transform the play.
+
+ :param play: The play to transform.
+ """
+ # Ensure we have no includes in this play
+ task_groups = ("tasks", "pre_tasks", "post_tasks", "handlers")
+ for task_group in task_groups:
+ tasks = self._dive(play.get(task_group, []))
+ for task in tasks:
+ if self.is_ineligible_for_transform(task):
+ return
+ remove_play_become_user = False
+ for task_group in task_groups:
+ tasks = self._dive(play.get(task_group, []))
+ for task in tasks:
+ b_in_t = "become" in task
+ bu_in_t = "become_user" in task
+ b_in_p = "become" in play
+ bu_in_p = "become_user" in play
+ if b_in_t and not bu_in_t and bu_in_p:
+ # Preserve the end comment if become is the last key
+ comment = None
+ if list(task.keys())[-1] == "become" and "become" in task.ca.items:
+ comment = task.ca.items.pop("become")
+ become_index = list(task.keys()).index("become")
+ task.insert(become_index + 1, "become_user", play["become_user"])
+ if comment:
+ self._attach_comment_end(task, comment)
+ remove_play_become_user = True
+ if bu_in_t and not b_in_t and b_in_p:
+ become_user_index = list(task.keys()).index("become_user")
+ task.insert(become_user_index, "become", play["become"])
+ if bu_in_t and not b_in_t and not b_in_p:
+ # Preserve the end comment if become_user is the last key
+ comment = None
+ if (
+ list(task.keys())[-1] == "become_user"
+ and "become_user" in task.ca.items
+ ):
+ comment = task.ca.items.pop("become_user")
+ task.pop("become_user")
+ if comment:
+ self._attach_comment_end(task, comment)
+ if remove_play_become_user:
+ del play["become_user"]
+
+ def _attach_comment_end(
+ self,
+ obj: CommentedMap | CommentedSeq,
+ comment: Any,
+ ) -> None:
+ """Attach a comment to the end of the object.
+
+ :param obj: The object to attach the comment to.
+ :param comment: The comment to attach.
+ """
+ if isinstance(obj, CommentedMap):
+ last = list(obj.keys())[-1]
+ if not isinstance(obj[last], CommentedSeq | CommentedMap):
+ obj.ca.items[last] = comment
+ return
+ self._attach_comment_end(obj[last], comment)
+ elif isinstance(obj, CommentedSeq):
+ if not isinstance(obj[-1], CommentedSeq | CommentedMap):
+ obj.ca.items[len(obj)] = comment
+ return
+ self._attach_comment_end(obj[-1], comment)
# testing code to be loaded only with pytest or when executed the rule file
if "pytest" in sys.modules:
- from ansiblelint.rules import RulesCollection # pylint: disable=ungrouped-imports
- from ansiblelint.runner import Runner # pylint: disable=ungrouped-imports
+ from ansiblelint.rules import RulesCollection
+ from ansiblelint.runner import Runner
- def test_partial_become_positive() -> None:
- """Positive test for partial-become."""
+ def test_partial_become_pass() -> None:
+ """No errors found for partial-become."""
collection = RulesCollection()
collection.register(BecomeUserWithoutBecomeRule())
success = "examples/playbooks/rule-partial-become-without-become-pass.yml"
good_runner = Runner(success, rules=collection)
assert [] == good_runner.run()
- def test_partial_become_negative() -> None:
- """Negative test for partial-become."""
+ def test_partial_become_fail() -> None:
+ """Errors found for partial-become."""
collection = RulesCollection()
collection.register(BecomeUserWithoutBecomeRule())
failure = "examples/playbooks/rule-partial-become-without-become-fail.yml"
diff --git a/src/ansiblelint/rules/playbook_extension.py b/src/ansiblelint/rules/playbook_extension.py
index b4ca41c..a08c984 100644
--- a/src/ansiblelint/rules/playbook_extension.py
+++ b/src/ansiblelint/rules/playbook_extension.py
@@ -1,4 +1,5 @@
"""Implementation of playbook-extension rule."""
+
# Copyright (c) 2016, Tsukinowa Inc. <info@tsukinowa.jp>
# Copyright (c) 2018, Ansible Project
from __future__ import annotations
@@ -39,7 +40,8 @@ class PlaybookExtensionRule(AnsibleLintRule):
if "pytest" in sys.modules:
import pytest
- from ansiblelint.rules import RulesCollection # pylint: disable=ungrouped-imports
+ # pylint: disable=ungrouped-imports
+ from ansiblelint.rules import RulesCollection
@pytest.mark.parametrize(
("file", "expected"),
diff --git a/src/ansiblelint/rules/risky_file_permissions.md b/src/ansiblelint/rules/risky_file_permissions.md
index 2a62a6d..ad46871 100644
--- a/src/ansiblelint/rules/risky_file_permissions.md
+++ b/src/ansiblelint/rules/risky_file_permissions.md
@@ -50,7 +50,7 @@ Modules that are checked:
- name: Safe example of using ini_file (2nd solution)
community.general.ini_file:
path: foo
- mode: 0600 # explicitly sets the desired permissions, to make the results predictable
+ mode: "0600" # explicitly sets the desired permissions, to make the results predictable
- name: Safe example of using copy (3rd solution)
ansible.builtin.copy:
diff --git a/src/ansiblelint/rules/risky_file_permissions.py b/src/ansiblelint/rules/risky_file_permissions.py
index f4494eb..7fe3870 100644
--- a/src/ansiblelint/rules/risky_file_permissions.py
+++ b/src/ansiblelint/rules/risky_file_permissions.py
@@ -137,8 +137,9 @@ class MissingFilePermissionsRule(AnsibleLintRule):
if "pytest" in sys.modules:
import pytest
- from ansiblelint.rules import RulesCollection # pylint: disable=ungrouped-imports
- from ansiblelint.testing import RunFromText # pylint: disable=ungrouped-imports
+ # pylint: disable=ungrouped-imports
+ from ansiblelint.rules import RulesCollection
+ from ansiblelint.testing import RunFromText
@pytest.mark.parametrize(
("file", "expected"),
diff --git a/src/ansiblelint/rules/risky_octal.py b/src/ansiblelint/rules/risky_octal.py
index e3651ea..e3dad38 100644
--- a/src/ansiblelint/rules/risky_octal.py
+++ b/src/ansiblelint/rules/risky_octal.py
@@ -1,4 +1,5 @@
"""Implementation of risky-octal rule."""
+
# Copyright (c) 2013-2014 Will Thames <will@thames.id.au>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
diff --git a/src/ansiblelint/rules/risky_shell_pipe.md b/src/ansiblelint/rules/risky_shell_pipe.md
index 302d0d9..dfede8e 100644
--- a/src/ansiblelint/rules/risky_shell_pipe.md
+++ b/src/ansiblelint/rules/risky_shell_pipe.md
@@ -7,7 +7,7 @@ The return status of a pipeline is the exit status of the command. The
`pipefail` option ensures that tasks fail as expected if the first command
fails.
-As this requirement does apply to PowerShell, for shell commands that have
+As this requirement does not apply to PowerShell, for shell commands that have
`pwsh` inside `executable` attribute, this rule will not trigger.
## Problematic Code
@@ -30,10 +30,14 @@ As this requirement does apply to PowerShell, for shell commands that have
become: false
tasks:
- name: Pipeline with pipefail
- ansible.builtin.shell: set -o pipefail && false | cat
+ ansible.builtin.shell:
+ cmd: set -o pipefail && false | cat
+ executable: /bin/bash
- name: Pipeline with pipefail, multi-line
- ansible.builtin.shell: |
- set -o pipefail # <-- adding this will prevent surprises
- false | cat
+ ansible.builtin.shell:
+ cmd: |
+ set -o pipefail # <-- adding this will prevent surprises
+ false | cat
+ executable: /bin/bash
```
diff --git a/src/ansiblelint/rules/risky_shell_pipe.py b/src/ansiblelint/rules/risky_shell_pipe.py
index 58a6f5f..b0c6063 100644
--- a/src/ansiblelint/rules/risky_shell_pipe.py
+++ b/src/ansiblelint/rules/risky_shell_pipe.py
@@ -1,4 +1,5 @@
"""Implementation of risky-shell-pipe rule."""
+
from __future__ import annotations
import re
@@ -62,8 +63,9 @@ class ShellWithoutPipefail(AnsibleLintRule):
if "pytest" in sys.modules:
import pytest
- from ansiblelint.rules import RulesCollection # pylint: disable=ungrouped-imports
- from ansiblelint.runner import Runner # pylint: disable=ungrouped-imports
+ # pylint: disable=ungrouped-imports
+ from ansiblelint.rules import RulesCollection
+ from ansiblelint.runner import Runner
@pytest.mark.parametrize(
("file", "expected"),
diff --git a/src/ansiblelint/rules/role_name.py b/src/ansiblelint/rules/role_name.py
index 499c086..ebe0b1a 100644
--- a/src/ansiblelint/rules/role_name.py
+++ b/src/ansiblelint/rules/role_name.py
@@ -1,4 +1,5 @@
"""Implementation of role-name rule."""
+
# Copyright (c) 2020 Gael Chamoulaud <gchamoul@redhat.com>
# Copyright (c) 2020 Sorin Sbarnea <ssbarnea@redhat.com>
#
@@ -94,6 +95,26 @@ class RoleNames(AnsibleLintRule):
if file.kind not in ("meta", "role", "playbook"):
return result
+ if file.kind == "meta":
+ for role in file.data.get("dependencies", []):
+ if isinstance(role, dict):
+ role_name = role["role"]
+ elif isinstance(role, str):
+ role_name = role
+ else:
+ msg = "Role dependency has unexpected type."
+ raise TypeError(msg)
+ if "/" in role_name:
+ result.append(
+ self.create_matcherror(
+ f"Avoid using paths when importing roles. ({role_name})",
+ filename=file,
+ lineno=role_name.ansible_pos[1],
+ tag=f"{self.id}[path]",
+ ),
+ )
+ return result
+
if file.kind == "playbook":
for play in file.data:
if "roles" in play:
@@ -143,7 +164,7 @@ class RoleNames(AnsibleLintRule):
if meta_data:
try:
return str(meta_data["galaxy_info"]["role_name"])
- except KeyError:
+ except (KeyError, TypeError):
pass
return default
@@ -151,8 +172,9 @@ class RoleNames(AnsibleLintRule):
if "pytest" in sys.modules:
import pytest
- from ansiblelint.rules import RulesCollection # pylint: disable=ungrouped-imports
- from ansiblelint.runner import Runner # pylint: disable=ungrouped-imports
+ # pylint: disable=ungrouped-imports
+ from ansiblelint.rules import RulesCollection
+ from ansiblelint.runner import Runner
@pytest.mark.parametrize(
("test_file", "failure"),
@@ -168,3 +190,44 @@ if "pytest" in sys.modules:
for result in results:
assert result.tag == "role-name[path]"
assert len(results) == failure
+
+ @pytest.mark.parametrize(
+ ("test_file", "failure"),
+ (pytest.param("examples/roles/role_with_deps_paths", 3, id="fail"),),
+ )
+ def test_role_deps_path_names(
+ default_rules_collection: RulesCollection,
+ test_file: str,
+ failure: int,
+ ) -> None:
+ """Test rule matches."""
+ results = Runner(
+ test_file,
+ rules=default_rules_collection,
+ ).run()
+ expected_errors = (
+ ("role-name[path]", 3),
+ ("role-name[path]", 9),
+ ("role-name[path]", 10),
+ )
+ assert len(expected_errors) == failure
+ for idx, result in enumerate(results):
+ assert result.tag == expected_errors[idx][0]
+ assert result.lineno == expected_errors[idx][1]
+ assert len(results) == failure
+
+ @pytest.mark.parametrize(
+ ("test_file", "failure"),
+ (pytest.param("examples/roles/test-no-deps-role", 0, id="no_deps"),),
+ )
+ def test_role_no_deps(
+ default_rules_collection: RulesCollection,
+ test_file: str,
+ failure: int,
+ ) -> None:
+ """Test role if no dependencies are present in meta/main.yml."""
+ results = Runner(
+ test_file,
+ rules=default_rules_collection,
+ ).run()
+ assert len(results) == failure
diff --git a/src/ansiblelint/rules/run_once.py b/src/ansiblelint/rules/run_once.py
index 78968b6..d656711 100644
--- a/src/ansiblelint/rules/run_once.py
+++ b/src/ansiblelint/rules/run_once.py
@@ -1,4 +1,5 @@
"""Optional Ansible-lint rule to warn use of run_once with strategy free."""
+
from __future__ import annotations
import sys
@@ -34,7 +35,7 @@ class RunOnce(AnsibleLintRule):
if not file or file.kind != "playbook" or not data:
return []
- strategy = data.get("strategy", None)
+ strategy = data.get("strategy")
run_once = data.get("run_once", False)
if (not strategy and not run_once) or strategy != "free":
return []
@@ -43,7 +44,6 @@ class RunOnce(AnsibleLintRule):
message="Play uses strategy: free",
filename=file,
tag=f"{self.id}[play]",
- # pylint: disable=protected-access
lineno=strategy._line_number, # noqa: SLF001
),
]
@@ -74,8 +74,9 @@ class RunOnce(AnsibleLintRule):
if "pytest" in sys.modules:
import pytest
- from ansiblelint.rules import RulesCollection # pylint: disable=ungrouped-imports
- from ansiblelint.runner import Runner # pylint: disable=ungrouped-imports
+ # pylint: disable=ungrouped-imports
+ from ansiblelint.rules import RulesCollection
+ from ansiblelint.runner import Runner
@pytest.mark.parametrize(
("test_file", "failure"),
diff --git a/src/ansiblelint/rules/sanity.md b/src/ansiblelint/rules/sanity.md
index 5b4f3a4..f17cdaf 100644
--- a/src/ansiblelint/rules/sanity.md
+++ b/src/ansiblelint/rules/sanity.md
@@ -1,10 +1,10 @@
# sanity
This rule checks the `tests/sanity/ignore-x.x.txt` file for disallowed ignores.
-This rule is extremely opinionated and enforced by Partner Engineering. The
+This rule is extremely opinionated and enforced by Partner Engineering as a requirement for Red Hat Certification. The
currently allowed ruleset is subject to change, but is starting at a minimal
number of allowed ignores for maximum test enforcement. Any commented-out ignore
-entries are not evaluated.
+entries are not evaluated, and ignore files for unsupported versions of ansible-core are not evaluated.
This rule can produce messages like:
@@ -29,10 +29,9 @@ Currently allowed ignores for all Ansible versions are:
- `compile-2.7!skip`
- `compile-3.5`
- `compile-3.5!skip`
-
-Additionally allowed ignores for Ansible 2.9 are:
-- `validate-modules:deprecation-mismatch`
-- `validate-modules:invalid-documentation`
+- `shellcheck`
+- `shebang`
+- `pylint:used-before-assignment`
## Problematic code
diff --git a/src/ansiblelint/rules/sanity.py b/src/ansiblelint/rules/sanity.py
index 09fe7cc..921e712 100644
--- a/src/ansiblelint/rules/sanity.py
+++ b/src/ansiblelint/rules/sanity.py
@@ -1,6 +1,8 @@
"""Implementation of sanity rule."""
+
from __future__ import annotations
+import re
import sys
from typing import TYPE_CHECKING
@@ -27,12 +29,7 @@ class CheckSanityIgnoreFiles(AnsibleLintRule):
# Partner Engineering defines this list. Please contact PE for changes.
- allowed_ignores_v2_9 = [
- "validate-modules:deprecation-mismatch", # Note: 2.9 expects a deprecated key in the METADATA. It was removed in later versions.
- "validate-modules:invalid-documentation", # Note: The removed_at_date key in the deprecated section is invalid for 2.9.
- ]
-
- allowed_ignores_all = [
+ allowed_ignores = [
"validate-modules:missing-gplv3-license",
"action-plugin-docs", # Added for Networking Collections
"import-2.6",
@@ -47,7 +44,18 @@ class CheckSanityIgnoreFiles(AnsibleLintRule):
"compile-2.7!skip",
"compile-3.5",
"compile-3.5!skip",
+ "shebang", # Unreliable test
+ "shellcheck", # Unreliable test
+ "pylint:used-before-assignment", # Unreliable test
+ ]
+
+ no_check_ignore_files = [
+ "ignore-2.9",
+ "ignore-2.10",
+ "ignore-2.11",
+ "ignore-2.12",
]
+
_ids = {
"sanity[cannot-ignore]": "Ignore file contains ... at line ..., which is not a permitted ignore.",
"sanity[bad-ignore]": "Ignore file entry at ... is formatted incorrectly. Please review.",
@@ -62,44 +70,55 @@ class CheckSanityIgnoreFiles(AnsibleLintRule):
results: list[MatchError] = []
test = ""
+ check_dirs = {
+ "plugins",
+ "roles",
+ }
+
if file.kind != "sanity-ignore-file":
return []
with file.path.open(encoding="utf-8") as ignore_file:
entries = ignore_file.read().splitlines()
- ignores = self.allowed_ignores_all
-
- # If there is a ignore-2.9.txt file, add the v2_9 list of allowed ignores
- if "ignore-2.9.txt" in str(file.abspath):
- ignores = self.allowed_ignores_all + self.allowed_ignores_v2_9
+ if any(name in str(file.abspath) for name in self.no_check_ignore_files):
+ return []
for line_num, entry in enumerate(entries, 1):
- if entry and entry[0] != "#":
- try:
- if "#" in entry:
- entry, _ = entry.split("#")
- (_, test) = entry.split()
- if test not in ignores:
+ base_ignore_dir = ""
+
+ if entry:
+ # match up to the first "/"
+ regex = re.match("[^/]*", entry)
+
+ if regex:
+ base_ignore_dir = regex.group(0)
+
+ if base_ignore_dir in check_dirs:
+ try:
+ if "#" in entry:
+ entry, _ = entry.split("#")
+ (_, test) = entry.split()
+ if test not in self.allowed_ignores:
+ results.append(
+ self.create_matcherror(
+ message=f"Ignore file contains {test} at line {line_num}, which is not a permitted ignore.",
+ tag="sanity[cannot-ignore]",
+ lineno=line_num,
+ filename=file,
+ ),
+ )
+
+ except ValueError:
results.append(
self.create_matcherror(
- message=f"Ignore file contains {test} at line {line_num}, which is not a permitted ignore.",
- tag="sanity[cannot-ignore]",
+ message=f"Ignore file entry at {line_num} is formatted incorrectly. Please review.",
+ tag="sanity[bad-ignore]",
lineno=line_num,
filename=file,
),
)
- except ValueError:
- results.append(
- self.create_matcherror(
- message=f"Ignore file entry at {line_num} is formatted incorrectly. Please review.",
- tag="sanity[bad-ignore]",
- lineno=line_num,
- filename=file,
- ),
- )
-
return results
@@ -107,8 +126,9 @@ class CheckSanityIgnoreFiles(AnsibleLintRule):
if "pytest" in sys.modules:
import pytest
- from ansiblelint.rules import RulesCollection # pylint: disable=ungrouped-imports
- from ansiblelint.runner import Runner # pylint: disable=ungrouped-imports
+ # pylint: disable=ungrouped-imports
+ from ansiblelint.rules import RulesCollection
+ from ansiblelint.runner import Runner
@pytest.mark.parametrize(
("test_file", "failures", "tags"),
diff --git a/src/ansiblelint/rules/schema.py b/src/ansiblelint/rules/schema.py
index 32ff2eb..6997acd 100644
--- a/src/ansiblelint/rules/schema.py
+++ b/src/ansiblelint/rules/schema.py
@@ -1,7 +1,9 @@
"""Rule definition for JSON Schema Validations."""
+
from __future__ import annotations
import logging
+import re
import sys
from typing import TYPE_CHECKING, Any
@@ -13,6 +15,7 @@ from ansiblelint.schemas.main import validate_file_schema
from ansiblelint.text import has_jinja
if TYPE_CHECKING:
+ from ansiblelint.config import Options
from ansiblelint.utils import Task
@@ -94,7 +97,11 @@ class ValidateSchemaRule(AnsibleLintRule):
def matchplay(self, file: Lintable, data: dict[str, Any]) -> list[MatchError]:
"""Return matches found for a specific playbook."""
results: list[MatchError] = []
- if not data or file.kind not in ("tasks", "handlers", "playbook"):
+ if (
+ not data
+ or file.kind not in ("tasks", "handlers", "playbook")
+ or file.failed()
+ ):
return results
# check at play level
results.extend(self._get_field_matches(file=file, data=data))
@@ -117,7 +124,7 @@ class ValidateSchemaRule(AnsibleLintRule):
message=msg,
lineno=data.get("__line__", 1),
lintable=file,
- rule=ValidateSchemaRule(),
+ rule=self,
details=ValidateSchemaRule.description,
tag=f"schema[{file.kind}]",
),
@@ -129,9 +136,13 @@ class ValidateSchemaRule(AnsibleLintRule):
task: Task,
file: Lintable | None = None,
) -> bool | str | MatchError | list[MatchError]:
- results = []
+ results: list[MatchError] = []
if not file:
file = Lintable("", kind="tasks")
+
+ if file.failed():
+ return results
+
results.extend(self._get_field_matches(file=file, data=task.raw_task))
for key in pre_checks["task"]:
if key in task.raw_task:
@@ -141,7 +152,7 @@ class ValidateSchemaRule(AnsibleLintRule):
MatchError(
message=msg,
lintable=file,
- rule=ValidateSchemaRule(),
+ rule=self,
details=ValidateSchemaRule.description,
tag=f"schema[{tag}]",
),
@@ -151,12 +162,15 @@ class ValidateSchemaRule(AnsibleLintRule):
def matchyaml(self, file: Lintable) -> list[MatchError]:
"""Return JSON validation errors found as a list of MatchError(s)."""
result: list[MatchError] = []
+
+ if file.failed():
+ return result
+
if file.kind not in JSON_SCHEMAS:
return result
- errors = validate_file_schema(file)
- if errors:
- if errors[0].startswith("Failed to load YAML file"):
+ for error in validate_file_schema(file):
+ if error.startswith("Failed to load YAML file"):
_logger.debug(
"Ignored failure to load %s for schema validation, as !vault may cause it.",
file,
@@ -165,13 +179,14 @@ class ValidateSchemaRule(AnsibleLintRule):
result.append(
MatchError(
- message=errors[0],
+ message=error,
lintable=file,
- rule=ValidateSchemaRule(),
+ rule=self,
details=ValidateSchemaRule.description,
tag=f"schema[{file.kind}]",
),
)
+ break
if not result:
result = super().matchyaml(file)
@@ -183,7 +198,6 @@ if "pytest" in sys.modules:
import pytest
# pylint: disable=ungrouped-imports
- from ansiblelint.config import options
from ansiblelint.rules import RulesCollection
from ansiblelint.runner import Runner
@@ -191,27 +205,30 @@ if "pytest" in sys.modules:
("file", "expected_kind", "expected"),
(
pytest.param(
- "examples/collection/galaxy.yml",
+ "examples/.collection/galaxy.yml",
"galaxy",
- ["'GPL' is not one of"],
+ [r".*'GPL' is not one of.*https://"],
id="galaxy",
),
pytest.param(
"examples/roles/invalid_requirements_schema/meta/requirements.yml",
"requirements",
- ["{'foo': 'bar'} is not valid under any of the given schemas"],
+ [
+ # r".*{'foo': 'bar'} is not valid under any of the given schemas.*https://",
+ r".*{'foo': 'bar'} is not of type 'array'.*https://",
+ ],
id="requirements",
),
pytest.param(
"examples/roles/invalid_meta_schema/meta/main.yml",
"meta",
- ["False is not of type 'string'"],
+ [r".*False is not of type 'string'.*https://"],
id="meta",
),
pytest.param(
"examples/playbooks/vars/invalid_vars_schema.yml",
"vars",
- ["'123' does not match any of the regexes"],
+ [r".* '123' does not match any of the regexes.*https://"],
id="vars",
),
pytest.param(
@@ -223,14 +240,23 @@ if "pytest" in sys.modules:
pytest.param(
"examples/ee_broken/execution-environment.yml",
"execution-environment",
- ["{'foo': 'bar'} is not valid under any of the given schemas"],
+ [
+ r".*Additional properties are not allowed \('foo' was unexpected\).*https://",
+ ],
id="execution-environment-broken",
),
- ("examples/meta/runtime.yml", "meta-runtime", []),
+ pytest.param(
+ "examples/meta/runtime.yml",
+ "meta-runtime",
+ [],
+ id="meta-runtime",
+ ),
pytest.param(
"examples/broken_collection_meta_runtime/meta/runtime.yml",
"meta-runtime",
- ["Additional properties are not allowed ('foo' was unexpected)"],
+ [
+ r".*Additional properties are not allowed \('foo' was unexpected\).*https://",
+ ],
id="meta-runtime-broken",
),
pytest.param(
@@ -242,7 +268,9 @@ if "pytest" in sys.modules:
pytest.param(
"examples/inventory/broken_dev_inventory.yml",
"inventory",
- ["Additional properties are not allowed ('foo' was unexpected)"],
+ [
+ r".*Additional properties are not allowed \('foo' was unexpected\).*https://",
+ ],
id="inventory-broken",
),
pytest.param(
@@ -260,7 +288,17 @@ if "pytest" in sys.modules:
pytest.param(
"examples/broken/.ansible-lint",
"ansible-lint-config",
- ["Additional properties are not allowed ('foo' was unexpected)"],
+ [
+ r".*Additional properties are not allowed \('foo' was unexpected\).*https://",
+ ],
+ id="ansible-lint-config-broken",
+ ),
+ pytest.param(
+ "examples/broken_supported_ansible_also/.ansible-lint",
+ "ansible-lint-config",
+ [
+ r".*supported_ansible_also True is not of type 'array'.*https://",
+ ],
id="ansible-lint-config-broken",
),
pytest.param(
@@ -272,7 +310,9 @@ if "pytest" in sys.modules:
pytest.param(
"examples/broken/ansible-navigator.yml",
"ansible-navigator-config",
- ["Additional properties are not allowed ('ansible' was unexpected)"],
+ [
+ r".*Additional properties are not allowed \('ansible' was unexpected\).*https://",
+ ],
id="ansible-navigator-config-broken",
),
pytest.param(
@@ -284,20 +324,25 @@ if "pytest" in sys.modules:
pytest.param(
"examples/roles/broken_argument_specs/meta/argument_specs.yml",
"role-arg-spec",
- ["Additional properties are not allowed ('foo' was unexpected)"],
+ [
+ r".*Additional properties are not allowed \('foo' was unexpected\).*https://",
+ ],
id="role-arg-spec-broken",
),
pytest.param(
"examples/changelogs/changelog.yaml",
"changelog",
- ["Additional properties are not allowed ('foo' was unexpected)"],
+ [
+ r".*Additional properties are not allowed \('foo' was unexpected\).*https://",
+ ],
id="changelog",
),
pytest.param(
"examples/rulebooks/rulebook-fail.yml",
"rulebook",
[
- "Additional properties are not allowed ('that_should_not_be_here' was unexpected)",
+ # r".*Additional properties are not allowed \('that_should_not_be_here' was unexpected\).*https://",
+ r".*'sss' is not of type 'object'.*https://",
],
id="rulebook",
),
@@ -324,19 +369,24 @@ if "pytest" in sys.modules:
),
),
)
- def test_schema(file: str, expected_kind: str, expected: list[str]) -> None:
+ def test_schema(
+ file: str,
+ expected_kind: str,
+ expected: list[str],
+ config_options: Options,
+ ) -> None:
"""Validate parsing of ansible output."""
lintable = Lintable(file)
assert lintable.kind == expected_kind
- rules = RulesCollection(options=options)
+ rules = RulesCollection(options=config_options)
rules.register(ValidateSchemaRule())
results = Runner(lintable, rules=rules).run()
assert len(results) == len(expected), results
for idx, result in enumerate(results):
assert result.filename.endswith(file)
- assert expected[idx] in result.message
+ assert re.match(expected[idx], result.message)
assert result.tag == f"schema[{expected_kind}]"
@pytest.mark.parametrize(
@@ -356,12 +406,13 @@ if "pytest" in sys.modules:
expected_kind: str,
expected_tag: str,
count: int,
+ config_options: Options,
) -> None:
"""Validate ability to detect schema[moves]."""
lintable = Lintable(file)
assert lintable.kind == expected_kind
- rules = RulesCollection(options=options)
+ rules = RulesCollection(options=config_options)
rules.register(ValidateSchemaRule())
results = Runner(lintable, rules=rules).run()
diff --git a/src/ansiblelint/rules/syntax_check.md b/src/ansiblelint/rules/syntax_check.md
index e8197a5..566fa33 100644
--- a/src/ansiblelint/rules/syntax_check.md
+++ b/src/ansiblelint/rules/syntax_check.md
@@ -9,7 +9,7 @@ You can exclude these files from linting, but it is better to make sure they can
be loaded by Ansible. This is often achieved by editing the inventory file
and/or `ansible.cfg` so ansible can load required variables.
-If undefined variables cause the failure, you can use the jinja `default()`
+If undefined variables cause the failure, you can use the Jinja `default()`
filter to provide fallback values, like in the example below.
This rule is among the few `unskippable` rules that cannot be added to
@@ -20,9 +20,32 @@ fixtures that are invalid on purpose.
One of the most common sources of errors is a failure to assert the presence of
various variables at the beginning of the playbook.
-This rule can produce messages like below:
+This rule can produce messages like:
-- `syntax-check[empty-playbook]` is raised when a playbook file has no content.
+- `syntax-check[empty-playbook]`: Empty playbook, nothing to do
+- `syntax-check[malformed]`: A malformed block was encountered while loading a block
+- `syntax-check[missing-file]`: Unable to retrieve file contents ... Could not find or access ...
+- `syntax-check[unknown-module]`: couldn't resolve module/action
+- `syntax-check[specific]`: for other errors not mentioned above.
+
+## syntax-check[unknown-module]
+
+The linter relies on ansible-core code to load the ansible code and it will
+produce a syntax error if the code refers to ansible content that is not
+installed. You must ensure that all collections and roles used inside your
+repository are listed inside a [`requirements.yml`](https://docs.ansible.com/ansible/latest/galaxy/user_guide.html#installing-roles-and-collections-from-the-same-requirements-yml-file) file, so the linter can
+install them when they are missing.
+
+Valid location for `requirements.yml` are:
+
+- `requirements.yml`
+- `roles/requirements.yml`
+- `collections/requirements.yml`
+- `tests/requirements.yml`
+- `tests/integration/requirements.yml`
+- `tests/unit/requirements.yml`
+
+Note: If requirements are test related then they should be inside `tests/`.
## Problematic code
diff --git a/src/ansiblelint/rules/syntax_check.py b/src/ansiblelint/rules/syntax_check.py
index c6a4c5e..9b072f6 100644
--- a/src/ansiblelint/rules/syntax_check.py
+++ b/src/ansiblelint/rules/syntax_check.py
@@ -1,4 +1,5 @@
"""Rule definition for ansible syntax check."""
+
from __future__ import annotations
import re
@@ -15,6 +16,8 @@ class KnownError:
regex: re.Pattern[str]
+# Order matters, we only report the first matching pattern, the one at the end
+# is used to match generic or less specific patterns.
OUTPUT_PATTERNS = (
KnownError(
tag="missing-file",
@@ -25,9 +28,9 @@ OUTPUT_PATTERNS = (
),
),
KnownError(
- tag="specific",
+ tag="no-file",
regex=re.compile(
- r"^ERROR! (?P<title>[^\n]*)\n\nThe error appears to be in '(?P<filename>[\w\/\.\-]+)': line (?P<line>\d+), column (?P<column>\d+)",
+ r"^ERROR! (?P<title>No file specified for [^\n]*)",
re.MULTILINE | re.S | re.DOTALL,
),
),
@@ -45,6 +48,28 @@ OUTPUT_PATTERNS = (
re.MULTILINE | re.S | re.DOTALL,
),
),
+ KnownError(
+ tag="unknown-module",
+ regex=re.compile(
+ r"^ERROR! (?P<title>couldn't resolve module/action [^\n]*)\n\nThe error appears to be in '(?P<filename>[\w\/\.\-]+)': line (?P<line>\d+), column (?P<column>\d+)",
+ re.MULTILINE | re.S | re.DOTALL,
+ ),
+ ),
+ KnownError(
+ tag="specific",
+ regex=re.compile(
+ r"^ERROR! (?P<title>[^\n]*)\n\nThe error appears to be in '(?P<filename>[\w\/\.\-]+)': line (?P<line>\d+), column (?P<column>\d+)",
+ re.MULTILINE | re.S | re.DOTALL,
+ ),
+ ),
+ # "ERROR! the role 'this_role_is_missing' was not found in ROLE_INCLUDE_PATHS\n\nThe error appears to be in 'FILE_PATH': line 5, column 7, but may\nbe elsewhere in the file depending on the exact syntax problem.\n\nThe offending line appears to be:\n\n roles:\n - this_role_is_missing\n ^ here\n"
+ KnownError(
+ tag="specific",
+ regex=re.compile(
+ r"^ERROR! (?P<title>the role '.*' was not found in[^\n]*)'(?P<filename>[\w\/\.\-]+)': line (?P<line>\d+), column (?P<column>\d+)",
+ re.MULTILINE | re.S | re.DOTALL,
+ ),
+ ),
)
diff --git a/src/ansiblelint/rules/var_naming.md b/src/ansiblelint/rules/var_naming.md
index 3386a0c..e4034f0 100644
--- a/src/ansiblelint/rules/var_naming.md
+++ b/src/ansiblelint/rules/var_naming.md
@@ -22,7 +22,7 @@ Possible errors messages:
- `var-naming[no-jinja]`: Variables names must not contain jinja2 templating.
- `var-naming[pattern]`: Variables names should match ... regex.
- `var-naming[no-role-prefix]`: Variables names from within roles should use
- `role_name_` as a prefix.
+ `role_name_` as a prefix. Underlines are accepted before the prefix.
- `var-naming[no-reserved]`: Variables names must not be Ansible reserved names.
- `var-naming[read-only]`: This special variable is read-only.
diff --git a/src/ansiblelint/rules/var_naming.py b/src/ansiblelint/rules/var_naming.py
index 389530d..14a4c40 100644
--- a/src/ansiblelint/rules/var_naming.py
+++ b/src/ansiblelint/rules/var_naming.py
@@ -1,4 +1,5 @@
"""Implementation of var-naming rule."""
+
from __future__ import annotations
import keyword
@@ -9,13 +10,19 @@ from typing import TYPE_CHECKING, Any
from ansible.parsing.yaml.objects import AnsibleUnicode
from ansible.vars.reserved import get_reserved_names
-from ansiblelint.config import options
-from ansiblelint.constants import ANNOTATION_KEYS, LINE_NUMBER_KEY, RC
+from ansiblelint.config import Options, options
+from ansiblelint.constants import (
+ ANNOTATION_KEYS,
+ LINE_NUMBER_KEY,
+ PLAYBOOK_ROLE_KEYWORDS,
+ RC,
+)
from ansiblelint.errors import MatchError
from ansiblelint.file_utils import Lintable
from ansiblelint.rules import AnsibleLintRule, RulesCollection
from ansiblelint.runner import Runner
from ansiblelint.skip_utils import get_rule_skips_from_line
+from ansiblelint.text import has_jinja, is_fqcn_or_name
from ansiblelint.utils import parse_yaml_from_file
if TYPE_CHECKING:
@@ -160,10 +167,15 @@ class VariableNamingRule(AnsibleLintRule):
rule=self,
)
- if prefix and not ident.startswith(f"{prefix}_"):
+ if (
+ prefix
+ and not ident.lstrip("_").startswith(f"{prefix}_")
+ and not has_jinja(prefix)
+ and is_fqcn_or_name(prefix)
+ ):
return MatchError(
tag="var-naming[no-role-prefix]",
- message="Variables names from within roles should use role_name_ as a prefix.",
+ message=f"Variables names from within roles should use {prefix}_ as a prefix.",
rule=self,
)
return None
@@ -187,6 +199,37 @@ class VariableNamingRule(AnsibleLintRule):
else our_vars[LINE_NUMBER_KEY]
)
raw_results.append(match_error)
+ roles = data.get("roles", [])
+ for role in roles:
+ if isinstance(role, AnsibleUnicode):
+ continue
+ role_fqcn = role.get("role", role.get("name"))
+ prefix = role_fqcn.split("/" if "/" in role_fqcn else ".")[-1]
+ for key in list(role.keys()):
+ if key not in PLAYBOOK_ROLE_KEYWORDS:
+ match_error = self.get_var_naming_matcherror(key, prefix=prefix)
+ if match_error:
+ match_error.filename = str(file.path)
+ match_error.message += f" (vars: {key})"
+ match_error.lineno = (
+ key.ansible_pos[1]
+ if isinstance(key, AnsibleUnicode)
+ else role[LINE_NUMBER_KEY]
+ )
+ raw_results.append(match_error)
+
+ our_vars = role.get("vars", {})
+ for key in our_vars:
+ match_error = self.get_var_naming_matcherror(key, prefix=prefix)
+ if match_error:
+ match_error.filename = str(file.path)
+ match_error.message += f" (vars: {key})"
+ match_error.lineno = (
+ key.ansible_pos[1]
+ if isinstance(key, AnsibleUnicode)
+ else our_vars[LINE_NUMBER_KEY]
+ )
+ raw_results.append(match_error)
if raw_results:
lines = file.content.splitlines()
for match in raw_results:
@@ -266,7 +309,8 @@ class VariableNamingRule(AnsibleLintRule):
if str(file.kind) == "vars" and file.data:
meta_data = parse_yaml_from_file(str(file.path))
for key in meta_data:
- match_error = self.get_var_naming_matcherror(key)
+ prefix = file.role if file.role else ""
+ match_error = self.get_var_naming_matcherror(key, prefix=prefix)
if match_error:
match_error.filename = filename
match_error.lineno = key.ansible_pos[1]
@@ -298,13 +342,21 @@ if "pytest" in sys.modules:
@pytest.mark.parametrize(
("file", "expected"),
(
- pytest.param("examples/playbooks/rule-var-naming-fail.yml", 7, id="0"),
+ pytest.param(
+ "examples/playbooks/var-naming/rule-var-naming-fail.yml",
+ 7,
+ id="0",
+ ),
pytest.param("examples/Taskfile.yml", 0, id="1"),
),
)
- def test_invalid_var_name_playbook(file: str, expected: int) -> None:
+ def test_invalid_var_name_playbook(
+ file: str,
+ expected: int,
+ config_options: Options,
+ ) -> None:
"""Test rule matches."""
- rules = RulesCollection(options=options)
+ rules = RulesCollection(options=config_options)
rules.register(VariableNamingRule())
results = Runner(Lintable(file), rules=rules).run()
assert len(results) == expected
@@ -337,6 +389,40 @@ if "pytest" in sys.modules:
assert result.tag == expected_errors[idx][0]
assert result.lineno == expected_errors[idx][1]
+ def test_var_naming_with_role_prefix(
+ default_rules_collection: RulesCollection,
+ ) -> None:
+ """Test rule matches."""
+ results = Runner(
+ Lintable("examples/roles/role_vars_prefix_detection"),
+ rules=default_rules_collection,
+ ).run()
+ assert len(results) == 2
+ for result in results:
+ assert result.tag == "var-naming[no-role-prefix]"
+
+ def test_var_naming_with_role_prefix_plays(
+ default_rules_collection: RulesCollection,
+ ) -> None:
+ """Test rule matches."""
+ results = Runner(
+ Lintable("examples/playbooks/role_vars_prefix_detection.yml"),
+ rules=default_rules_collection,
+ exclude_paths=["examples/roles/role_vars_prefix_detection"],
+ ).run()
+ expected_errors = (
+ ("var-naming[no-role-prefix]", 9),
+ ("var-naming[no-role-prefix]", 12),
+ ("var-naming[no-role-prefix]", 15),
+ ("var-naming[no-role-prefix]", 25),
+ ("var-naming[no-role-prefix]", 32),
+ ("var-naming[no-role-prefix]", 45),
+ )
+ assert len(results) == len(expected_errors)
+ for idx, result in enumerate(results):
+ assert result.tag == expected_errors[idx][0]
+ assert result.lineno == expected_errors[idx][1]
+
def test_var_naming_with_pattern() -> None:
"""Test rule matches."""
role_path = "examples/roles/var_naming_pattern/tasks/main.yml"
@@ -364,7 +450,7 @@ if "pytest" in sys.modules:
def test_var_naming_with_include_role_import_role() -> None:
"""Test with include role and import role."""
- role_path = "examples/test_collection/roles/my_role/tasks/main.yml"
+ role_path = "examples/.test_collection/roles/my_role/tasks/main.yml"
result = run_ansible_lint(role_path)
assert result.returncode == RC.SUCCESS
assert "var-naming" not in result.stdout
diff --git a/src/ansiblelint/rules/yaml.md b/src/ansiblelint/rules/yaml.md
index 8dc56eb..654f80e 100644
--- a/src/ansiblelint/rules/yaml.md
+++ b/src/ansiblelint/rules/yaml.md
@@ -1,6 +1,8 @@
# yaml
-This rule checks YAML syntax and is an implementation of `yamllint`.
+This rule checks YAML syntax by using [yamllint] library but with a
+[specific default configuration](#yamllint-configuration), one that is
+compatible with both, our internal reformatter (`--fix`) and also [prettier].
You can disable YAML syntax violations by adding `yaml` to the `skip_list` in
your Ansible-lint configuration as follows:
@@ -53,6 +55,7 @@ Some of the detailed error codes that you might see are:
- `yaml[empty-lines]` - _too many blank lines (...> ...)_
- `yaml[indentation]` - _Wrong indentation: expected ... but found ..._
- `yaml[key-duplicates]` - _Duplication of key "..." in mapping_
+- `yaml[line-length]` - _Line too long (... > ... characters)_
- `yaml[new-line-at-end-of-file]` - _No new line character at the end of file_
- `yaml[octal-values]`: forbidden implicit or explicit [octal](#octals) value
- `yaml[syntax]` - YAML syntax is broken
@@ -72,6 +75,13 @@ for it does check these. If for some reason, you do not want to follow our
defaults, you can create a `.yamllint` file in your project and this will take
precedence over our defaults.
+## Additional Information for Multiline Strings
+
+Adhering to yaml[line-length] rule, for writing multiline strings we recommend
+using Block Style Indicator: literal style indicated by a pipe (|) or folded
+style indicated by a right angle bracket (>), instead of escaping the newlines
+with backslashes. Reference [guide] for writing multiple line strings in yaml.
+
## Problematic code
```yaml
@@ -91,7 +101,53 @@ foo2: "0o777" # <-- Explicitly quoting octal is less risky.
bar: ... # Correct comment indentation.
```
+## Yamllint configuration
+
+If you decide to add a custom yamllint config to your project, ansible-lint
+might refuse to run if it detects that some of your options are incompatible and
+ask you to correct them. When this happens, you will see a message like the one
+below:
+
+```
+CRITICAL Found incompatible custom yamllint configuration (.yamllint), please either remove the file or edit it to comply with:
+ - comments.min-spaces-from-content must be 1
+ - braces.min-spaces-inside must be 0
+ - braces.max-spaces-inside must be 1
+ - octal-values.forbid-implicit-octal must be true
+ - octal-values.forbid-explicit-octal must be true
+
+Read https://ansible.readthedocs.io/projects/lint/rules/yaml/ for more details regarding why we have these requirements.
+```
+
+!!! warning
+
+ [Auto-fix](../autofix.md) functionality will change **inline comment indentation to one
+ character instead of two**, which is the default of [yamllint]. The reason
+ for this decision was to keep reformatting compatibility
+ with [prettier], which is the most popular reformatter.
+
+ ```yaml title=".yamllint"
+ rules:
+ comments:
+ min-spaces-from-content: 1 # prettier compatibility
+ ```
+
+ There is no need to create this yamllint config file, but if you also
+ run yamllint yourself, you might want to create it to make it behave
+ the same way as ansible-lint.
+
+Below you can find the default yamllint configuration that our linter will use
+when there is no custom file present.
+
+```yaml
+{!../src/ansiblelint/data/.yamllint!}
+```
+
[1.1]: https://yaml.org/spec/1.1/
[1.2.0]: https://yaml.org/spec/1.2.0/
[1.2.2]: https://yaml.org/spec/1.2.2/
[yaml specification]: https://yaml.org/
+[guide]:
+ https://docs.ansible.com/ansible/latest/reference_appendices/YAMLSyntax.html#yaml-basics
+[prettier]: https://prettier.io/
+[yamllint]: https://yamllint.readthedocs.io/en/stable/
diff --git a/src/ansiblelint/rules/yaml_rule.py b/src/ansiblelint/rules/yaml_rule.py
index 4da4d41..3ec5b59 100644
--- a/src/ansiblelint/rules/yaml_rule.py
+++ b/src/ansiblelint/rules/yaml_rule.py
@@ -1,27 +1,29 @@
"""Implementation of yaml linting rule (yamllint integration)."""
+
from __future__ import annotations
import logging
import sys
-from collections.abc import Iterable
+from collections.abc import Iterable, MutableMapping, MutableSequence
from typing import TYPE_CHECKING
from yamllint.linter import run as run_yamllint
from ansiblelint.constants import LINE_NUMBER_KEY, SKIPPED_RULES_KEY
from ansiblelint.file_utils import Lintable
-from ansiblelint.rules import AnsibleLintRule
+from ansiblelint.rules import AnsibleLintRule, TransformMixin
from ansiblelint.yaml_utils import load_yamllint_config
if TYPE_CHECKING:
from typing import Any
+ from ansiblelint.config import Options
from ansiblelint.errors import MatchError
_logger = logging.getLogger(__name__)
-class YamllintRule(AnsibleLintRule):
+class YamllintRule(AnsibleLintRule, TransformMixin):
"""Violations reported by yamllint."""
id = "yaml"
@@ -73,6 +75,12 @@ class YamllintRule(AnsibleLintRule):
self.severity = "VERY_LOW"
if problem.level == "error":
self.severity = "MEDIUM"
+ # Ignore truthy violation with github workflows ("on:" keys)
+ if problem.rule == "truthy" and file.path.parent.parts[-2:] == (
+ ".github",
+ "workflows",
+ ):
+ continue
matches.append(
self.create_matcherror(
# yamllint does return lower-case sentences
@@ -85,6 +93,22 @@ class YamllintRule(AnsibleLintRule):
)
return matches
+ def transform(
+ self: YamllintRule,
+ match: MatchError,
+ lintable: Lintable,
+ data: MutableMapping[str, Any] | MutableSequence[Any] | str,
+ ) -> None:
+ """Transform yaml.
+
+ :param match: MatchError instance
+ :param lintable: Lintable instance
+ :param data: data to transform
+ """
+ # This method does nothing because the YAML reformatting is implemented
+ # in data dumper. Still presence of this method helps us with
+ # documentation generation.
+
def _combine_skip_rules(data: Any) -> set[str]:
"""Return a consolidated list of skipped rules."""
@@ -107,7 +131,7 @@ def _fetch_skips(data: Any, collector: dict[int, set[str]]) -> dict[int, set[str
collector[data.get(LINE_NUMBER_KEY)].update(rules)
if isinstance(data, Iterable) and not isinstance(data, str):
if isinstance(data, dict):
- for _entry, value in data.items():
+ for value in data.values():
_fetch_skips(value, collector)
else: # must be some kind of list
for entry in data:
@@ -128,7 +152,6 @@ if "pytest" in sys.modules:
import pytest
# pylint: disable=ungrouped-imports
- from ansiblelint.config import options
from ansiblelint.rules import RulesCollection
from ansiblelint.runner import Runner
@@ -180,15 +203,26 @@ if "pytest" in sys.modules:
[],
id="rule-yaml-pass",
),
+ pytest.param(
+ "examples/yamllint/.github/workflows/ci.yml",
+ "yaml",
+ [],
+ id="rule-yaml-github-workflow",
+ ),
),
)
@pytest.mark.filterwarnings("ignore::ansible_compat.runtime.AnsibleWarning")
- def test_yamllint(file: str, expected_kind: str, expected: list[str]) -> None:
+ def test_yamllint(
+ file: str,
+ expected_kind: str,
+ expected: list[str],
+ config_options: Options,
+ ) -> None:
"""Validate parsing of ansible output."""
lintable = Lintable(file)
assert lintable.kind == expected_kind
- rules = RulesCollection(options=options)
+ rules = RulesCollection(options=config_options)
rules.register(YamllintRule())
results = Runner(lintable, rules=rules).run()
diff --git a/src/ansiblelint/runner.py b/src/ansiblelint/runner.py
index 9d3500d..f487329 100644
--- a/src/ansiblelint/runner.py
+++ b/src/ansiblelint/runner.py
@@ -1,8 +1,10 @@
"""Runner implementation."""
+
from __future__ import annotations
import json
import logging
+import math
import multiprocessing
import multiprocessing.pool
import os
@@ -12,7 +14,9 @@ import tempfile
import warnings
from dataclasses import dataclass
from fnmatch import fnmatch
+from functools import cache
from pathlib import Path
+from tempfile import NamedTemporaryFile
from typing import TYPE_CHECKING, Any
from ansible.errors import AnsibleError
@@ -23,31 +27,24 @@ from ansible_compat.runtime import AnsibleWarning
import ansiblelint.skip_utils
import ansiblelint.utils
-from ansiblelint._internal.rules import (
- BaseRule,
- LoadingFailureRule,
- RuntimeErrorRule,
- WarningRule,
-)
from ansiblelint.app import App, get_app
from ansiblelint.constants import States
from ansiblelint.errors import LintWarning, MatchError, WarnSource
from ansiblelint.file_utils import Lintable, expand_dirs_in_lintables
from ansiblelint.logger import timed_info
-from ansiblelint.rules.syntax_check import OUTPUT_PATTERNS, AnsibleSyntaxCheckRule
+from ansiblelint.rules.syntax_check import OUTPUT_PATTERNS
from ansiblelint.text import strip_ansi_escape
from ansiblelint.utils import (
PLAYBOOK_DIR,
- _include_children,
- _roles_children,
- _taskshandlers_children,
+ HandleChildren,
+ parse_examples_from_plugin,
template,
)
if TYPE_CHECKING:
- from collections.abc import Generator
- from typing import Callable
+ from collections.abc import Callable, Generator
+ from ansiblelint._internal.rules import BaseRule
from ansiblelint.config import Options
from ansiblelint.constants import FileType
from ansiblelint.rules import RulesCollection
@@ -77,11 +74,13 @@ class Runner:
verbosity: int = 0,
checked_files: set[Lintable] | None = None,
project_dir: str | None = None,
+ _skip_ansible_syntax_check: bool = False,
) -> None:
"""Initialize a Runner instance."""
self.rules = rules
self.lintables: set[Lintable] = set()
self.project_dir = os.path.abspath(project_dir) if project_dir else None
+ self.skip_ansible_syntax_check = _skip_ansible_syntax_check
if skip_list is None:
skip_list = []
@@ -107,6 +106,8 @@ class Runner:
checked_files = set()
self.checked_files = checked_files
+ self.app = get_app(cached=True)
+
def _update_exclude_paths(self, exclude_paths: list[str]) -> None:
if exclude_paths:
# These will be (potentially) relative paths
@@ -172,19 +173,19 @@ class Runner:
if isinstance(warn.source, WarnSource):
match = MatchError(
message=warn.source.message or warn.category.__name__,
- rule=WarningRule(),
- filename=warn.source.filename.filename,
+ rule=self.rules["warning"],
+ lintable=Lintable(warn.source.filename.filename),
tag=warn.source.tag,
lineno=warn.source.lineno,
)
else:
filename = warn.source
match = MatchError(
- message=warn.message
- if isinstance(warn.message, str)
- else "?",
- rule=WarningRule(),
- filename=str(filename),
+ message=(
+ warn.message if isinstance(warn.message, str) else "?"
+ ),
+ rule=self.rules["warning"],
+ lintable=Lintable(str(filename)),
)
matches.append(match)
continue
@@ -215,7 +216,7 @@ class Runner:
lintable=lintable,
message=str(lintable.exc),
details=str(lintable.exc.__cause__),
- rule=LoadingFailureRule(),
+ rule=self.rules["load-failure"],
tag=f"load-failure[{lintable.exc.__class__.__name__.lower()}]",
),
)
@@ -226,60 +227,63 @@ class Runner:
MatchError(
lintable=lintable,
message="File or directory not found.",
- rule=LoadingFailureRule(),
+ rule=self.rules["load-failure"],
tag="load-failure[not-found]",
),
)
# -- phase 1 : syntax check in parallel --
- app = get_app(offline=True)
+ if not self.skip_ansible_syntax_check:
+ # app = get_app(cached=True)
- def worker(lintable: Lintable) -> list[MatchError]:
- # pylint: disable=protected-access
- return self._get_ansible_syntax_check_matches(
- lintable=lintable,
- app=app,
- )
+ def worker(lintable: Lintable) -> list[MatchError]:
+ return self._get_ansible_syntax_check_matches(
+ lintable=lintable,
+ app=self.app,
+ )
- for lintable in self.lintables:
- if lintable.kind not in ("playbook", "role") or lintable.stop_processing:
- continue
- files.append(lintable)
+ for lintable in self.lintables:
+ if (
+ lintable.kind not in ("playbook", "role")
+ or lintable.stop_processing
+ ):
+ continue
+ files.append(lintable)
- # avoid resource leak warning, https://github.com/python/cpython/issues/90549
- # pylint: disable=unused-variable
- global_resource = multiprocessing.Semaphore() # noqa: F841
+ # avoid resource leak warning, https://github.com/python/cpython/issues/90549
+ # pylint: disable=unused-variable
+ global_resource = multiprocessing.Semaphore() # noqa: F841
- pool = multiprocessing.pool.ThreadPool(processes=multiprocessing.cpu_count())
- return_list = pool.map(worker, files, chunksize=1)
- pool.close()
- pool.join()
- for data in return_list:
- matches.extend(data)
+ pool = multiprocessing.pool.ThreadPool(processes=threads())
+ return_list = pool.map(worker, files, chunksize=1)
+ pool.close()
+ pool.join()
+ for data in return_list:
+ matches.extend(data)
+
+ matches = self._filter_excluded_matches(matches)
- matches = self._filter_excluded_matches(matches)
# -- phase 2 ---
- if not matches:
- # do our processing only when ansible syntax check passed in order
- # to avoid causing runtime exceptions. Our processing is not as
- # resilient to be able process garbage.
- matches.extend(self._emit_matches(files))
+ # do our processing only when ansible syntax check passed in order
+ # to avoid causing runtime exceptions. Our processing is not as
+ # resilient to be able process garbage.
+ matches.extend(self._emit_matches(files))
- # remove duplicates from files list
- files = [value for n, value in enumerate(files) if value not in files[:n]]
+ # remove duplicates from files list
+ files = [value for n, value in enumerate(files) if value not in files[:n]]
- for file in self.lintables:
- if file in self.checked_files or not file.kind:
- continue
- _logger.debug(
- "Examining %s of type %s",
- ansiblelint.file_utils.normpath(file.path),
- file.kind,
- )
+ for file in self.lintables:
+ if file in self.checked_files or not file.kind or file.failed():
+ continue
+ _logger.debug(
+ "Examining %s of type %s",
+ ansiblelint.file_utils.normpath(file.path),
+ file.kind,
+ )
- matches.extend(
- self.rules.run(file, tags=set(self.tags), skip_list=self.skip_list),
- )
+ matches.extend(
+ self.rules.run(file, tags=set(self.tags), skip_list=self.skip_list),
+ )
# update list of checked files
self.checked_files.update(self.lintables)
@@ -296,7 +300,12 @@ class Runner:
app: App,
) -> list[MatchError]:
"""Run ansible syntax check and return a list of MatchError(s)."""
- default_rule: BaseRule = AnsibleSyntaxCheckRule()
+ try:
+ default_rule: BaseRule = self.rules["syntax-check"]
+ except ValueError:
+ # if syntax-check is not loaded, we do not perform any syntax check,
+ # that might happen during testing
+ return []
fh = None
results = []
if lintable.kind not in ("playbook", "role"):
@@ -341,6 +350,9 @@ class Runner:
# https://github.com/paramiko/paramiko/issues/2038
env = app.runtime.environ.copy()
env["PYTHONWARNINGS"] = "ignore"
+ # Avoid execution failure if user customized any_unparsed_is_failed setting
+ # https://github.com/ansible/ansible-lint/issues/3650
+ env["ANSIBLE_INVENTORY_ANY_UNPARSED_IS_FAILED"] = "False"
run = subprocess.run(
cmd,
@@ -357,6 +369,7 @@ class Runner:
filename = lintable
lineno = 1
column = None
+ ignore_rc = False
stderr = strip_ansi_escape(run.stderr)
stdout = strip_ansi_escape(run.stdout)
@@ -376,25 +389,40 @@ class Runner:
details = groups.get("details", "")
lineno = int(groups.get("line", 1))
- if "filename" in groups:
+ if (
+ "filename" in groups
+ and str(lintable.path.absolute()) != groups["filename"]
+ and lintable.filename != groups["filename"]
+ ):
+ # avoids creating a new lintable object if the filename
+ # is matching as this might prevent Lintable.failed()
+ # feature from working well.
filename = Lintable(groups["filename"])
else:
filename = lintable
column = int(groups.get("column", 1))
- results.append(
- MatchError(
- message=title,
- lintable=filename,
- lineno=lineno,
- column=column,
- rule=rule,
- details=details,
- tag=f"{rule.id}[{pattern.tag}]",
- ),
- )
- if not results:
- rule = RuntimeErrorRule()
+ if (
+ pattern.tag in ("unknown-module", "specific")
+ and app.options.nodeps
+ ):
+ ignore_rc = True
+ else:
+ results.append(
+ MatchError(
+ message=title,
+ lintable=filename,
+ lineno=lineno,
+ column=column,
+ rule=rule,
+ details=details,
+ tag=f"{rule.id}[{pattern.tag}]",
+ ),
+ )
+ break
+
+ if not results and not ignore_rc:
+ rule = self.rules["internal-error"]
message = (
f"Unexpected error code {run.returncode} from "
f"execution of: {' '.join(cmd)}"
@@ -427,6 +455,9 @@ class Runner:
visited: set[Lintable] = set()
while visited != self.lintables:
for lintable in self.lintables - visited:
+ visited.add(lintable)
+ if not lintable.path.exists():
+ continue
try:
children = self.find_children(lintable)
for child in children:
@@ -437,11 +468,13 @@ class Runner:
except MatchError as exc:
if not exc.filename: # pragma: no branch
exc.filename = str(lintable.path)
- exc.rule = LoadingFailureRule()
+ exc.rule = self.rules["load-failure"]
yield exc
except AttributeError:
- yield MatchError(lintable=lintable, rule=LoadingFailureRule())
- visited.add(lintable)
+ yield MatchError(
+ lintable=lintable,
+ rule=self.rules["load-failure"],
+ )
def find_children(self, lintable: Lintable) -> list[Lintable]:
"""Traverse children of a single file or folder."""
@@ -452,21 +485,25 @@ class Runner:
add_all_plugin_dirs(playbook_dir or ".")
if lintable.kind == "role":
playbook_ds = AnsibleMapping({"roles": [{"role": str(lintable.path)}]})
+ elif lintable.kind == "plugin":
+ return self.plugin_children(lintable)
elif lintable.kind not in ("playbook", "tasks"):
return []
else:
try:
playbook_ds = ansiblelint.utils.parse_yaml_from_file(str(lintable.path))
except AnsibleError as exc:
- raise SystemExit(exc) from exc
+ msg = f"Loading {lintable.filename} caused an {type(exc).__name__} exception: {exc}, file was ignored."
+ logging.exception(msg)
+ return []
results = []
# playbook_ds can be an AnsibleUnicode string, which we consider invalid
if isinstance(playbook_ds, str):
- raise MatchError(lintable=lintable, rule=LoadingFailureRule())
+ raise MatchError(lintable=lintable, rule=self.rules["load-failure"])
for item in ansiblelint.utils.playbook_items(playbook_ds):
# if lintable.kind not in ["playbook"]:
for child in self.play_children(
- lintable.path.parent,
+ lintable,
item,
lintable.kind,
playbook_dir,
@@ -487,35 +524,40 @@ class Runner:
if path != path_str:
child.path = Path(path)
child.name = child.path.name
-
results.append(child)
return results
def play_children(
self,
- basedir: Path,
+ lintable: Lintable,
item: tuple[str, Any],
parent_type: FileType,
playbook_dir: str,
) -> list[Lintable]:
"""Flatten the traversed play tasks."""
# pylint: disable=unused-argument
- delegate_map: dict[str, Callable[[str, Any, Any, FileType], list[Lintable]]] = {
- "tasks": _taskshandlers_children,
- "pre_tasks": _taskshandlers_children,
- "post_tasks": _taskshandlers_children,
- "block": _taskshandlers_children,
- "include": _include_children,
- "ansible.builtin.include": _include_children,
- "import_playbook": _include_children,
- "ansible.builtin.import_playbook": _include_children,
- "roles": _roles_children,
- "dependencies": _roles_children,
- "handlers": _taskshandlers_children,
- "include_tasks": _include_children,
- "ansible.builtin.include_tasks": _include_children,
- "import_tasks": _include_children,
- "ansible.builtin.import_tasks": _include_children,
+ basedir = lintable.path.parent
+ handlers = HandleChildren(self.rules, app=self.app)
+
+ delegate_map: dict[
+ str,
+ Callable[[Lintable, Any, Any, FileType], list[Lintable]],
+ ] = {
+ "tasks": handlers.taskshandlers_children,
+ "pre_tasks": handlers.taskshandlers_children,
+ "post_tasks": handlers.taskshandlers_children,
+ "block": handlers.taskshandlers_children,
+ "include": handlers.include_children,
+ "ansible.builtin.include": handlers.include_children,
+ "import_playbook": handlers.include_children,
+ "ansible.builtin.import_playbook": handlers.include_children,
+ "roles": handlers.roles_children,
+ "dependencies": handlers.roles_children,
+ "handlers": handlers.taskshandlers_children,
+ "include_tasks": handlers.include_children,
+ "ansible.builtin.include_tasks": handlers.include_children,
+ "import_tasks": handlers.include_children,
+ "ansible.builtin.import_tasks": handlers.include_children,
}
(k, v) = item
add_all_plugin_dirs(str(basedir.resolve()))
@@ -527,11 +569,92 @@ class Runner:
{"playbook_dir": PLAYBOOK_DIR or str(basedir.resolve())},
fail_on_undefined=False,
)
- return delegate_map[k](str(basedir), k, v, parent_type)
+ return delegate_map[k](lintable, k, v, parent_type)
return []
+ def plugin_children(self, lintable: Lintable) -> list[Lintable]:
+ """Collect lintable sections from plugin file."""
+ offset, content = parse_examples_from_plugin(lintable)
+ if not content:
+ # No examples, nothing to see here
+ return []
+ examples = Lintable(
+ name=lintable.name,
+ content=content,
+ kind="yaml",
+ base_kind="text/yaml",
+ parent=lintable,
+ )
+ examples.line_offset = offset
-def _get_matches(rules: RulesCollection, options: Options) -> LintResult:
+ # pylint: disable=consider-using-with
+ examples.file = NamedTemporaryFile(
+ mode="w+",
+ suffix=f"_{lintable.path.name}.yaml",
+ )
+ examples.file.write(content)
+ examples.file.flush()
+ examples.filename = examples.file.name
+ examples.path = Path(examples.file.name)
+ return [examples]
+
+
+@cache
+def threads() -> int:
+ """Determine how many threads to use.
+
+ Inside containers we want to respect limits imposed.
+
+ When present /sys/fs/cgroup/cpu.max can contain something like:
+ $ podman/docker run -it --rm --cpus 1.5 ubuntu:latest cat /sys/fs/cgroup/cpu.max
+ 150000 100000
+ # "max 100000" is returned when no limits are set.
+
+ See: https://github.com/python/cpython/issues/80235
+ See: https://github.com/python/cpython/issues/70879
+ """
+ os_cpu_count = multiprocessing.cpu_count()
+ # Cgroup CPU bandwidth limit available in Linux since 2.6 kernel
+
+ cpu_max_fname = "/sys/fs/cgroup/cpu.max"
+ cfs_quota_fname = "/sys/fs/cgroup/cpu/cpu.cfs_quota_us"
+ cfs_period_fname = "/sys/fs/cgroup/cpu/cpu.cfs_period_us"
+ if os.path.exists(cpu_max_fname):
+ # cgroup v2
+ # https://www.kernel.org/doc/html/latest/admin-guide/cgroup-v2.html
+ with open(cpu_max_fname, encoding="utf-8") as fh:
+ cpu_quota_us, cpu_period_us = fh.read().strip().split()
+ elif os.path.exists(cfs_quota_fname) and os.path.exists(cfs_period_fname):
+ # cgroup v1
+ # https://www.kernel.org/doc/html/latest/scheduler/sched-bwc.html#management
+ with open(cfs_quota_fname, encoding="utf-8") as fh:
+ cpu_quota_us = fh.read().strip()
+ with open(cfs_period_fname, encoding="utf-8") as fh:
+ cpu_period_us = fh.read().strip()
+ else:
+ # No Cgroup CPU bandwidth limit (e.g. non-Linux platform)
+ cpu_quota_us = "max"
+ cpu_period_us = "100000" # unused, for consistency with default values
+
+ if cpu_quota_us == "max":
+ # No active Cgroup quota on a Cgroup-capable platform
+ return os_cpu_count
+ cpu_quota_us_int = int(cpu_quota_us)
+ cpu_period_us_int = int(cpu_period_us)
+ if cpu_quota_us_int > 0 and cpu_period_us_int > 0:
+ return math.ceil(cpu_quota_us_int / cpu_period_us_int)
+ # Setting a negative cpu_quota_us value is a valid way to disable
+ # cgroup CPU bandwidth limits
+ return os_cpu_count
+
+
+def get_matches(rules: RulesCollection, options: Options) -> LintResult:
+ """Get matches for given rules and options.
+
+ :param rules: Rules to use for linting.
+ :param options: Options to use for linting.
+ :returns: LintResult containing matches and checked files.
+ """
lintables = ansiblelint.utils.get_lintables(opts=options, args=options.lintables)
for rule in rules:
@@ -551,6 +674,7 @@ def _get_matches(rules: RulesCollection, options: Options) -> LintResult:
verbosity=options.verbosity,
checked_files=checked_files,
project_dir=options.project_dir,
+ _skip_ansible_syntax_check=options._skip_ansible_syntax_check, # noqa: SLF001
)
matches.extend(runner.run())
diff --git a/src/ansiblelint/schemas/__main__.py b/src/ansiblelint/schemas/__main__.py
index e3ec8ae..e216c0b 100644
--- a/src/ansiblelint/schemas/__main__.py
+++ b/src/ansiblelint/schemas/__main__.py
@@ -1,4 +1,5 @@
"""Module containing cached JSON schemas."""
+
import json
import logging
import os
@@ -68,7 +69,10 @@ def refresh_schemas(min_age_seconds: int = 3600 * 24) -> int:
raise RuntimeError(msg)
path = Path(__file__).parent.resolve() / f"{kind}.json"
_logger.debug("Refreshing %s schema ...", kind)
- request = Request(url)
+ if not url.startswith(("http:", "https:")):
+ msg = f"Unexpected url schema: {url}"
+ raise ValueError(msg)
+ request = Request(url) # noqa: S310
etag = data.get("etag", "")
if etag:
request.add_header("If-None-Match", f'"{data.get("etag")}"')
@@ -108,7 +112,6 @@ def refresh_schemas(min_age_seconds: int = 3600 * 24) -> int:
get_schema.cache_clear()
else:
store_file.touch()
- changed = 1
return changed
diff --git a/src/ansiblelint/schemas/__store__.json b/src/ansiblelint/schemas/__store__.json
index d4bcdca..d66d675 100644
--- a/src/ansiblelint/schemas/__store__.json
+++ b/src/ansiblelint/schemas/__store__.json
@@ -1,10 +1,10 @@
{
"ansible-lint-config": {
- "etag": "0ec39ba1ca9c20aea463f7f536c6903c88288f47c1b2b2b3d53b527c293f8cc3",
+ "etag": "a0bb8004fad70bab34fad94a45b2698125127142ec6b2c8900976aa2bd96a86c",
"url": "https://raw.githubusercontent.com/ansible/ansible-lint/main/src/ansiblelint/schemas/ansible-lint-config.json"
},
"ansible-navigator-config": {
- "etag": "dd0f0dea68266ae61e5a8d6aed0a1279fdee16f2da4911bc27970241df80f798",
+ "etag": "431f1a81acc74fe1112d5839551105bc2fa4e0314d811699eb525dae4fe3760d",
"url": "https://raw.githubusercontent.com/ansible/ansible-navigator/main/src/ansible_navigator/data/ansible-navigator.json"
},
"changelog": {
@@ -12,19 +12,19 @@
"url": "https://raw.githubusercontent.com/ansible/ansible-lint/main/src/ansiblelint/schemas/changelog.json"
},
"execution-environment": {
- "etag": "f3abb1716134227ccd667607840dd7bdebfd02a8980603df031282126dc78264",
+ "etag": "2e1b1d02460fb93892252439e9634d9574dfdd37aea82af32f4622dacd5990b5",
"url": "https://raw.githubusercontent.com/ansible/ansible-lint/main/src/ansiblelint/schemas/execution-environment.json"
},
"galaxy": {
- "etag": "61f38feb51dc7eaff43ab22f3759b3a5202776ee75ee4204f07135282817f724",
+ "etag": "4224ac235cc5657bf77b5834cea48b4d573cc8b666694f788590e213adfb8113",
"url": "https://raw.githubusercontent.com/ansible/ansible-lint/main/src/ansiblelint/schemas/galaxy.json"
},
"inventory": {
- "etag": "3dcd4890bf31e634a7c4f6138286a42b4985393f210f7ffaa840c2127876aa55",
+ "etag": "b52c251a121e2e807928db7b4e09338babde9e74a50d0f74e8908f6e230d101d",
"url": "https://raw.githubusercontent.com/ansible/ansible-lint/main/src/ansiblelint/schemas/inventory.json"
},
"meta": {
- "etag": "0f376059285181985711b4271a6ff34a8dde662b9fc221d09bdcd64e4fbf86bf",
+ "etag": "fdff861b226b13b711dd7f94301ed5becd6dc5d8d4e872f909d4a3d8133d600a",
"url": "https://raw.githubusercontent.com/ansible/ansible-lint/main/src/ansiblelint/schemas/meta.json"
},
"meta-runtime": {
@@ -32,31 +32,31 @@
"url": "https://raw.githubusercontent.com/ansible/ansible-lint/main/src/ansiblelint/schemas/meta-runtime.json"
},
"molecule": {
- "etag": "3456b2e5aaa02fde359ff147cff81d01a37c07f5e10542b6b8b61aaaf8c756a6",
+ "etag": "3b625438c28e884ac42a14c09ca542fc3e1b4466abaf47d0c28646e0857d3fb5",
"url": "https://raw.githubusercontent.com/ansible/ansible-lint/main/src/ansiblelint/schemas/molecule.json"
},
"playbook": {
- "etag": "acbd5edfc66279f8c3f6f8a99d0874669a254983ace5e4a2cce6105489ab3e21",
+ "etag": "4f8cbba62fcf8a1fa6e8ef5e42696aec5b0876487478df83a7ffdf8bdbb4abcf",
"url": "https://raw.githubusercontent.com/ansible/ansible-lint/main/src/ansiblelint/schemas/playbook.json"
},
"requirements": {
- "etag": "93c6ccd1f79f58134795b85f9b1193d6e18417dd01a9d1f37d9f247562a1e6fe",
+ "etag": "5ae3a6058ac626a341338c760db7cef7f02a8911c7293c7e129dbc6b0f8bb86d",
"url": "https://raw.githubusercontent.com/ansible/ansible-lint/main/src/ansiblelint/schemas/requirements.json"
},
"role-arg-spec": {
- "etag": "498a6f716c7e99bd474ae9e7d34b3f43fbf2aad750f769392fc8e29fa590be6c",
+ "etag": "e41a42e1ca634a9eb2edbc4a180f404bdc71e17aafa464e6651387c08152bbc5",
"url": "https://raw.githubusercontent.com/ansible/ansible-lint/main/src/ansiblelint/schemas/role-arg-spec.json"
},
"rulebook": {
- "etag": "f0bbd0ecd656b2298febccc6da0ecf4a7bd239cc112b9de8292c1f50bad612e0",
+ "etag": "baba5774a46fcc2bc8c4a8c2f25b49df64a0856e415dbf601b0559f215e55968",
"url": "https://raw.githubusercontent.com/ansible/ansible-rulebook/main/ansible_rulebook/schema/ruleset_schema.json"
},
"tasks": {
- "etag": "f9fbc0855680d1321fa3902181131d73838d922362d8dfb85a4f59402240cc07",
+ "etag": "9f3b54cf5cc432d57c9691fb3108a7f37996ab0875e2abb66eda0aa62437dcdc",
"url": "https://raw.githubusercontent.com/ansible/ansible-lint/main/src/ansiblelint/schemas/tasks.json"
},
"vars": {
- "etag": "5d6c2c22a58f2b48c2a8d8d129f2516e4f17ffc78a2c9ba045eb5ede0ff749d7",
+ "etag": "73feaa77561d1d5b0bebe6cd66d499a28d67037055ac6d746139a38c9d28ca04",
"url": "https://raw.githubusercontent.com/ansible/ansible-lint/main/src/ansiblelint/schemas/vars.json"
}
}
diff --git a/src/ansiblelint/schemas/ansible-lint-config.json b/src/ansiblelint/schemas/ansible-lint-config.json
index f7d50e4..7f53ffd 100644
--- a/src/ansiblelint/schemas/ansible-lint-config.json
+++ b/src/ansiblelint/schemas/ansible-lint-config.json
@@ -17,6 +17,7 @@
"$id": "https://raw.githubusercontent.com/ansible/ansible-lint/main/src/ansiblelint/schemas/ansible-lint-config.json",
"$schema": "http://json-schema.org/draft-07/schema",
"additionalProperties": false,
+ "description": "https://ansible.readthedocs.io/projects/lint/configuring/",
"examples": [
".ansible-lint",
".config/ansible-lint.yml",
@@ -60,6 +61,11 @@
"title": "Loop Var Prefix",
"type": "string"
},
+ "max_block_depth": {
+ "title": "Maximum Block Depth",
+ "type": "integer",
+ "default": 20
+ },
"mock_modules": {
"items": {
"type": "string"
@@ -242,6 +248,13 @@
"title": "Strict",
"type": "boolean"
},
+ "supported_ansible_also": {
+ "items": {
+ "type": "string"
+ },
+ "title": "Add supported ansible versions",
+ "type": "array"
+ },
"tags": {
"items": {
"type": "string"
diff --git a/src/ansiblelint/schemas/ansible-navigator-config.json b/src/ansiblelint/schemas/ansible-navigator-config.json
index e81a878..d528267 100644
--- a/src/ansiblelint/schemas/ansible-navigator-config.json
+++ b/src/ansiblelint/schemas/ansible-navigator-config.json
@@ -1,6 +1,7 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"additionalProperties": false,
+ "description": "See https://ansible.readthedocs.io/projects/navigator/settings/",
"properties": {
"ansible-navigator": {
"additionalProperties": false,
@@ -9,7 +10,7 @@
"additionalProperties": false,
"properties": {
"cmdline": {
- "description": "Extra parameters passed to the corresponding command",
+ "description": "Extra parameters passed to the underlying ansible command (e.g. ansible-playbook, ansible-doc, etc)",
"type": "string"
},
"config": {
@@ -524,7 +525,7 @@
"required": [
"ansible-navigator"
],
- "title": "ansible-navigator settings v3",
+ "title": "ansible-navigator settings v24",
"type": "object",
- "version": "3"
+ "version": "24"
}
diff --git a/src/ansiblelint/schemas/ansible.json b/src/ansiblelint/schemas/ansible.json
index 94846d0..9423f7a 100644
--- a/src/ansiblelint/schemas/ansible.json
+++ b/src/ansiblelint/schemas/ansible.json
@@ -1,5 +1,10 @@
{
"$defs": {
+ "removed-include-module": {
+ "markdownDescription": "See [include module](https://docs.ansible.com/ansible/latest/collections/ansible/builtin/include_module.html)",
+ "not": {},
+ "title": "Replace 'include' with either 'ansible.builtin.include_tasks' or 'ansible.builtin.import_tasks'"
+ },
"ansible.builtin.import_playbook": {
"additionalProperties": false,
"oneOf": [
@@ -163,7 +168,7 @@
},
"ignore_unreachable": {
"title": "Ignore Unreachable",
- "type": "boolean"
+ "$ref": "#/$defs/templated-boolean"
},
"module_defaults": {
"title": "Module Defaults"
@@ -348,8 +353,8 @@
"type": "boolean"
},
"gather_facts": {
- "title": "Gather Facts",
- "type": "boolean"
+ "$ref": "#/$defs/templated-boolean",
+ "title": "Gather Facts"
},
"gather_subset": {
"items": {
@@ -523,11 +528,11 @@
},
"ignore_unreachable": {
"title": "Ignore Unreachable",
- "type": "boolean"
+ "$ref": "#/$defs/templated-boolean"
},
"max_fail_percentage": {
"title": "Max Fail Percentage",
- "type": "number"
+ "$ref": "#/$defs/templated-integer"
},
"module_defaults": {
"title": "Module Defaults"
@@ -540,15 +545,23 @@
"$ref": "#/$defs/templated-boolean"
},
"order": {
- "enum": [
- "default",
- "sorted",
- "reverse_sorted",
- "reverse_inventory",
- "shuffle"
+ "oneOf": [
+ {
+ "enum": [
+ "inventory",
+ "reverse_inventory",
+ "reverse_sorted",
+ "shuffle",
+ "sorted"
+ ],
+ "type": "string"
+ },
+ {
+ "$ref": "#/$defs/full-jinja"
+ }
],
"title": "Order",
- "type": "string"
+ "markdownDescription": "See https://docs.ansible.com/ansible/latest/playbook_guide/playbooks_strategies.html#ordering-execution-based-on-inventory"
},
"port": {
"$ref": "#/$defs/templated-integer",
@@ -720,7 +733,7 @@
},
"ignore_unreachable": {
"title": "Ignore Unreachable",
- "type": "boolean"
+ "$ref": "#/$defs/templated-boolean"
},
"module_defaults": {
"title": "Module Defaults"
@@ -831,6 +844,15 @@
"title": "Action",
"type": "string"
},
+ "ansible.builtin.include": {
+ "$ref": "#/$defs/removed-include-module"
+ },
+ "include": {
+ "$ref": "#/$defs/removed-include-module"
+ },
+ "ansible.legacy.include": {
+ "$ref": "#/$defs/removed-include-module"
+ },
"any_errors_fatal": {
"$ref": "#/$defs/templated-boolean",
"title": "Any Errors Fatal"
@@ -914,7 +936,7 @@
},
"ignore_unreachable": {
"title": "Ignore Unreachable",
- "type": "boolean"
+ "$ref": "#/$defs/templated-boolean"
},
"listen": {
"anyOf": [
@@ -1196,6 +1218,7 @@
"$id": "https://raw.githubusercontent.com/ansible/ansible-lint/main/src/ansiblelint/schemas/ansible.json",
"$schema": "http://json-schema.org/draft-07/schema",
"additionalProperties": false,
+ "description": "https://docs.ansible.com/ansible/latest/reference_appendices/playbooks_keywords.html",
"examples": [],
"title": "Ansible Schemas Bundle 22.4",
"type": ["array", "object"]
diff --git a/src/ansiblelint/schemas/execution-environment.json b/src/ansiblelint/schemas/execution-environment.json
index 4720a93..7d44ab3 100644
--- a/src/ansiblelint/schemas/execution-environment.json
+++ b/src/ansiblelint/schemas/execution-environment.json
@@ -302,7 +302,8 @@
},
"$id": "https://raw.githubusercontent.com/ansible/ansible-lint/main/src/ansiblelint/schemas/execution-environment.json",
"$schema": "http://json-schema.org/draft-07/schema",
- "description": "See \nV1: https://docs.ansible.com/automation-controller/latest/html/userguide/ee_reference.html\nV3: https://ansible-builder.readthedocs.io/en/latest/definition/",
+ "description": "See https://ansible-builder.readthedocs.io/en/latest/definition/ for V3 or https://docs.ansible.com/automation-controller/latest/html/userguide/ee_reference.html for older V1 format.\n",
+ "documentation_url": "https://ansible.readthedocs.io/projects/builder/en/latest/definition/",
"examples": ["execution-environment.yml"],
"oneOf": [{ "$ref": "#/$defs/v3" }, { "$ref": "#/$defs/v1" }],
"title": "Ansible Execution Environment Schema v1/v3"
diff --git a/src/ansiblelint/schemas/galaxy.json b/src/ansiblelint/schemas/galaxy.json
index 6381f28..ae03445 100644
--- a/src/ansiblelint/schemas/galaxy.json
+++ b/src/ansiblelint/schemas/galaxy.json
@@ -13,6 +13,7 @@
"description": "An enumeration.",
"enum": [
"0BSD",
+ "389-exception",
"AAL",
"ADSL",
"AFL-1.1",
@@ -26,6 +27,7 @@
"AGPL-3.0-or-later",
"AMDPLPA",
"AML",
+ "AML-glslang",
"AMPAS",
"ANTLR-PD",
"ANTLR-PD-fallback",
@@ -35,10 +37,14 @@
"APSL-1.1",
"APSL-1.2",
"APSL-2.0",
+ "ASWF-Digital-Assets-1.0",
+ "ASWF-Digital-Assets-1.1",
"Abstyles",
"AdaCore-doc",
"Adobe-2006",
+ "Adobe-Display-PostScript",
"Adobe-Glyph",
+ "Adobe-Utopia",
"Afmparse",
"Aladdin",
"Apache-1.0",
@@ -50,13 +56,21 @@
"Artistic-1.0-Perl",
"Artistic-1.0-cl8",
"Artistic-2.0",
+ "Asterisk-exception",
+ "Autoconf-exception-2.0",
+ "Autoconf-exception-3.0",
+ "Autoconf-exception-generic",
+ "Autoconf-exception-generic-3.0",
+ "Autoconf-exception-macro",
"BSD-1-Clause",
"BSD-2-Clause",
+ "BSD-2-Clause-Darwin",
"BSD-2-Clause-Patent",
"BSD-2-Clause-Views",
"BSD-3-Clause",
"BSD-3-Clause-Attribution",
"BSD-3-Clause-Clear",
+ "BSD-3-Clause-HP",
"BSD-3-Clause-LBNL",
"BSD-3-Clause-Modification",
"BSD-3-Clause-No-Military-License",
@@ -64,6 +78,9 @@
"BSD-3-Clause-No-Nuclear-License-2014",
"BSD-3-Clause-No-Nuclear-Warranty",
"BSD-3-Clause-Open-MPI",
+ "BSD-3-Clause-Sun",
+ "BSD-3-Clause-acpica",
+ "BSD-3-Clause-flex",
"BSD-4-Clause",
"BSD-4-Clause-Shortened",
"BSD-4-Clause-UC",
@@ -71,20 +88,29 @@
"BSD-4.3TAHOE",
"BSD-Advertising-Acknowledgement",
"BSD-Attribution-HPND-disclaimer",
+ "BSD-Inferno-Nettverk",
"BSD-Protection",
"BSD-Source-Code",
+ "BSD-Source-beginning-file",
+ "BSD-Systemics",
+ "BSD-Systemics-W3Works",
"BSL-1.0",
"BUSL-1.1",
"Baekmuk",
"Bahyph",
"Barr",
"Beerware",
+ "Bison-exception-1.24",
+ "Bison-exception-2.2",
"BitTorrent-1.0",
"BitTorrent-1.1",
"Bitstream-Charter",
"Bitstream-Vera",
"BlueOak-1.0.0",
+ "Boehm-GC",
+ "Bootloader-exception",
"Borceux",
+ "Brian-Gladman-2-Clause",
"Brian-Gladman-3-Clause",
"C-UDA-1.0",
"CAL-1.0",
@@ -96,6 +122,7 @@
"CC-BY-2.5-AU",
"CC-BY-3.0",
"CC-BY-3.0-AT",
+ "CC-BY-3.0-AU",
"CC-BY-3.0-DE",
"CC-BY-3.0-IGO",
"CC-BY-3.0-NL",
@@ -138,6 +165,7 @@
"CC-BY-SA-3.0",
"CC-BY-SA-3.0-AT",
"CC-BY-SA-3.0-DE",
+ "CC-BY-SA-3.0-IGO",
"CC-BY-SA-4.0",
"CC-PDDC",
"CC0-1.0",
@@ -159,7 +187,9 @@
"CERN-OHL-S-2.0",
"CERN-OHL-W-2.0",
"CFITSIO",
+ "CLISP-exception-2.0",
"CMU-Mach",
+ "CMU-Mach-nodoc",
"CNRI-Jython",
"CNRI-Python",
"CNRI-Python-GPL-Compatible",
@@ -169,19 +199,26 @@
"CPOL-1.02",
"CUA-OPL-1.0",
"Caldera",
+ "Caldera-no-preamble",
"ClArtistic",
+ "Classpath-exception-2.0",
"Clips",
"Community-Spec-1.0",
"Condor-1.1",
"Cornell-Lossless-JPEG",
+ "Cronyx",
"Crossword",
"CrystalStacker",
"Cube",
"D-FSL-1.0",
+ "DEC-3-Clause",
"DL-DE-BY-2.0",
+ "DL-DE-ZERO-2.0",
"DOC",
"DRL-1.0",
+ "DRL-1.1",
"DSDP",
+ "DigiRule-FOSS-exception",
"Dotseqn",
"ECL-1.0",
"ECL-2.0",
@@ -198,16 +235,27 @@
"Entessa",
"ErlPL-1.1",
"Eurosym",
+ "FBM",
"FDK-AAC",
+ "FLTK-exception",
"FSFAP",
+ "FSFAP-no-warranty-disclaimer",
"FSFUL",
"FSFULLR",
"FSFULLRWD",
"FTL",
"Fair",
+ "Fawkes-Runtime-exception",
+ "Ferguson-Twofish",
+ "Font-exception-2.0",
"Frameworx-1.0",
"FreeBSD-DOC",
"FreeImage",
+ "Furuseth",
+ "GCC-exception-2.0",
+ "GCC-exception-2.0-note",
+ "GCC-exception-3.1",
+ "GCR-docs",
"GD",
"GFDL-1.1-invariants-only",
"GFDL-1.1-invariants-or-later",
@@ -229,20 +277,43 @@
"GFDL-1.3-or-later",
"GL2PS",
"GLWTPL",
+ "GNAT-exception",
+ "GNOME-examples-exception",
+ "GNU-compiler-exception",
"GPL-1.0-only",
"GPL-1.0-or-later",
"GPL-2.0-only",
"GPL-2.0-or-later",
+ "GPL-3.0-interface-exception",
+ "GPL-3.0-linking-exception",
+ "GPL-3.0-linking-source-exception",
"GPL-3.0-only",
"GPL-3.0-or-later",
+ "GPL-CC-1.0",
+ "GStreamer-exception-2005",
+ "GStreamer-exception-2008",
"Giftware",
"Glide",
"Glulxe",
+ "Gmsh-exception",
"Graphics-Gems",
"HP-1986",
+ "HP-1989",
"HPND",
+ "HPND-DEC",
+ "HPND-Fenneberg-Livingston",
+ "HPND-INRIA-IMAG",
+ "HPND-Kevlin-Henney",
+ "HPND-MIT-disclaimer",
"HPND-Markus-Kuhn",
+ "HPND-Pbmplus",
+ "HPND-UC",
+ "HPND-doc",
+ "HPND-doc-sell",
"HPND-export-US",
+ "HPND-export-US-modify",
+ "HPND-sell-MIT-disclaimer-xserver",
+ "HPND-sell-regexpr",
"HPND-sell-variant",
"HPND-sell-variant-MIT-disclaimer",
"HTMLTIDY",
@@ -256,9 +327,11 @@
"IPA",
"IPL-1.0",
"ISC",
+ "ISC-Veillard",
"ImageMagick",
"Imlib2",
"Info-ZIP",
+ "Inner-Net-2.0",
"Intel",
"Intel-ACPI",
"Interbase-1.0",
@@ -267,7 +340,9 @@
"JSON",
"Jam",
"JasPer-2.0",
+ "Kastrup",
"Kazlib",
+ "KiCad-libraries-exception",
"Knuth-CTAN",
"LAL-1.2",
"LAL-1.3",
@@ -275,10 +350,14 @@
"LGPL-2.0-or-later",
"LGPL-2.1-only",
"LGPL-2.1-or-later",
+ "LGPL-3.0-linking-exception",
"LGPL-3.0-only",
"LGPL-3.0-or-later",
"LGPLLR",
+ "LLGPL",
+ "LLVM-exception",
"LOOP",
+ "LPD-document",
"LPL-1.0",
"LPL-1.02",
"LPPL-1.0",
@@ -288,24 +367,36 @@
"LPPL-1.3c",
"LZMA-SDK-9.11-to-9.20",
"LZMA-SDK-9.22",
+ "LZMA-exception",
"Latex2e",
+ "Latex2e-translated-notice",
"Leptonica",
"LiLiQ-P-1.1",
"LiLiQ-R-1.1",
"LiLiQ-Rplus-1.1",
"Libpng",
+ "Libtool-exception",
"Linux-OpenIB",
+ "Linux-man-pages-1-para",
"Linux-man-pages-copyleft",
+ "Linux-man-pages-copyleft-2-para",
+ "Linux-man-pages-copyleft-var",
+ "Linux-syscall-note",
+ "Lucida-Bitmap-Fonts",
"MIT",
"MIT-0",
"MIT-CMU",
+ "MIT-Festival",
"MIT-Modern-Variant",
"MIT-Wu",
"MIT-advertising",
"MIT-enna",
"MIT-feh",
"MIT-open-group",
+ "MIT-testregex",
"MITNFA",
+ "MMIXware",
+ "MPEG-SSG",
"MPL-1.0",
"MPL-1.1",
"MPL-2.0",
@@ -314,8 +405,11 @@
"MS-PL",
"MS-RL",
"MTLL",
+ "Mackerras-3-Clause",
+ "Mackerras-3-Clause-acknowledgment",
"MakeIndex",
"Martin-Birgmeier",
+ "McPhee-slideshow",
"Minpack",
"MirOS",
"Motosoto",
@@ -332,6 +426,7 @@
"NICTA-1.0",
"NIST-PD",
"NIST-PD-fallback",
+ "NIST-Software",
"NLOD-1.0",
"NLOD-2.0",
"NLPL",
@@ -350,7 +445,9 @@
"Noweb",
"O-UDA-1.0",
"OCCT-PL",
+ "OCCT-exception-1.0",
"OCLC-2.0",
+ "OCaml-LGPL-linking-exception",
"ODC-By-1.0",
"ODbL-1.0",
"OFFIS",
@@ -383,8 +480,10 @@
"OLDAP-2.6",
"OLDAP-2.7",
"OLDAP-2.8",
+ "OLFL-1.3",
"OML",
"OPL-1.0",
+ "OPL-UK-3.0",
"OPUBL-1.0",
"OSET-PL-2.1",
"OSL-1.0",
@@ -392,14 +491,20 @@
"OSL-2.0",
"OSL-2.1",
"OSL-3.0",
+ "OpenJDK-assembly-exception-1.0",
"OpenPBS-2.3",
"OpenSSL",
+ "OpenSSL-standalone",
+ "OpenVision",
+ "PADL",
"PDDL-1.0",
"PHP-3.0",
"PHP-3.01",
+ "PS-or-PDF-font-exception-20170817",
"PSF-2.0",
"Parity-6.0.0",
"Parity-7.0.0",
+ "Pixar",
"Plexus",
"PolyForm-Noncommercial-1.0.0",
"PolyForm-Small-Business-1.0.0",
@@ -408,7 +513,11 @@
"Python-2.0.1",
"QPL-1.0",
"QPL-1.0-INRIA-2004",
+ "QPL-1.0-INRIA-2004-exception",
"Qhull",
+ "Qt-GPL-exception-1.0",
+ "Qt-LGPL-exception-1.1",
+ "Qwt-exception-1.0",
"RHeCos-1.1",
"RPL-1.1",
"RPL-1.5",
@@ -417,22 +526,31 @@
"RSCPL",
"Rdisc",
"Ruby",
+ "SANE-exception",
"SAX-PD",
+ "SAX-PD-2.0",
"SCEA",
"SGI-B-1.0",
"SGI-B-1.1",
"SGI-B-2.0",
+ "SGI-OpenGL",
+ "SGP4",
"SHL-0.5",
"SHL-0.51",
+ "SHL-2.0",
+ "SHL-2.1",
"SISSL",
"SISSL-1.2",
+ "SL",
"SMLNJ",
"SMPPL",
"SNIA",
"SPL-1.0",
"SSH-OpenSSH",
"SSH-short",
+ "SSLeay-standalone",
"SSPL-1.0",
+ "SWI-exception",
"SWL",
"Saxpath",
"SchemeReport",
@@ -440,29 +558,42 @@
"Sendmail-8.23",
"SimPL-2.0",
"Sleepycat",
+ "Soundex",
"Spencer-86",
"Spencer-94",
"Spencer-99",
"SugarCRM-1.1.3",
+ "Sun-PPP",
"SunPro",
+ "Swift-exception",
"Symlinks",
"TAPR-OHL-1.0",
"TCL",
"TCP-wrappers",
+ "TGPPL-1.0",
"TMate",
"TORQUE-1.1",
"TOSL",
"TPDL",
"TPL-1.0",
"TTWL",
+ "TTYP0",
"TU-Berlin-1.0",
"TU-Berlin-2.0",
+ "TermReadKey",
+ "Texinfo-exception",
+ "UBDL-exception",
"UCAR",
"UCL-1.0",
+ "UMich-Merit",
"UPL-1.0",
+ "URT-RLE",
+ "Unicode-3.0",
"Unicode-DFS-2015",
"Unicode-DFS-2016",
"Unicode-TOU",
+ "Universal-FOSS-exception-1.0",
+ "UnixCrypt",
"Unlicense",
"VOSTROM",
"VSL-1.0",
@@ -472,12 +603,16 @@
"W3C-20150513",
"WTFPL",
"Watcom-1.0",
+ "Widget-Workshop",
"Wsuipa",
+ "WxWindows-exception-3.1",
"X11",
"X11-distribute-modifications-variant",
"XFree86-1.1",
"XSkat",
+ "Xdebug-1.03",
"Xerox",
+ "Xfig",
"Xnet",
"YPL-1.0",
"YPL-1.1",
@@ -485,35 +620,67 @@
"ZPL-2.0",
"ZPL-2.1",
"Zed",
+ "Zeeff",
"Zend-2.0",
"Zimbra-1.3",
"Zimbra-1.4",
"Zlib",
+ "bcrypt-Solar-Designer",
"blessing",
"bzip2-1.0.6",
+ "check-cvs",
"checkmk",
"copyleft-next-0.3.0",
"copyleft-next-0.3.1",
+ "cryptsetup-OpenSSL-exception",
"curl",
"diffmark",
+ "dtoa",
"dvipdfm",
+ "eCos-exception-2.0",
"eGenix",
"etalab-2.0",
+ "fmt-exception",
+ "freertos-exception-2.0",
+ "fwlw",
"gSOAP-1.3b",
+ "gnu-javamail-exception",
"gnuplot",
+ "gtkbook",
+ "hdparm",
+ "i2p-gpl-java-exception",
"iMatix",
"libpng-2.0",
+ "libpri-OpenH323-exception",
"libselinux-1.0",
"libtiff",
"libutil-David-Nugent",
+ "lsof",
+ "magaz",
+ "mailprio",
+ "metamail",
+ "mif-exception",
"mpi-permissive",
"mpich2",
"mplus",
+ "openvpn-openssl-exception",
+ "pnmstitch",
"psfrag",
"psutils",
+ "python-ldap",
+ "radvd",
"snprintf",
+ "softSurfer",
+ "ssh-keyscan",
+ "stunnel-exception",
+ "swrule",
+ "u-boot-exception-2.0",
+ "ulem",
+ "vsftpd-openssl-exception",
"w3m",
+ "x11vnc-openssl-exception",
"xinetd",
+ "xkeyboard-config-Zinoviev",
"xlock",
"xpp",
"zlib-acknowledgement"
@@ -525,6 +692,7 @@
"$schema": "http://json-schema.org/draft-07/schema",
"additionalProperties": false,
"examples": ["galaxy.yml"],
+ "markdownDescription": "https://docs.ansible.com/ansible/latest/dev_guide/collections_galaxy_meta.html",
"properties": {
"authors": {
"items": {
diff --git a/src/ansiblelint/schemas/inventory.json b/src/ansiblelint/schemas/inventory.json
index 80333ce..06cf2ca 100644
--- a/src/ansiblelint/schemas/inventory.json
+++ b/src/ansiblelint/schemas/inventory.json
@@ -45,7 +45,7 @@
"$id": "https://raw.githubusercontent.com/ansible/ansible-lint/main/src/ansiblelint/schemas/inventory.json",
"$schema": "http://json-schema.org/draft-07/schema",
"additionalProperties": true,
- "description": "Ansible Inventory Schema",
+ "description": "See https://docs.ansible.com/ansible/latest/inventory_guide/intro_inventory.html",
"examples": [
"inventory.yaml",
"inventory.yml",
diff --git a/src/ansiblelint/schemas/main.py b/src/ansiblelint/schemas/main.py
index 590aea3..45b0c48 100644
--- a/src/ansiblelint/schemas/main.py
+++ b/src/ansiblelint/schemas/main.py
@@ -1,8 +1,11 @@
"""Module containing cached JSON schemas."""
+
from __future__ import annotations
import json
import logging
+import re
+import typing
from typing import TYPE_CHECKING
import jsonschema
@@ -18,20 +21,95 @@ if TYPE_CHECKING:
from ansiblelint.file_utils import Lintable
+def find_best_deep_match(
+ errors: jsonschema.ValidationError,
+) -> jsonschema.ValidationError:
+ """Return the deepest schema validation error."""
+
+ def iter_validation_error(
+ err: jsonschema.ValidationError,
+ ) -> typing.Iterator[jsonschema.ValidationError]:
+ if err.context:
+ for e in err.context:
+ yield e
+ yield from iter_validation_error(e)
+
+ return max(iter_validation_error(errors), key=_deep_match_relevance)
+
+
def validate_file_schema(file: Lintable) -> list[str]:
"""Return list of JSON validation errors found."""
+ schema = {}
if file.kind not in JSON_SCHEMAS:
return [f"Unable to find JSON Schema '{file.kind}' for '{file.path}' file."]
try:
# convert yaml to json (keys are converted to strings)
yaml_data = yaml_load_safe(file.content)
json_data = json.loads(json.dumps(yaml_data))
- jsonschema.validate(
- instance=json_data,
- schema=_schema_cache[file.kind],
- )
+ schema = _schema_cache[file.kind]
+
+ validator = jsonschema.validators.validator_for(schema)
+ v = validator(schema)
+ try:
+ error = next(v.iter_errors(json_data))
+ except StopIteration:
+ return []
+ if error.context:
+ error = find_best_deep_match(error)
+ # determine if we want to use our own messages embedded into schemas inside title/markdownDescription fields
+ if "not" in error.schema and len(error.schema["not"]) == 0:
+ message = error.schema["title"]
+ schema = error.schema
+ else:
+ message = f"{error.json_path} {error.message}"
+
+ documentation_url = ""
+ for json_schema in (error.schema, schema):
+ for k in ("description", "markdownDescription"):
+ if k in json_schema:
+ # Find standalone URLs and also markdown urls.
+ match = re.search(
+ r"\[.*?\]\((?P<url>https?://[^\s]+)\)|(?P<url2>https?://[^\s]+)",
+ json_schema[k],
+ )
+ if match:
+ documentation_url = next(
+ x for x in match.groups() if x is not None
+ )
+ break
+ if documentation_url:
+ break
+ if documentation_url:
+ if not message.endswith("."):
+ message += "."
+ message += f" See {documentation_url}"
except yaml.constructor.ConstructorError as exc:
return [f"Failed to load YAML file '{file.path}': {exc.problem}"]
except ValidationError as exc:
- return [exc.message]
- return []
+ message = exc.message
+ documentation_url = ""
+ for k in ("description", "markdownDescription"):
+ if k in schema:
+ # Find standalone URLs and also markdown urls.
+ match = re.search(
+ r"\[.*?\]\((https?://[^\s]+)\)|https?://[^\s]+",
+ schema[k],
+ )
+ if match:
+ documentation_url = match.groups()[0]
+ break
+ if documentation_url:
+ if not message.endswith("."):
+ message += "."
+ message += f" See {documentation_url}"
+ return [message]
+ return [message]
+
+
+def _deep_match_relevance(error: jsonschema.ValidationError) -> tuple[bool | int, ...]:
+ validator = error.validator
+ return (
+ validator not in ("anyOf", "oneOf"), # type: ignore[comparison-overlap]
+ len(error.absolute_path),
+ -len(error.path),
+ )
diff --git a/src/ansiblelint/schemas/meta.json b/src/ansiblelint/schemas/meta.json
index 384d113..8971817 100644
--- a/src/ansiblelint/schemas/meta.json
+++ b/src/ansiblelint/schemas/meta.json
@@ -110,6 +110,25 @@
"title": "ArchLinuxPlatformModel",
"type": "object"
},
+ "AstraLinuxPlatformModel": {
+ "properties": {
+ "name": {
+ "const": "Astra Linux",
+ "title": "Name",
+ "type": "string"
+ },
+ "versions": {
+ "default": "all",
+ "items": {
+ "enum": ["1.8", "1.7", "1.6", "2.12", "all"],
+ "type": "string"
+ },
+ "type": "array"
+ }
+ },
+ "title": "AstraLinuxPlatformModel",
+ "type": "object"
+ },
"ClearLinuxPlatformModel": {
"properties": {
"name": {
@@ -168,6 +187,7 @@
"sid",
"squeeze",
"stretch",
+ "trixie",
"wheezy",
"all"
],
@@ -267,7 +287,14 @@
"versions": {
"default": "all",
"items": {
- "enum": ["ascii", "beowulf", "ceres", "jessie", "all"],
+ "enum": [
+ "ascii",
+ "beowulf",
+ "chimaera",
+ "daedalus",
+ "jessie",
+ "all"
+ ],
"type": "string"
},
"type": "array"
@@ -286,7 +313,7 @@
"versions": {
"default": "all",
"items": {
- "enum": ["5.2", "5.4", "all"],
+ "enum": ["5.2", "5.4", "5.6", "5.8", "6.0", "6.2", "6.4", "all"],
"type": "string"
},
"type": "array"
@@ -348,6 +375,8 @@
"36",
"37",
"38",
+ "39",
+ "40",
"all"
],
"type": "string"
@@ -503,7 +532,7 @@
"namespace": {
"markdownDescription": "Used by molecule and ansible-lint to compute FQRN for roles outside collections",
"minLength": 2,
- "pattern": "^[a-z][a-z0-9_]+$",
+ "pattern": "^[a-z][a-z0-9_-]+$",
"title": "Namespace Name",
"type": "string"
},
@@ -838,7 +867,15 @@
"versions": {
"default": "all",
"items": {
- "enum": ["17.01", "18.06", "19.07", "21.02", "22.03", "all"],
+ "enum": [
+ "17.01",
+ "18.06",
+ "19.07",
+ "21.02",
+ "22.03",
+ "23.05",
+ "all"
+ ],
"type": "string"
},
"type": "array"
@@ -879,6 +916,7 @@
"8.8",
"9.0",
"9.1",
+ "9.2",
"all"
],
"type": "string"
@@ -908,6 +946,39 @@
"title": "PAN-OSPlatformModel",
"type": "object"
},
+ "RockyLinuxPlatformModel": {
+ "properties": {
+ "name": {
+ "const": "Rocky",
+ "title": "Name",
+ "type": "string"
+ },
+ "versions": {
+ "default": "all",
+ "items": {
+ "enum": [
+ "8.0",
+ "8.1",
+ "8.2",
+ "8.3",
+ "8.4",
+ "8.5",
+ "8.6",
+ "8.7",
+ "8.8",
+ "9.0",
+ "9.1",
+ "9.2",
+ "all"
+ ],
+ "type": "string"
+ },
+ "type": "array"
+ }
+ },
+ "title": "RockyLinuxPlatformModel",
+ "type": "object"
+ },
"SLESPlatformModel": {
"properties": {
"name": {
@@ -1038,7 +1109,6 @@
"artful",
"bionic",
"cosmic",
- "cuttlefish",
"disco",
"eoan",
"focal",
@@ -1046,7 +1116,11 @@
"hirsute",
"impish",
"jammy",
+ "kinetic",
"lucid",
+ "lunar",
+ "mantic",
+ "noble",
"maverick",
"natty",
"oneiric",
@@ -1199,6 +1273,8 @@
"Mojave",
"Monterey",
"Sierra",
+ "Sonoma",
+ "Ventura",
"all"
],
"type": "string"
@@ -1372,6 +1448,9 @@
"$ref": "#/$defs/PAN-OSPlatformModel"
},
{
+ "$ref": "#/$defs/RockyLinuxPlatformModel"
+ },
+ {
"$ref": "#/$defs/SLESPlatformModel"
},
{
@@ -1447,6 +1526,7 @@
},
"$id": "https://raw.githubusercontent.com/ansible/ansible-lint/main/src/ansiblelint/schemas/meta.json",
"$schema": "http://json-schema.org/draft-07/schema",
+ "description": "https://docs.ansible.com/ansible/latest/playbook_guide/playbooks_reuse_roles.html#using-role-dependencies",
"examples": ["meta/main.yml"],
"properties": {
"additionalProperties": false,
@@ -1459,7 +1539,14 @@
},
"dependencies": {
"items": {
- "$ref": "#/$defs/DependencyModel"
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "$ref": "#/$defs/DependencyModel"
+ }
+ ]
},
"title": "Dependencies",
"type": "array"
diff --git a/src/ansiblelint/schemas/molecule.json b/src/ansiblelint/schemas/molecule.json
index d957f08..21f1610 100644
--- a/src/ansiblelint/schemas/molecule.json
+++ b/src/ansiblelint/schemas/molecule.json
@@ -512,6 +512,7 @@
"$id": "https://raw.githubusercontent.com/ansible/ansible-lint/main/src/ansiblelint/schemas/molecule.json",
"$schema": "http://json-schema.org/draft-07/schema",
"additionalProperties": false,
+ "description": "https://ansible.readthedocs.io/projects/molecule/configuration/",
"examples": ["molecule/*/molecule.yml"],
"properties": {
"dependency": {
diff --git a/src/ansiblelint/schemas/playbook.json b/src/ansiblelint/schemas/playbook.json
index 983033f..f4d315b 100644
--- a/src/ansiblelint/schemas/playbook.json
+++ b/src/ansiblelint/schemas/playbook.json
@@ -171,8 +171,8 @@
"$ref": "#/$defs/ignore_errors"
},
"ignore_unreachable": {
- "title": "Ignore Unreachable",
- "type": "boolean"
+ "$ref": "#/$defs/templated-boolean",
+ "title": "Ignore Unreachable"
},
"module_defaults": {
"title": "Module Defaults"
@@ -363,8 +363,8 @@
"type": "boolean"
},
"gather_facts": {
- "title": "Gather Facts",
- "type": "boolean"
+ "$ref": "#/$defs/templated-boolean",
+ "title": "Gather Facts"
},
"gather_subset": {
"items": {
@@ -537,12 +537,12 @@
"$ref": "#/$defs/ignore_errors"
},
"ignore_unreachable": {
- "title": "Ignore Unreachable",
- "type": "boolean"
+ "$ref": "#/$defs/templated-boolean",
+ "title": "Ignore Unreachable"
},
"max_fail_percentage": {
- "title": "Max Fail Percentage",
- "type": "number"
+ "$ref": "#/$defs/templated-integer",
+ "title": "Max Fail Percentage"
},
"module_defaults": {
"title": "Module Defaults"
@@ -555,15 +555,23 @@
"$ref": "#/$defs/templated-boolean"
},
"order": {
- "enum": [
- "default",
- "sorted",
- "reverse_sorted",
- "reverse_inventory",
- "shuffle"
+ "markdownDescription": "See https://docs.ansible.com/ansible/latest/playbook_guide/playbooks_strategies.html#ordering-execution-based-on-inventory",
+ "oneOf": [
+ {
+ "enum": [
+ "inventory",
+ "reverse_inventory",
+ "reverse_sorted",
+ "shuffle",
+ "sorted"
+ ],
+ "type": "string"
+ },
+ {
+ "$ref": "#/$defs/full-jinja"
+ }
],
- "title": "Order",
- "type": "string"
+ "title": "Order"
},
"port": {
"$ref": "#/$defs/templated-integer",
@@ -740,8 +748,8 @@
"$ref": "#/$defs/ignore_errors"
},
"ignore_unreachable": {
- "title": "Ignore Unreachable",
- "type": "boolean"
+ "$ref": "#/$defs/templated-boolean",
+ "title": "Ignore Unreachable"
},
"module_defaults": {
"title": "Module Defaults"
@@ -796,6 +804,11 @@
"title": "play-role",
"type": "object"
},
+ "removed-include-module": {
+ "markdownDescription": "See [include module](https://docs.ansible.com/ansible/latest/collections/ansible/builtin/include_module.html)",
+ "not": {},
+ "title": "Replace 'include' with either 'ansible.builtin.include_tasks' or 'ansible.builtin.import_tasks'"
+ },
"tags": {
"anyOf": [
{
@@ -847,6 +860,12 @@
"title": "Action",
"type": "string"
},
+ "ansible.builtin.include": {
+ "$ref": "#/$defs/removed-include-module"
+ },
+ "ansible.legacy.include": {
+ "$ref": "#/$defs/removed-include-module"
+ },
"any_errors_fatal": {
"$ref": "#/$defs/templated-boolean",
"title": "Any Errors Fatal"
@@ -929,8 +948,11 @@
"$ref": "#/$defs/ignore_errors"
},
"ignore_unreachable": {
- "title": "Ignore Unreachable",
- "type": "boolean"
+ "$ref": "#/$defs/templated-boolean",
+ "title": "Ignore Unreachable"
+ },
+ "include": {
+ "$ref": "#/$defs/removed-include-module"
},
"listen": {
"anyOf": [
diff --git a/src/ansiblelint/schemas/requirements.json b/src/ansiblelint/schemas/requirements.json
index dc7ded6..ef8d2a4 100644
--- a/src/ansiblelint/schemas/requirements.json
+++ b/src/ansiblelint/schemas/requirements.json
@@ -130,6 +130,7 @@
"$ref": "#/$defs/RequirementsV2Model"
}
],
+ "description": "https://docs.ansible.com/ansible/latest/galaxy/user_guide.html#installing-roles-and-collections-from-the-same-requirements-yml-file",
"examples": ["requirements.yml"],
"title": "Ansible Requirements Schema"
}
diff --git a/src/ansiblelint/schemas/role-arg-spec.json b/src/ansiblelint/schemas/role-arg-spec.json
index 433993e..111fbe5 100644
--- a/src/ansiblelint/schemas/role-arg-spec.json
+++ b/src/ansiblelint/schemas/role-arg-spec.json
@@ -1,5 +1,73 @@
{
"$defs": {
+ "attribute": {
+ "additionalProperties": false,
+ "properties": {
+ "description": {
+ "description": "Detailed explanation of what this attribute does. It should be written in full sentences.",
+ "oneOf": [
+ {
+ "type": "string"
+ },
+ {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ }
+ ]
+ },
+ "details": {
+ "description": "Detailed explanation of what this attribute does. It should be written in full sentences.",
+ "oneOf": [
+ {
+ "type": "string"
+ },
+ {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ }
+ ]
+ },
+ "membership": {
+ "oneOf": [
+ {
+ "type": "string"
+ },
+ {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ }
+ ]
+ },
+ "platform": {
+ "oneOf": [
+ {
+ "type": "string"
+ },
+ {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ }
+ ]
+ },
+ "support": {
+ "enum": ["full", "partial", "none", "N/A"],
+ "type": "string"
+ },
+ "version_added": {
+ "type": "string"
+ }
+ },
+ "required": ["description", "support"],
+ "title": "Attribute"
+ },
"datatype": {
"enum": [
"str",
@@ -38,6 +106,12 @@
"entry_point": {
"additionalProperties": false,
"properties": {
+ "attributes": {
+ "additionalProperties": {
+ "$ref": "#/$defs/attribute"
+ },
+ "type": "object"
+ },
"author": {
"oneOf": [
{
@@ -64,6 +138,9 @@
}
]
},
+ "examples": {
+ "type": "string"
+ },
"options": {
"additionalProperties": {
"$ref": "#/$defs/option"
@@ -146,30 +223,27 @@
"title": "Entry Point",
"type": "object"
},
+ "full-jinja": {
+ "pattern": "^\\{[\\{%](.|[\r\n])*[\\}%]\\}$",
+ "type": "string"
+ },
"option": {
"additionalProperties": false,
- "aliases": {
- "items": {
+ "markdownDescription": "See [argument-spec](https://docs.ansible.com/ansible/latest/dev_guide/developing_program_flow_modules.html#argument-spec)",
+ "properties": {
+ "apply_defaults": {
"type": "string"
},
- "type": "array"
- },
- "apply_defaults": {
- "type": "string"
- },
- "deprecated_aliases": {
- "items": {
- "$ref": "#/$defs/deprecated_alias"
- },
- "type": "array"
- },
- "markdownDescription": "xxx",
- "options": {
- "$ref": "#/$defs/option"
- },
- "properties": {
"choices": {
- "type": "array"
+ "oneOf": [
+ {
+ "type": "array"
+ },
+ {
+ "$ref": "#/$defs/full-jinja",
+ "type": "string"
+ }
+ ]
},
"default": {
"default": "None"
@@ -213,6 +287,70 @@
"default": false,
"type": "boolean"
},
+ "mutually_exclusive": {
+ "type": "array",
+ "items": {
+ "items": {
+ "type": "string"
+ }
+ }
+ },
+ "required_together": {
+ "type": "array",
+ "items": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ },
+ "required_one_of": {
+ "type": "array",
+ "items": {
+ "items": {
+ "type": "string"
+ }
+ }
+ },
+ "required_if": {
+ "type": "array",
+ "items": {
+ "type": "array",
+ "prefixItems": [
+ {
+ "type": "string"
+ },
+ {},
+ {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ {
+ "type": "boolean"
+ }
+ ],
+ "minItems": 3,
+ "maxItems": 4
+ }
+ },
+ "required_by": {
+ "type": "object",
+ "additionalProperties": {
+ "oneOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ ]
+ }
+ },
"type": {
"$ref": "#/$defs/datatype",
"markdownDescription": "See [argument-spec](https://docs.ansible.com/ansible/latest/dev_guide/developing_program_flow_modules.html#argument-spec"
@@ -237,7 +375,7 @@
"$schema": "http://json-schema.org/draft-07/schema",
"additionalProperties": false,
"examples": ["meta/argument_specs.yml"],
- "markdownDescription": "Add entry point, usually `main`.\nSee [role-argument-validation](https://docs.ansible.com/ansible/latest/user_guide/playbooks_reuse_roles.html#role-argument-validation)",
+ "markdownDescription": "See [role-argument-validation](https://docs.ansible.com/ansible/latest/user_guide/playbooks_reuse_roles.html#role-argument-validation)",
"properties": {
"argument_specs": {
"additionalProperties": {
diff --git a/src/ansiblelint/schemas/rulebook.json b/src/ansiblelint/schemas/rulebook.json
index 6c441cd..6321f08 100644
--- a/src/ansiblelint/schemas/rulebook.json
+++ b/src/ansiblelint/schemas/rulebook.json
@@ -1,6 +1,8 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://raw.githubusercontent.com/ansible/ansible-rulebook/main/ansible_rulebook/schema/ruleset_schema.json",
+ "title": "Ansible Rulebook",
+ "description": "See https://ansible.readthedocs.io/projects/rulebook/en/stable/rulebooks.html",
"type": "array",
"items": {
"$ref": "#/$defs/ruleset"
@@ -25,12 +27,19 @@
"type": "boolean",
"default": false
},
+ "match_multiple_rules": {
+ "type": "boolean",
+ "default": false
+ },
"name": {
"type": "string"
},
"execution_strategy": {
"type": "string",
- "enum": ["sequential", "parallel"],
+ "enum": [
+ "parallel",
+ "sequential"
+ ],
"default": "sequential"
},
"sources": {
@@ -47,6 +56,7 @@
}
},
"required": [
+ "name",
"hosts",
"sources",
"rules"
@@ -147,6 +157,9 @@
"type": "string"
},
{
+ "type": "boolean"
+ },
+ {
"$ref": "#/$defs/all-condition"
},
{
@@ -171,6 +184,9 @@
"$ref": "#/$defs/run-job-template-action"
},
{
+ "$ref": "#/$defs/run-workflow-template-action"
+ },
+ {
"$ref": "#/$defs/post-event-action"
},
{
@@ -190,6 +206,9 @@
},
{
"$ref": "#/$defs/shutdown-action"
+ },
+ {
+ "$ref": "#/$defs/pg-notify-action"
}
]
}
@@ -206,6 +225,9 @@
"$ref": "#/$defs/run-job-template-action"
},
{
+ "$ref": "#/$defs/run-workflow-template-action"
+ },
+ {
"$ref": "#/$defs/post-event-action"
},
{
@@ -225,6 +247,9 @@
},
{
"$ref": "#/$defs/shutdown-action"
+ },
+ {
+ "$ref": "#/$defs/pg-notify-action"
}
]
}
@@ -342,9 +367,6 @@
"run_module": {
"type": "object",
"properties": {
- "copy_files": {
- "type": "boolean"
- },
"name": {
"type": "string"
},
@@ -376,7 +398,10 @@
"type": "number"
},
"module_args": {
- "type": "object"
+ "type": [
+ "object",
+ "string"
+ ]
},
"extra_vars": {
"type": "object"
@@ -442,6 +467,91 @@
],
"additionalProperties": false
},
+ "run-workflow-template-action": {
+ "type": "object",
+ "properties": {
+ "run_workflow_template": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "organization": {
+ "type": "string"
+ },
+ "job_args": {
+ "type": "object"
+ },
+ "post_events": {
+ "type": "boolean"
+ },
+ "set_facts": {
+ "type": "boolean"
+ },
+ "ruleset": {
+ "type": "string"
+ },
+ "var_root": {
+ "type": "string"
+ },
+ "retry": {
+ "type": "boolean"
+ },
+ "retries": {
+ "type": "integer"
+ },
+ "delay": {
+ "type": "integer"
+ }
+ },
+ "required": [
+ "name",
+ "organization"
+ ],
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "run_workflow_template"
+ ],
+ "additionalProperties": false
+ },
+ "pg-notify-action": {
+ "type": "object",
+ "properties": {
+ "pg_notify": {
+ "type": "object",
+ "properties": {
+ "dsn": {
+ "type": "string"
+ },
+ "channel": {
+ "type": "string"
+ },
+ "event": {
+ "type": [
+ "string",
+ "object"
+ ]
+ },
+ "remove_meta": {
+ "type": "boolean",
+ "default": false
+ }
+ },
+ "required": [
+ "dsn",
+ "channel",
+ "event"
+ ],
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "pg_notify"
+ ],
+ "additionalProperties": false
+ },
"post-event-action": {
"type": "object",
"properties": {
diff --git a/src/ansiblelint/schemas/tasks.json b/src/ansiblelint/schemas/tasks.json
index ec7f85d..d6efec8 100644
--- a/src/ansiblelint/schemas/tasks.json
+++ b/src/ansiblelint/schemas/tasks.json
@@ -123,8 +123,8 @@
"$ref": "#/$defs/ignore_errors"
},
"ignore_unreachable": {
- "title": "Ignore Unreachable",
- "type": "boolean"
+ "$ref": "#/$defs/templated-boolean",
+ "title": "Ignore Unreachable"
},
"module_defaults": {
"title": "Module Defaults"
@@ -238,6 +238,11 @@
"markdownDescription": "Use for protecting sensitive data. See [no_log](https://docs.ansible.com/ansible/latest/reference_appendices/logging.html)",
"title": "no_log"
},
+ "removed-include-module": {
+ "markdownDescription": "See [include module](https://docs.ansible.com/ansible/latest/collections/ansible/builtin/include_module.html)",
+ "not": {},
+ "title": "Replace 'include' with either 'ansible.builtin.include_tasks' or 'ansible.builtin.import_tasks'"
+ },
"tags": {
"anyOf": [
{
@@ -289,6 +294,12 @@
"title": "Action",
"type": "string"
},
+ "ansible.builtin.include": {
+ "$ref": "#/$defs/removed-include-module"
+ },
+ "ansible.legacy.include": {
+ "$ref": "#/$defs/removed-include-module"
+ },
"any_errors_fatal": {
"$ref": "#/$defs/templated-boolean",
"title": "Any Errors Fatal"
@@ -371,8 +382,11 @@
"$ref": "#/$defs/ignore_errors"
},
"ignore_unreachable": {
- "title": "Ignore Unreachable",
- "type": "boolean"
+ "$ref": "#/$defs/templated-boolean",
+ "title": "Ignore Unreachable"
+ },
+ "include": {
+ "$ref": "#/$defs/removed-include-module"
},
"listen": {
"anyOf": [
diff --git a/src/ansiblelint/schemas/vars.json b/src/ansiblelint/schemas/vars.json
index c0b66e8..44acb10 100644
--- a/src/ansiblelint/schemas/vars.json
+++ b/src/ansiblelint/schemas/vars.json
@@ -17,6 +17,7 @@
"type": "null"
}
],
+ "description": "https://docs.ansible.com/ansible/latest/playbook_guide/playbooks_variables.html",
"examples": [
"playbooks/vars/*.yml",
"vars/*.yml",
diff --git a/src/ansiblelint/skip_utils.py b/src/ansiblelint/skip_utils.py
index f2f6177..e1a3a8f 100644
--- a/src/ansiblelint/skip_utils.py
+++ b/src/ansiblelint/skip_utils.py
@@ -199,7 +199,7 @@ def _append_skipped_rules(
ruamel_tasks = _get_tasks_from_blocks(ruamel_task_blocks)
# append skipped_rules for each task
- for ruamel_task, pyyaml_task in zip(ruamel_tasks, pyyaml_tasks):
+ for ruamel_task, pyyaml_task in zip(ruamel_tasks, pyyaml_tasks, strict=False):
# ignore empty tasks
if not pyyaml_task and not ruamel_task:
continue
@@ -240,7 +240,7 @@ def _get_tasks_from_blocks(task_blocks: Sequence[Any]) -> Generator[Any, None, N
if not task or not is_nested_task(task):
return
for k in NESTED_TASK_KEYS:
- if k in task and task[k]:
+ if task.get(k):
if hasattr(task[k], "get"):
continue
for subtask in task[k]:
@@ -279,16 +279,14 @@ def _get_rule_skips_from_yaml(
yaml_comment_obj_strings.append(str(obj.ca.items))
if isinstance(obj, dict):
for val in obj.values():
- if isinstance(val, (dict, list)):
+ if isinstance(val, dict | list):
traverse_yaml(val)
elif isinstance(obj, list):
for element in obj:
- if isinstance(element, (dict, list)):
+ if isinstance(element, dict | list):
traverse_yaml(element)
- else:
- return
- if isinstance(yaml_input, (dict, list)):
+ if isinstance(yaml_input, dict | list):
traverse_yaml(yaml_input)
rule_id_list = []
diff --git a/src/ansiblelint/stats.py b/src/ansiblelint/stats.py
index 67320b8..79475d2 100644
--- a/src/ansiblelint/stats.py
+++ b/src/ansiblelint/stats.py
@@ -1,4 +1,5 @@
"""Module hosting functionality about reporting."""
+
from __future__ import annotations
from dataclasses import dataclass, field
diff --git a/src/ansiblelint/testing/__init__.py b/src/ansiblelint/testing/__init__.py
index e7f6c1b..9c5463f 100644
--- a/src/ansiblelint/testing/__init__.py
+++ b/src/ansiblelint/testing/__init__.py
@@ -1,4 +1,5 @@
"""Test utils for ansible-lint."""
+
from __future__ import annotations
import os
@@ -13,7 +14,6 @@ from ansiblelint.app import get_app
if TYPE_CHECKING:
# https://github.com/PyCQA/pylint/issues/3240
- # pylint: disable=unsubscriptable-object
CompletedProcess = subprocess.CompletedProcess[Any]
from ansiblelint.errors import MatchError
from ansiblelint.rules import RulesCollection
@@ -156,4 +156,5 @@ def run_ansible_lint(
cwd=cwd,
env=_env,
text=True,
+ encoding="utf-8",
)
diff --git a/src/ansiblelint/testing/fixtures.py b/src/ansiblelint/testing/fixtures.py
index 814a076..05e1ad7 100644
--- a/src/ansiblelint/testing/fixtures.py
+++ b/src/ansiblelint/testing/fixtures.py
@@ -5,21 +5,19 @@ file:
pytest_plugins = ['ansiblelint.testing']
"""
+
from __future__ import annotations
-import copy
from typing import TYPE_CHECKING
import pytest
-from ansiblelint.config import Options, options
+from ansiblelint.config import Options
from ansiblelint.constants import DEFAULT_RULESDIR
from ansiblelint.rules import RulesCollection
from ansiblelint.testing import RunFromText
if TYPE_CHECKING:
- from collections.abc import Iterator
-
from _pytest.fixtures import SubRequest
@@ -29,13 +27,12 @@ if TYPE_CHECKING:
def fixture_default_rules_collection() -> RulesCollection:
"""Return default rule collection."""
assert DEFAULT_RULESDIR.is_dir()
- # For testing we want to manually enable opt-in rules
- test_options = copy.deepcopy(options)
- test_options.enable_list = ["no-same-owner"]
+ config_options = Options()
+ config_options.enable_list = ["no-same-owner"]
# That is instantiated very often and do want to avoid ansible-galaxy
# install errors due to concurrency.
- test_options.offline = True
- return RulesCollection(rulesdirs=[DEFAULT_RULESDIR], options=test_options)
+ config_options.offline = True
+ return RulesCollection(rulesdirs=[DEFAULT_RULESDIR], options=config_options)
@pytest.fixture()
@@ -45,9 +42,10 @@ def default_text_runner(default_rules_collection: RulesCollection) -> RunFromTex
@pytest.fixture()
-def rule_runner(request: SubRequest, config_options: Options) -> RunFromText:
+def rule_runner(request: SubRequest) -> RunFromText:
"""Return runner for a specific rule class."""
rule_class = request.param
+ config_options = Options()
config_options.enable_list.append(rule_class().id)
collection = RulesCollection(options=config_options)
collection.register(rule_class())
@@ -55,9 +53,6 @@ def rule_runner(request: SubRequest, config_options: Options) -> RunFromText:
@pytest.fixture(name="config_options")
-def fixture_config_options() -> Iterator[Options]:
+def fixture_config_options() -> Options:
"""Return configuration options that will be restored after testrun."""
- global options # pylint: disable=global-statement,invalid-name # noqa: PLW0603
- original_options = copy.deepcopy(options)
- yield options
- options = original_options
+ return Options()
diff --git a/src/ansiblelint/text.py b/src/ansiblelint/text.py
index 038fde1..3510f75 100644
--- a/src/ansiblelint/text.py
+++ b/src/ansiblelint/text.py
@@ -1,4 +1,5 @@
"""Text utils."""
+
from __future__ import annotations
import re
@@ -6,6 +7,7 @@ from functools import cache
RE_HAS_JINJA = re.compile(r"{[{%#].*[%#}]}", re.DOTALL)
RE_HAS_GLOB = re.compile("[][*?]")
+RE_IS_FQCN_OR_NAME = re.compile(r"^\w+(\.\w+\.\w+)?$")
def strip_ansi_escape(data: str | bytes) -> str:
@@ -47,3 +49,9 @@ def has_jinja(value: str) -> bool:
def has_glob(value: str) -> bool:
"""Return true if a string looks like having a glob pattern."""
return bool(isinstance(value, str) and RE_HAS_GLOB.search(value))
+
+
+@cache
+def is_fqcn_or_name(value: str) -> bool:
+ """Return true if a string seems to be a module/filter old name or a fully qualified one."""
+ return bool(isinstance(value, str) and RE_IS_FQCN_OR_NAME.search(value))
diff --git a/src/ansiblelint/transformer.py b/src/ansiblelint/transformer.py
index 3716ef9..c610704 100644
--- a/src/ansiblelint/transformer.py
+++ b/src/ansiblelint/transformer.py
@@ -1,8 +1,10 @@
+# cspell:ignore classinfo
"""Transformer implementation."""
+
from __future__ import annotations
import logging
-from typing import TYPE_CHECKING, Union, cast
+from typing import TYPE_CHECKING, cast
from ruamel.yaml.comments import CommentedMap, CommentedSeq
@@ -20,7 +22,6 @@ __all__ = ["Transformer"]
_logger = logging.getLogger(__name__)
-# pylint: disable=too-few-public-methods
class Transformer:
"""Transformer class marshals transformations.
@@ -33,6 +34,17 @@ class Transformer:
pre-requisite for the planned rule-specific transforms.
"""
+ DUMP_MSG = "Rewriting yaml file:"
+ FIX_NA_MSG = "Rule specific fix not available for:"
+ FIX_NE_MSG = "Rule specific fix not enabled for:"
+ FIX_APPLY_MSG = "Applying rule specific fix for:"
+ FIX_FAILED_MSG = "Rule specific fix failed for:"
+ FIX_ISSUE_MSG = (
+ "Please file an issue for this with the task or playbook that caused the error."
+ )
+ FIX_APPLIED_MSG = "Rule specific fix applied for:"
+ FIX_NOT_APPLIED_MSG = "Rule specific fix not applied for:"
+
def __init__(self, result: LintResult, options: Options):
"""Initialize a Transformer instance."""
self.write_set = self.effective_write_set(options.write_list)
@@ -44,8 +56,8 @@ class Transformer:
self.matches_per_file: dict[Lintable, list[MatchError]] = {
file: [] for file in result.files
}
-
- for match in self.matches:
+ not_ignored = [match for match in self.matches if not match.ignored]
+ for match in not_ignored:
try:
lintable = lintables[match.filename]
except KeyError:
@@ -93,10 +105,13 @@ class Transformer:
# We need a fresh YAML() instance for each load because ruamel.yaml
# stores intermediate state during load which could affect loading
# any other files. (Based on suggestion from ruamel.yaml author)
- yaml = FormattedYAML()
+ yaml = FormattedYAML(
+ # Ansible only uses YAML 1.1, but others files should use newer 1.2 (ruamel.yaml defaults to 1.2)
+ version=(1, 1) if file.is_owned_by_ansible() else None,
+ )
- ruamel_data = yaml.loads(data)
- if not isinstance(ruamel_data, (CommentedMap, CommentedSeq)):
+ ruamel_data = yaml.load(data)
+ if not isinstance(ruamel_data, CommentedMap | CommentedSeq):
# This is an empty vars file or similar which loads as None.
# It is not safe to write this file or data-loss is likely.
# Only maps and sequences can preserve comments. Skip it.
@@ -110,6 +125,7 @@ class Transformer:
self._do_transforms(file, ruamel_data or data, file_is_yaml, matches)
if file_is_yaml:
+ _logger.debug("%s %s, version=%s", self.DUMP_MSG, file, yaml.version)
# noinspection PyUnboundLocalVariable
file.content = yaml.dumps(ruamel_data)
@@ -125,17 +141,19 @@ class Transformer:
) -> None:
"""Do Rule-Transforms handling any last-minute MatchError inspections."""
for match in sorted(matches):
+ match_id = f"{match.tag}/{match.match_type} {match.filename}:{match.lineno}"
if not isinstance(match.rule, TransformMixin):
+ logging.debug("%s %s", self.FIX_NA_MSG, match_id)
continue
if self.write_set != {"all"}:
rule = cast(AnsibleLintRule, match.rule)
rule_definition = set(rule.tags)
rule_definition.add(rule.id)
if rule_definition.isdisjoint(self.write_set):
- # rule transform not requested. Skip it.
+ logging.debug("%s %s", self.FIX_NE_MSG, match_id)
continue
if file_is_yaml and not match.yaml_path:
- data = cast(Union[CommentedMap, CommentedSeq], data)
+ data = cast(CommentedMap | CommentedSeq, data)
if match.match_type == "play":
match.yaml_path = get_path_to_play(file, match.lineno, data)
elif match.task or file.kind in (
@@ -144,4 +162,16 @@ class Transformer:
"playbook",
):
match.yaml_path = get_path_to_task(file, match.lineno, data)
- match.rule.transform(match, file, data)
+
+ logging.debug("%s %s", self.FIX_APPLY_MSG, match_id)
+ try:
+ match.rule.transform(match, file, data)
+ except Exception as exc: # pylint: disable=broad-except
+ _logger.error("%s %s", self.FIX_FAILED_MSG, match_id) # noqa: TRY400
+ _logger.exception(exc) # noqa: TRY401
+ _logger.error(self.FIX_ISSUE_MSG) # noqa: TRY400
+ continue
+ if match.fixed:
+ _logger.debug("%s %s", self.FIX_APPLIED_MSG, match_id)
+ else:
+ _logger.error("%s %s", self.FIX_NOT_APPLIED_MSG, match_id)
diff --git a/src/ansiblelint/utils.py b/src/ansiblelint/utils.py
index 9cb97aa..3d0e535 100644
--- a/src/ansiblelint/utils.py
+++ b/src/ansiblelint/utils.py
@@ -22,26 +22,35 @@
"""Generic utility helpers."""
from __future__ import annotations
+import ast
import contextlib
import inspect
import logging
import os
import re
-from collections.abc import Generator, ItemsView, Iterator, Mapping, Sequence
+from collections.abc import ItemsView, Iterable, Iterator, Mapping, Sequence
from dataclasses import _MISSING_TYPE, dataclass, field
-from functools import cache
+from functools import cache, lru_cache
from pathlib import Path
-from typing import Any
+from typing import TYPE_CHECKING, Any
+import ruamel.yaml.parser
import yaml
from ansible.errors import AnsibleError, AnsibleParserError
from ansible.module_utils.parsing.convert_bool import boolean
from ansible.parsing.dataloader import DataLoader
from ansible.parsing.mod_args import ModuleArgsParser
+from ansible.parsing.plugin_docs import read_docstring
+from ansible.parsing.splitter import split_args
from ansible.parsing.yaml.constructor import AnsibleConstructor, AnsibleMapping
from ansible.parsing.yaml.loader import AnsibleLoader
from ansible.parsing.yaml.objects import AnsibleBaseYAMLObject, AnsibleSequence
-from ansible.plugins.loader import add_all_plugin_dirs
+from ansible.plugins.loader import (
+ PluginLoadContext,
+ action_loader,
+ add_all_plugin_dirs,
+ module_loader,
+)
from ansible.template import Templar
from ansible.utils.collection_loader import AnsibleCollectionConfig
from yaml.composer import Composer
@@ -51,7 +60,7 @@ from ansiblelint._internal.rules import (
AnsibleParserErrorRule,
RuntimeErrorRule,
)
-from ansiblelint.app import get_app
+from ansiblelint.app import App, get_app
from ansiblelint.config import Options, options
from ansiblelint.constants import (
ANNOTATION_KEYS,
@@ -67,8 +76,10 @@ from ansiblelint.constants import (
from ansiblelint.errors import MatchError
from ansiblelint.file_utils import Lintable, discover_lintables
from ansiblelint.skip_utils import is_nested_task
-from ansiblelint.text import removeprefix
+from ansiblelint.text import has_jinja, removeprefix
+if TYPE_CHECKING:
+ from ansiblelint.rules import RulesCollection
# ansible-lint doesn't need/want to know about encrypted secrets, so we pass a
# string as the password to enable such yaml files to be opened and parsed
# successfully.
@@ -164,7 +175,6 @@ def ansible_template(
for _i in range(10):
try:
templated = templar.template(varname, **kwargs)
- return templated
except AnsibleError as exc:
if lookup_error in exc.message:
return varname
@@ -186,16 +196,14 @@ def ansible_template(
_logger.warning(err)
raise
- # pylint: disable=protected-access
- templar.environment.filters._delegatee[ # noqa: SLF001
- missing_filter
- ] = mock_filter
+ templar.environment.filters._delegatee[missing_filter] = mock_filter # fmt: skip # noqa: SLF001
# Record the mocked filter so we can warn the user
if missing_filter not in options.mock_filters:
_logger.debug("Mocking missing filter %s", missing_filter)
options.mock_filters.append(missing_filter)
continue
raise
+ return templated
return None
@@ -210,26 +218,23 @@ BLOCK_NAME_TO_ACTION_TYPE_MAP = {
}
-def tokenize(line: str) -> tuple[str, list[str], dict[str, str]]:
+def tokenize(value: str) -> tuple[list[str], dict[str, str]]:
"""Parse a string task invocation."""
- tokens = line.lstrip().split(" ")
- if tokens[0] == "-":
- tokens = tokens[1:]
- if tokens[0] == "action:" or tokens[0] == "local_action:":
- tokens = tokens[1:]
- command = tokens[0].replace(":", "")
-
- args = []
- kwargs = {}
- non_kv_found = False
- for arg in tokens[1:]:
- if "=" in arg and not non_kv_found:
- key_value = arg.split("=", 1)
- kwargs[key_value[0]] = key_value[1]
+ # We do not try to tokenize something very simple because it would fail to
+ # work for a case like: task_include: path with space.yml
+ if value and "=" not in value:
+ return ([value], {})
+
+ parts = split_args(value)
+ args: list[str] = []
+ kwargs: dict[str, str] = {}
+ for part in parts:
+ if "=" not in part:
+ args.append(part)
else:
- non_kv_found = True
- args.append(arg)
- return (command, args, kwargs)
+ k, v = part.split("=", 1)
+ kwargs[k] = v
+ return (args, kwargs)
def playbook_items(pb_data: AnsibleBaseYAMLObject) -> ItemsView: # type: ignore[type-arg]
@@ -278,106 +283,179 @@ def template(
return value
-def _include_children(
- basedir: str,
- k: str,
- v: Any,
- parent_type: FileType,
-) -> list[Lintable]:
- # handle special case include_tasks: name=filename.yml
- if k in INCLUSION_ACTION_NAMES and isinstance(v, dict) and "file" in v:
- v = v["file"]
-
- # we cannot really parse any jinja2 in includes, so we ignore them
- if not v or "{{" in v:
- return []
-
- if "import_playbook" in k and COLLECTION_PLAY_RE.match(v):
- # Any import_playbooks from collections should be ignored as ansible
- # own syntax check will handle them.
- return []
-
- # handle include: filename.yml tags=blah
- # pylint: disable=unused-variable
- (command, args, kwargs) = tokenize(f"{k}: {v}")
-
- result = path_dwim(basedir, args[0])
- while basedir not in ["", "/"]:
- if os.path.exists(result):
- break
- basedir = os.path.dirname(basedir)
- result = path_dwim(basedir, args[0])
-
- return [Lintable(result, kind=parent_type)]
-
-
-def _taskshandlers_children(
- basedir: str,
- k: str,
- v: None | Any,
- parent_type: FileType,
-) -> list[Lintable]:
- results: list[Lintable] = []
- if v is None:
- raise MatchError(
- message="A malformed block was encountered while loading a block.",
- rule=RuntimeErrorRule(),
- )
- for task_handler in v:
- # ignore empty tasks, `-`
- if not task_handler:
- continue
+@dataclass
+class HandleChildren:
+ """Parse task, roles and children."""
+
+ rules: RulesCollection = field(init=True, repr=False)
+ app: App
+
+ def include_children( # pylint: disable=too-many-return-statements
+ self,
+ lintable: Lintable,
+ k: str,
+ v: Any,
+ parent_type: FileType,
+ ) -> list[Lintable]:
+ """Include children."""
+ basedir = str(lintable.path.parent)
+ # import_playbook only accepts a string as argument (no dict syntax)
+ if k in (
+ "import_playbook",
+ "ansible.builtin.import_playbook",
+ ) and not isinstance(v, str):
+ return []
+
+ # handle special case include_tasks: name=filename.yml
+ if k in INCLUSION_ACTION_NAMES and isinstance(v, dict) and "file" in v:
+ v = v["file"]
+
+ # we cannot really parse any jinja2 in includes, so we ignore them
+ if not v or "{{" in v:
+ return []
+
+ if k in ("import_playbook", "ansible.builtin.import_playbook"):
+ included = Path(basedir) / v
+ if self.app.runtime.has_playbook(v, basedir=Path(basedir)):
+ if included.exists():
+ return [Lintable(included, kind=parent_type)]
+ return []
+ msg = f"Failed to find {v} playbook."
+ logging.error(msg)
+ return []
+
+ # handle include: filename.yml tags=blah
+ (args, kwargs) = tokenize(v)
+
+ if args:
+ file = args[0]
+ elif "file" in kwargs:
+ file = kwargs["file"]
+ else:
+ return []
- with contextlib.suppress(LookupError):
- children = _get_task_handler_children_for_tasks_or_playbooks(
- task_handler,
- basedir,
- k,
- parent_type,
+ result = path_dwim(basedir, file)
+ while basedir not in ["", "/"]:
+ if os.path.exists(result):
+ break
+ basedir = os.path.dirname(basedir)
+ result = path_dwim(basedir, file)
+
+ return [Lintable(result, kind=parent_type)]
+
+ def taskshandlers_children(
+ self,
+ lintable: Lintable,
+ k: str,
+ v: None | Any,
+ parent_type: FileType,
+ ) -> list[Lintable]:
+ """TasksHandlers Children."""
+ basedir = str(lintable.path.parent)
+ results: list[Lintable] = []
+ if v is None or isinstance(v, int | str):
+ raise MatchError(
+ message="A malformed block was encountered while loading a block.",
+ rule=RuntimeErrorRule(),
)
- results.append(children)
- continue
+ for task_handler in v:
+ # ignore empty tasks, `-`
+ if not task_handler:
+ continue
- if any(x in task_handler for x in ROLE_IMPORT_ACTION_NAMES):
- task_handler = normalize_task_v2(task_handler)
- _validate_task_handler_action_for_role(task_handler["action"])
- results.extend(
- _roles_children(
+ with contextlib.suppress(LookupError):
+ children = _get_task_handler_children_for_tasks_or_playbooks(
+ task_handler,
basedir,
k,
- [task_handler["action"].get("name")],
parent_type,
- main=task_handler["action"].get("tasks_from", "main"),
- ),
- )
- continue
+ )
+ results.append(children)
+ continue
- if "block" not in task_handler:
- continue
+ if any(x in task_handler for x in ROLE_IMPORT_ACTION_NAMES):
+ task_handler = normalize_task_v2(
+ Task(task_handler, filename=str(lintable.path)),
+ )
+ self._validate_task_handler_action_for_role(task_handler["action"])
+ name = task_handler["action"].get("name")
+ if has_jinja(name):
+ # we cannot deal with dynamic imports
+ continue
+ results.extend(
+ self.roles_children(lintable, k, [name], parent_type),
+ )
+ continue
- results.extend(
- _taskshandlers_children(basedir, k, task_handler["block"], parent_type),
- )
- if "rescue" in task_handler:
- results.extend(
- _taskshandlers_children(
- basedir,
- k,
- task_handler["rescue"],
- parent_type,
+ if "block" not in task_handler:
+ continue
+
+ for elem in ("block", "rescue", "always"):
+ if elem in task_handler:
+ results.extend(
+ self.taskshandlers_children(
+ lintable,
+ k,
+ task_handler[elem],
+ parent_type,
+ ),
+ )
+
+ return results
+
+ def _validate_task_handler_action_for_role(self, th_action: dict[str, Any]) -> None:
+ """Verify that the task handler action is valid for role include."""
+ module = th_action["__ansible_module__"]
+
+ if "name" not in th_action:
+ raise MatchError(
+ message=f"Failed to find required 'name' key in {module!s}",
+ rule=self.rules.rules[0],
+ lintable=Lintable(
+ (
+ self.rules.options.lintables[0]
+ if self.rules.options.lintables
+ else "."
+ ),
),
)
- if "always" in task_handler:
- results.extend(
- _taskshandlers_children(
- basedir,
- k,
- task_handler["always"],
- parent_type,
- ),
+
+ if not isinstance(th_action["name"], str):
+ raise MatchError(
+ message=f"Value assigned to 'name' key on '{module!s}' is not a string.",
+ rule=self.rules.rules[1],
)
- return results
+ def roles_children(
+ self,
+ lintable: Lintable,
+ k: str,
+ v: Sequence[Any],
+ parent_type: FileType,
+ ) -> list[Lintable]:
+ """Roles children."""
+ # pylint: disable=unused-argument # parent_type)
+ basedir = str(lintable.path.parent)
+ results: list[Lintable] = []
+ if not v or not isinstance(v, Iterable):
+ # typing does not prevent junk from being passed in
+ return results
+ for role in v:
+ if isinstance(role, dict):
+ if "role" in role or "name" in role:
+ if "tags" not in role or "skip_ansible_lint" not in role["tags"]:
+ results.extend(
+ _look_for_role_files(
+ basedir,
+ role.get("role", role.get("name")),
+ ),
+ )
+ elif k != "dependencies":
+ msg = f'role dict {role} does not contain a "role" or "name" key'
+ raise SystemExit(msg)
+ else:
+ results.extend(_look_for_role_files(basedir, role))
+ return results
def _get_task_handler_children_for_tasks_or_playbooks(
@@ -393,13 +471,27 @@ def _get_task_handler_children_for_tasks_or_playbooks(
for task_handler_key in INCLUSION_ACTION_NAMES:
with contextlib.suppress(KeyError):
# ignore empty tasks
- if not task_handler: # pragma: no branch
+ if not task_handler or isinstance(task_handler, str): # pragma: no branch
continue
- file_name = task_handler[task_handler_key]
- if isinstance(file_name, Mapping) and file_name.get("file", None):
- file_name = file_name["file"]
+ file_name = ""
+ action_args = task_handler[task_handler_key]
+ if isinstance(action_args, str):
+ (args, kwargs) = tokenize(action_args)
+ if len(args) == 1:
+ file_name = args[0]
+ elif kwargs.get("file", None):
+ file_name = kwargs["file"]
+ else:
+ # ignore invalid data (syntax check will outside the scope)
+ continue
+
+ if isinstance(action_args, Mapping) and action_args.get("file", None):
+ file_name = action_args["file"]
+ if not file_name:
+ # ignore invalid data (syntax check will outside the scope)
+ continue
f = path_dwim(basedir, file_name)
while basedir not in ["", "/"]:
if os.path.exists(f):
@@ -411,50 +503,6 @@ def _get_task_handler_children_for_tasks_or_playbooks(
raise LookupError(msg)
-def _validate_task_handler_action_for_role(th_action: dict[str, Any]) -> None:
- """Verify that the task handler action is valid for role include."""
- module = th_action["__ansible_module__"]
-
- if "name" not in th_action:
- raise MatchError(message=f"Failed to find required 'name' key in {module!s}")
-
- if not isinstance(th_action["name"], str):
- raise MatchError(
- message=f"Value assigned to 'name' key on '{module!s}' is not a string.",
- )
-
-
-def _roles_children(
- basedir: str,
- k: str,
- v: Sequence[Any],
- parent_type: FileType, # noqa: ARG001
- main: str = "main",
-) -> list[Lintable]:
- # pylint: disable=unused-argument # parent_type)
- results: list[Lintable] = []
- if not v:
- # typing does not prevent junk from being passed in
- return results
- for role in v:
- if isinstance(role, dict):
- if "role" in role or "name" in role:
- if "tags" not in role or "skip_ansible_lint" not in role["tags"]:
- results.extend(
- _look_for_role_files(
- basedir,
- role.get("role", role.get("name")),
- main=main,
- ),
- )
- elif k != "dependencies":
- msg = f'role dict {role} does not contain a "role" or "name" key'
- raise SystemExit(msg)
- else:
- results.extend(_look_for_role_files(basedir, role, main=main))
- return results
-
-
def _rolepath(basedir: str, role: str) -> str | None:
role_path = None
@@ -469,7 +517,7 @@ def _rolepath(basedir: str, role: str) -> str | None:
path_dwim(basedir, os.path.join("..", role)),
]
- for loc in get_app(offline=True).runtime.config.default_roles_path:
+ for loc in get_app(cached=True).runtime.config.default_roles_path:
loc = os.path.expanduser(loc)
possible_paths.append(path_dwim(loc, role))
@@ -486,12 +534,7 @@ def _rolepath(basedir: str, role: str) -> str | None:
return role_path
-def _look_for_role_files(
- basedir: str,
- role: str,
- main: str | None = "main", # noqa: ARG001
-) -> list[Lintable]:
- # pylint: disable=unused-argument # main
+def _look_for_role_files(basedir: str, role: str) -> list[Lintable]:
role_path = _rolepath(basedir, role)
if not role_path: # pragma: no branch
return []
@@ -539,13 +582,14 @@ def _extract_ansible_parsed_keys_from_task(
return result
-def normalize_task_v2(task: dict[str, Any]) -> dict[str, Any]:
+def normalize_task_v2(task: Task) -> dict[str, Any]:
"""Ensure tasks have a normalized action key and strings are converted to python objects."""
+ raw_task = task.raw_task
result: dict[str, Any] = {}
ansible_parsed_keys = ("action", "local_action", "args", "delegate_to")
- if is_nested_task(task):
- _extract_ansible_parsed_keys_from_task(result, task, ansible_parsed_keys)
+ if is_nested_task(raw_task):
+ _extract_ansible_parsed_keys_from_task(result, raw_task, ansible_parsed_keys)
# Add dummy action for block/always/rescue statements
result["action"] = {
"__ansible_module__": "block/always/rescue",
@@ -554,7 +598,7 @@ def normalize_task_v2(task: dict[str, Any]) -> dict[str, Any]:
return result
- sanitized_task = _sanitize_task(task)
+ sanitized_task = _sanitize_task(raw_task)
mod_arg_parser = ModuleArgsParser(sanitized_task)
try:
@@ -562,12 +606,11 @@ def normalize_task_v2(task: dict[str, Any]) -> dict[str, Any]:
skip_action_validation=options.skip_action_validation,
)
except AnsibleParserError as exc:
- # pylint: disable=raise-missing-from
raise MatchError(
rule=AnsibleParserErrorRule(),
message=exc.message,
- filename=task.get(FILENAME_KEY, "Unknown"),
- lineno=task.get(LINE_NUMBER_KEY, 0),
+ lintable=Lintable(task.filename or ""),
+ lineno=raw_task.get(LINE_NUMBER_KEY, 1),
) from exc
# denormalize shell -> command conversion
@@ -577,13 +620,13 @@ def normalize_task_v2(task: dict[str, Any]) -> dict[str, Any]:
_extract_ansible_parsed_keys_from_task(
result,
- task,
+ raw_task,
(*ansible_parsed_keys, action),
)
if not isinstance(action, str):
msg = f"Task actions can only be strings, got {action}"
- raise RuntimeError(msg)
+ raise TypeError(msg)
action_unnormalized = action
# convert builtin fqn calls to short forms because most rules know only
# about short calls but in the future we may switch the normalization to do
@@ -599,17 +642,6 @@ def normalize_task_v2(task: dict[str, Any]) -> dict[str, Any]:
return result
-def normalize_task(task: dict[str, Any], filename: str) -> dict[str, Any]:
- """Unify task-like object structures."""
- ansible_action_type = task.get("__ansible_action_type__", "task")
- if "__ansible_action_type__" in task:
- del task["__ansible_action_type__"]
- task = normalize_task_v2(task)
- task[FILENAME_KEY] = filename
- task["__ansible_action_type__"] = ansible_action_type
- return task
-
-
def task_to_str(task: dict[str, Any]) -> str:
"""Make a string identifier for the given task."""
name = task.get("name")
@@ -634,7 +666,7 @@ def task_to_str(task: dict[str, Any]) -> str:
_raw_params = action.get("_raw_params", [])
if isinstance(_raw_params, list):
for item in _raw_params:
- args.append(str(item))
+ args.extend(str(item))
else:
args.append(_raw_params)
@@ -698,7 +730,11 @@ class Task(dict[str, Any]):
@property
def name(self) -> str | None:
"""Return the name of the task."""
- return self.raw_task.get("name", None)
+ name = self.raw_task.get("name", None)
+ if name is not None and not isinstance(name, str):
+ msg = "Task name can only be a string."
+ raise RuntimeError(msg)
+ return name
@property
def action(self) -> str:
@@ -706,7 +742,7 @@ class Task(dict[str, Any]):
action_name = self.normalized_task["action"]["__ansible_module_original__"]
if not isinstance(action_name, str):
msg = "Task actions can only be strings."
- raise RuntimeError(msg)
+ raise TypeError(msg)
return action_name
@property
@@ -729,10 +765,7 @@ class Task(dict[str, Any]):
"""Return the name of the task."""
if not hasattr(self, "_normalized_task"):
try:
- self._normalized_task = normalize_task(
- self.raw_task,
- filename=self.filename,
- )
+ self._normalized_task = self._normalize_task()
except MatchError as err:
self.error = err
# When we cannot normalize it, we just use the raw task instead
@@ -740,15 +773,35 @@ class Task(dict[str, Any]):
self._normalized_task = self.raw_task
if isinstance(self._normalized_task, _MISSING_TYPE):
msg = "Task was not normalized"
- raise RuntimeError(msg)
+ raise TypeError(msg)
return self._normalized_task
+ def _normalize_task(self) -> dict[str, Any]:
+ """Unify task-like object structures."""
+ ansible_action_type = self.raw_task.get("__ansible_action_type__", "task")
+ if "__ansible_action_type__" in self.raw_task:
+ del self.raw_task["__ansible_action_type__"]
+ task = normalize_task_v2(self)
+ task[FILENAME_KEY] = self.filename
+ task["__ansible_action_type__"] = ansible_action_type
+ return task
+
@property
def skip_tags(self) -> list[str]:
"""Return the list of tags to skip."""
skip_tags: list[str] = self.raw_task.get(SKIPPED_RULES_KEY, [])
return skip_tags
+ def is_handler(self) -> bool:
+ """Return true for tasks that are handlers."""
+ is_handler_file = False
+ if isinstance(self._normalized_task, dict):
+ file_name = str(self._normalized_task["action"].get(FILENAME_KEY, None))
+ if file_name:
+ paths = file_name.split("/")
+ is_handler_file = "handlers" in paths
+ return is_handler_file if is_handler_file else ".handlers[" in self.position
+
def __repr__(self) -> str:
"""Return a string representation of the task."""
return f"Task('{self.name}' [{self.position}])"
@@ -761,7 +814,7 @@ class Task(dict[str, Any]):
"""Allow access as task[...]."""
return self.normalized_task[index]
- def __iter__(self) -> Generator[str, None, None]:
+ def __iter__(self) -> Iterator[str]:
"""Provide support for 'key in task'."""
yield from (f for f in self.normalized_task)
@@ -857,7 +910,7 @@ def parse_yaml_linenumbers(
node = Composer.compose_node(loader, parent, index)
if not isinstance(node, yaml.nodes.Node):
msg = "Unexpected yaml data."
- raise RuntimeError(msg)
+ raise TypeError(msg)
node.__line__ = line + 1 # type: ignore[attr-defined]
return node
@@ -870,9 +923,7 @@ def parse_yaml_linenumbers(
if hasattr(node, "__line__"):
mapping[LINE_NUMBER_KEY] = node.__line__
else:
- mapping[
- LINE_NUMBER_KEY
- ] = mapping._line_number # pylint: disable=protected-access # noqa: SLF001
+ mapping[LINE_NUMBER_KEY] = mapping._line_number # noqa: SLF001
mapping[FILENAME_KEY] = lintable.path
return mapping
@@ -895,8 +946,9 @@ def parse_yaml_linenumbers(
yaml.parser.ParserError,
yaml.scanner.ScannerError,
yaml.constructor.ConstructorError,
+ ruamel.yaml.parser.ParserError,
) as exc:
- msg = "Failed to load YAML file"
+ msg = f"Failed to load YAML file: {lintable.path}"
raise RuntimeError(msg) from exc
if len(result) == 0:
@@ -975,7 +1027,6 @@ def is_playbook(filename: str) -> bool:
return False
-# pylint: disable=too-many-statements
def get_lintables(
opts: Options = options,
args: list[str] | None = None,
@@ -1018,3 +1069,49 @@ def _extend_with_roles(lintables: list[Lintable]) -> None:
def convert_to_boolean(value: Any) -> bool:
"""Use Ansible to convert something to a boolean."""
return bool(boolean(value))
+
+
+def parse_examples_from_plugin(lintable: Lintable) -> tuple[int, str]:
+ """Parse yaml inside plugin EXAMPLES string.
+
+ Store a line number offset to realign returned line numbers later
+ """
+ offset = 1
+ parsed = ast.parse(lintable.content)
+ for child in parsed.body:
+ if isinstance(child, ast.Assign):
+ label = child.targets[0]
+ if isinstance(label, ast.Name) and label.id == "EXAMPLES":
+ offset = child.lineno - 1
+ break
+
+ docs = read_docstring(str(lintable.path))
+ examples = docs["plainexamples"]
+
+ # Ignore the leading newline and lack of document start
+ # as including those in EXAMPLES would be weird.
+ return offset, (f"---{examples}" if examples else "")
+
+
+@lru_cache
+def load_plugin(name: str) -> PluginLoadContext:
+ """Return loaded ansible plugin/module."""
+ loaded_module = action_loader.find_plugin_with_context(
+ name,
+ ignore_deprecated=True,
+ check_aliases=True,
+ )
+ if not loaded_module.resolved:
+ loaded_module = module_loader.find_plugin_with_context(
+ name,
+ ignore_deprecated=True,
+ check_aliases=True,
+ )
+ if not loaded_module.resolved and name.startswith("ansible.builtin."):
+ # fallback to core behavior of using legacy
+ loaded_module = module_loader.find_plugin_with_context(
+ name.replace("ansible.builtin.", "ansible.legacy."),
+ ignore_deprecated=True,
+ check_aliases=True,
+ )
+ return loaded_module
diff --git a/src/ansiblelint/version.py b/src/ansiblelint/version.py
index a65c3cf..80a0f7d 100644
--- a/src/ansiblelint/version.py
+++ b/src/ansiblelint/version.py
@@ -1,4 +1,5 @@
"""Ansible-lint version information."""
+
try:
from ._version import version as __version__
except ImportError: # pragma: no cover
diff --git a/src/ansiblelint/yaml_utils.py b/src/ansiblelint/yaml_utils.py
index cc7e9ef..a1b963d 100644
--- a/src/ansiblelint/yaml_utils.py
+++ b/src/ansiblelint/yaml_utils.py
@@ -1,4 +1,5 @@
"""Utility helpers to simplify working with yaml-based data."""
+
# pylint: disable=too-many-lines
from __future__ import annotations
@@ -6,21 +7,23 @@ import functools
import logging
import os
import re
-from collections.abc import Iterator, Sequence
+from collections.abc import Callable, Iterator, Sequence
from io import StringIO
from pathlib import Path
from re import Pattern
-from typing import TYPE_CHECKING, Any, Callable, Union, cast
+from typing import TYPE_CHECKING, Any, cast
import ruamel.yaml.events
from ruamel.yaml.comments import CommentedMap, CommentedSeq, Format
+from ruamel.yaml.composer import ComposerError
from ruamel.yaml.constructor import RoundTripConstructor
from ruamel.yaml.emitter import Emitter, ScalarAnalysis
# Module 'ruamel.yaml' does not explicitly export attribute 'YAML'; implicit reexport disabled
# To make the type checkers happy, we import from ruamel.yaml.main instead.
from ruamel.yaml.main import YAML
-from ruamel.yaml.scalarint import ScalarInt
+from ruamel.yaml.parser import ParserError
+from ruamel.yaml.scalarint import HexInt, ScalarInt
from yamllint.config import YamlLintConfig
from ansiblelint.constants import (
@@ -32,7 +35,8 @@ from ansiblelint.utils import Task
if TYPE_CHECKING:
# noinspection PyProtectedMember
- from ruamel.yaml.comments import LineCol # pylint: disable=ungrouped-imports
+ from ruamel.yaml.comments import LineCol
+ from ruamel.yaml.compat import StreamTextType
from ruamel.yaml.nodes import ScalarNode
from ruamel.yaml.representer import RoundTripRepresenter
from ruamel.yaml.tokens import CommentToken
@@ -41,28 +45,18 @@ if TYPE_CHECKING:
_logger = logging.getLogger(__name__)
-YAMLLINT_CONFIG = """
-extends: default
-rules:
- comments:
- # https://github.com/prettier/prettier/issues/6780
- min-spaces-from-content: 1
- # https://github.com/adrienverge/yamllint/issues/384
- comments-indentation: false
- document-start: disable
- # 160 chars was the default used by old E204 rule, but
- # you can easily change it or disable in your .yamllint file.
- line-length:
- max: 160
- # We are adding an extra space inside braces as that's how prettier does it
- # and we are trying not to fight other linters.
- braces:
- min-spaces-inside: 0 # yamllint defaults to 0
- max-spaces-inside: 1 # yamllint defaults to 0
- octal-values:
- forbid-implicit-octal: true # yamllint defaults to false
- forbid-explicit-octal: true # yamllint defaults to false
-"""
+
+class CustomYamlLintConfig(YamlLintConfig): # type: ignore[misc]
+ """Extension of YamlLintConfig."""
+
+ def __init__(
+ self,
+ content: str | None = None,
+ file: str | Path | None = None,
+ ) -> None:
+ """Initialize config."""
+ super().__init__(content, file)
+ self.incompatible = ""
def deannotate(data: Any) -> Any:
@@ -80,10 +74,10 @@ def deannotate(data: Any) -> Any:
return data
-@functools.lru_cache(maxsize=1)
-def load_yamllint_config() -> YamlLintConfig:
+def load_yamllint_config() -> CustomYamlLintConfig:
"""Load our default yamllint config and any customized override file."""
- config = YamlLintConfig(content=YAMLLINT_CONFIG)
+ config = CustomYamlLintConfig(file=Path(__file__).parent / "data" / ".yamllint")
+ config.incompatible = ""
# if we detect local yamllint config we use it but raise a warning
# as this is likely to get out of sync with our internal config.
for path in [
@@ -100,10 +94,65 @@ def load_yamllint_config() -> YamlLintConfig:
"internal yamllint config.",
file,
)
- config_override = YamlLintConfig(file=str(file))
- config_override.extend(config)
- config = config_override
+ custom_config = CustomYamlLintConfig(file=str(file))
+ custom_config.extend(config)
+ config = custom_config
break
+
+ # Look for settings incompatible with our reformatting
+ checks: list[tuple[str, str | int | bool]] = [
+ (
+ "comments.min-spaces-from-content",
+ 1,
+ ),
+ (
+ "comments-indentation",
+ False,
+ ),
+ (
+ "braces.min-spaces-inside",
+ 0,
+ ),
+ (
+ "braces.max-spaces-inside",
+ 1,
+ ),
+ (
+ "octal-values.forbid-implicit-octal",
+ True,
+ ),
+ (
+ "octal-values.forbid-explicit-octal",
+ True,
+ ),
+ # (
+ # "key-duplicates.forbid-duplicated-merge-keys", # v1.34.0+
+ # True,
+ # ),
+ # (
+ # "quoted-strings.quote-type", "double",
+ # ),
+ # (
+ # "quoted-strings.required", "only-when-needed",
+ # ),
+ ]
+ errors = []
+ for setting, expected_value in checks:
+ v = config.rules
+ for key in setting.split("."):
+ if not isinstance(v, dict): # pragma: no cover
+ break
+ if key not in v: # pragma: no cover
+ break
+ v = v[key]
+ if v != expected_value:
+ msg = f"{setting} must be {str(expected_value).lower()}"
+ errors.append(msg)
+ if errors:
+ nl = "\n"
+ msg = f"Found incompatible custom yamllint configuration ({file}), please either remove the file or edit it to comply with:{nl} - {(nl + ' - ').join(errors)}.{nl}{nl}Read https://ansible.readthedocs.io/projects/lint/rules/yaml/ for more details regarding why we have these requirements. Fix mode will not be available."
+ config.incompatible = msg
+
_logger.debug("Effective yamllint rules used: %s", config.rules)
return config
@@ -196,7 +245,7 @@ def _nested_items_path(
"""
# we have to cast each convert_to_tuples assignment or mypy complains
# that both assignments (for dict and list) do not have the same type
- convert_to_tuples_type = Callable[[], Iterator[tuple[Union[str, int], Any]]]
+ convert_to_tuples_type = Callable[[], Iterator[tuple[str | int, Any]]]
if isinstance(data_collection, dict):
convert_data_collection_to_tuples = cast(
convert_to_tuples_type,
@@ -214,7 +263,7 @@ def _nested_items_path(
if key in (*ANNOTATION_KEYS, *ignored_keys):
continue
yield key, value, parent_path
- if isinstance(value, (dict, list)):
+ if isinstance(value, dict | list):
yield from _nested_items_path(
data_collection=value,
parent_path=[*parent_path, key],
@@ -232,7 +281,7 @@ def get_path_to_play(
raise ValueError(msg)
if lintable.kind != "playbook" or not isinstance(ruamel_data, CommentedSeq):
return []
- lc: LineCol # lc uses 0-based counts # pylint: disable=invalid-name
+ lc: LineCol # lc uses 0-based counts
# lineno is 1-based. Convert to 0-based.
line_index = lineno - 1
@@ -245,10 +294,10 @@ def get_path_to_play(
else:
next_play_line_index = None
- lc = play.lc # pylint: disable=invalid-name
+ lc = play.lc
if not isinstance(lc.line, int):
msg = f"expected lc.line to be an int, got {lc.line!r}"
- raise RuntimeError(msg)
+ raise TypeError(msg)
if lc.line == line_index:
return [play_index]
if play_index > 0 and prev_play_line_index < line_index < lc.line:
@@ -300,6 +349,10 @@ def _get_path_to_task_in_playbook(
else:
next_play_line_index = None
+ # We clearly haven't found the right spot yet if a following play starts on an earlier line.
+ if next_play_line_index and lineno > next_play_line_index:
+ continue
+
play_keys = list(play.keys())
for tasks_keyword in PLAYBOOK_TASK_KEYWORDS:
if not play.get(tasks_keyword):
@@ -381,7 +434,7 @@ def _get_path_to_task_in_tasks_block(
if not isinstance(task.lc.line, int):
msg = f"expected task.lc.line to be an int, got {task.lc.line!r}"
- raise RuntimeError(msg)
+ raise TypeError(msg)
if task.lc.line == line_index:
return [task_index]
if task_index > 0 and prev_task_line_index < line_index < task.lc.line:
@@ -418,6 +471,8 @@ def _get_path_to_task_in_nested_tasks_block(
continue
next_task_key = task_keys_by_index.get(task_index + 1, None)
if next_task_key is not None:
+ if task.lc.data[next_task_key][2] < lineno:
+ continue
next_task_key_line_index = task.lc.data[next_task_key][0]
else:
next_task_key_line_index = None
@@ -461,7 +516,6 @@ class OctalIntYAML11(ScalarInt):
v = format(data, "o")
anchor = data.yaml_anchor(any=True)
# noinspection PyProtectedMember
- # pylint: disable=protected-access
return representer.insert_underscore(
"0",
v,
@@ -498,7 +552,9 @@ class CustomConstructor(RoundTripConstructor):
value_s = value_su.replace("_", "")
if value_s[0] in "+-":
value_s = value_s[1:]
- if value_s[0] == "0":
+ if value_s[0:2] == "0x":
+ ret = HexInt(ret, width=len(value_s) - 2)
+ elif value_s[0] == "0":
# got an octal in YAML 1.1
ret = OctalIntYAML11(
ret,
@@ -582,15 +638,33 @@ class FormattedEmitter(Emitter):
"""Select how to quote scalars if needed."""
style = super().choose_scalar_style()
if (
- style == "" # noqa: PLC1901
+ style == ""
and self.event.value.startswith("0")
and len(self.event.value) > 1
):
- if self.event.tag == "tag:yaml.org,2002:int" and self.event.implicit[0]:
- # ensures that "0123" string does not lose its quoting
+ # We have an as-yet unquoted token that starts with "0" (but is not itself the digit 0).
+ # It could be:
+ # - hexadecimal like "0xF1"; comes tagged as int. Should continue unquoted to continue as an int.
+ # - octal like "0666" or "0o755"; comes tagged as str. **Should** be quoted to be cross-YAML compatible.
+ # - string like "0.0.0.0" and "00-header". Should not be quoted, unless it has a quote in it.
+ if (
+ self.event.value.startswith("0x")
+ and self.event.tag == "tag:yaml.org,2002:int"
+ and self.event.implicit[0]
+ ):
+ # hexadecimal
+ self.event.tag = "tag:yaml.org,2002:str"
+ return ""
+ try:
+ int(self.event.value, 8)
+ except ValueError:
+ pass
+ # fallthrough to string
+ else:
+ # octal
self.event.tag = "tag:yaml.org,2002:str"
self.event.implicit = (True, True, True)
- return '"'
+ return '"'
if style != "'":
# block scalar, double quoted, etc.
return style
@@ -598,6 +672,17 @@ class FormattedEmitter(Emitter):
return "'"
return self.preferred_quote
+ def increase_indent(
+ self,
+ flow: bool = False, # noqa: FBT002
+ sequence: bool | None = None,
+ indentless: bool = False, # noqa: FBT002
+ ) -> None:
+ super().increase_indent(flow, sequence, indentless)
+ # If our previous node was a sequence and we are still trying to indent, don't
+ if self.indents.last_seq():
+ self.indent = self.column + 1
+
def write_indicator(
self,
indicator: str, # ruamel.yaml typehint is wrong. This is a string.
@@ -620,6 +705,9 @@ class FormattedEmitter(Emitter):
and not self._in_empty_flow_map
):
indicator = (" " * spaces_inside) + "}"
+ # Indicator sometimes comes with embedded spaces we need to squish
+ if indicator == " -" and self.indents.last_seq():
+ indicator = "-"
super().write_indicator(indicator, need_whitespace, whitespace, indention)
# if it is the start of a flow mapping, and it's not time
# to wrap the lines, insert a space.
@@ -691,16 +779,21 @@ class FormattedEmitter(Emitter):
and not value.strip()
and not isinstance(
self.event,
- (
- ruamel.yaml.events.CollectionEndEvent,
- ruamel.yaml.events.DocumentEndEvent,
- ruamel.yaml.events.StreamEndEvent,
- ),
+ ruamel.yaml.events.CollectionEndEvent
+ | ruamel.yaml.events.DocumentEndEvent
+ | ruamel.yaml.events.StreamEndEvent
+ | ruamel.yaml.events.MappingStartEvent,
)
):
# drop pure whitespace pre comments
# does not apply to End events since they consume one of the newlines.
value = ""
+ elif (
+ pre
+ and not value.strip()
+ and isinstance(self.event, ruamel.yaml.events.MappingStartEvent)
+ ):
+ value = self._re_repeat_blank_lines.sub("", value)
elif pre:
# preserve content in pre comment with at least one newline,
# but no extra blank lines.
@@ -727,13 +820,25 @@ class FormattedEmitter(Emitter):
class FormattedYAML(YAML):
"""A YAML loader/dumper that handles ansible content better by default."""
- def __init__(
+ default_config = {
+ "explicit_start": True,
+ "explicit_end": False,
+ "width": 160,
+ "indent_sequences": True,
+ "preferred_quote": '"',
+ "min_spaces_inside": 0,
+ "max_spaces_inside": 1,
+ }
+
+ def __init__( # pylint: disable=too-many-arguments
self,
*,
typ: str | None = None,
pure: bool = False,
output: Any = None,
plug_ins: list[str] | None = None,
+ version: tuple[int, int] | None = None,
+ config: dict[str, bool | int | str] | None = None,
):
"""Return a configured ``ruamel.yaml.YAML`` instance.
@@ -793,15 +898,18 @@ class FormattedYAML(YAML):
tasks:
- name: Task
"""
- # Default to reading/dumping YAML 1.1 (ruamel.yaml defaults to 1.2)
- self._yaml_version_default: tuple[int, int] = (1, 1)
- self._yaml_version: str | tuple[int, int] = self._yaml_version_default
-
+ if version:
+ if isinstance(version, str):
+ x, y = version.split(".", maxsplit=1)
+ version = (int(x), int(y))
+ self._yaml_version_default: tuple[int, int] = version
+ self._yaml_version: tuple[int, int] = self._yaml_version_default
super().__init__(typ=typ, pure=pure, output=output, plug_ins=plug_ins)
# NB: We ignore some mypy issues because ruamel.yaml typehints are not great.
- config = self._defaults_from_yamllint_config()
+ if not config:
+ config = self._defaults_from_yamllint_config()
# these settings are derived from yamllint config
self.explicit_start: bool = config["explicit_start"] # type: ignore[assignment]
@@ -854,15 +962,8 @@ class FormattedYAML(YAML):
@staticmethod
def _defaults_from_yamllint_config() -> dict[str, bool | int | str]:
"""Extract FormattedYAML-relevant settings from yamllint config if possible."""
- config = {
- "explicit_start": True,
- "explicit_end": False,
- "width": 160,
- "indent_sequences": True,
- "preferred_quote": '"',
- "min_spaces_inside": 0,
- "max_spaces_inside": 1,
- }
+ config = FormattedYAML.default_config
+
for rule, rule_config in load_yamllint_config().rules.items():
if not rule_config:
# rule disabled
@@ -895,10 +996,10 @@ class FormattedYAML(YAML):
elif quote_type == "double":
config["preferred_quote"] = '"'
- return cast(dict[str, Union[bool, int, str]], config)
+ return cast(dict[str, bool | int | str], config)
- @property # type: ignore[override]
- def version(self) -> str | tuple[int, int]:
+ @property
+ def version(self) -> tuple[int, int] | None:
"""Return the YAML version used to parse or dump.
Ansible uses PyYAML which only supports YAML 1.1. ruamel.yaml defaults to 1.2.
@@ -906,19 +1007,25 @@ class FormattedYAML(YAML):
We can relax the version requirement once ansible uses a version of PyYAML
that includes this PR: https://github.com/yaml/pyyaml/pull/555
"""
- return self._yaml_version
+ if hasattr(self, "_yaml_version"):
+ return self._yaml_version
+ return None
@version.setter
- def version(self, value: str | tuple[int, int] | None) -> None:
+ def version(self, value: tuple[int, int] | None) -> None:
"""Ensure that yaml version uses our default value.
The yaml Reader updates this value based on the ``%YAML`` directive in files.
So, if a file does not include the directive, it sets this to None.
But, None effectively resets the parsing version to YAML 1.2 (ruamel's default).
"""
- self._yaml_version = value if value is not None else self._yaml_version_default
+ if value is not None:
+ self._yaml_version = value
+ elif hasattr(self, "_yaml_version_default"):
+ self._yaml_version = self._yaml_version_default
+ # We do nothing if the object did not have a previous default version defined
- def loads(self, stream: str) -> Any:
+ def load(self, stream: Path | StreamTextType) -> Any:
"""Load YAML content from a string while avoiding known ruamel.yaml issues."""
if not isinstance(stream, str):
msg = f"expected a str but got {type(stream)}"
@@ -928,10 +1035,18 @@ class FormattedYAML(YAML):
# https://sourceforge.net/p/ruamel-yaml/tickets/460/
text, preamble_comment = self._pre_process_yaml(stream)
- data = self.load(stream=text)
+ try:
+ data = super().load(stream=text)
+ except ComposerError:
+ data = self.load_all(stream=text)
+ except ParserError:
+ data = None
+ _logger.error( # noqa: TRY400
+ "Invalid yaml, verify the file contents and try again.",
+ )
if preamble_comment is not None and isinstance(
data,
- (CommentedMap, CommentedSeq),
+ CommentedMap | CommentedSeq,
):
data.preamble_comment = preamble_comment # type: ignore[union-attr]
# Because data can validly also be None for empty documents, we cannot
@@ -948,15 +1063,20 @@ class FormattedYAML(YAML):
stream.write(preamble_comment)
self.dump(data, stream)
text = stream.getvalue()
- return self._post_process_yaml(text)
+ strip_version_directive = hasattr(self, "_yaml_version_default")
+ return self._post_process_yaml(
+ text,
+ strip_version_directive=strip_version_directive,
+ strip_explicit_start=not self.explicit_start,
+ )
def _prevent_wrapping_flow_style(self, data: Any) -> None:
- if not isinstance(data, (CommentedMap, CommentedSeq)):
+ if not isinstance(data, CommentedMap | CommentedSeq):
return
for key, value, parent_path in nested_items_path(data):
- if not isinstance(value, (CommentedMap, CommentedSeq)):
+ if not isinstance(value, CommentedMap | CommentedSeq):
continue
- fa: Format = value.fa # pylint: disable=invalid-name
+ fa: Format = value.fa
if fa.flow_style():
predicted_indent = self._predict_indent_length(parent_path, key)
predicted_width = len(str(value))
@@ -1036,7 +1156,12 @@ class FormattedYAML(YAML):
return text, "".join(preamble_comments) or None
@staticmethod
- def _post_process_yaml(text: str) -> str:
+ def _post_process_yaml(
+ text: str,
+ *,
+ strip_version_directive: bool = False,
+ strip_explicit_start: bool = False,
+ ) -> str:
"""Handle known issues with ruamel.yaml dumping.
Make sure there's only one newline at the end of the file.
@@ -1048,6 +1173,14 @@ class FormattedYAML(YAML):
Make sure null list items don't end in a space.
"""
+ # remove YAML directive
+ if strip_version_directive and text.startswith("%YAML"):
+ text = text.split("\n", 1)[1]
+
+ # remove explicit document start
+ if strip_explicit_start and text.startswith("---"):
+ text = text.split("\n", 1)[1]
+
text = text.rstrip("\n") + "\n"
lines = text.splitlines(keepends=True)
@@ -1092,9 +1225,9 @@ class FormattedYAML(YAML):
def clean_json(
obj: Any,
- func: Callable[[str], Any] = lambda key: key.startswith("__")
- if isinstance(key, str)
- else False,
+ func: Callable[[str], Any] = lambda key: (
+ key.startswith("__") if isinstance(key, str) else False
+ ),
) -> Any:
"""Remove all keys matching the condition from a nested JSON-like object.