Skip to content

Commit 8805eab

Browse files
ssbarneabluikko
andauthored
Improve no-changed-when rule (#3050)
Co-authored-by: bluikko <[email protected]>
1 parent 95d8895 commit 8805eab

File tree

6 files changed

+97
-152
lines changed

6 files changed

+97
-152
lines changed

.github/workflows/tox.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ jobs:
7070
WSLENV: FORCE_COLOR:PYTEST_REQPASS:TOXENV:GITHUB_STEP_SUMMARY
7171
# Number of expected test passes, safety measure for accidental skip of
7272
# tests. Update value if you add/remove tests.
73-
PYTEST_REQPASS: 796
73+
PYTEST_REQPASS: 791
7474

7575
steps:
7676
- name: Activate WSL1
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
- name: Fixture for no-changed-when (fail with 3 occurrences)
3+
hosts: all
4+
tasks:
5+
- name: Register command output, but cat still does not change anything
6+
ansible.builtin.command: cat {{ my_file | quote }}
7+
register: my_output
8+
- name: Block level 1
9+
block:
10+
- name: Block level 2
11+
block:
12+
- name: Basic command task, should fail
13+
ansible.builtin.command: cat my_file
14+
- name: Basic shell task, should fail
15+
shell: cat my_file # noqa: fqcn command-instead-of-shell
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
- name: Fixture for no-changed-when (pass)
3+
hosts: all
4+
tasks:
5+
- name: Handle command output with return code # noqa: command-instead-of-shell
6+
ansible.builtin.command: cat {{ my_file | quote }}
7+
register: my_output
8+
changed_when: my_output.rc != 0
9+
10+
- name: Handle shell output with return code # noqa: command-instead-of-shell
11+
ansible.builtin.shell: cat {{ my_file | quote }}
12+
register: my_output
13+
changed_when: my_output.rc != 0
14+
15+
- name: Handle shell output with false changed_when # noqa: command-instead-of-shell
16+
ansible.builtin.shell: cat {{ my_file | quote }}
17+
register: my_output
18+
changed_when: false
19+
20+
- name: Command with argument
21+
command: createfile.sh # noqa: fqcn
22+
args:
23+
creates: /tmp/????unknown_files????

examples/playbooks/rule-no-free-form-pass.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,4 @@
1616
# https://github.com/ansible/ansible-lint/issues/2573
1717
ansible.builtin.command: localectl set-locale LANG=en_GB.UTF-8
1818
when: not ansible_check_mode
19+
changed_when: false

src/ansiblelint/rules/no_changed_when.md

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,20 @@
11
# no-changed-when
22

3-
This rule checks that tasks return changes to results or conditions.
4-
Unless tasks only read information, you should ensure that they return changes in the following ways:
3+
This rule checks that tasks return changes to results or conditions. Unless
4+
tasks only read information, you should ensure that they return changes in the
5+
following ways:
56

67
- Register results or conditions and use the `changed_when` clause.
78
- Use the `creates` or `removes` argument.
89

9-
You should use the `when` clause to run tasks only if a check returns a particular result.
10+
You should always use the `changed_when` clause on tasks that do not naturally
11+
detect if a change has occurred or not. Some of the most common examples are
12+
[shell] and [command] modules, which run arbitrary commands.
13+
14+
One very common workaround is to use a boolean value like `changed_when: false`
15+
if the task never changes anything or `changed_when: true` if it always
16+
changes something, but you can also use any expressions, including ones that
17+
use the registered result of a task, like in our example below.
1018

1119
## Problematic Code
1220

@@ -31,3 +39,8 @@ You should use the `when` clause to run tasks only if a check returns a particul
3139
register: my_output # <- Registers the command output.
3240
changed_when: my_output.rc != 0 # <- Uses the return code to define when the task has changed.
3341
```
42+
43+
[shell]:
44+
https://docs.ansible.com/ansible/latest/collections/ansible/builtin/shell_module.html
45+
[command]:
46+
https://docs.ansible.com/ansible/latest/collections/ansible/builtin/command_module.html

src/ansiblelint/rules/no_changed_when.py

Lines changed: 41 additions & 148 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import sys
2525
from typing import TYPE_CHECKING, Any
2626

27+
from ansiblelint.errors import MatchError
2728
from ansiblelint.rules import AnsibleLintRule
2829

2930
if TYPE_CHECKING:
@@ -34,168 +35,60 @@ class CommandHasChangesCheckRule(AnsibleLintRule):
3435
"""Commands should not change things if nothing needs doing."""
3536

3637
id = "no-changed-when"
37-
description = """
38-
Tasks should tell Ansible when to return ``changed``, unless the task only reads
39-
information. To do this, set ``changed_when``, use the ``creates`` or
40-
``removes`` argument, or use ``when`` to run the task only if another check has
41-
a particular result.
42-
43-
For example, this task registers the ``shell`` output and uses the return code
44-
to define when the task has changed.
45-
46-
```yaml
47-
- name: Handle shell output with return code
48-
ansible.builtin.shell: cat {{ my_file|quote }}
49-
register: my_output
50-
changed_when: my_output.rc != 0
51-
```
52-
53-
The following example will trigger the rule since the task does not
54-
handle the output of the ``command``.
55-
56-
```yaml
57-
- name: Does not handle any output or return codes
58-
ansible.builtin.command: cat {{ my_file|quote }}
59-
```
60-
"""
6138
severity = "HIGH"
6239
tags = ["command-shell", "idempotency"]
6340
version_added = "historic"
6441

65-
_commands = ["command", "shell", "raw"]
42+
_commands = [
43+
"ansible.builtin.command",
44+
"ansible.builtin.shell",
45+
"ansible.builtin.raw",
46+
"ansible.legacy.command",
47+
"ansible.legacy.shell",
48+
"ansible.legacy.raw",
49+
"command",
50+
"shell",
51+
"raw",
52+
]
6653

6754
def matchtask(
6855
self, task: dict[str, Any], file: Lintable | None = None
69-
) -> bool | str:
56+
) -> list[MatchError]:
57+
result = []
7058
# tasks in a block are "meta" type
7159
if task["__ansible_action_type__"] in ["task", "meta"]:
72-
if task["action"]["__ansible_module__"] in self._commands:
73-
return (
74-
"changed_when" not in task
75-
and "when" not in task
76-
and "creates" not in task["action"]
77-
and "removes" not in task["action"]
78-
)
79-
return False
60+
if task["action"]["__ansible_module__"] in self._commands and (
61+
"changed_when" not in task
62+
and "creates" not in task["action"]
63+
and "removes" not in task["action"]
64+
):
65+
result.append(self.create_matcherror(filename=file))
66+
return result
8067

8168

8269
if "pytest" in sys.modules:
8370
import pytest
8471

85-
NO_CHANGE_COMMAND_RC = """
86-
- hosts: all
87-
tasks:
88-
- name: Handle command output with return code
89-
ansible.builtin.command: cat {{ my_file|quote }}
90-
register: my_output
91-
changed_when: my_output.rc != 0
92-
"""
93-
94-
NO_CHANGE_SHELL_RC = """
95-
- hosts: all
96-
tasks:
97-
- name: Handle shell output with return code
98-
ansible.builtin.shell: cat {{ my_file|quote }}
99-
register: my_output
100-
changed_when: my_output.rc != 0
101-
"""
102-
103-
NO_CHANGE_SHELL_FALSE = """
104-
- hosts: all
105-
tasks:
106-
- name: Handle shell output with false changed_when
107-
ansible.builtin.shell: cat {{ my_file|quote }}
108-
register: my_output
109-
changed_when: false
110-
"""
111-
112-
NO_CHANGE_ARGS = """
113-
- hosts: all
114-
tasks:
115-
- name: Command with argument
116-
command: createfile.sh
117-
args:
118-
creates: /tmp/????unknown_files????
119-
"""
120-
121-
NO_CHANGE_REGISTER_FAIL = """
122-
- hosts: all
123-
tasks:
124-
- name: Register command output, but cat still does not change anything
125-
ansible.builtin.command: cat {{ my_file|quote }}
126-
register: my_output
127-
"""
128-
129-
# also test to ensure it catches tasks in nested blocks.
130-
NO_CHANGE_COMMAND_FAIL = """
131-
- hosts: all
132-
tasks:
133-
- block:
134-
- block:
135-
- name: Basic command task, should fail
136-
ansible.builtin.command: cat my_file
137-
"""
138-
139-
NO_CHANGE_SHELL_FAIL = """
140-
- hosts: all
141-
tasks:
142-
- name: Basic shell task, should fail
143-
shell: cat my_file
144-
"""
145-
146-
@pytest.mark.parametrize(
147-
"rule_runner", (CommandHasChangesCheckRule,), indirect=["rule_runner"]
148-
)
149-
def test_no_change_command_rc(rule_runner: Any) -> None:
150-
"""This should pass since ``*_when`` is used."""
151-
results = rule_runner.run_playbook(NO_CHANGE_COMMAND_RC)
152-
assert len(results) == 0
153-
154-
@pytest.mark.parametrize(
155-
"rule_runner", (CommandHasChangesCheckRule,), indirect=["rule_runner"]
156-
)
157-
def test_no_change_shell_rc(rule_runner: Any) -> None:
158-
"""This should pass since ``*_when`` is used."""
159-
results = rule_runner.run_playbook(NO_CHANGE_SHELL_RC)
160-
assert len(results) == 0
161-
162-
@pytest.mark.parametrize(
163-
"rule_runner", (CommandHasChangesCheckRule,), indirect=["rule_runner"]
164-
)
165-
def test_no_change_shell_false(rule_runner: Any) -> None:
166-
"""This should pass since ``*_when`` is used."""
167-
results = rule_runner.run_playbook(NO_CHANGE_SHELL_FALSE)
168-
assert len(results) == 0
169-
170-
@pytest.mark.parametrize(
171-
"rule_runner", (CommandHasChangesCheckRule,), indirect=["rule_runner"]
172-
)
173-
def test_no_change_args(rule_runner: Any) -> None:
174-
"""This test should not pass since the command doesn't do anything."""
175-
results = rule_runner.run_playbook(NO_CHANGE_ARGS)
176-
assert len(results) == 0
177-
178-
@pytest.mark.parametrize(
179-
"rule_runner", (CommandHasChangesCheckRule,), indirect=["rule_runner"]
180-
)
181-
def test_no_change_register_fail(rule_runner: Any) -> None:
182-
"""This test should not pass since cat still doesn't do anything."""
183-
results = rule_runner.run_playbook(NO_CHANGE_REGISTER_FAIL)
184-
assert len(results) == 1
185-
186-
@pytest.mark.parametrize(
187-
"rule_runner", (CommandHasChangesCheckRule,), indirect=["rule_runner"]
188-
)
189-
def test_no_change_command_fail(rule_runner: Any) -> None:
190-
"""This test should fail because command isn't handled."""
191-
# this also ensures that this catches tasks in nested blocks
192-
results = rule_runner.run_playbook(NO_CHANGE_COMMAND_FAIL)
193-
assert len(results) == 1
72+
from ansiblelint.rules import RulesCollection # pylint: disable=ungrouped-imports
73+
from ansiblelint.runner import Runner # pylint: disable=ungrouped-imports
19474

19575
@pytest.mark.parametrize(
196-
"rule_runner", (CommandHasChangesCheckRule,), indirect=["rule_runner"]
76+
("file", "expected"),
77+
(
78+
pytest.param(
79+
"examples/playbooks/rule-no-changed-when-pass.yml", 0, id="pass"
80+
),
81+
pytest.param(
82+
"examples/playbooks/rule-no-changed-when-fail.yml", 3, id="fail"
83+
),
84+
),
19785
)
198-
def test_no_change_shell_fail(rule_runner: Any) -> None:
199-
"""This test should fail because shell isn't handled.."""
200-
results = rule_runner.run_playbook(NO_CHANGE_SHELL_FAIL)
201-
assert len(results) == 1
86+
def test_rule_no_changed_when(
87+
default_rules_collection: RulesCollection, file: str, expected: int
88+
) -> None:
89+
"""Validate no-changed-when rule."""
90+
results = Runner(file, rules=default_rules_collection).run()
91+
92+
for result in results:
93+
assert result.rule.id == CommandHasChangesCheckRule.id, result
94+
assert len(results) == expected

0 commit comments

Comments
 (0)