diff options
Diffstat (limited to 'src/ansiblelint/rules/syntax_check.py')
-rw-r--r-- | src/ansiblelint/rules/syntax_check.py | 240 |
1 files changed, 240 insertions, 0 deletions
diff --git a/src/ansiblelint/rules/syntax_check.py b/src/ansiblelint/rules/syntax_check.py new file mode 100644 index 0000000..cec94e6 --- /dev/null +++ b/src/ansiblelint/rules/syntax_check.py @@ -0,0 +1,240 @@ +"""Rule definition for ansible syntax check.""" +from __future__ import annotations + +import json +import re +import subprocess +import sys +from dataclasses import dataclass +from typing import Any + +from ansiblelint._internal.rules import BaseRule, RuntimeErrorRule +from ansiblelint.app import get_app +from ansiblelint.config import options +from ansiblelint.errors import MatchError +from ansiblelint.file_utils import Lintable +from ansiblelint.logger import timed_info +from ansiblelint.rules import AnsibleLintRule +from ansiblelint.text import strip_ansi_escape + + +@dataclass +class KnownError: + """Class that tracks result of linting.""" + + tag: str + regex: re.Pattern[str] + + +OUTPUT_PATTERNS = ( + KnownError( + tag="missing-file", + regex=re.compile( + # do not use <filename> capture group for this because we want to report original file, not the missing target one + r"(?P<title>Unable to retrieve file contents)\n(?P<details>Could not find or access '(?P<value>.*)'[^\n]*)", + 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, + ), + ), + KnownError( + tag="empty-playbook", + regex=re.compile( + "Empty playbook, nothing to do", re.MULTILINE | re.S | re.DOTALL + ), + ), + KnownError( + tag="malformed", + regex=re.compile( + "^ERROR! (?P<title>A malformed block was encountered while loading a block[^\n]*)", + re.MULTILINE | re.S | re.DOTALL, + ), + ), +) + + +class AnsibleSyntaxCheckRule(AnsibleLintRule): + """Ansible syntax check failed.""" + + id = "syntax-check" + severity = "VERY_HIGH" + tags = ["core", "unskippable"] + version_added = "v5.0.0" + _order = 0 + + @staticmethod + # pylint: disable=too-many-locals,too-many-branches + def _get_ansible_syntax_check_matches(lintable: Lintable) -> list[MatchError]: + """Run ansible syntax check and return a list of MatchError(s).""" + default_rule: BaseRule = AnsibleSyntaxCheckRule() + results = [] + if lintable.kind not in ("playbook", "role"): + return [] + + with timed_info( + "Executing syntax check on %s %s", lintable.kind, lintable.path + ): + # To avoid noisy warnings we pass localhost as current inventory: + # [WARNING]: No inventory was parsed, only implicit localhost is available + # [WARNING]: provided hosts list is empty, only localhost is available. Note that the implicit localhost does not match 'all' + if lintable.kind == "playbook": + cmd = [ + "ansible-playbook", + "-i", + "localhost,", + "--syntax-check", + str(lintable.path.expanduser()), + ] + else: # role + cmd = [ + "ansible", + "localhost", + "--syntax-check", + "--module-name=include_role", + "--args", + f"name={str(lintable.path.expanduser())}", + ] + if options.extra_vars: + cmd.extend(["--extra-vars", json.dumps(options.extra_vars)]) + + # To reduce noisy warnings like + # CryptographyDeprecationWarning: Blowfish has been deprecated + # https://github.com/paramiko/paramiko/issues/2038 + env = get_app().runtime.environ.copy() + env["PYTHONWARNINGS"] = "ignore" + + run = subprocess.run( + cmd, + stdin=subprocess.PIPE, + capture_output=True, + shell=False, # needed when command is a list + text=True, + check=False, + env=env, + ) + + if run.returncode != 0: + message = None + filename = lintable + linenumber = 1 + column = None + tag = None + + stderr = strip_ansi_escape(run.stderr) + stdout = strip_ansi_escape(run.stdout) + if stderr: + details = stderr + if stdout: + details += "\n" + stdout + else: + details = stdout + + for pattern in OUTPUT_PATTERNS: + rule = default_rule + match = re.search(pattern.regex, stderr) + if match: + groups = match.groupdict() + title = groups.get("title", match.group(0)) + details = groups.get("details", "") + linenumber = int(groups.get("line", 1)) + + if "filename" in groups: + filename = Lintable(groups["filename"]) + else: + filename = lintable + column = int(groups.get("column", 1)) + results.append( + MatchError( + message=title, + filename=filename, + linenumber=linenumber, + column=column, + rule=rule, + details=details, + tag=f"{rule.id}[{pattern.tag}]", + ) + ) + + if not results: + rule = RuntimeErrorRule() + message = ( + f"Unexpected error code {run.returncode} from " + f"execution of: {' '.join(cmd)}" + ) + results.append( + MatchError( + message=message, + filename=filename, + linenumber=linenumber, + column=column, + rule=rule, + details=details, + tag=tag, + ) + ) + + return results + + +# testing code to be loaded only with pytest or when executed the rule file +if "pytest" in sys.modules: + + def test_get_ansible_syntax_check_matches() -> None: + """Validate parsing of ansible output.""" + lintable = Lintable( + "examples/playbooks/conflicting_action.yml", kind="playbook" + ) + # pylint: disable=protected-access + result = AnsibleSyntaxCheckRule._get_ansible_syntax_check_matches(lintable) + assert result[0].linenumber == 4 + assert result[0].column == 7 + assert ( + result[0].message + == "conflicting action statements: ansible.builtin.debug, ansible.builtin.command" + ) + # We internally convert absolute paths returned by ansible into paths + # relative to current directory. + assert result[0].filename.endswith("/conflicting_action.yml") + assert len(result) == 1 + + def test_empty_playbook() -> None: + """Validate detection of empty-playbook.""" + lintable = Lintable("examples/playbooks/empty_playbook.yml", kind="playbook") + # pylint: disable=protected-access + result = AnsibleSyntaxCheckRule._get_ansible_syntax_check_matches(lintable) + assert result[0].linenumber == 1 + # We internally convert absolute paths returned by ansible into paths + # relative to current directory. + assert result[0].filename.endswith("/empty_playbook.yml") + assert result[0].tag == "syntax-check[empty-playbook]" + assert result[0].message == "Empty playbook, nothing to do" + assert len(result) == 1 + + def test_extra_vars_passed_to_command(config_options: Any) -> None: + """Validate `extra-vars` are passed to syntax check command.""" + config_options.extra_vars = { + "foo": "bar", + "complex_variable": ":{;\t$()", + } + lintable = Lintable("examples/playbooks/extra_vars.yml", kind="playbook") + + # pylint: disable=protected-access + result = AnsibleSyntaxCheckRule._get_ansible_syntax_check_matches(lintable) + + assert not result + + def test_syntax_check_role() -> None: + """Validate syntax check of a broken role.""" + lintable = Lintable("examples/playbooks/roles/invalid_due_syntax", kind="role") + # pylint: disable=protected-access + result = AnsibleSyntaxCheckRule._get_ansible_syntax_check_matches(lintable) + assert len(result) == 1, result + assert result[0].linenumber == 2 + assert result[0].filename == "examples/roles/invalid_due_syntax/tasks/main.yml" + assert result[0].tag == "syntax-check[specific]" + assert result[0].message == "no module/action detected in task." |