summaryrefslogtreecommitdiffstats
path: root/src/ansiblelint/rules/syntax_check.py
blob: cec94e6c4dcc1cb9baa9cc0b2e3d309ec06f5d50 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
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."