Skip to content

Commit 50373ef

Browse files
koalajoe23cidrblockpre-commit-ci[bot]alisonlhart
authored
make rule deprecated-local-action preserve module parameters (#4733)
Co-authored-by: Bradley A. Thornton <[email protected]> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Alison Hart <[email protected]>
1 parent 359d866 commit 50373ef

File tree

6 files changed

+246
-42
lines changed

6 files changed

+246
-42
lines changed
Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,36 @@
11
---
2-
- name: Sample
3-
ansible.builtin.command: echo 123
2+
- name: Sample 1
3+
command: echo 123
44
delegate_to: localhost
5+
- name: Sample 2
6+
command:
7+
cmd: echo 456
8+
delegate_to: localhost
9+
- name: Sample 3
10+
ansible.builtin.debug:
11+
delegate_to: localhost
12+
- name: Sample 4 - Dict-form local_action
13+
user:
14+
name: alice
15+
state: present
16+
delegate_to: localhost
17+
vars:
18+
foo: bar
19+
- name: Sample 5 - String-form local_action (all key=value)
20+
file: path=/tmp/foo state=touch mode=0644
21+
delegate_to: localhost
22+
tags: always
23+
- name: Sample 6 - String-form local_action (positional arguments)
24+
shell: echo "hello world"
25+
delegate_to: localhost
26+
- name: Sample 7 - String-form local_action (single positional argument)
27+
command: hostname
28+
delegate_to: localhost
29+
- name: Sample 8 - Dict-form local_action with no params (only module)
30+
ping:
31+
delegate_to: localhost
32+
- name: Sample 9 - Args copied over
33+
copy: src=/etc/hosts dest=/tmp/hosts
34+
delegate_to: localhost
35+
args:
36+
remote_src: true
Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,30 @@
11
---
2-
- name: Sample
2+
- name: Sample 1
33
local_action: command echo 123
4+
- name: Sample 2
5+
local_action:
6+
module: command
7+
cmd: echo 456
8+
- name: Sample 3
9+
local_action: ansible.builtin.debug
10+
- name: Sample 4 - Dict-form local_action
11+
local_action:
12+
module: user
13+
name: alice
14+
state: present
15+
vars:
16+
foo: bar
17+
- name: Sample 5 - String-form local_action (all key=value)
18+
local_action: file path=/tmp/foo state=touch mode=0644
19+
tags: always
20+
- name: Sample 6 - String-form local_action (positional arguments)
21+
local_action: shell echo "hello world"
22+
- name: Sample 7 - String-form local_action (single positional argument)
23+
local_action: command hostname
24+
- name: Sample 8 - Dict-form local_action with no params (only module)
25+
local_action:
26+
module: ping
27+
- name: Sample 9 - Args copied over
28+
local_action: copy src=/etc/hosts dest=/tmp/hosts
29+
args:
30+
remote_src: yes

src/ansiblelint/rules/deprecated_local_action.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ This rule recommends using `delegate_to: localhost` instead of the
1616
1717
```yaml
1818
- name: Task example
19-
ansible.builtin.debug:
19+
ansible.builtin.debug:
2020
delegate_to: localhost # <-- recommended way to run on localhost
2121
```
2222

src/ansiblelint/rules/deprecated_local_action.py

Lines changed: 170 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -4,29 +4,37 @@
44
# Copyright (c) 2018, Ansible Project
55
from __future__ import annotations
66

7-
import copy
87
import logging
9-
import os
108
import sys
119
from pathlib import Path
12-
from typing import TYPE_CHECKING
10+
from typing import TYPE_CHECKING, Any
1311

12+
from ruamel.yaml.comments import CommentedMap
13+
14+
from ansiblelint.errors import MatchError
15+
from ansiblelint.file_utils import Lintable
1416
from ansiblelint.rules import AnsibleLintRule, TransformMixin
1517
from ansiblelint.runner import get_matches
1618
from ansiblelint.transformer import Transformer
1719

1820
if TYPE_CHECKING:
19-
from ruamel.yaml.comments import CommentedMap, CommentedSeq
21+
from ruamel.yaml.comments import CommentedSeq
2022

2123
from ansiblelint.config import Options
22-
from ansiblelint.errors import MatchError
23-
from ansiblelint.file_utils import Lintable
2424
from ansiblelint.utils import Task
2525

2626

2727
_logger = logging.getLogger(__name__)
2828

2929

30+
class LocalActionTransformError(Exception):
31+
"""Exception raised when a local_action is not processed correctly."""
32+
33+
def __init__(self, message: str) -> None:
34+
"""Initialize the LocalActionTransformError with a message."""
35+
_logger.error(message)
36+
37+
3038
class TaskNoLocalActionRule(AnsibleLintRule, TransformMixin):
3139
"""Do not use 'local_action', use 'delegate_to: localhost'."""
3240

@@ -52,37 +60,108 @@ def transform(
5260
lintable: Lintable,
5361
data: CommentedMap | CommentedSeq | str,
5462
) -> None:
55-
if match.tag == self.id:
56-
# we do not want perform a partial modification accidentally
57-
original_target_task = self.seek(match.yaml_path, data)
58-
target_task = copy.deepcopy(original_target_task)
59-
for _ in range(len(target_task)):
60-
k, v = target_task.popitem(False)
61-
if k == "local_action":
62-
if isinstance(v, dict):
63-
module_name = v["module"]
64-
target_task[module_name] = None
65-
target_task["delegate_to"] = "localhost"
66-
elif isinstance(v, str):
67-
module_name, module_value = v.split(" ", 1)
68-
target_task[module_name] = module_value
69-
target_task["delegate_to"] = "localhost"
70-
else: # pragma: no cover
71-
_logger.debug(
72-
"Ignored unexpected data inside %s transform.",
73-
self.id,
74-
)
75-
return
63+
"""Transform the task to use delegate_to: localhost.
64+
65+
Args:
66+
match: The match object.
67+
lintable: The lintable object.
68+
data: The data to transform.
69+
"""
70+
try:
71+
self.perform_transform(match, lintable, data)
72+
except LocalActionTransformError as e:
73+
match.fixed = False
74+
match.message = str(e)
75+
return
76+
77+
def perform_transform(
78+
self,
79+
match: MatchError,
80+
lintable: Lintable,
81+
data: CommentedMap | CommentedSeq | str,
82+
) -> None:
83+
"""Transform the task to use delegate_to: localhost.
84+
85+
Args:
86+
match: The match object.
87+
lintable: The lintable object.
88+
data: The data to transform.
89+
90+
Raises:
91+
LocalActionTransformError: If the local_action is not dict | str.
92+
"""
93+
original_task = self.seek(match.yaml_path, data)
94+
task_location = f"{lintable.name}:{match.lineno}"
95+
96+
target_task = {}
97+
98+
for k, v in original_task.items():
99+
if k == "local_action":
100+
if isinstance(v, dict):
101+
target_task.update(self.process_dict(v, task_location))
102+
elif isinstance(v, str):
103+
target_task.update(self.process_string(v, task_location))
76104
else:
77-
target_task[k] = v
105+
err = f"Unsupported local_action type '{type(v).__name__}' in task at {task_location}"
106+
raise LocalActionTransformError(err)
107+
target_task["delegate_to"] = "localhost"
108+
else:
109+
target_task[k] = v
110+
78111
match.fixed = True
79-
original_target_task.clear()
80-
original_target_task.update(target_task)
112+
original_task.clear()
113+
original_task.update(target_task)
114+
115+
def process_dict(
116+
self, local_action: dict[str, Any], task_location: str
117+
) -> dict[str, Any]:
118+
"""Process a dict-form local_action.
119+
120+
Args:
121+
local_action: The local_action dictionary.
122+
task_location: The location of the task.
123+
124+
Returns:
125+
A dictionary with the module and parameters.
126+
127+
Raises:
128+
LocalActionTransformError: If the local_action dictionary is missing a 'module' key.
129+
"""
130+
if "module" not in local_action:
131+
err = f"No 'module' key in local_action in task at {task_location}"
132+
raise LocalActionTransformError(err)
133+
return {
134+
local_action["module"]: {
135+
k: v for k, v in local_action.items() if k != "module"
136+
}
137+
or None
138+
}
139+
140+
def process_string(
141+
self, local_action: str, task_location: str
142+
) -> dict[str, str | None]:
143+
"""Process a string-form local_action.
144+
145+
Args:
146+
local_action: The local_action string.
147+
task_location: The location of the task.
148+
149+
Returns:
150+
A dictionary with the module and parameters.
151+
152+
Raises:
153+
LocalActionTransformError: If the local_action string is empty or whitespace.
154+
"""
155+
if not local_action or not local_action.strip():
156+
err = f"Empty local_action string in task at {task_location}"
157+
raise LocalActionTransformError(err)
158+
parts = local_action.split(" ", 1)
159+
return {parts[0]: parts[1] if len(parts) > 1 else None}
81160

82161

83162
# testing code to be loaded only with pytest or when executed the rule file
84163
if "pytest" in sys.modules:
85-
from unittest import mock
164+
import pytest
86165

87166
from ansiblelint.rules import RulesCollection
88167
from ansiblelint.runner import Runner
@@ -94,27 +173,80 @@ def test_local_action(default_rules_collection: RulesCollection) -> None:
94173
rules=default_rules_collection,
95174
).run()
96175

