summaryrefslogtreecommitdiffstats
path: root/src/ansiblelint/rules/syntax_check.py
diff options
context:
space:
mode:
Diffstat (limited to 'src/ansiblelint/rules/syntax_check.py')
-rw-r--r--src/ansiblelint/rules/syntax_check.py240
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."