diff --git a/.github/workflows/tox.yml b/.github/workflows/tox.yml index a8b7328c5d..581d05a0ba 100644 --- a/.github/workflows/tox.yml +++ b/.github/workflows/tox.yml @@ -70,7 +70,7 @@ jobs: env: # Number of expected test passes, safety measure for accidental skip of # tests. Update value if you add/remove tests. - PYTEST_REQPASS: 845 + PYTEST_REQPASS: 847 steps: - uses: actions/checkout@v4 with: diff --git a/examples/playbooks/nodeps.yml b/examples/playbooks/nodeps.yml new file mode 100644 index 0000000000..0ca1aa3a13 --- /dev/null +++ b/examples/playbooks/nodeps.yml @@ -0,0 +1,6 @@ +--- +- name: Example + hosts: localhost + tasks: + - name: Calling a module that is not installed + a.b.c: {} diff --git a/examples/playbooks/nodeps2.yml b/examples/playbooks/nodeps2.yml new file mode 100644 index 0000000000..fc784d0220 --- /dev/null +++ b/examples/playbooks/nodeps2.yml @@ -0,0 +1,7 @@ +--- +- name: Fixture for nodeps with missing filter + hosts: localhost + tasks: + - name: Calling a module that is not installed + ansible.builtin.debug: + msg: "{{ foo | missing_filter }}" diff --git a/src/ansiblelint/__main__.py b/src/ansiblelint/__main__.py index 019838f1cd..938ca525df 100755 --- a/src/ansiblelint/__main__.py +++ b/src/ansiblelint/__main__.py @@ -318,6 +318,7 @@ def main(argv: list[str] | None = None) -> int: ), ) or options.offline + or options.nodeps ) if not skip_schema_update: diff --git a/src/ansiblelint/config.py b/src/ansiblelint/config.py index 9be22824af..467d9e4f3b 100644 --- a/src/ansiblelint/config.py +++ b/src/ansiblelint/config.py @@ -173,6 +173,12 @@ class Options: # pylint: disable=too-many-instance-attributes ignore_file: Path | None = None max_tasks: int = 100 max_block_depth: int = 20 + nodeps: bool = bool(int(os.environ.get("ANSIBLE_LINT_NODEPS", "0"))) + + def __post_init__(self) -> None: + """Extra initialization logic.""" + if self.nodeps: + self.offline = True options = Options() diff --git a/src/ansiblelint/constants.py b/src/ansiblelint/constants.py index ad051ba47f..e9b44c37c6 100644 --- a/src/ansiblelint/constants.py +++ b/src/ansiblelint/constants.py @@ -13,6 +13,7 @@ "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 = ( diff --git a/src/ansiblelint/rules/syntax_check.md b/src/ansiblelint/rules/syntax_check.md index e8197a5c60..659e3afab3 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,13 @@ 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. ## Problematic code diff --git a/src/ansiblelint/rules/syntax_check.py b/src/ansiblelint/rules/syntax_check.py index c6a4c5efb5..2b5b65b850 100644 --- a/src/ansiblelint/rules/syntax_check.py +++ b/src/ansiblelint/rules/syntax_check.py @@ -15,6 +15,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,23 +27,30 @@ class KnownError: ), ), KnownError( - tag="specific", + tag="empty-playbook", regex=re.compile( - r"^ERROR! (?P[^\n]*)\n\nThe error appears to be in '(?P<filename>[\w\/\.\-]+)': line (?P<line>\d+), column (?P<column>\d+)", + "Empty playbook, nothing to do", re.MULTILINE | re.S | re.DOTALL, ), ), KnownError( - tag="empty-playbook", + tag="malformed", regex=re.compile( - "Empty playbook, nothing to do", + "^ERROR! (?P<title>A malformed block was encountered while loading a block[^\n]*)", re.MULTILINE | re.S | re.DOTALL, ), ), KnownError( - tag="malformed", + tag="unknown-module", regex=re.compile( - "^ERROR! (?P<title>A malformed block was encountered while loading a block[^\n]*)", + 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, ), ), diff --git a/src/ansiblelint/runner.py b/src/ansiblelint/runner.py index 835632dae2..9482d2eda4 100644 --- a/src/ansiblelint/runner.py +++ b/src/ansiblelint/runner.py @@ -372,6 +372,7 @@ def _get_ansible_syntax_check_matches( filename = lintable lineno = 1 column = None + ignore_rc = False stderr = strip_ansi_escape(run.stderr) stdout = strip_ansi_escape(run.stdout) @@ -396,19 +397,24 @@ def _get_ansible_syntax_check_matches( 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: + + if pattern.tag == "unknown-module" 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 " diff --git a/test/test_examples.py b/test/test_examples.py index 246c4a385f..64f48f7521 100644 --- a/test/test_examples.py +++ b/test/test_examples.py @@ -23,9 +23,9 @@ def test_example(default_rules_collection: RulesCollection) -> None: "examples/playbooks/syntax-error-string.yml", 6, 7, - id="syntax-error", + id="0", ), - pytest.param("examples/playbooks/syntax-error.yml", 2, 3, id="syntax-error"), + pytest.param("examples/playbooks/syntax-error.yml", 2, 3, id="1"), ), ) def test_example_syntax_error( diff --git a/test/test_main.py b/test/test_main.py index 3b8510b785..dbca659349 100644 --- a/test/test_main.py +++ b/test/test_main.py @@ -82,3 +82,25 @@ def test_get_version_warning( else: assert check in msg assert len(msg.split("\n")) == outlen + + +@pytest.mark.parametrize( + ("lintable"), + ( + pytest.param("examples/playbooks/nodeps.yml", id="1"), + pytest.param("examples/playbooks/nodeps2.yml", id="2"), + ), +) +def test_nodeps(lintable: str) -> None: + """Asserts ability to be called w/ or w/o venv activation.""" + env = os.environ.copy() + env["ANSIBLE_LINT_NODEPS"] = "1" + py_path = Path(sys.executable).parent + proc = subprocess.run( + [str(py_path / "ansible-lint"), lintable], + check=False, + capture_output=True, + text=True, + env=env, + ) + assert proc.returncode == 0, proc