97-
assert len(results) == 1
98-
assert results[0].tag == "deprecated-local-action"
176+
assert any(result.tag == "deprecated-local-action" for result in results)
177+
178+
@pytest.mark.parametrize(
179+
("data", "prefix"),
180+
(
181+
(
182+
CommentedMap({"local_action": True}),
183+
"Unsupported local_action type 'bool'",
184+
),
185+
(
186+
CommentedMap({"local_action": 123}),
187+
"Unsupported local_action type 'int'",
188+
),
189+
(
190+
CommentedMap({"local_action": 12.34}),
191+
"Unsupported local_action type 'float'",
192+
),
193+
(
194+
CommentedMap({"local_action": []}),
195+
"Unsupported local_action type 'list'",
196+
),
197+
(CommentedMap({"local_action": {}}), "No 'module' key in local_action"),
198+
(CommentedMap({"local_action": ""}), "Empty local_action string"),
199+
(CommentedMap({"local_action": " "}), "Empty local_action string"),
200+
),
201+
ids=[
202+
"bool",
203+
"int",
204+
"float",
205+
"list",
206+
"empty_dict",
207+
"empty_string",
208+
"whitespace_string",
209+
],
210+
)
211+
def test_local_action_transform_fail(
212+
caplog: pytest.LogCaptureFixture, data: CommentedMap, prefix: str
213+
) -> None:
214+
"""Test transform functionality for a failure.
215+
216+
Args:
217+
caplog: The pytest fixture for capturing logs.
218+
data: The data to test.
219+
prefix: The expected error prefix.
220+
"""
221+
file = "site.yml"
222+
rule = TaskNoLocalActionRule()
223+
lintable = Lintable(name=file)
224+
match_error = MatchError(message="error", lintable=lintable, lineno=1)
225+
with pytest.raises(LocalActionTransformError):
226+
rule.perform_transform(match_error, lintable, data)
227+
assert f"{prefix} in task at {file}:1" in caplog.text
99228

100-
@mock.patch.dict(os.environ, {"ANSIBLE_LINT_WRITE_TMP": "1"}, clear=True)
101229
def test_local_action_transform(
102230
config_options: Options,
103-
default_rules_collection: RulesCollection,
231+
monkeypatch: pytest.MonkeyPatch,
104232
) -> None:
105233
"""Test transform functionality for no-log-password rule."""
234+
monkeypatch.setenv("ANSIBLE_LINT_WRITE_TMP", "1")
235+
106236
playbook = Path("examples/playbooks/tasks/local_action.yml")
107237
config_options.write_list = ["all"]
108238

109239
config_options.lintables = [str(playbook)]
240+
only_local_action_rule: RulesCollection = RulesCollection()
241+
only_local_action_rule.register(TaskNoLocalActionRule())
110242
runner_result = get_matches(
111-
rules=default_rules_collection,
243+
rules=only_local_action_rule,
112244
options=config_options,
113245
)
114246
transformer = Transformer(result=runner_result, options=config_options)
115247
transformer.run()
116248
matches = runner_result.matches
117-
assert len(matches) == 3
249+
assert any(error.tag == "deprecated-local-action" for error in matches)
118250

119251
orig_content = playbook.read_text(encoding="utf-8")
120252
expected_content = playbook.with_suffix(
@@ -126,4 +258,5 @@ def test_local_action_transform(
126258

127259
assert orig_content != transformed_content
128260
assert expected_content == transformed_content
261+
129262
playbook.with_suffix(f".tmp{playbook.suffix}").unlink()

src/ansiblelint/schemas/__store__.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@
5656
"url": "https://raw.githubusercontent.com/ansible/ansible-lint/main/src/ansiblelint/schemas/role-arg-spec.json"
5757
},
5858
"rulebook": {
59-
"etag": "586088ca8247a8ba635f31d41d265f5309f3b690583eaa7dbd8a045f36eddfa9",
59+
"etag": "9f785b5986cdbfe3b4ba911cbb65263df150bb595b22e4f191081b9360717d33",
6060
"url": "https://raw.githubusercontent.com/ansible/ansible-rulebook/main/ansible_rulebook/schema/ruleset_schema.json"
6161
},
6262
"tasks": {

src/ansiblelint/schemas/rulebook.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -481,6 +481,12 @@
481481
},
482482
"lock": {
483483
"type": "string"
484+
},
485+
"labels": {
486+
"type": "array",
487+
"items": {
488+
"type": "string"
489+
}
484490
}
485491
},
486492
"required": [
@@ -537,6 +543,12 @@
537543
},
538544
"lock": {
539545
"type": "string"
546+
},
547+
"labels": {
548+
"type": "array",
549+
"items": {
550+
"type": "string"
551+
}
540552
}
541553
},
542554
"required": [

0 commit comments

Comments
 (0)